Skip to content

Commit 80df046

Browse files
[FIX]: 시나리오생성디버그 (#126)
* [FIX]: 시나리오생성디버그 * [Refactor] 마이너변경
1 parent c44a261 commit 80df046

File tree

17 files changed

+374
-175
lines changed

17 files changed

+374
-175
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ uploads/
2222

2323
# 테스트 이미지 경로 (LocalStorageServiceTest)
2424
test-uploads/
25-
./test-uploads/
25+
./test-uploads/
26+
27+
### Environment Variables ###
28+
.env

back/.gitignore

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ out/
3939
### Environment Variables ###
4040
.env
4141

42-
# AI 생성 이미지 저장 경로
43-
uploads/
44-
./uploads/
45-
4642
# 테스트 이미지 경로 (LocalStorageServiceTest)
4743
test-uploads/
4844
./test-uploads/

back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public interface ScenarioRepository extends JpaRepository<Scenario, Long> {
2323
@Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.id = :id AND s.user.id = :userId")
2424
Optional<Scenario> findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);
2525

26+
// 시나리오 상태 폴링용 조회 (권한 검증 포함, 최소 데이터만 로딩)
27+
@Query("SELECT s FROM Scenario s WHERE s.id = :id AND s.user.id = :userId")
28+
Optional<Scenario> findByIdAndUserIdForStatusCheck(@Param("id") Long id, @Param("userId") Long userId);
29+
2630
// 베이스 시나리오 조회
2731
Optional<Scenario> findByBaseLineIdAndDecisionLineIsNull(Long baseLineId);
2832

@@ -33,6 +37,22 @@ public interface ScenarioRepository extends JpaRepository<Scenario, Long> {
3337
@Query("SELECT s FROM Scenario s WHERE s.decisionLine.id = :decisionLineId")
3438
Optional<Scenario> findByDecisionLineId(@Param("decisionLineId") Long decisionLineId);
3539

40+
// AI 생성용 시나리오 조회 - 1단계: DecisionLine + DecisionNodes
41+
@Query("SELECT DISTINCT s FROM Scenario s " +
42+
"LEFT JOIN FETCH s.decisionLine dl " +
43+
"LEFT JOIN FETCH dl.decisionNodes dn " +
44+
"WHERE s.id = :id")
45+
Optional<Scenario> findByIdWithDecisionNodes(@Param("id") Long id);
46+
47+
// AI 생성용 시나리오 조회 - 2단계: BaseLine + BaseNodes + User
48+
@Query("SELECT DISTINCT s FROM Scenario s " +
49+
"LEFT JOIN FETCH s.decisionLine dl " +
50+
"LEFT JOIN FETCH dl.baseLine bl " +
51+
"LEFT JOIN FETCH bl.baseNodes bn " +
52+
"LEFT JOIN FETCH bl.user " +
53+
"WHERE s.id = :id")
54+
Optional<Scenario> findByIdWithDecisionLineAndBaseLine(@Param("id") Long id);
55+
3656
// DecisionLine 기반 시나리오 조회 (권한 검증 포함, baseLine.scenarios fetch)
3757
@Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.decisionLine.id = :decisionLineId AND s.user.id = :userId")
3858
Optional<Scenario> findByDecisionLineIdAndUserId(

back/src/main/java/com/back/domain/scenario/service/ScenarioService.java

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ private ScenarioValidationResult validateScenarioCreation(
108108
ScenarioCreateRequest request,
109109
@Nullable DecisionNodeNextRequest lastDecision) {
110110

111-
// DecisionLine 존재 여부 확인
112-
DecisionLine decisionLine = decisionLineRepository.findById(request.decisionLineId())
111+
// DecisionLine 존재 여부 확인 (User EAGER 로딩)
112+
DecisionLine decisionLine = decisionLineRepository.findWithUserById(request.decisionLineId())
113113
.orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND));
114114

115115
// 권한 검증
@@ -312,17 +312,20 @@ public void processScenarioGenerationAsync(Long scenarioId) {
312312
// 1. 상태를 PROCESSING으로 업데이트 (별도 트랜잭션)
313313
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null);
314314

315-
// 2. AI 시나리오 생성 (트랜잭션 외부에서 실행)
316-
AiScenarioGenerationResult result = executeAiGeneration(scenarioId);
315+
// 2. AI 생성에 필요한 모든 데이터를 트랜잭션 내에서 미리 로드
316+
Scenario scenarioWithData = scenarioTransactionService.prepareScenarioData(scenarioId);
317317

318-
// 3. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션)
318+
// 3. AI 시나리오 생성 (트랜잭션 외부에서 실행)
319+
AiScenarioGenerationResult result = executeAiGeneration(scenarioWithData);
320+
321+
// 4. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션)
319322
scenarioTransactionService.saveAiResult(scenarioId, result);
320323
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null);
321324

322325
log.info("Scenario generation completed successfully for ID: {}", scenarioId);
323326

324327
} catch (Exception e) {
325-
// 4. 실패 상태 업데이트 (별도 트랜잭션)
328+
// 5. 실패 상태 업데이트 (별도 트랜잭션)
326329
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.FAILED,
327330
"시나리오 생성 실패: " + e.getMessage());
328331
log.error("Scenario generation failed for ID: {}, error: {}",
@@ -331,11 +334,8 @@ public void processScenarioGenerationAsync(Long scenarioId) {
331334
}
332335

333336
// AI 호출 전용 메서드 (트랜잭션 없음)
334-
private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) {
335-
Scenario scenario = scenarioRepository.findById(scenarioId)
336-
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));
337-
338-
// AI 호출 로직 (트랜잭션 외부에서 실행)
337+
private AiScenarioGenerationResult executeAiGeneration(Scenario scenario) {
338+
// AI 호출 로직 (미리 로드된 데이터 사용)
339339
DecisionLine decisionLine = scenario.getDecisionLine();
340340
BaseLine baseLine = decisionLine.getBaseLine();
341341

@@ -347,7 +347,7 @@ private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) {
347347
.generateDecisionScenario(decisionLine, baseScenario)
348348
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
349349
.exceptionally(ex -> {
350-
log.error("Decision scenario generation timeout or error for scenario ID: {}", scenarioId, ex);
350+
log.error("Decision scenario generation timeout or error for scenario ID: {}", scenario.getId(), ex);
351351
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "시나리오 생성 시간 초과 (60초)");
352352
})
353353
.join();
@@ -365,12 +365,12 @@ private Scenario ensureBaseScenarioExists(BaseLine baseLine) {
365365
private Scenario createBaseScenario(BaseLine baseLine) {
366366
log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId());
367367

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

@@ -394,7 +394,7 @@ private Scenario createBaseScenario(BaseLine baseLine) {
394394
@Transactional(readOnly = true)
395395
public ScenarioStatusResponse getScenarioStatus(Long scenarioId, Long userId) {
396396
// 권한 검증 및 조회
397-
Scenario scenario = scenarioRepository.findByIdAndUserId(scenarioId, userId)
397+
Scenario scenario = scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)
398398
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));
399399

