-
Notifications
You must be signed in to change notification settings - Fork 0
[refactor] #184 - AI 히스토리 조회 성능 개선 및 프롬프트 상세화 #199
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
base: develop
Are you sure you want to change the base?
Changes from all commits
1e0b8eb
d516b27
7ddfa98
91253f7
3774cbb
7be55b2
c47a9c6
b71de38
71dd33b
22661d7
cd78cb2
1cc4eda
5d6ce65
4a1dc82
2dab214
ec746dd
0b28be8
b814edf
65216d2
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 |
|---|---|---|
|
|
@@ -25,9 +25,14 @@ public String build( | |
| - 비슷한 투두명 기록과 태그 기록이 모두 있으면 둘을 함께 보고, 비슷한 투두명 기록을 조금 더 중요하게 봐. | ||
| - 기록이 아예 없으면 현재 투두명만 기준으로 일반적인 예상 소요 시간을 판단해. | ||
|
|
||
| 기록 신뢰도 판단 기준: | ||
| - 각 기록 그룹 앞의 요약(count/avgMinutes/minMinutes/maxMinutes)은 이미 정확히 계산된 값이니 그대로 신뢰하고, 직접 다시 계산하지 마. | ||
| - count가 1이면 그 값 하나에 과도하게 의존하지 말고 일반적인 감각과 함께 보수적으로 조정해. | ||
| - count가 3 이상이면 avgMinutes를 중심으로 판단하되, minMinutes~maxMinutes 범위를 크게 벗어난 추천은 피해. | ||
|
|
||
| 규칙: | ||
| - 응답은 반드시 JSON 객체 하나만 반환해. | ||
| - recommendedMinutes는 분 단위 정수로 반환해. | ||
| - recommendedMinutes는 1 이상의 분 단위 정수로 반환해. | ||
| - 실제 기록에 없는 패턴은 만들지 마. | ||
|
|
||
| 반환해야 할 응답 JSON 형식: | ||
|
|
@@ -56,9 +61,30 @@ public String build( | |
|
|
||
| private String formatHistories(List<TodoDurationHistory> histories) { | ||
| if (histories == null || histories.isEmpty()) { | ||
| return "[]"; | ||
| return "요약: {\"count\":0}\n기록: []"; | ||
| } | ||
|
|
||
| return "요약: %s\n기록: %s".formatted(summarize(histories), listHistories(histories)); | ||
|
Collaborator
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. 여기에서의 응답과 위에서의(64) 응답은 뭐가 다른건가요?
Collaborator
Author
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. 64는 histories가 비어있을 때 나가는 고정 응답이고(count 0짜리 요약 + 빈 기록) |
||
| } | ||
|
|
||
| private String summarize(List<TodoDurationHistory> histories) { | ||
| List<Integer> minutes = histories.stream() | ||
| .map(history -> toMinutes(history.actualSeconds())) | ||
| .toList(); | ||
| int count = minutes.size(); | ||
| double avgSeconds = histories.stream() | ||
| .mapToInt(TodoDurationHistory::actualSeconds) | ||
| .average() | ||
| .orElse(0); | ||
| int avg = Math.max(1, (int)Math.round(avgSeconds / 60.0)); | ||
| int min = minutes.stream().mapToInt(Integer::intValue).min().orElse(0); | ||
| int max = minutes.stream().mapToInt(Integer::intValue).max().orElse(0); | ||
|
|
||
| return """ | ||
| {"count":%d,"avgMinutes":%d,"minMinutes":%d,"maxMinutes":%d}""".formatted(count, avg, min, max); | ||
| } | ||
|
|
||
| private String listHistories(List<TodoDurationHistory> histories) { | ||
| return histories.stream() | ||
| .map(history -> """ | ||
| {"title":"%s","date":"%s","actualMinutes":%d} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,12 +4,15 @@ | |
| import java.time.LocalDateTime; | ||
| import java.time.ZoneId; | ||
| import java.time.ZoneOffset; | ||
| import java.util.Comparator; | ||
| import java.util.List; | ||
| import java.util.stream.Stream; | ||
|
|
||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; | ||
| import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; | ||
| import com.Timo.Timo.domain.timer.entity.TimerRecord; | ||
|
|
||
| import jakarta.persistence.EntityManager; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
@@ -18,6 +21,9 @@ | |
| @RequiredArgsConstructor | ||
| public class AiTodoQueryRepository { | ||
|
|
||
| private static final int CANDIDATE_WINDOW = 200; | ||
| private static final int UNMATCHED_PRIORITY = 3; | ||
|
|
||
| private final EntityManager entityManager; | ||
|
|
||
| public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { | ||
|
|
@@ -59,39 +65,103 @@ public List<TodoDurationHistory> findActualDurationHistoriesBySimilarTitle( | |
| ZoneId userZoneId, | ||
| int limit | ||
| ) { | ||
| List<TodoDurationHistoryRow> rows = entityManager.createQuery(""" | ||
| select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( | ||
| t.title, | ||
| tr.actualSeconds, | ||
| coalesce(tr.endedAt, tr.startedAt) | ||
| ) | ||
| String trimmedTitle = title == null ? "" : title.trim(); | ||
| String normalizedSearchTitle = normalize(title); | ||
|
|
||
| List<TimerRecord> exactMatches = findExactMatchCandidates(userId, trimmedTitle, toExclusive, limit); | ||
|
|
||
| int remaining = limit - exactMatches.size(); | ||
| List<TimerRecord> partialMatches = remaining > 0 | ||
| ? findPartialMatchCandidates(userId, normalizedSearchTitle, toExclusive, remaining) | ||
| : List.of(); | ||
|
|
||
| return Stream.concat(exactMatches.stream(), partialMatches.stream()) | ||
| .map(record -> toHistory(record, userZoneId)) | ||
| .toList(); | ||
| } | ||
|
|
||
| private List<TimerRecord> findExactMatchCandidates( | ||
| Long userId, | ||
| String trimmedTitle, | ||
| LocalDateTime toExclusive, | ||
| int limit | ||
| ) { | ||
| if (trimmedTitle.isBlank() || limit <= 0) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| return entityManager.createQuery(""" | ||
| select tr | ||
| from TimerRecord tr | ||
| join tr.todo t | ||
| join fetch tr.todo t | ||
| where t.user.id = :userId | ||
| and tr.user.id = :userId | ||
| and t.title = :title | ||
|
Collaborator
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. [p2] PR 설명에는 "todos.title 인덱스로 찾는다"고 되어 있는데, Todo 엔티티에 title 인덱스가 현재 없습니다! 복합 인덱스를 추가하거나 설명을 수정해주시면 좋을 것 같습니다! |
||
| and tr.actualSeconds is not null | ||
| and coalesce(tr.endedAt, tr.startedAt) < :toExclusive | ||
| and ( | ||
| lower(t.title) like lower(concat('%', :title, '%')) | ||
| or lower(:title) like lower(concat('%', t.title, '%')) | ||
| ) | ||
| order by | ||
| case | ||
| when lower(t.title) = lower(:title) then 0 | ||
| when lower(t.title) like lower(concat('%', :title, '%')) then 1 | ||
| when lower(:title) like lower(concat('%', t.title, '%')) then 2 | ||
| else 3 | ||
| end, | ||
| coalesce(tr.endedAt, tr.startedAt) desc, | ||
| tr.id desc | ||
| """, TodoDurationHistoryRow.class) | ||
| and tr.endedAt < :toExclusive | ||
| order by tr.endedAt desc, tr.id desc | ||
| """, TimerRecord.class) | ||
| .setParameter("userId", userId) | ||
| .setParameter("title", title) | ||
| .setParameter("title", trimmedTitle) | ||
| .setParameter("toExclusive", toExclusive) | ||
| .setMaxResults(limit) | ||
| .getResultList(); | ||
| } | ||
|
|
||
| return toHistories(rows, userZoneId); | ||
| private List<TimerRecord> findPartialMatchCandidates( | ||
| Long userId, | ||
| String normalizedSearchTitle, | ||
| LocalDateTime toExclusive, | ||
| int limit | ||
| ) { | ||
| if (limit <= 0) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| List<TimerRecord> candidates = entityManager.createQuery(""" | ||
| select tr | ||
| from TimerRecord tr | ||
| join fetch tr.todo t | ||
| where tr.user.id = :userId | ||
| and tr.actualSeconds is not null | ||
| and tr.endedAt < :toExclusive | ||
| order by tr.endedAt desc, tr.id desc | ||
| """, TimerRecord.class) | ||
| .setParameter("userId", userId) | ||
| .setParameter("toExclusive", toExclusive) | ||
| .setMaxResults(CANDIDATE_WINDOW) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Collaborator
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. [p1]이것도 코드래빗이랑 겹치는 것 같긴 하지만... 혹은,
Collaborator
Author
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. 위에 코드래빗이 이야기 한 내용이라 커밋 ec746dd에서 함께 수정됐습니다. 정확 일치는 윈도우 없이 todos.title 인덱스로 전체 기록에서 바로 찾고 부분 일치만 후보 윈도우(200건)에서 판정하도록 쿼리를 분리했습니다.
Collaborator
Author
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. PR 설명에 업데이트된 내용 반영해뒀습니다. 같이 보시면 바뀐 로직 이해하시는 데 도움이 될 것 같아요
Comment on lines
+123
to
+131
Collaborator
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. [p3] 후보 200건을 join fetch로 엔티티째 영속성 컨텍스트에 올리고 있는 것 같네요. 실제로 쓰는 건 id, title, actualSeconds, endedAt 뿐이라서 기존처럼 DTO 프로젝션으로 가져오는게 더 가벼울 것 같아요. |
||
| .getResultList(); | ||
|
|
||
| return candidates.stream() | ||
| .map(record -> new ScoredCandidate( | ||
| record, | ||
| matchPriority(normalize(record.getTodo().getTitle()), normalizedSearchTitle) | ||
| )) | ||
| .filter(scored -> scored.priority() > 0 && scored.priority() < UNMATCHED_PRIORITY) | ||
| .sorted(Comparator.comparingInt(ScoredCandidate::priority) | ||
| .thenComparing(scored -> scored.record().getEndedAt(), Comparator.reverseOrder())) | ||
| .limit(limit) | ||
| .map(ScoredCandidate::record) | ||
| .toList(); | ||
| } | ||
|
|
||
| private int matchPriority(String candidateTitle, String searchTitle) { | ||
| if (candidateTitle.equals(searchTitle)) { | ||
| return 0; | ||
| } | ||
| if (candidateTitle.contains(searchTitle)) { | ||
| return 1; | ||
| } | ||
| if (searchTitle.contains(candidateTitle)) { | ||
| return 2; | ||
| } | ||
| return UNMATCHED_PRIORITY; | ||
| } | ||
|
|
||
| private String normalize(String value) { | ||
| return value == null ? "" : value.trim().toLowerCase(); | ||
| } | ||
|
|
||
| private record ScoredCandidate(TimerRecord record, int priority) { | ||
| } | ||
|
|
||
| public List<TodoDurationHistory> findActualDurationHistoriesByTagId( | ||
|
|
@@ -152,6 +222,14 @@ private List<TodoDurationHistory> toHistories(List<TodoDurationHistoryRow> rows, | |
| .toList(); | ||
| } | ||
|
|
||
| private TodoDurationHistory toHistory(TimerRecord record, ZoneId userZoneId) { | ||
| return new TodoDurationHistory( | ||
| record.getTodo().getTitle(), | ||
| record.getActualSeconds(), | ||
| toUserLocalDate(record.getEndedAt(), userZoneId) | ||
| ); | ||
| } | ||
|
|
||
| private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { | ||
| return utcDateTime | ||
| .atZone(ZoneOffset.UTC) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.Timo.Timo.domain.ai.service; | ||
|
|
||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class AiFeedbackPersistenceService { | ||
|
|
||
| private final TimerRecordRepository timerRecordRepository; | ||
|
|
||
| @Async("aiHistoryExecutor") | ||
| @Transactional | ||
| public void persistFeedback(Long timerId, String feedback) { | ||
| try { | ||
| timerRecordRepository.findById(timerId) | ||
| .ifPresentOrElse( | ||
| timerRecord -> timerRecord.updateAiFeedback(feedback), | ||
| () -> log.warn("AI 피드백 저장 대상 타이머 기록을 찾을 수 없습니다. timerId={}", timerId) | ||
| ); | ||
| } catch (Exception exception) { | ||
| log.error("AI 피드백 저장 실패. timerId={}", timerId, exception); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package com.Timo.Timo.domain.ai.service; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.time.ZoneId; | ||
| import java.util.List; | ||
| import java.util.concurrent.CompletableFuture; | ||
|
|
||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; | ||
| import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class AiHistoryAsyncQueryService { | ||
|
|
||
| private final AiTodoQueryRepository aiTodoQueryRepository; | ||
|
|
||
| @Async("aiHistoryExecutor") | ||
| @Transactional(readOnly = true) | ||
| public CompletableFuture<List<TodoDurationHistory>> findSimilarTitleHistories( | ||
| Long userId, | ||
| String title, | ||
| LocalDateTime toExclusive, | ||
| ZoneId userZoneId, | ||
| int limit | ||
| ) { | ||
| return CompletableFuture.completedFuture( | ||
| aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( | ||
| userId, | ||
| title, | ||
| toExclusive, | ||
| userZoneId, | ||
| limit | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| @Async("aiHistoryExecutor") | ||
| @Transactional(readOnly = true) | ||
| public CompletableFuture<List<TodoDurationHistory>> findRecentTagHistories( | ||
| Long userId, | ||
| Long tagId, | ||
| LocalDateTime toExclusive, | ||
| ZoneId userZoneId, | ||
| int limit | ||
| ) { | ||
| return CompletableFuture.completedFuture( | ||
| aiTodoQueryRepository.findActualDurationHistoriesByTagId( | ||
| userId, | ||
| tagId, | ||
| toExclusive, | ||
| userZoneId, | ||
| limit | ||
| ) | ||
| ); | ||
| } | ||
| } |
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.
자세하게 기록된 거 좋네요