Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 +51 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

[LEVEL: low] dotenv-linter 경고를 유발하는 키 순서입니다.
문제: GEMINI_PROJECT_IDGEMINI_LOCATION, GEMINI_MODEL보다 먼저 있어 UnorderedKey 경고가 발생합니다.
영향: dotenv-linter를 경고 엄격 모드로 운영하면 파이프라인이 실패할 수 있습니다.
제안: GEMINI_PROJECT_ID를 두 키 뒤로 이동해 경고를 제거하세요.

최소 수정 예시
 GEMINI_API_KEY=your-gemini-api-key
-GEMINI_PROJECT_ID=your-gcp-project-id
 GEMINI_LOCATION=us-central1
 GEMINI_MODEL=gemini-1.5-flash
+GEMINI_PROJECT_ID=your-gcp-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
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 51 - 53, dotenv-linter reports UnorderedKey
because GEMINI_PROJECT_ID appears before GEMINI_LOCATION and GEMINI_MODEL; fix
by moving the GEMINI_PROJECT_ID entry so it appears after GEMINI_LOCATION and
GEMINI_MODEL in the .env example, ensuring the three keys are ordered as
GEMINI_LOCATION, GEMINI_MODEL, GEMINI_PROJECT_ID to satisfy the linter.

Comment on lines +50 to +53
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명에는 GEMINI_MODE 환경변수가 필요하다고 되어 있지만, 실제 예시/설정은 GEMINI_MODEL을 사용하고 있습니다. 문서(PR 설명)와 설정 키 중 하나로 통일해 혼선을 방지해 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +53
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명에는 추가 환경변수로 GEMINI_API_KEY, GEMINI_MODE가 언급되어 있는데, 실제 코드/예시 파일에서는 API Key를 사용하지 않고(VertexAI는 기본 자격증명/서비스 계정 기반) 모델 변수도 GEMINI_MODEL로 정의되어 있습니다. 사용자 혼선을 막기 위해 PR 설명(또는 환경변수 예시)을 현재 구현과 일치하도록 수정해 주세요.

Copilot uses AI. Check for mistakes.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ dependencies {
// notification
implementation 'com.google.firebase:firebase-admin:9.2.0'

// Gemini AI
implementation 'com.google.cloud:google-cloud-vertexai:1.15.0'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,6 @@ long countByClubIdAndPosition(

void deleteByUserId(Integer userId);

@Query("SELECT COUNT(cm) FROM ClubMember cm")
long countAll();
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ default ClubRecruitment getByClubId(Integer clubId) {
return findByClubId(clubId)
.orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_RECRUITMENT));
}

