Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1e0b8eb
feat: AI 소요시간 조회용 비동기 히스토리 조회 서비스 추가
aneykrap Jul 16, 2026
d516b27
feat(ai): AI 히스토리 조회용 Redis 캐시 추가
aneykrap Jul 16, 2026
7ddfa98
refactor(ai): AI 히스토리 조회를 병렬 처리하도록 개선
aneykrap Jul 16, 2026
91253f7
feat(ai): AI 피드백 비동기 저장 서비스 추가
aneykrap Jul 16, 2026
3774cbb
refactor(ai): 타이머 종료 후 AI 피드백 저장과 히스토리 캐시 갱신 처리 개선
aneykrap Jul 16, 2026
7be55b2
chore(ai): AI 히스토리 비동기 실행기 설정 추가
aneykrap Jul 16, 2026
c47a9c6
chore(ai): AI 서비스 중복 로그 제거
aneykrap Jul 16, 2026
b71de38
refactor(ai): AI 히스토리 조회 성능 개선을 위한 인덱스 추가
aneykrap Sep 1, 2026
71dd33b
refactor(ai): 유사 title 히스토리 조회를 인덱스 후보 조회 + 앱단 유사도 판정으로 개선
aneykrap Sep 1, 2026
22661d7
refactor(ai): 유사 title 히스토리 조회를 인덱스 후보 조회 + 앱단 유사도 판정으로 개선
aneykrap Sep 1, 2026
cd78cb2
refactor(timer): timer_records 인덱스 마이그레이션 스크립트 추가
aneykrap Sep 1, 2026
1cc4eda
refactor(timer): 스크립트 삭제
aneykrap Sep 1, 2026
5d6ce65
refactor(ai): candidate window size 30으로 수정
aneykrap Sep 1, 2026
4a1dc82
refactor(ai): 타이머 완료 트랜잭션에 REQUIRES_NEW를 명시해 커밋 순서 보장
aneykrap Sep 2, 2026
2dab214
refactor(ai): 캐시 버전 조회 시점 고정 및 Redis 장애 격리
aneykrap Sep 2, 2026
ec746dd
fix(ai): 제목 정확 일치가 최근 후보 윈도우 밖이면 누락되던 문제 수정
aneykrap Sep 30, 2026
0b28be8
fix(ai): avgMinutes를 원본 초 단위로 계산
aneykrap Sep 30, 2026
b814edf
fix(ai): 캐시 키에 제목 hashCode 대신 Base64 인코딩 사용
aneykrap Sep 30, 2026
65216d2
fix(ai): AI 피드백 저장 실패를 로그로 관측 가능하게 수정
aneykrap Sep 30, 2026
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 @@ -25,9 +25,14 @@ public String build(
- 비슷한 투두명 기록과 태그 기록이 모두 있으면 둘을 함께 보고, 비슷한 투두명 기록을 조금 더 중요하게 봐.
- 기록이 아예 없으면 현재 투두명만 기준으로 일반적인 예상 소요 시간을 판단해.

기록 신뢰도 판단 기준:
- 각 기록 그룹 앞의 요약(count/avgMinutes/minMinutes/maxMinutes)은 이미 정확히 계산된 값이니 그대로 신뢰하고, 직접 다시 계산하지 마.
- count가 1이면 그 값 하나에 과도하게 의존하지 말고 일반적인 감각과 함께 보수적으로 조정해.
- count가 3 이상이면 avgMinutes를 중심으로 판단하되, minMinutes~maxMinutes 범위를 크게 벗어난 추천은 피해.

규칙:
- 응답은 반드시 JSON 객체 하나만 반환해.
- recommendedMinutes는 분 단위 정수로 반환해.
- recommendedMinutes는 1 이상의 분 단위 정수로 반환해.
- 실제 기록에 없는 패턴은 만들지 마.

반환해야 할 응답 JSON 형식:
Expand Down Expand Up @@ -56,9 +61,30 @@ public String build(

private String formatHistories(List<TodoDurationHistory> histories) {
if (histories == null || histories.isEmpty()) {
return "[]";
return "요약: {\"count\":0}\n기록: []";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자세하게 기록된 거 좋네요

}

return "요약: %s\n기록: %s".formatted(summarize(histories), listHistories(histories));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기에서의 응답과 위에서의(64) 응답은 뭐가 다른건가요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

64는 histories가 비어있을 때 나가는 고정 응답이고(count 0짜리 요약 + 빈 기록)
67은 기록이 있을 때 summarize()/listHistories()로 실제 count·avg·min·max를 계산해서 채운 응답입니다

}

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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ public String build(
- 1순위: 비슷한 투두명 실제 소요시간 기록
- 2순위: 같은 태그의 최근 실제 소요시간 기록
- 3순위: 기록이 없으면 이번 태스크의 연장 또는 조기 종료 여부
- 각 기록 그룹 앞의 요약(count/avgMinutes/minMinutes/maxMinutes)은 이미 정확히 계산된 값이니 그대로 신뢰하고, 직접 다시 계산하지 마.
- count가 1이면 그 값 하나만으로 단정하지 말고 "아직 데이터가 적다"는 뉘앙스 없이 조심스럽게만 반영해.
3. 다음 행동 추천
- 다음에 예상 시간을 어떻게 잡으면 좋을지 제안해.
- count가 3 이상인 그룹이 있으면 그 avgMinutes를 다음 예상 시간 제안의 기준으로 우선 사용해.

규칙:
- 응답은 반드시 JSON 객체 하나만 반환해.
- feedback은 한국어 1~2문장으로 자연스럽게 작성해.
- feedback은 현재 결과 관찰, 패턴 해석, 다음 행동 추천을 압축해서 포함해.
- 실제 기록에 없는 패턴은 만들지 마.
- 기록이 부족하면 부족하다고 길게 말하지 말고, 이번 결과 기준으로만 제안해.
- 다음 예상 시간은 분 단위로 제안해.
- 다음 예상 시간은 1 이상의 분 단위 정수로 제안해.

반환해야 할 응답 JSON 형식:
{
Expand Down Expand Up @@ -70,9 +73,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));
}

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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[p1]이것도 코드래빗이랑 겹치는 것 같긴 하지만...
현재는 제목 매칭 전에 사용자의 전체 이력에서 최근 30건을 먼저 제한하고 있습니다. 이렇게 조회를 하게 되면 시간 단축은 많이 될 것 같네요!!
그런데 이렇게 되면 최근 30건 밖에 있는 정확 일치 기록은 조회되지 않고, 최근의 부분 일치 기록이 더 오래된 정확 일치 기록보다 우선될 수 있습니다. 기존 로직의 “정확 일치 > 부분 일치” 우선순위는 전체 이력이 아니라 최근 30건 후보 안에서만 유지됩니다.
어떤걸 우선할지는 예나님이 판단해야겠지만 제목에 우선순위를 두지 않는다면 30건보다는 좀더 많은 건 수를 고려하는게 좋지 않을 까 생각합니다.

혹은,
정확 일치는 정규화된 제목 컬럼/인덱스로 먼저 조회하고, 부족한 개수만 부분 일치 후보로 보충하거나, 현재 방식이 근사 검색이라는 점을 명시하는 방향이 필요해 보입니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 코드래빗이 이야기 한 내용이라 커밋 ec746dd에서 함께 수정됐습니다. 정확 일치는 윈도우 없이 todos.title 인덱스로 전체 기록에서 바로 찾고 부분 일치만 후보 윈도우(200건)에서 판정하도록 쿼리를 분리했습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명에 업데이트된 내용 반영해뒀습니다. 같이 보시면 바뀐 로직 이해하시는 데 도움이 될 것 같아요

Comment on lines +123 to +131

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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(
Expand Down Expand Up @@ -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)
Expand Down
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
)
);
}
}
Loading
Loading