3434@ RequiredArgsConstructor
3535@ Transactional
3636public class IssueListServiceImpl implements IssueListService {
37+ public record DifficultyResult (Tag tag , double score ) {}
38+
3739 private static final double SCORE_THRESHOLD = 0.4 ;
3840
3941 private final IssueListRepository issueListRepository ;
@@ -78,23 +80,34 @@ private List<CompletableFuture<IssueListResponseDTO.IssueResponseDTO>> classifyB
7880 // 1) Redis 캐시 및 DB 검사
7981 for (int i = 0 ; i < batch .size (); i ++) {
8082 IssueListRequestDTO .IssueRequestDTO dto = batch .get (i );
81- String key = "issue:" + dto .getIssueId ();
83+ String issueId = dto .getIssueId ();
84+ String key = "issue:" + issueId ;
8285 String cached = redisTemplate .opsForValue ().get (key );
8386 if (cached != null ) {
8487 Tag tag = Tag .valueOf (cached .toUpperCase ());
8588 // 즉시 completedFuture 리턴
8689 futures .add (
8790 CompletableFuture .completedFuture (
88- issueListConverter .toResponseDTO (dto .getIssueId (), tag )
91+ IssueListResponseDTO .IssueResponseDTO .builder ()
92+ .issueId (issueId )
93+ .difficulty (tag .toLowerCase ())
94+ .score (1.0 )
95+ .build ()
8996 )
9097 );
9198 continue ;
9299 }
93100 // DB 체크
94- IssueList existingIssue = issueListRepository .findById (dto . getIssueId () ).orElse (null );
101+ IssueList existingIssue = issueListRepository .findById (issueId ).orElse (null );
95102 if (existingIssue != null ) {
103+ Tag tag = existingIssue .getDifficulty ();
96104 futures .add (CompletableFuture .completedFuture (
97- issueListConverter .toResponseDTO (dto .getIssueId (), existingIssue .getDifficulty ())));
105+ IssueListResponseDTO .IssueResponseDTO .builder ()
106+ .issueId (issueId )
107+ .difficulty (tag .toLowerCase ())
108+ .score (1.0 )
109+ .build ()
110+ ));
98111 continue ;
99112 }
100113 toClassify .add (dto );
@@ -107,32 +120,33 @@ private List<CompletableFuture<IssueListResponseDTO.IssueResponseDTO>> classifyB
107120 List <CompletableFuture <IssueClassificationResult >> classificationFutures = new ArrayList <>();
108121
109122 for (IssueListRequestDTO .IssueRequestDTO dto : toClassify ) {
123+ String issueId = dto .getIssueId ();
124+ String key = "issue:" + issueId ;
110125 // GitHub API 호출 (제목/본문)
111126 CompletableFuture <String []> gitHubFuture = CompletableFuture .supplyAsync (
112- () -> extractTitleAndBody (dto . getIssueId () ),
127+ () -> extractTitleAndBody (issueId ),
113128 classifierExecutor
114129 );
115130
116131 // GitHub 결과를 받은 뒤 AI 분류
117- CompletableFuture <Tag > aiFuture = gitHubFuture .thenComposeAsync (parts -> {
118- String title = parts [0 ];
119- String body = parts [1 ];
120- return classifyWithAI (title , body );
121- }, classifierExecutor );
122-
123- // AI 분류 결과를 받은 뒤 캐시 저장/DB 저장 + DTO 변환
124- CompletableFuture <IssueClassificationResult > resultFuture = aiFuture .thenApplyAsync (tag -> {
125- // 캐시 저장
126- String key = "issue:" + dto .getIssueId ();
127- cacheDifficulty (key , tag );
128- // DB 저장
129- persistDifficulty (dto , tag );
130- // 응답 DTO 생성
131- IssueListResponseDTO .IssueResponseDTO responseDTO =
132- issueListConverter .toResponseDTO (dto .getIssueId (), tag );
133-
134- return new IssueClassificationResult (dto .getIssueId (), responseDTO );
135- }, classifierExecutor );
132+ CompletableFuture <DifficultyResult > diffFuture = gitHubFuture
133+ .thenComposeAsync (parts -> classifyWithAI (parts [0 ], parts [1 ]), classifierExecutor );
134+
135+ CompletableFuture <IssueClassificationResult > resultFuture = diffFuture
136+ .thenApplyAsync (diffRes -> {
137+ // 캐시/DB 저장
138+ cacheDifficulty (key , diffRes .tag ());
139+ persistDifficulty (dto , diffRes .tag ());
140+
141+ // score 포함 DTO 생성
142+ IssueListResponseDTO .IssueResponseDTO responseDTO =
143+ IssueListResponseDTO .IssueResponseDTO .builder ()
144+ .issueId (issueId )
145+ .difficulty (diffRes .tag ().toLowerCase ())
146+ .score (diffRes .score ())
147+ .build ();
148+ return new IssueClassificationResult (issueId , responseDTO );
149+ }, classifierExecutor );
136150
137151 classificationFutures .add (resultFuture );
138152 }
@@ -162,7 +176,12 @@ private List<CompletableFuture<IssueListResponseDTO.IssueResponseDTO>> classifyB
162176 }
163177 }
164178 // 실패 시 기본값 반환
165- return issueListConverter .toResponseDTO (toClassify .get (finalIdx ).getIssueId (), Tag .MISC );
179+ String issueId = toClassify .get (finalIdx ).getIssueId ();
180+ return IssueListResponseDTO .IssueResponseDTO .builder ()
181+ .issueId (issueId )
182+ .difficulty (Tag .MISC .toLowerCase ())
183+ .score (1.0 )
184+ .build ();
166185 }, classifierExecutor );
167186
168187 futures .add (batchIndex , wrapperFuture );
@@ -193,41 +212,48 @@ private String[] extractTitleAndBody(String issueUrl) {
193212 * AI 서버에 단건 분류 요청을 보내고, JSON 문자열을 Tag로 파싱하여 CompletableFuture<Tag>로 반환합니다.
194213 * 내부적으로 WebClient Mono → toFuture()로 변환되며, 파싱 단계는 classifierExecutor에서 실행됩니다.
195214 */
196- public CompletableFuture <Tag > classifyWithAI (String title , String body ) {
215+ public CompletableFuture <DifficultyResult > classifyWithAI (String title , String body ) {
197216 long start = System .currentTimeMillis ();
198217
199218 return aiClassifierClient .classify (title , body )
200- .timeout (Duration .ofSeconds (20 ))
201- // 1) 네트워크/타임아웃 에러
219+ .timeout (Duration .ofSeconds (30 ))
202220 .onErrorResume (TimeoutException .class , e -> {
203- log .error ("[{}] AI 호출 타임아웃 ({}ms) — issue: {}" ,
204- "NETWORK" , System .currentTimeMillis () - start , title , e );
205- // fallback JSON
221+ log .error ("[NETWORK] AI 호출 타임아웃: {}ms — {}" , System .currentTimeMillis ()-start , title , e );
206222 return aiClassifierClient .defaultSingleResult ();
207223 })
208224 .onErrorResume (WebClientResponseException .class , e -> {
209- log .error ("[{}] AI HTTP 에러: {} {} — issue: {}" ,
210- "HTTP" , e .getRawStatusCode (), e .getStatusText (), title , e );
225+ log .error ("[HTTP] AI HTTP 에러 {} {} — {}" , e .getRawStatusCode (), e .getStatusText (), title , e );
211226 return aiClassifierClient .defaultSingleResult ();
212227 })
213228 .onErrorResume (e -> {
214- log .error ("[{}] AI 호출 예외: {} — issue: {}" ,
215- "UNKNOWN_CALL" , e .getMessage (), title , e );
229+ log .error ("[UNKNOWN_CALL] AI 호출 예외 — {}" , title , e );
216230 return aiClassifierClient .defaultSingleResult ();
217231 })
218232 .toFuture ()
219233 .thenApplyAsync (jsonResult -> {
220234 try {
221- // 2) JSON 파싱
222- Tag tag = parseJsonToTag (jsonResult );
223- log .info ("[{}] 분류 완료: {} ({}ms) — issue: {}" ,
224- "SUCCESS" , tag , System .currentTimeMillis () - start , title );
225- return tag ;
226- } catch (Exception parseEx ) {
227- // 3) 파싱 오류
228- log .error ("[{}] AI 응답 파싱 실패 ({}ms) — issue: {}, json={}" ,
229- "PARSE_ERROR" , System .currentTimeMillis () - start , title , jsonResult , parseEx );
230- return Tag .UNKNOWN ;
235+ JsonNode first = new ObjectMapper ()
236+ .readTree (jsonResult )
237+ .get ("results" )
238+ .get (0 );
239+
240+ String diff = first .get ("difficulty" ).asText ("misc" ).toLowerCase ();
241+ double score = first .get ("score" ).asDouble (0.0 );
242+
243+ Tag tag = switch (diff ) {
244+ case "easy" -> Tag .EASY ;
245+ case "medium" -> Tag .MEDIUM ;
246+ case "hard" -> Tag .HARD ;
247+ case "misc" -> Tag .MISC ;
248+ default -> Tag .UNKNOWN ;
249+ };
250+
251+ log .info ("[SUCCESS] {} → {} (score={}) {}ms" , title , tag , score , System .currentTimeMillis ()-start );
252+ return new DifficultyResult (tag , score );
253+
254+ } catch (Exception ex ) {
255+ log .error ("[PARSE_ERROR] AI 응답 파싱 실패 — {}" , title , ex );
256+ return new DifficultyResult (Tag .UNKNOWN , 0.0 );
231257 }
232258 }, classifierExecutor );
233259 }
@@ -345,36 +371,54 @@ private static class IssueClassificationResult {
345371 /**
346372 * 단건 분류 요청 (캐시/DB/AI 순)
347373 */
348- public CompletableFuture <IssueListResponseDTO .IssueResponseDTO > classify (IssueListRequestDTO .IssueRequestDTO dto ) {
349- String key = "issue:" + dto .getIssueId ();
350- return CompletableFuture .supplyAsync (() -> {
351- // DB 우선 체크
352- IssueList existing = issueListRepository .findById (dto .getIssueId ()).orElse (null );
353- if (existing != null ) {
354- return "DB:" + existing .getDifficulty ().name ().toLowerCase ();
355- }
356- String redisCached = redisTemplate .opsForValue ().get (key );
357- return redisCached != null ? "REDIS:" + redisCached : null ;
358- }, classifierExecutor ).thenCompose (source -> {
359- if (source != null ) {
360- if (source .startsWith ("DB:" )) {
361- Tag tag = Tag .valueOf (source .substring (3 ).toUpperCase ());
362- return CompletableFuture .completedFuture (issueListConverter .toResponseDTO (dto .getIssueId (), tag ));
363- } else { // REDIS case
364- Tag tag = Tag .valueOf (source .substring (6 ).toUpperCase ());
365- return CompletableFuture .completedFuture (issueListConverter .toResponseDTO (dto .getIssueId (), tag ));
366- }
367- }
368- // GitHub fetch + AI classify
369- CompletableFuture <Tag > tagFuture = CompletableFuture .supplyAsync (
370- () -> extractTitleAndBody (dto .getIssueId ()), classifierExecutor
371- ).thenComposeAsync (parts -> classifyWithAI (parts [0 ], parts [1 ]), classifierExecutor );
372-
373- return tagFuture .thenApplyAsync (tag -> {
374- cacheDifficulty (key , tag );
375- persistDifficulty (dto , tag );
376- return issueListConverter .toResponseDTO (dto .getIssueId (), tag );
377- }, classifierExecutor );
378- });
379- }
374+ // public CompletableFuture<IssueListResponseDTO.IssueResponseDTO> classify(IssueListRequestDTO.IssueRequestDTO dto) {
375+ // String issueId = dto.getIssueId();
376+ // String key = "issue:" + issueId;
377+ // return CompletableFuture.supplyAsync(() -> {
378+ // // DB 우선 체크
379+ // IssueList existing = issueListRepository.findById(issueId).orElse(null);
380+ // if (existing != null) {
381+ // Tag tag = existing.getDifficulty();
382+ // return "DB:" + tag.name().toLowerCase();
383+ // }
384+ // String redisCached = redisTemplate.opsForValue().get(key);
385+ // return redisCached != null ? "REDIS:" + redisCached : null;
386+ // }, classifierExecutor).thenCompose(source -> {
387+ // if (source != null) {
388+ // if (source.startsWith("DB:")) {
389+ // Tag tag = Tag.valueOf(source.substring(3).toUpperCase());
390+ // return CompletableFuture.completedFuture(
391+ // IssueListResponseDTO.IssueResponseDTO.builder()
392+ // .issueId(issueId)
393+ // .difficulty(tag.toLowerCase())
394+ // .score(1.0)
395+ // .build()
396+ // );
397+ // } else { // REDIS case
398+ // Tag tag = Tag.valueOf(source.substring(6).toUpperCase());
399+ // return CompletableFuture.completedFuture(
400+ // IssueListResponseDTO.IssueResponseDTO.builder()
401+ // .issueId(issueId)
402+ // .difficulty(tag.toLowerCase())
403+ // .score(1.0)
404+ // .build()
405+ // );
406+ // }
407+ // }
408+ // // GitHub fetch + AI classify
409+ // CompletableFuture<DifficultyResult> diffFuture = CompletableFuture.supplyAsync(
410+ // () -> extractTitleAndBody(issueId), classifierExecutor
411+ // ).thenComposeAsync(parts -> classifyWithAI(parts[0], parts[1]), classifierExecutor);
412+ //
413+ // return diffFuture.thenApplyAsync(diffRes -> {
414+ // cacheDifficulty(key, diffRes.tag());
415+ // persistDifficulty(dto, diffRes.tag());
416+ // return IssueListResponseDTO.IssueResponseDTO.builder()
417+ // .issueId(issueId)
418+ // .difficulty(diffRes.tag().toLowerCase())
419+ // .score(diffRes.score())
420+ // .build();
421+ // }, classifierExecutor);
422+ // });
423+ // }
380424}
0 commit comments