400400
// DTO 변환 및 반환

back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.back.domain.scenario.repository.SceneTypeRepository;
1010
import com.back.global.ai.dto.result.BaseScenarioResult;
1111
import com.back.global.ai.dto.result.DecisionScenarioResult;
12+
import com.back.global.ai.exception.AiParsingException;
1213
import com.back.global.ai.service.AiService;
1314
import com.back.global.exception.ApiException;
1415
import com.back.global.exception.ErrorCode;
@@ -44,6 +45,7 @@ public class ScenarioTransactionService {
4445
private final BaseLineRepository baseLineRepository;
4546
private final AiService aiService;
4647
private final ObjectMapper objectMapper;
48+
private final com.back.global.ai.config.ImageAiConfig imageAiConfig;
4749

4850
// 상태 업데이트 전용 트랜잭션 메서드
4951
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -138,7 +140,7 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) {
138140
try {
139141
if (imagePrompt != null && !imagePrompt.trim().isEmpty()) {
140142
String imageUrl = aiService.generateImage(imagePrompt)
141-
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
143+
.orTimeout(imageAiConfig.getTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS)
142144
.exceptionally(ex -> {
143145
log.warn("Image generation timeout or error for scenario {}: {}",
144146
scenario.getId(), ex.getMessage());
@@ -164,24 +166,30 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) {
164166
}
165167

166168
private int calculateTotalScore(Map<Type, Integer> indicatorScores) {
169+
if (indicatorScores == null || indicatorScores.isEmpty()) {
170+
log.warn("indicatorScores is null or empty, returning 0");
171+
return 0;
172+
}
167173
return indicatorScores.values().stream()
168174
.mapToInt(Integer::intValue)
169175
.sum();
170176
}
171177

172178
private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult) {
173-
List<SceneType> sceneTypes = aiResult.indicatorScores()
174-
.entrySet().stream()
175-
.map(entry -> {
176-
Type type = entry.getKey();
177-
int point = entry.getValue();
178-
String analysis = aiResult.indicatorAnalysis().get(type);
179+
if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) {
180+
log.warn("No indicator scores available for scenario {}", scenario.getId());
181+
return;
182+
}
183+
184+
List<SceneType> sceneTypes = aiResult.indicators().stream()
185+
.map(indicator -> {
186+
Type type = Type.valueOf(indicator.type());
179187

180188
return com.back.domain.scenario.entity.SceneType.builder()
181189
.scenario(scenario)
182190
.type(type)
183-
.point(point) // AI가 분석한 실제 점수
184-
.analysis(analysis != null ? analysis : "현재 " + type.name() + " 상황 분석")
191+
.point(indicator.point()) // AI가 분석한 실제 점수
192+
.analysis(indicator.analysis() != null ? indicator.analysis() : "현재 " + type.name() + " 상황 분석")
185193
.build();
186194
})
187195
.toList();
@@ -190,18 +198,19 @@ private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult
190198
}
191199

192200
private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult aiResult) {
193-
List<com.back.domain.scenario.entity.SceneType> sceneTypes = aiResult.indicatorScores()
194-
.entrySet().stream()
195-
.map(entry -> {
196-
Type type = entry.getKey();
197-
int point = entry.getValue();
198-
String analysis = aiResult.indicatorAnalysis().get(type);
201+
if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) {
202+
throw new AiParsingException("Indicator scores are missing in AI response for scenario " + scenario.getId());
203+
}
204+
205+
List<com.back.domain.scenario.entity.SceneType> sceneTypes = aiResult.indicators().stream()
206+
.map(indicator -> {
207+
Type type = Type.valueOf(indicator.type());
199208

200209
return com.back.domain.scenario.entity.SceneType.builder()
201210
.scenario(scenario)
202211
.type(type)
203-
.point(point)
204-
.analysis(analysis != null ? analysis : "분석 정보를 가져올 수 없습니다.")
212+
.point(indicator.point())
213+
.analysis(indicator.analysis() != null ? indicator.analysis() : "분석 정보를 가져올 수 없습니다.")
205214
.build();
206215
})
207216
.toList();
@@ -210,10 +219,12 @@ private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult
210219
}
211220

212221
private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResult) {
213-
List<SceneCompare> compares = aiResult.comparisonResults()
214-
.entrySet().stream()
215-
.map(entry -> {
216-
String resultTypeStr = entry.getKey().toUpperCase();
222+
if (aiResult.comparisons() == null || aiResult.comparisons().isEmpty()) {
223+
throw new AiParsingException("Comparison results are missing in AI response for scenario " + scenario.getId());
224+
}
225+
List<SceneCompare> compares = aiResult.comparisons().stream()
226+
.map(comparison -> {
227+
String resultTypeStr = comparison.type().toUpperCase();
217228
com.back.domain.scenario.entity.SceneCompareResultType resultType;
218229
try {
219230
resultType = com.back.domain.scenario.entity.SceneCompareResultType.valueOf(resultTypeStr);
@@ -225,7 +236,7 @@ private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResu
225236
return SceneCompare.builder()
226237
.scenario(scenario)
227238
.resultType(resultType)
228-
.compareResult(entry.getValue())
239+
.compareResult(comparison.analysis())
229240
.build();
230241
})
231242
.toList();
@@ -240,4 +251,21 @@ private void updateBaseLineTitle(BaseLine baseLine, String baselineTitle) {
240251
log.info("BaseLine title updated for ID: {}", baseLine.getId());
241252
}
242253
}
254+
255+
@Transactional(readOnly = true)
256+
public Scenario prepareScenarioData(Long scenarioId) {
257+
// MultipleBagFetchException을 피하기 위해 별도 쿼리로 각 컬렉션을 초기화
258+
// 1. DecisionNodes 초기화
259+
Scenario scenario = scenarioRepository.findById(scenarioId)
260+
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));
261+
scenario.getDecisionLine().getDecisionNodes().size(); // 프록시 초기화
262+
263+
// 2. BaseNodes 초기화
264+
scenario.getBaseLine().getBaseNodes().size(); // 프록시 초기화
265+
266+
// 3. User 엔티티 초기화 (새로운 오류 방지)
267+
scenario.getUser().getMbti(); // User 프록시 초기화
268+
269+
return scenario;
270+
}
243271
}

back/src/main/java/com/back/global/ai/client/image/StableDiffusionImageClient.java

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,31 +118,42 @@ private Mono<String> extractImageData(String response) {
118118
try {
119119
JsonNode rootNode = objectMapper.readTree(response);
120120

121-
// Stability AI 공식 응답 구조: { "artifacts": [{ "base64": "..." }] }
122-
if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray()) {
121+
// 1. 단순 응답 구조 체크: {"image": "..."}
122+
if (rootNode.has("image")) {
123+
String base64Data = rootNode.get("image").asText();
124+
if (base64Data != null && !base64Data.isEmpty()) {
125+
log.info("Image generated successfully (simple structure). Base64 length: {}", base64Data.length());
126+
return Mono.just(base64Data);
127+
}
128+
}
129+
130+
// 2. 공식 응답 구조 체크: { "artifacts": [{...}] }
131+
if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray() && !rootNode.get("artifacts").isEmpty()) {
123132
JsonNode firstArtifact = rootNode.get("artifacts").get(0);
124133

125-
// finishReason 검증
126-
if (firstArtifact.has("finishReason")) {
127-
String finishReason = firstArtifact.get("finishReason").asText();
128-
if (!"SUCCESS".equals(finishReason)) {
129-
log.error("Image generation failed with reason: {}", finishReason);
130-
return Mono.error(new AiServiceException(
131-
ErrorCode.AI_GENERATION_FAILED,
132-
"Image generation failed: " + finishReason
133-
));
134+
if (firstArtifact != null) {
135+
// finishReason 검증
136+
if (firstArtifact.has("finishReason")) {
137+
String finishReason = firstArtifact.get("finishReason").asText();
138+
if (!"SUCCESS".equals(finishReason)) {
139+
log.error("Image generation failed with reason: {}", finishReason);
140+
return Mono.error(new AiServiceException(
141+
ErrorCode.AI_GENERATION_FAILED,
142+
"Image generation failed: " + finishReason
143+
));
144+
}
134145
}
135-
}
136146

137-
// Base64 데이터 추출
138-
if (firstArtifact.has("base64")) {
139-
String base64Data = firstArtifact.get("base64").asText();
140-
log.info("Image generated successfully. Base64 length: {}", base64Data.length());
141-
return Mono.just(base64Data);
147+
// Base64 데이터 추출
148+
if (firstArtifact.has("base64")) {
149+
String base64Data = firstArtifact.get("base64").asText();
150+
log.info("Image generated successfully (artifacts structure). Base64 length: {}", base64Data.length());
151+
return Mono.just(base64Data);
152+
}
142153
}
143154
}
144155

145-
// 응답 구조가 예상과 다를 경우
156+
// 두 구조 모두 해당하지 않을 경우
146157
log.error("Unexpected Stable Diffusion API response structure: {}", response);
147158
return Mono.error(new AiServiceException(
148159
ErrorCode.AI_GENERATION_FAILED,

0 commit comments

Comments
 (0)