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
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,15 @@ private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) {
// 베이스 시나리오 확보
Scenario baseScenario = ensureBaseScenarioExists(baseLine);

// AI 호출 (트랜잭션 외부)
// AI 호출 (트랜잭션 외부) with 타임아웃 (60초)
DecisionScenarioResult aiResult = aiService
.generateDecisionScenario(decisionLine, baseScenario).join();
.generateDecisionScenario(decisionLine, baseScenario)
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.exceptionally(ex -> {
log.error("Decision scenario generation timeout or error for scenario ID: {}", scenarioId, ex);
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "시나리오 생성 시간 초과 (60초)");
})
.join();

return new AiScenarioGenerationResult(aiResult);
}
Expand All @@ -206,8 +212,14 @@ private Scenario ensureBaseScenarioExists(BaseLine baseLine) {
private Scenario createBaseScenario(BaseLine baseLine) {
log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId());

// 1. AI 호출
BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine).join();
// 1. AI 호출 with 타임아웃 (60초)
BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine)
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.exceptionally(ex -> {
log.error("Base scenario generation timeout or error for BaseLine ID: {}", baseLine.getId(), ex);
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (60초)");
})
.join();

// 2. 베이스 시나리오 엔티티 생성
Scenario baseScenario = Scenario.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ private Map<String, Object> createGeminiRequest(String prompt, int maxTokens) {
Map.of("parts", List.of(Map.of("text", prompt)))
),
"generationConfig", Map.of(
"temperature", 0.7,
"topK", 40,
"temperature", 0.8, // 시나리오 생성용 창의성 향상 (0.7 → 0.8)
"topK", 3, // 성능 최적화 (40 → 3, 10-15% 속도 향상)
"topP", 0.95,
"maxOutputTokens", maxTokens // AiRequest의 maxTokens 사용
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* [요약] 베이스 시나리오 생성용 AI 파라미터 바인딩 전용 클래스
* - application.yml 의 ai.baseScenario.* 값을 바인딩한다.
*/
package com.back.global.ai.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "ai.base-scenario")
public class BaseScenarioAiProperties {
private int maxOutputTokens = 1000;

// getters/setters
public int getMaxOutputTokens() { return maxOutputTokens; }
public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* [요약] 결정 시나리오 생성용 AI 파라미터 바인딩 전용 클래스
* - application.yml 의 ai.decisionScenario.* 값을 바인딩한다.
*/
package com.back.global.ai.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "ai.decision-scenario")
public class DecisionScenarioAiProperties {
private int maxOutputTokens = 1200;

// getters/setters
public int getMaxOutputTokens() { return maxOutputTokens; }
public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@

@ConfigurationProperties(prefix = "ai.situation")
public class SituationAiProperties {
private int topK = 5;
private int contextCharLimit = 1000;
private int maxOutputTokens = 384;
private int topK = 3;
private int maxOutputTokens = 128;

// getters/setters
public int getTopK() { return topK; }
public void setTopK(int topK) { this.topK = topK; }
public int getContextCharLimit() { return contextCharLimit; }
public void setContextCharLimit(int contextCharLimit) { this.contextCharLimit = contextCharLimit; }
public int getMaxOutputTokens() { return maxOutputTokens; }
public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
Expand All @@ -11,6 +12,11 @@
*/
@Configuration
@ConfigurationProperties(prefix = "ai.text.gemini")
@EnableConfigurationProperties({
SituationAiProperties.class,
BaseScenarioAiProperties.class,
DecisionScenarioAiProperties.class
})
@Data
public class TextAiConfig {
String apiKey;
Expand Down
173 changes: 60 additions & 113 deletions back/src/main/java/com/back/global/ai/prompt/BaseScenarioPrompt.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,103 +14,62 @@
public class BaseScenarioPrompt {

private static final String PROMPT_TEMPLATE = """
당신은 인생 시나리오 분석 전문가입니다. 사용자의 현재 삶 상황을 기반으로 3년 후 예상 시나리오를 생성해주세요.
당신은 인생 시나리오 분석 전문가입니다. 현재 삶을 기반으로 3년 후 시나리오를 생성하세요.

## 현재 삶 정보
## 사용자 기본 정보
사용자 ID: {userId}
사용자 현재 나이: {currentAge}세
현재 나이: {currentAge}세
현재 연도: {currentYear}년
베이스라인 설명: {baselineDescription}
생년월일: {birthday}
성별: {gender}
MBTI: {mbti}
중요시하는 가치관: {beliefs}

## 사용자 성향 정보(1~10척도)
현재 삶 만족도: {lifeSatis}
현재 관계 만족도: {relationship}
워라밸 중요도: {workLifeBal}
위험 회피 성향: {riskAvoid}

## 현재 삶 정보
베이스라인: {baselineDescription}

## 현재 분기점들
{baseNodes}

## 요구사항
다음 형식으로 JSON 응답을 생성해주세요:

## 요구사항 (JSON 형식)
```json
{
"job": "현재 연도로부터 3년 후 예상 직업 (구체적으로)",
"summary": "현재 연도로부터 3년 후 삶의 한 줄 요약 (50자 내외)",
"description": "현재 연도로부터 3년 후 상세 시나리오 (500-800자, 구체적이고 현실적으로)",
"total": 240~260, // 지표 점수 합계 (유동적으로 조정)
"baselineTitle": "현재 연도로부터 3년 후 상황을 5-8자로 요약한 제목",
"job": "3년 후 예상 직업 (구체적으로)",
"summary": "3년 후 삶의 요약 (50자 내외)",
"description": "3년 후 상세 시나리오 (500-800자)",
"total": 240~260,
"baselineTitle": "3년 후 상황 제목 (5-8자)",
"indicators": [
{
"type": "경제",
"point": 40~60, // 유동적 조정 (평균 50점 기준)
"analysis": "경제적 상황 분석 (200자 내외, 현실적이고 균형잡힌 시각)"
},
{
"type": "행복",
"point": 40~60, // 유동적 조정 (평균 50점 기준)
"analysis": "행복 지수 분석 (200자 내외)"
},
{
"type": "관계",
"point": 40~60, // 유동적 조정 (평균 50점 기준)
"analysis": "인간관계 상황 분석 (200자 내외)"
},
{
"type": "직업",
"point": 40~60, // 유동적 조정 (평균 50점 기준)
"analysis": "직업/커리어 분석 (200자 내외)"
},
{
"type": "건강",
"point": 40~60, // 유동적 조정 (평균 50점 기준)
"analysis": "건강 상태 분석 (200자 내외)"
}
{"type": "경제", "point": 40~60, "analysis": "분석 (200자)"},
{"type": "행복", "point": 40~60, "analysis": "분석 (200자)"},
{"type": "관계", "point": 40~60, "analysis": "분석 (200자)"},
{"type": "직업", "point": 40~60, "analysis": "분석 (200자)"},
{"type": "건강", "point": 40~60, "analysis": "분석 (200자)"}
],
"timelineTitles": {
"{timelineYears}": "BaseNode들에 해당하는 연도별 제목 (5단어 이내)"
"{timelineYears}": "연도별 제목 (5단어 이내)"
}
}
```

## 작성 가이드라인
1. **현실성**: 현재 상황에서 합리적으로 예상 가능한 시나리오 작성
2. **균형성**: 모든 지표를 평균 50점(보통 수준) 기준으로 40~60점 범위에서 유동적 조정하여 현실적 기준선 제공
3. **구체성**: 추상적 표현보다는 구체적이고 실질적인 내용 포함
4. **긍정성**: 부정적이지 않은 현실적 시나리오 (절망적이거나 과도하게 낙관적이지 않게)
5. **연속성**: 타임라인이 논리적으로 연결되도록 구성
6. **개인화**: 제공된 BaseNode 정보를 충분히 반영

## 작성 예시

### 직업 예시
- "중견기업 마케팅팀 과장" (구체적 직급과 부서)
- "프리랜서 그래픽 디자이너" (구체적 직업군)
- "중학교 수학 교사" (구체적 교과목과 급별)
## 작성 규칙
1. 현실적이고 구체적인 시나리오
2. 모든 지표 평균 50점 (40~60점 범위)
3. 균형잡힌 분석 (과도한 긍정/부정 금지)
4. BaseNode 정보 반영

### 요약 예시 (50자 내외)
- "안정적인 직장생활 속에서 가족과의 시간을 소중히 여기는 삶"
- "전문성을 쌓으며 점진적 성장을 추구하는 균형잡힌 일상"
- "현재 위치에서 꾸준히 발전하며 안정감을 추구하는 생활"
## 예시 (간략)
- 직업: "중견기업 마케팅팀 과장"
- 요약: "안정적인 직장생활 속에서 가족과의 시간을 소중히 여기는 삶"
- 경제(50점): "평균적 연봉 수준으로 안정적이나 자산 증식은 제한적입니다."

### 지표별 분석 예시

**경제 (40~60점) 분석 예시**
- "현재 연봉 수준을 유지하며, 적절한 저축으로 안정적인 경제 기반을 구축했습니다. 특별한 투자 수익은 없지만 생활비 걱정 없이 지낼 수 있는 수준입니다."
- "월급쟁이의 평균적 소득 수준으로, 큰 부채는 없으나 특별한 자산 증식도 어려운 상태입니다. 기본 생활은 충족되지만 여유자금은 제한적입니다."

**행복 (40~60점) 분석 예시**
- "일과 개인생활의 균형을 어느 정도 유지하고 있으나, 특별한 성취감이나 만족감은 크지 않습니다. 일상의 소소한 즐거움에서 행복을 찾고 있습니다."
- "현재 상황에 큰 불만은 없지만 특별한 열정이나 목표도 뚜렷하지 않은 상태입니다. 평온하지만 다소 밋밋한 일상을 보내고 있습니다."

**관계 (40~60점) 분석 예시**
- "가족, 친구들과 적당한 거리를 유지하며 관계를 이어가고 있습니다. 깊은 갈등은 없으나 특별히 밀접한 관계도 많지 않은 상태입니다."
- "직장 동료들과는 업무적으로 원만하고, 개인적 인간관계도 무난한 수준을 유지하고 있습니다. 새로운 만남보다는 기존 관계 유지에 중점을 두고 있습니다."

**직업 (40~60점) 분석 예시**
- "현재 직무에서 평균적인 성과를 보이며 안정적으로 업무를 수행하고 있습니다. 큰 승진은 어렵지만 현 위치를 유지하는 데는 문제없는 상황입니다."
- "업무 스킬이 어느 정도 안정화되어 특별한 성장은 없지만 실수나 문제도 없는 무난한 직장생활을 이어가고 있습니다."

**건강 (40~60점) 분석 예시**
- "특별한 질병은 없으나 운동 부족과 업무 스트레스로 체력이 예전만 못한 상태입니다. 기본적인 건강관리는 하고 있지만 적극적이지는 않습니다."
- "정기 건강검진에서 큰 이상은 없으나, 수면 부족과 불규칙한 식습관으로 컨디션 관리가 필요한 시점입니다."

반드시 유효한 JSON 형식으로만 응답해주세요.
반드시 유효한 JSON 형식으로만 응답하세요.
""";

/**
Expand Down Expand Up @@ -166,46 +125,34 @@ public static String generatePrompt(BaseLine baseLine) {
timelineYears.append('"').append(actualYear).append('"').append(": \"제목 (5단어 이내)\"");
}

// 사용자 정보 추출 (null-safe)
var user = baseLine.getUser();
String birthday = user.getBirthdayAt() != null ? user.getBirthdayAt().toLocalDate().toString() : "정보 없음";
String gender = user.getGender() != null ? user.getGender().name() : "정보 없음";
String mbti = user.getMbti() != null ? user.getMbti().name() : "정보 없음";
String beliefs = user.getBeliefs() != null && !user.getBeliefs().trim().isEmpty() ? user.getBeliefs() : "정보 없음";

// 성향 정보 (1-10 척도, null일 수 있음)
String lifeSatis = user.getLifeSatis() != null ? String.valueOf(user.getLifeSatis()) : "미입력";
String relationship = user.getRelationship() != null ? String.valueOf(user.getRelationship()) : "미입력";
String workLifeBal = user.getWorkLifeBal() != null ? String.valueOf(user.getWorkLifeBal()) : "미입력";
String riskAvoid = user.getRiskAvoid() != null ? String.valueOf(user.getRiskAvoid()) : "미입력";

return PROMPT_TEMPLATE
.replace("{userId}", String.valueOf(baseLine.getUser().getId()))
.replace("{userId}", String.valueOf(user.getId()))
.replace("{currentAge}", String.valueOf(userCurrentAge))
.replace("{currentYear}", String.valueOf(currentYear))
.replace("{birthday}", birthday)
.replace("{gender}", gender)
.replace("{mbti}", mbti)
.replace("{lifeSatis}", lifeSatis)
.replace("{relationship}", relationship)
.replace("{workLifeBal}", workLifeBal)
.replace("{riskAvoid}", riskAvoid)
.replace("{beliefs}", beliefs)
.replace("{baselineDescription}",
baseLine.getTitle() != null ? baseLine.getTitle() : "베이스라인 제목 없음")
.replace("{baseNodes}", baseNodesInfo.toString())
.replace("{timelineYears}", timelineYears.toString());
}

/**
* 프롬프트 템플릿에서 사용할 수 있는 변수들을 검증한다.
*
* @param baseLine 검증할 베이스라인
* @return 검증 결과 (true: 유효, false: 무효)
*/
public static boolean validateBaseLine(BaseLine baseLine) {
if (baseLine == null || baseLine.getUser() == null) {
return false;
}

// BaseNode가 없어도 기본 시나리오 생성 가능
return true;
}

/**
* 예상 토큰 수를 계산한다.
* 베이스 프롬프트 + BaseNode 정보를 고려하여 대략적인 토큰 수 반환
*
* @param baseLine 토큰 수 계산할 베이스라인
* @return 예상 토큰 수
*/
public static int estimateTokens(BaseLine baseLine) {
int baseTokens = 800; // 기본 프롬프트 토큰 수

if (baseLine != null && baseLine.getBaseNodes() != null) {
// BaseNode당 약 50토큰 추가 (카테고리 + 제목 + 내용)
baseTokens += baseLine.getBaseNodes().size() * 50;
}

return baseTokens;
}
}
Loading