@Query("""
SELECT COUNT(cr)
FROM ClubRecruitment cr
WHERE cr.isAlwaysRecruiting = true
OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP)
""")
long countCurrentlyRecruiting();
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ default Club getById(Integer id) {
List<Club> findAll();

Club save(Club club);

@Query("SELECT COUNT(c) FROM Club c")
long countAll();
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,11 @@ List<User> findUserIdsByUniversityAndStudentYear(
AND u.deletedAt IS NULL
""")
List<User> findAllByIdIn(@Param("ids") List<Integer> ids);

@Query("""
SELECT COUNT(u)
FROM User u
WHERE u.deletedAt IS NULL
""")
long countActiveUsers();
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public final class SecurityPaths {
"/swagger-ui.html",
"/v3/api-docs/**",
"/swagger-resources/**",
"/error"
"/error",
"/slack/events"
};

public static final String[] DENY_PATHS = {};
Expand Down
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "GeminiClient.java" -o -name "GeminiProperties.java" | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 226


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 6485


🏁 Script executed:

find . -name "GeminiProperties.java" -type f

Repository: BCSDLab/KONECT_BACK_END

Length of output: 148


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 450


🌐 Web query:

Google Cloud VertexAI Java API Key authentication initialization

💡 Result:

Vertex AI’s Java client library (google-cloud-vertexai) authenticates with OAuth2 credentials (Application Default Credentials / service accounts), not an API key. Google’s Vertex AI auth guide describes using Application Default Credentials (ADC) for client libraries. [1]

Recommended: ADC (no code changes)

  1. Install / init gcloud, then create ADC:
gcloud init
gcloud auth application-default login

The Vertex AI Java client will pick these up automatically via ADC. [1]

Service account key (local/dev) – via env var

export 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 VertexAI.Builder#setCredentials(Credentials) hook. [2]

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] VertexAI.Builder#setCredentials Java reference https://docs.cloud.google.com/java/docs/reference/google-cloud-vertexai/latest/com.google.cloud.vertexai.VertexAI.Builder


🌐 Web query:

VertexAI Java constructor projectId location authentication method ADC

💡 Result:

In the Vertex AI Java SDK (com.google.cloud:google-cloud-vertexai), you typically pass projectId and location when constructing VertexAI, and rely on Application Default Credentials (ADC) for auth.

Option A (most common): VertexAI + ADC

import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.vertexai.VertexAI;

GoogleCredentials creds = GoogleCredentials.getApplicationDefault();

try (VertexAI vertexAi =
    new VertexAI.Builder()
        .setProjectId("YOUR_PROJECT_ID")
        .setLocation("us-central1")          // e.g. us-central1
        .setCredentials(creds)               // ADC
        .build()) {

  // use vertexAi...
}

VertexAI.Builder supports setProjectId, setLocation, and setCredentials. [1]

Option B: Lower-level API client (Prediction) + ADC

If you’re directly using PredictionServiceClient, configure credentials via settings:

import com.google.auth.oauth2.GoogleCredentials;
import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.cloud.vertexai.api.PredictionServiceClient;
import com.google.cloud.vertexai.api.PredictionServiceSettings;

GoogleCredentials creds = GoogleCredentials.getApplicationDefault();

PredictionServiceSettings settings =
    PredictionServiceSettings.newBuilder()
        .setCredentialsProvider(FixedCredentialsProvider.create(creds))
        .build();

try (PredictionServiceClient client = PredictionServiceClient.create(settings)) {
  // client.predict(...)
}

The Java docs show customizing credentials this way. [2]

Getting ADC locally

On a dev machine, create ADC by running:

gcloud auth application-default login

This stores credentials that Java libraries pick up via GoogleCredentials.getApplicationDefault(). [3]

Sources: VertexAI.Builder API reference [1], PredictionServiceClient API reference [2], Google Cloud ADC setup (Vertex AI auth) [3].


🏁 Script executed:

find . -name "*.md" -o -name "*.yml" -o -name "*.yaml" | xargs grep -l "GEMINI_API_KEY" 2>/dev/null | head -5

Repository: BCSDLab/KONECT_BACK_END

Length of output: 118


🏁 Script executed:

rg "GEMINI_API_KEY" --type md --type yml --type yaml | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 94


🏁 Script executed:

cat -n src/main/resources/application-infrastructure.yml | grep -A 5 -B 5 "GEMINI_API_KEY"

Repository: BCSDLab/KONECT_BACK_END

Length of output: 367


🏁 Script executed:

rg "GEMINI_API_KEY" | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 193


[LEVEL: high] Gemini 클라이언트의 인증 방식이 라이브러리 문서와 불일치합니다.

문제: GeminiPropertiesapiKey를 선언하고 설정에서 GEMINI_API_KEY 환경변수를 필수로 지정했으나, Google Cloud VertexAI Java SDK는 API Key 인증을 지원하지 않으며 오직 ADC(Application Default Credentials, OAuth2 서비스 계정)만 사용합니다. 따라서 apiKey는 선언만 되고 코드에서 전혀 사용되지 않으며, 실제 인증은 GOOGLE_APPLICATION_CREDENTIALS 환경변수나 gcloud 설정에 의존합니다.

영향: ADC가 설정되지 않은 환경에서 VertexAI 초기화 시 인증 예외가 발생하여 callGemini가 IOException을 catch하고 "UNSUPPORTED"를 반환하므로, AI 기반 기능이 지속적으로 장애 상태가 됩니다(재현: GOOGLE_APPLICATION_CREDENTIALS 미설정 상태에서 AI 요청 전송).

제안: (1) GeminiProperties에서 apiKey 필드를 제거하고, (2) application-infrastructure.yml.env.exampleGEMINI_API_KEY 설정을 제거한 후, (3) 환경 설정 문서에서 ADC 설정 방법(예: gcloud auth application-default login 또는 GOOGLE_APPLICATION_CREDENTIALS 환경변수)을 명시하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java`
around lines 135 - 139, GeminiProperties declares an unused apiKey and the code
relies on ADC (OAuth2 service account) rather than API key; remove the apiKey
field from GeminiProperties and any references to it in GeminiClient (including
constructor usage or validation), delete GEMINI_API_KEY entries from
application-infrastructure.yml and .env.example, and update documentation to
instruct using Application Default Credentials (e.g., gcloud auth
application-default login or setting GOOGLE_APPLICATION_CREDENTIALS) for Vertex
AI; also ensure callGemini/GeminiClient initialization fails with a clear error
when ADC is not available instead of silently returning "UNSUPPORTED".


@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
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callGemini()가 호출될 때마다 VertexAI와 GenerativeModel을 매번 생성/종료하고 있어 요청량이 늘면 연결/초기화 오버헤드가 커질 수 있습니다. VertexAI/GenerativeModel을 싱글톤 빈으로 재사용하거나, 최소한 클라이언트 생성 비용을 줄이는 형태로 리팩터링해 주세요.

Copilot uses AI. Check for mistakes.
} 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "GeminiProperties.java" 2>/dev/null

Repository: BCSDLab/KONECT_BACK_END

Length of output: 148


🏁 Script executed:

find . -type f -name "*.java" -path "*gemini*config*" 2>/dev/null

Repository: BCSDLab/KONECT_BACK_END

Length of output: 148


🏁 Script executed:

cd src/main/java && find . -type f -name "*Properties.java" | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 615


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 450


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/global/config/SecurityProperties.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 578


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/infrastructure/oauth/AppleOAuthProperties.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 736


🏁 Script executed:

grep -r "GeminiProperties" src/main/java --include="*.java" -B 2 -A 2

Repository: 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 -10

Repository: BCSDLab/KONECT_BACK_END

Length of output: 1323


🏁 Script executed:

cat pom.xml | grep -A 5 "spring.boot.version\|<version>" | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 106


🏁 Script executed:

find . -name "pom.xml" -o -name "build.gradle" | head -5

Repository: BCSDLab/KONECT_BACK_END

Length of output: 81


🏁 Script executed:

cat -n src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java

Repository: BCSDLab/KONECT_BACK_END

Length of output: 6485


🏁 Script executed:

cat build.gradle | grep -i "spring" | head -20

Repository: BCSDLab/KONECT_BACK_END

Length of output: 1004


🏁 Script executed:

grep -r "@Validated" src/main/java --include="*.java" -B 3 -A 1 | head -40

Repository: BCSDLab/KONECT_BACK_END

Length of output: 1609


[LEVEL: medium] Gemini 설정값의 필수 검증이 없습니다.

@ConfigurationProperties 바인딩만 있고 검증 애노테이션이 없어, 설정 파일에 값이 누락되어도 null로 바인딩되어 런타임까지 지연됩니다. GeminiClient의 projectId(), location(), model() 호출 시점에 NullPointerException이 발생할 수 있습니다.

프로퍼티 바인딩 단계에서 누락을 감지하도록 @Validated@NotBlank를 추가하세요.

최소 수정 예시
 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ConfigurationProperties(prefix = "gemini")
public record GeminiProperties(
String apiKey,
String projectId,
String location,
String model
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(
`@NotBlank` String apiKey,
`@NotBlank` String projectId,
`@NotBlank` String location,
`@NotBlank` String model
) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java`
around lines 5 - 10, GeminiProperties currently lacks validation so missing
config values bind as null; update the record to enable validation by annotating
the type with `@Validated` and add `@NotBlank` to required components (apiKey,
projectId, location, model) on the GeminiProperties record so Spring validates
at bind time (use javax.validation.constraints.NotBlank and
org.springframework.validation.annotation.Validated). Ensure the annotations are
placed on the record declaration and on each record component (String apiKey,
String projectId, String location, String model) so projectId(), location(),
model(), and apiKey() cannot be empty.

) {
Comment on lines +5 to +10
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GeminiProperties에 apiKey가 포함되어 있고 환경변수(GEMINI_API_KEY)도 추가됐지만, GeminiClient에서는 apiKey를 전혀 사용하지 않습니다. 실제 인증이 ADC/서비스 계정 기반이라면 apiKey 설정을 제거하고 문서를 정리하거나, 반대로 API Key 기반 호출이 목표라면 apiKey를 실제 호출 경로에 반영해 설정-구현 불일치를 해소해 주세요.

Copilot uses AI. Check for mistakes.

}
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);

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);
}
}
Loading