-
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
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,26 @@ 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) 응답은 뭐가 다른건가요? |
||
| } | ||
|
|
||
| private String summarize(List<TodoDurationHistory> histories) { | ||
| List<Integer> minutes = histories.stream() | ||
| .map(history -> toMinutes(history.actualSeconds())) | ||
| .toList(); | ||
| int count = minutes.size(); | ||
| int avg = Math.round(minutes.stream().mapToInt(Integer::intValue).sum() / (float) count); | ||
|
Comment on lines
+72
to
+75
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 두 prompt builder에서 원본 초 단위 평균을 사용해 주세요. 현재 두 위치 모두 기록별로 분 단위 반올림을 수행한 뒤 평균을 계산합니다. 이 방식은
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| 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,14 @@ | |
| import java.time.LocalDateTime; | ||
| import java.time.ZoneId; | ||
| import java.time.ZoneOffset; | ||
| import java.util.Comparator; | ||
| import java.util.List; | ||
|
|
||
| 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 +20,9 @@ | |
| @RequiredArgsConstructor | ||
| public class AiTodoQueryRepository { | ||
|
|
||
| private static final int CANDIDATE_WINDOW = 30; | ||
| private static final int UNMATCHED_PRIORITY = 3; | ||
|
|
||
| private final EntityManager entityManager; | ||
|
|
||
| public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { | ||
|
|
@@ -59,39 +64,53 @@ 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) | ||
| ) | ||
| List<TimerRecord> candidates = entityManager.createQuery(""" | ||
| select tr | ||
| from TimerRecord tr | ||
| join tr.todo t | ||
| where t.user.id = :userId | ||
| and tr.user.id = :userId | ||
| join fetch tr.todo t | ||
| where tr.user.id = :userId | ||
| 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("toExclusive", toExclusive) | ||
| .setMaxResults(limit) | ||
| .setMaxResults(CANDIDATE_WINDOW) | ||
|
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. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 제목 우선순위를 유지하려면 후보 제한을 매칭 판정 뒤에 적용하세요. 현재는 최근 30건만 조회한 뒤 제목 우선순위를 계산합니다. 따라서 최근 30건 밖의 완전 일치 기록은 결과에 포함될 수 없습니다. 최근 부분 일치 기록이 더 오래된 완전 일치 기록보다 선택될 수 있으므로, 기존 매칭 우선순위를 유지하지 못합니다. 제목 매칭 우선순위를 보존하는 조회 방식으로 변경하세요. 예를 들어 우선순위별 후보를 별도로 제한하거나, 검색 가능한 제목 인덱스를 사용하세요. As per path instructions, "쿼리 성능과 N+1 문제가 없는지 확인해 주세요." 🤖 Prompt for AI AgentsSource: Path instructions
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]이것도 코드래빗이랑 겹치는 것 같긴 하지만... 혹은, |
||
| .getResultList(); | ||
|
|
||
| return toHistories(rows, userZoneId); | ||
| String normalizedSearchTitle = normalize(title); | ||
|
|
||
| return candidates.stream() | ||
| .map(record -> new ScoredCandidate( | ||
| record, | ||
| matchPriority(normalize(record.getTodo().getTitle()), normalizedSearchTitle) | ||
| )) | ||
| .filter(scored -> scored.priority() < UNMATCHED_PRIORITY) | ||
| .sorted(Comparator.comparingInt(ScoredCandidate::priority) | ||
| .thenComparing(scored -> scored.record().getEndedAt(), Comparator.reverseOrder())) | ||
| .limit(limit) | ||
| .map(scored -> toHistory(scored.record(), userZoneId)) | ||
| .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 +171,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,23 @@ | ||
| 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; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class AiFeedbackPersistenceService { | ||
|
|
||
| private final TimerRecordRepository timerRecordRepository; | ||
|
|
||
| @Async("aiHistoryExecutor") | ||
| @Transactional | ||
| public void persistFeedback(Long timerId, String feedback) { | ||
| timerRecordRepository.findById(timerId) | ||
| .ifPresent(timerRecord -> timerRecord.updateAiFeedback(feedback)); | ||
|
Comment on lines
+19
to
+21
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. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/team-timo-timo-server-ae62be98 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target service ---'
cat -n src/main/java/com/Timo/Timo/domain/ai/service/AiFeedbackPersistenceService.java
printf '%s\n' '--- directly bound symbols ---'
rg -n -C 5 'persistFeedback|aiHistoryExecutor|class AsyncConfig|`@EnableAsync`|`@Retryable`|outbox|compensation|TimerService' src/main/java
printf '%s\n' '--- relevant convention files ---'
for f in /tmp/coderabbit-repo-knowledge/team-timo-timo-server-ae62be98/*/*.md; do
if grep -qE 'domain|service|transaction|async|failure|retry|AI|feedback' "$f"; then
echo "### $f"
cat "$f"
fi
doneRepository: Team-Timo/Timo-Server Length of output: 20808 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- AsyncConfig ---'
cat -n src/main/java/com/Timo/Timo/global/config/AsyncConfig.java
printf '%s\n' '--- TimerService finish flow ---'
sed -n '175,235p' src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java
printf '%s\n' '--- repository and entity update contract ---'
rg -n -C 8 'interface TimerRecordRepository|class TimerRecord|updateAiFeedback|aiFeedback' src/main/java/com/Timo/Timo/domain/timer
printf '%s\n' '--- async/error/retry configuration ---'
rg -n -C 4 '`@Async`|AsyncUncaughtExceptionHandler|TaskDecorator|`@Retryable`|RetryTemplate|EnableRetry|outbox|aiHistoryExecutor|spring\.task' src/main/java src/main/resources pom.xml build.gradle* 2>/dev/null || true
printf '%s\n' '--- Spring dependency versions ---'
rg -n -C 2 'spring-boot|spring-context|spring-retry' pom.xml build.gradle* 2>/dev/null || trueRepository: Team-Timo/Timo-Server Length of output: 28023 🌐 Web query:
💡 Result: In the Spring Framework, asynchronous methods annotated with Citations:
비동기 피드백 저장에 내구성 있는 재시도 경로를 추가하세요.
🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| } | ||
| 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.
자세하게 기록된 거 좋네요