diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java index 661b4c23..6d5c84fe 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -15,22 +15,20 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @RestController -@RequestMapping("/api/v1/todos") +@RequestMapping("/api/v1/ai") @RequiredArgsConstructor -@Tag(name = "AI Todo", description = "투두 AI API") public class AiTodoController implements AiTodoDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/recommend-duration") + @PostMapping("/duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request @@ -50,4 +48,5 @@ public ResponseEntity> recommendDuration BaseResponse.onSuccess(AiSuccessCode.DURATION_RECOMMENDED, response) ); } + } diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java index 1972f93f..330d496b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -18,6 +18,7 @@ public interface AiTodoDocs { @Operation( + tags = "AI", summary = "AI 예상 소요 시간 추천", description = """ 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. @@ -28,7 +29,6 @@ public interface AiTodoDocs { 3. 기록이 없으면 현재 투두명 기준 Gemini는 위 기록을 종합해 예상 소요 시간을 생성합니다. - 호출량과 토큰 사용량을 줄이기 위해 각 기록은 최근 순 최대 5개씩만 전달합니다. RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. """ ) @@ -80,4 +80,4 @@ ResponseEntity> recommendDuration( ) RecommendDurationRequest request ); -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java index b8c697f6..aaa91337 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -6,4 +6,4 @@ public record TodoDurationHistory( String title, Integer actualSeconds, LocalDate date -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java new file mode 100644 index 00000000..a1b99ef6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.dto; + +public record TodoFeedbackSource( + String title, + Long tagId, + String tagName, + Integer estimatedSeconds, + Integer actualSeconds +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java index 56ed02fc..a0cb3347 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java @@ -12,5 +12,4 @@ public record RecommendDurationRequest( @Schema(description = "투두 태그 ID", example = "1", nullable = true) Long tagId -) { -} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java new file mode 100644 index 00000000..3dfe7100 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java @@ -0,0 +1,3 @@ +package com.Timo.Timo.domain.ai.dto.response; + +public record GeminiTodoFeedback(String feedback) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java index 9ff309a2..982cc1f6 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -11,9 +11,12 @@ @RequiredArgsConstructor public enum AiErrorCode implements BaseErrorCode { - AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."); + AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."), + AI_CONFIG_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 설정 정보가 올바르지 않습니다."), + AI_RESPONSE_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답을 처리하는 중 오류가 발생했습니다."), + AI_INVALID_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답 형식이 올바르지 않습니다."); private final HttpStatus httpStatus; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java index 5d791f4b..b1a5fcba 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -89,4 +89,4 @@ private String escapeJsonString(String value) { .replace("\r", " ") .replace("\t", " "); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java new file mode 100644 index 00000000..19dcdbdb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java @@ -0,0 +1,106 @@ +package com.Timo.Timo.domain.ai.prompt; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; + +@Component +public class TodoFeedbackPromptBuilder { + + public String build( + TodoFeedbackSource source, + List similarTitleHistories, + List recentTagHistories + ) { + return """ + 너는 Timo 투두 앱에서 사용자의 실제 작업 시간을 분석해 짧은 피드백을 작성하는 시간 계획 코치야. + 이번 태스크의 예상 소요 시간과 실제 소요 시간을 비교하고, 과거 기록을 참고해 다음 계획을 제안해. + + 피드백 판단 구조: + 1. 현재 결과 관찰 + - 이번 태스크가 예상 시간보다 길어졌는지, 거의 맞았는지, 일찍 끝났는지 짧게 분석해. + 2. 패턴 해석 + - 패턴 근거는 아래 우선순위로 선택해. + - 1순위: 비슷한 투두명 실제 소요시간 기록 + - 2순위: 같은 태그의 최근 실제 소요시간 기록 + - 3순위: 기록이 없으면 이번 태스크의 연장 또는 조기 종료 여부 + 3. 다음 행동 추천 + - 다음에 예상 시간을 어떻게 잡으면 좋을지 제안해. + + 규칙: + - 응답은 반드시 JSON 객체 하나만 반환해. + - feedback은 한국어 1~2문장으로 자연스럽게 작성해. + - feedback은 현재 결과 관찰, 패턴 해석, 다음 행동 추천을 압축해서 포함해. + - 실제 기록에 없는 패턴은 만들지 마. + - 기록이 부족하면 부족하다고 길게 말하지 말고, 이번 결과 기준으로만 제안해. + - 다음 예상 시간은 분 단위로 제안해. + + 반환해야 할 응답 JSON 형식: + { + "feedback": "이번 작업은 예상보다 조금 길어졌어요. 비슷한 Work 태그의 작업들도 살짝 길어지는 편이라, 다음에는 60분 정도로 잡아보면 좋아요." + } + + 아래 데이터는 응답에 포함할 값이 아니라 피드백 생성에만 참고할 입력 데이터야. + + 입력 데이터 - 이번 태스크: + { + "title": "%s", + "tagName": "%s", + "estimatedMinutes": %d, + "actualMinutes": %d + } + + 입력 데이터 - 비슷한 투두명 실제 소요시간 기록: + %s + + 입력 데이터 - 같은 태그의 최근 실제 소요시간 기록: + %s + """.formatted( + escapeJsonString(source.title()), + escapeJsonString(source.tagName()), + toMinutes(source.estimatedSeconds()), + toMinutes(source.actualSeconds()), + formatHistories(similarTitleHistories), + formatHistories(recentTagHistories) + ); + } + + private String formatHistories(List histories) { + if (histories == null || histories.isEmpty()) { + return "[]"; + } + + return histories.stream() + .map(history -> """ + {"title":"%s","date":"%s","actualMinutes":%d} + """.formatted( + escapeJsonString(history.title()), + history.date(), + toMinutes(history.actualSeconds()) + ).trim()) + .toList() + .toString(); + } + + private int toMinutes(Integer durationSeconds) { + if (durationSeconds == null || durationSeconds <= 0) { + return 0; + } + return Math.max(1, (int)Math.round(durationSeconds / 60.0)); + } + + private String escapeJsonString(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " "); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index 9f9cbfae..adf319dc 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -1,16 +1,17 @@ package com.Timo.Timo.domain.ai.repository; -import java.sql.Date; -import java.sql.Timestamp; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; 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 jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; @Repository @@ -19,23 +20,57 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; + public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { + List sources = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.dto.TodoFeedbackSource( + t.title, + t.tagId, + tag.name, + t.durationSeconds, + 0 + ) + from Todo t + left join Tag tag on tag.id = t.tagId + where t.id = :todoId + and t.user.id = :userId + """, TodoFeedbackSource.class) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .getResultList(); + + if (sources.isEmpty()) { + return null; + } + + TodoFeedbackSource source = sources.get(0); + return new TodoFeedbackSource( + source.title(), + source.tagId(), + source.tagName(), + source.estimatedSeconds(), + findLatestActualSeconds(userId, todoId) + ); + } + public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and 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, '%')) @@ -47,80 +82,80 @@ 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.ended_at, tr.started_at) desc, + coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc - """) + """, TodoDurationHistoryRow.class) .setParameter("userId", userId) .setParameter("title", title) - .setParameter("today", today) - .setMaxResults(limit); + .setParameter("toExclusive", toExclusive) + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList()); + return toHistories(rows, userZoneId); } public List findActualDurationHistoriesByTagId( Long userId, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today - and t.tag_id = :tagId - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - """) + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + and t.tagId = :tagId + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, TodoDurationHistoryRow.class) .setParameter("userId", userId) .setParameter("tagId", tagId) - .setParameter("today", today) - .setMaxResults(limit); + .setParameter("toExclusive", toExclusive) + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList()); + return toHistories(rows, userZoneId); + } + + private Integer findLatestActualSeconds(Long userId, Long todoId) { + return entityManager.createQuery(""" + select tr.actualSeconds + from TimerRecord tr + where tr.todo.id = :todoId + and tr.user.id = :userId + and tr.actualSeconds is not null + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, Integer.class) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(0); } - @SuppressWarnings("unchecked") - private List toHistories(List rows) { - return ((List)rows).stream() + private List toHistories(List rows, ZoneId userZoneId) { + return rows.stream() .map(row -> new TodoDurationHistory( - (String)row[0], - toInteger(row[1]), - toLocalDate(row[2]) + row.title(), + row.actualSeconds(), + toUserLocalDate(row.recordedAt(), userZoneId) )) .toList(); } - private Long toLong(Object value) { - if (value == null) { - return null; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return null; - } - return ((Number)value).intValue(); - } - - private LocalDate toLocalDate(Object value) { - if (value instanceof LocalDate localDate) { - return localDate; - } - if (value instanceof Date date) { - return date.toLocalDate(); - } - if (value instanceof Timestamp timestamp) { - return timestamp.toLocalDateTime().toLocalDate(); - } - return LocalDate.parse(value.toString()); + private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDate(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java new file mode 100644 index 00000000..ef6d60f1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.time.LocalDateTime; + +public record TodoDurationHistoryRow( + String title, + Integer actualSeconds, + LocalDateTime recordedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java index edc5410f..e3a5a94e 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java @@ -136,4 +136,4 @@ private long secondsUntilNextDay() { LocalDateTime nextDay = now.toLocalDate().plusDays(1).atStartOfDay(); return Math.max(1, Duration.between(now, nextDay).toSeconds()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java index 8b3cb07a..8f90cc0f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -7,5 +7,4 @@ public record AiTodoHistories( List similarTitleHistories, List recentTagHistories -) { -} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java index f608a430..ffdd3627 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.ai.service; -import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; @@ -22,14 +23,16 @@ public AiTodoHistories findHistories( Long userId, String title, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { List similarTitleHistories = aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( userId, title, - today, + toExclusive, + userZoneId, limit ); List recentTagHistories = tagId == null @@ -37,7 +40,8 @@ public AiTodoHistories findHistories( : aiTodoQueryRepository.findActualDurationHistoriesByTagId( userId, tagId, - today, + toExclusive, + userZoneId, limit ); diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index dd4a4152..f3f215bb 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -1,14 +1,26 @@ package com.Timo.Timo.domain.ai.service; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import org.springframework.stereotype.Service; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; import com.Timo.Timo.domain.ai.dto.response.GeminiDurationRecommendation; +import com.Timo.Timo.domain.ai.dto.response.GeminiTodoFeedback; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; +import com.Timo.Timo.domain.ai.exception.AiErrorCode; import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; +import com.Timo.Timo.domain.ai.prompt.TodoFeedbackPromptBuilder; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -24,19 +36,24 @@ public class AiTodoService { private static final int TOKEN_ESTIMATE_CHAR_DIVISOR = 4; private final AiTodoHistoryService historyService; + private final AiTodoQueryRepository queryRepository; + private final UserRepository userRepository; private final TodoDurationPromptBuilder promptBuilder; + private final TodoFeedbackPromptBuilder feedbackPromptBuilder; private final GeminiService geminiService; private final ObjectMapper objectMapper; private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { - LocalDate today = LocalDate.now(ZoneOffset.UTC); + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); AiTodoHistories histories = historyService.findHistories( userId, request.title(), request.tagId(), - today, + toExclusive, + userZoneId, HISTORY_LIMIT ); @@ -58,6 +75,55 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio return validate(recommendation); } + public String createFeedback(Long userId, Long todoId) { + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); + if (source == null) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + AiTodoHistories histories = historyService.findHistories( + userId, + source.title(), + source.tagId(), + toExclusive, + userZoneId, + HISTORY_LIMIT + ); + + String prompt = feedbackPromptBuilder.build( + source, + histories.similarTitleHistories(), + histories.recentTagHistories() + ); + rateLimiter.validate(userId, estimateTokenCost(prompt)); + + log.info( + "AI todo feedback histories loaded. similarTitle={}, recentTag={}", + histories.similarTitleHistories().size(), + histories.recentTagHistories().size() + ); + + String geminiJson = geminiService.generateJson(prompt); + GeminiTodoFeedback feedback = parseFeedback(geminiJson); + return validateFeedback(feedback); + } + + private ZoneId getUserZoneId(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return ZoneId.of(user.getZoneId()); + } + + private LocalDateTime getTodayToExclusiveUtc(ZoneId userZoneId) { + return LocalDate.now(userZoneId) + .plusDays(1) + .atStartOfDay(userZoneId) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + } + private GeminiDurationRecommendation parseRecommendation(String geminiJson) { try { return objectMapper.readValue( @@ -65,7 +131,20 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { GeminiDurationRecommendation.class ); } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini duration recommendation.", exception); + log.warn("Failed to parse Gemini duration recommendation.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private GeminiTodoFeedback parseFeedback(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiTodoFeedback.class + ); + } catch (Exception exception) { + log.warn("Failed to parse Gemini todo feedback.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } @@ -73,7 +152,7 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen if (recommendation == null || recommendation.recommendedMinutes() == null ) { - throw new IllegalArgumentException("Gemini recommendation has missing fields."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); @@ -81,6 +160,17 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen return new RecommendDurationResponse(recommendedMinutes); } + private String validateFeedback(GeminiTodoFeedback feedback) { + if (feedback == null + || feedback.feedback() == null + || feedback.feedback().isBlank() + ) { + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); + } + + return feedback.feedback().trim(); + } + private int normalizeMinutes(int minutes) { return Math.max(1, minutes); } @@ -99,4 +189,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java index add42a84..5a31063f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; +import com.Timo.Timo.domain.ai.exception.AiErrorCode; +import com.Timo.Timo.global.exception.CustomException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,10 +37,10 @@ public class GeminiService { public String generateJson(String prompt) { if (apiKey == null || apiKey.isBlank()) { - throw new IllegalStateException("Gemini API key is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } if (model == null || model.isBlank()) { - throw new IllegalStateException("Gemini model is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } Map request = Map.of( @@ -84,12 +86,14 @@ private String extractText(String response) { .path("text"); if (textNode.isMissingNode() || textNode.asText().isBlank()) { - throw new IllegalStateException("Gemini response text is empty."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } return textNode.asText(); + } catch (CustomException exception) { + throw exception; } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini response.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java index f19e1fbe..c0ec6813 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,16 +1,16 @@ package com.Timo.Timo.domain.timer.controller; -import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; -import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; -import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; -import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.docs.TimerActiveControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; import com.Timo.Timo.domain.timer.dto.request.TimerExtendRequest; import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java index 7c83b01e..204bc21a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -21,7 +21,8 @@ public interface TimerCompleteControllerDocs { 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 + AI 피드백 생성에 실패해도 타이머 완료는 정상 처리되며 aiFeedback은 null로 반환 """ ) @ApiResponses({ diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java index 37566810..ff6bf603 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java @@ -21,7 +21,7 @@ public interface TimerStopControllerDocs { 사용자의 요청으로 타이머를 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → STOPPED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 """ ) @ApiResponses({ diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java index 893a983d..23a7cc4d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.dto.response; import com.Timo.Timo.domain.timer.entity.TimerRecord; +import io.swagger.v3.oas.annotations.media.Schema; public record TimerFinishResponse( Long timerId, @@ -8,6 +9,7 @@ public record TimerFinishResponse( String status, Integer plannedSeconds, Integer actualSeconds, + @Schema(description = "AI 피드백 문구. AI 호출 실패 시 null", nullable = true) String aiFeedback ) { public static TimerFinishResponse of(TimerRecord timerRecord) { @@ -20,4 +22,4 @@ public static TimerFinishResponse of(TimerRecord timerRecord) { timerRecord.getAiFeedback() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java index e5d8ed4c..a83e71b0 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -95,13 +95,16 @@ public void resume() { this.status = TimerStatus.RUNNING; } - public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds, String aiFeedback) { + public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds) { if (isFinished()) { throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); } this.status = targetStatus; this.endedAt = endedAt; this.actualSeconds = actualSeconds; + } + + public void updateAiFeedback(String aiFeedback) { this.aiFeedback = aiFeedback; } diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index 967a5bae..3fde84bb 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.timer.service; +import com.Timo.Timo.domain.ai.service.AiTodoService; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; @@ -27,9 +28,13 @@ import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -42,6 +47,8 @@ public class TimerService { private final TodoRepository todoRepository; private final UserRepository userRepository; private final TodoInstanceRepository todoInstanceRepository; + private final AiTodoService aiTodoService; + private final PlatformTransactionManager transactionManager; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -174,17 +181,38 @@ public void deleteTimersByTodo(Long todoId) { timerRecordRepository.deleteByTodoId(todoId); } - @Transactional public TimerFinishResponse completeTimer(Long userId, Long timerId) { return finishTimer(userId, timerId, TimerStatus.COMPLETED); } - @Transactional public TimerFinishResponse stopTimer(Long userId, Long timerId) { return finishTimer(userId, timerId, TimerStatus.STOPPED); } private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + FinishedTimer finishedTimer = transactionTemplate.execute(status -> + finishTimerInTransaction(userId, timerId, targetStatus) + ); + + String feedback = generateAiFeedback(userId, finishedTimer.todoId()); + if (feedback != null) { + transactionTemplate.executeWithoutResult(status -> + updateAiFeedback(timerId, feedback) + ); + } + + return new TimerFinishResponse( + finishedTimer.timerId(), + finishedTimer.todoId(), + finishedTimer.status(), + finishedTimer.plannedSeconds(), + finishedTimer.actualSeconds(), + feedback + ); + } + + private FinishedTimer finishTimerInTransaction(Long userId, Long timerId, TimerStatus targetStatus) { TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); @@ -198,12 +226,53 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) .ifPresent(activeSession -> activeSession.pause(now)); - timerRecord.finish(targetStatus, now, actualSeconds, null); + timerRecord.finish(targetStatus, now, actualSeconds); TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); instance.stopTimer(); instance.markCompleted(); - return TimerFinishResponse.of(timerRecord); + timerRecordRepository.flush(); + + return FinishedTimer.from(timerRecord); + } + + private String generateAiFeedback(Long userId, Long todoId) { + try { + return aiTodoService.createFeedback(userId, todoId); + } catch (RuntimeException exception) { + log.warn( + "AI feedback generation failed. todoId={}, userId={}", + todoId, + userId, + exception + ); + return null; + } + } + + private void updateAiFeedback(Long timerId, String feedback) { + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + timerRecord.updateAiFeedback(feedback); + } + + private record FinishedTimer( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds + ) { + + private static FinishedTimer from(TimerRecord timerRecord) { + return new FinishedTimer( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getActualSeconds() + ); + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java index 8673ab4e..533d105d 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,7 +16,7 @@ public enum TodoErrorCode implements BaseErrorCode { NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "TODO_400", "수정할 필드가 없습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "TODO_400", "isCompleted는 필수입니다."), INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다."), TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBTASK_404", "존재하지 않는 하위 태스크입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."),