-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Slack AI 챗봇 구현 #340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Slack AI 챗봇 구현 #340
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,12 @@ OAUTH_APPLE_KEY_ID=your-apple-key-id | |
| OAUTH_APPLE_PRIVATE_KEY_PATH=/path/to/apple/private/key.p8 | ||
| OAUTH_APPLE_CLIENT_SECRET=your-apple-client-secret | ||
|
|
||
| # Slack Webhooks (Optional) | ||
| # Slack Configuration | ||
| SLACK_SIGNING_SECRET=your-slack-signing-secret | ||
| SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL | ||
| SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL | ||
|
|
||
| # Gemini AI Configuration | ||
| GEMINI_PROJECT_ID=your-gcp-project-id | ||
| GEMINI_LOCATION=us-central1 | ||
| GEMINI_MODEL=gemini-1.5-flash | ||
|
Comment on lines
+50
to
+53
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package gg.agit.konect.infrastructure.gemini.client; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.google.cloud.vertexai.VertexAI; | ||
| import com.google.cloud.vertexai.api.GenerateContentResponse; | ||
| import com.google.cloud.vertexai.generativeai.GenerativeModel; | ||
| import com.google.cloud.vertexai.generativeai.ResponseHandler; | ||
|
|
||
| import gg.agit.konect.infrastructure.gemini.config.GeminiProperties; | ||
| import jakarta.annotation.PostConstruct; | ||
| import jakarta.annotation.PreDestroy; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class GeminiClient { | ||
|
|
||
| private static final String INTENT_ANALYSIS_PROMPT = """ | ||
| 당신은 KONECT 서비스의 데이터 분석 AI입니다. | ||
| 사용자의 질문을 분석하여 다음 중 하나의 쿼리 타입만 반환하세요. | ||
| 반드시 아래 목록 중 하나의 값만 반환하고, 다른 텍스트는 포함하지 마세요. | ||
|
|
||
| 가능한 쿼리 타입: | ||
| - USER_COUNT: 가입된 사용자 수, 회원 수, 유저 수 관련 질문 | ||
| - CLUB_COUNT: 전체 동아리 수, 동아리 개수 관련 질문 | ||
| - CLUB_RECRUITING_COUNT: 현재 모집 중인 동아리 수, 모집 현황 관련 질문 | ||
| - CLUB_MEMBER_TOTAL_COUNT: 전체 동아리원 수, 동아리 멤버 총 인원 관련 질문 | ||
| - UNKNOWN: 위 항목에 해당하지 않는 질문 | ||
|
|
||
| 예시: | ||
| - "가입된 사용자 수 알려줘" -> USER_COUNT | ||
| - "현재 모집 중인 동아리 몇개야?" -> CLUB_RECRUITING_COUNT | ||
| - "전체 동아리 수는?" -> CLUB_COUNT | ||
| - "동아리원이 총 몇명이야?" -> CLUB_MEMBER_TOTAL_COUNT | ||
| - "오늘 날씨 어때?" -> UNKNOWN | ||
| - "특정 사용자 이메일 알려줘" -> UNKNOWN | ||
|
|
||
| 사용자 질문: %s | ||
|
|
||
| 쿼리 타입: | ||
| """; | ||
|
|
||
| private static final String RESPONSE_GENERATION_PROMPT = """ | ||
| 당신은 KONECT 서비스의 친절한 AI 어시스턴트입니다. | ||
| 아래 정보를 바탕으로 사용자에게 자연스럽고 친절한 한국어로 응답해주세요. | ||
| 이모지를 적절히 사용하여 친근하게 답변해주세요. | ||
| 응답은 간결하게 2-3문장으로 작성해주세요. | ||
|
|
||
| 사용자 질문: %s | ||
| 조회된 데이터: %s | ||
|
|
||
| 응답: | ||
| """; | ||
|
|
||
| private final GeminiProperties geminiProperties; | ||
| private VertexAI vertexAI; | ||
| private GenerativeModel generativeModel; | ||
|
|
||
| public GeminiClient(GeminiProperties geminiProperties) { | ||
| this.geminiProperties = geminiProperties; | ||
| } | ||
|
Comment on lines
+58
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "GeminiClient.java" -o -name "GeminiProperties.java" | head -20Repository: BCSDLab/KONECT_BACK_END Length of output: 226 🏁 Script executed: cat -n src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 6485 🏁 Script executed: find . -name "GeminiProperties.java" -type fRepository: BCSDLab/KONECT_BACK_END Length of output: 148 🏁 Script executed: cat -n src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 450 🌐 Web query:
💡 Result: Vertex AI’s Java client library ( Recommended: ADC (no code changes)
gcloud init
gcloud auth application-default loginThe Vertex AI Java client will pick these up automatically via ADC. [1] Service account key (local/dev) – via env varexport GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"Then in Java you can just create the client normally (it uses ADC). [1] Service account key – explicitly in Java (initialization)Use the import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.vertexai.VertexAI;
import java.io.FileInputStream;
import java.util.List;
GoogleCredentials creds =
GoogleCredentials.fromStream(new FileInputStream("/path/to/service-account.json"))
.createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));
try (VertexAI vertexAi =
new VertexAI.Builder()
.setProjectId("YOUR_PROJECT_ID")
.setLocation("us-central1")
.setCredentials(creds)
.build()) {
// use vertexAi (e.g., new GenerativeModel(..., vertexAi))
}If you specifically need “API key auth”: that pattern is generally for other Google APIs; Vertex AI’s documented programmatic auth paths are ADC / gcloud credentials (OAuth2), not API keys. [1] Sources: [1] Vertex AI authentication guide (ADC / gcloud / REST) https://docs.cloud.google.com/vertex-ai/docs/authentication, [2] 🌐 Web query:
💡 Result: In the Vertex AI Java SDK ( Option A (most common):
|
||
|
|
||
| @PostConstruct | ||
| public void init() { | ||
| this.vertexAI = new VertexAI( | ||
| geminiProperties.projectId(), | ||
| geminiProperties.location() | ||
| ); | ||
| this.generativeModel = new GenerativeModel(geminiProperties.model(), vertexAI); | ||
| log.info("GeminiClient 초기화 완료: project={}, location={}, model={}", | ||
| geminiProperties.projectId(), | ||
| geminiProperties.location(), | ||
| geminiProperties.model() | ||
| ); | ||
| } | ||
|
|
||
| @PreDestroy | ||
| public void destroy() { | ||
| if (vertexAI != null) { | ||
| try { | ||
| vertexAI.close(); | ||
| log.info("GeminiClient 리소스 해제 완료"); | ||
| } catch (Exception e) { | ||
| log.warn("GeminiClient 리소스 해제 중 오류", e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public String analyzeIntent(String userQuery) { | ||
| String prompt = String.format(INTENT_ANALYSIS_PROMPT, userQuery); | ||
| String result = callGemini(prompt); | ||
| if (result == null) { | ||
| return "UNKNOWN"; | ||
| } | ||
| return result.trim().toUpperCase(); | ||
| } | ||
|
|
||
| public String generateResponse(String userQuery, String data) { | ||
| String prompt = String.format(RESPONSE_GENERATION_PROMPT, userQuery, data); | ||
| String result = callGemini(prompt); | ||
| return result != null ? result : "응답을 생성할 수 없습니다."; | ||
| } | ||
|
|
||
| private String callGemini(String prompt) { | ||
| if (generativeModel == null) { | ||
| log.error("GenerativeModel이 초기화되지 않았습니다."); | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| GenerateContentResponse response = generativeModel.generateContent(prompt); | ||
| return ResponseHandler.getText(response); | ||
|
Comment on lines
+107
to
+115
|
||
| } catch (IOException e) { | ||
| log.error("Gemini API 호출 실패", e); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,12 @@ | ||||||||||||||||||||||||||||||||||||||
| package gg.agit.konect.infrastructure.gemini.config; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @ConfigurationProperties(prefix = "gemini") | ||||||||||||||||||||||||||||||||||||||
| public record GeminiProperties( | ||||||||||||||||||||||||||||||||||||||
| String projectId, | ||||||||||||||||||||||||||||||||||||||
| String location, | ||||||||||||||||||||||||||||||||||||||
| String model | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "GeminiProperties.java" 2>/dev/nullRepository: BCSDLab/KONECT_BACK_END Length of output: 148 🏁 Script executed: find . -type f -name "*.java" -path "*gemini*config*" 2>/dev/nullRepository: BCSDLab/KONECT_BACK_END Length of output: 148 🏁 Script executed: cd src/main/java && find . -type f -name "*Properties.java" | head -20Repository: BCSDLab/KONECT_BACK_END Length of output: 615 🏁 Script executed: cat -n src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 450 🏁 Script executed: cat -n src/main/java/gg/agit/konect/global/config/SecurityProperties.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 578 🏁 Script executed: cat -n src/main/java/gg/agit/konect/infrastructure/oauth/AppleOAuthProperties.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 736 🏁 Script executed: grep -r "GeminiProperties" src/main/java --include="*.java" -B 2 -A 2Repository: BCSDLab/KONECT_BACK_END Length of output: 1855 🏁 Script executed: grep -r "@Validated" src/main/java --include="*.java"Repository: BCSDLab/KONECT_BACK_END Length of output: 330 🏁 Script executed: grep -r "@NotBlank\|@NotNull\|@NotEmpty" src/main/java --include="*.java" | head -10Repository: BCSDLab/KONECT_BACK_END Length of output: 1323 🏁 Script executed: cat pom.xml | grep -A 5 "spring.boot.version\|<version>" | head -20Repository: BCSDLab/KONECT_BACK_END Length of output: 106 🏁 Script executed: find . -name "pom.xml" -o -name "build.gradle" | head -5Repository: BCSDLab/KONECT_BACK_END Length of output: 81 🏁 Script executed: cat -n src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.javaRepository: BCSDLab/KONECT_BACK_END Length of output: 6485 🏁 Script executed: cat build.gradle | grep -i "spring" | head -20Repository: BCSDLab/KONECT_BACK_END Length of output: 1004 🏁 Script executed: grep -r "@Validated" src/main/java --include="*.java" -B 3 -A 1 | head -40Repository: BCSDLab/KONECT_BACK_END Length of output: 1609 [LEVEL: medium] Gemini 설정값의 필수 검증이 없습니다.
프로퍼티 바인딩 단계에서 누락을 감지하도록 최소 수정 예시 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;
+@Validated
`@ConfigurationProperties`(prefix = "gemini")
public record GeminiProperties(
- String apiKey,
- String projectId,
- String location,
- String model
+ `@NotBlank` String apiKey,
+ `@NotBlank` String projectId,
+ `@NotBlank` String location,
+ `@NotBlank` String model
) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+10
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package gg.agit.konect.infrastructure.slack.ai; | ||
|
|
||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
|
|
||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import gg.agit.konect.infrastructure.gemini.client.GeminiClient; | ||
| import gg.agit.konect.infrastructure.slack.client.SlackClient; | ||
| import gg.agit.konect.infrastructure.slack.config.SlackProperties; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class SlackAIService { | ||
|
|
||
| private static final Pattern AI_PREFIX_PATTERN = Pattern.compile("^[Aa][Ii]\\)\\s*(.+)$"); | ||
| private static final Pattern MENTION_PATTERN = Pattern.compile("^<@[^>]+>\\s*"); | ||
| private static final String UNKNOWN = "UNKNOWN"; | ||
|
|
||
| private final GeminiClient geminiClient; | ||
| private final StatisticsQueryExecutor queryExecutor; | ||
| private final SlackClient slackClient; | ||
| private final SlackProperties slackProperties; | ||
|
|
||
| public boolean isAIQuery(String text) { | ||
| if (text == null) { | ||
| return false; | ||
| } | ||
| return AI_PREFIX_PATTERN.matcher(text.trim()).matches(); | ||
| } | ||
|
|
||
| public String extractQuery(String text) { | ||
| Matcher matcher = AI_PREFIX_PATTERN.matcher(text.trim()); | ||
| if (matcher.matches()) { | ||
| return matcher.group(1).trim(); | ||
| } | ||
| return text; | ||
| } | ||
|
|
||
| public String normalizeAppMentionText(String text) { | ||
| if (text == null) { | ||
| return null; | ||
| } | ||
| return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); | ||
| } | ||
|
|
||
| @Async | ||
| public void processAIQuery(String text) { | ||
| try { | ||
| String userQuery = extractQuery(text); | ||
|
|
||
| // 빈 질문은 처리하지 않음 | ||
| if (userQuery == null || userQuery.isBlank()) { | ||
| log.debug("빈 질문으로 처리 중단"); | ||
| String guidanceMessage = formatSlackResponse( | ||
| "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`" | ||
| ); | ||
| slackClient.sendMessage(guidanceMessage, slackProperties.webhooks().event()); | ||
| return; | ||
| } | ||
|
|
||
| log.debug("AI 질문 처리 시작"); | ||
|
|
||
| // 1. Gemini에게 의도 분석 요청 | ||
| String queryType = geminiClient.analyzeIntent(userQuery); | ||
| log.debug("분석된 쿼리 타입: {}", queryType); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| String response; | ||
|
|
||
| // 2. 지원하지 않는 질문인 경우 | ||
| if (UNKNOWN.equals(queryType)) { | ||
| response = generateUnsupportedResponse(userQuery); | ||
| } else { | ||
| // 3. 안전한 통계 쿼리 실행 | ||
| String data = queryExecutor.execute(queryType); | ||
|
|
||
| if (data == null) { | ||
| response = generateUnsupportedResponse(userQuery); | ||
| } else { | ||
| // 4. Gemini에게 자연어 응답 생성 요청 | ||
| response = geminiClient.generateResponse(userQuery, data); | ||
| } | ||
| } | ||
|
|
||
| log.debug("AI 응답 생성 완료"); | ||
|
|
||
| // 5. Slack에 응답 전송 | ||
| String slackMessage = formatSlackResponse(response); | ||
| slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("AI 질문 처리 중 오류 발생", e); | ||
| String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; | ||
| slackClient.sendMessage(errorMessage, slackProperties.webhooks().event()); | ||
| } | ||
| } | ||
|
|
||
| private String generateUnsupportedResponse(String userQuery) { | ||
| return geminiClient.generateResponse( | ||
| userQuery, | ||
| "이 질문은 현재 지원하지 않는 유형입니다. " | ||
| + "사용자 수, 동아리 수, 모집 현황, 동아리원 수 등의 통계 질문을 해주세요." | ||
| ); | ||
| } | ||
|
|
||
| private String formatSlackResponse(String response) { | ||
| return String.format(":robot_face: *AI 응답*\n%s", response); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[LEVEL: low] dotenv-linter 경고를 유발하는 키 순서입니다.
문제:
GEMINI_PROJECT_ID가GEMINI_LOCATION,GEMINI_MODEL보다 먼저 있어UnorderedKey경고가 발생합니다.영향: dotenv-linter를 경고 엄격 모드로 운영하면 파이프라인이 실패할 수 있습니다.
제안:
GEMINI_PROJECT_ID를 두 키 뒤로 이동해 경고를 제거하세요.최소 수정 예시
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 52-52: [UnorderedKey] The GEMINI_LOCATION key should go before the GEMINI_PROJECT_ID key
(UnorderedKey)
[warning] 53-53: [UnorderedKey] The GEMINI_MODEL key should go before the GEMINI_PROJECT_ID key
(UnorderedKey)
🤖 Prompt for AI Agents