[feat] #35 - 소요시간 ai 추천#43
Conversation
Walkthrough투두 제목/태그 기반 AI 소요시간 추천 기능이 새로 추가되었다. 신규 컨트롤러/서비스/DTO/예외코드가 정의되고, 히스토리 조회 리포지토리, 프롬프트 빌더, Redis 기반 레이트 리미터, Gemini API 연동 서비스가 구현되었으며, local/prod 설정 파일에 관련 환경변수가 추가되었다. ChangesAI 소요시간 추천 기능
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant AiTodoController
participant AiTodoService
participant AiTodoHistoryService
participant AiRequestRateLimiter
participant GeminiService
Client->>AiTodoController: POST /recommend-duration (title, tagId)
AiTodoController->>AiTodoService: recommendDuration(userId, request)
AiTodoService->>AiTodoHistoryService: findHistories(userId, title, tagId, today, limit)
AiTodoHistoryService-->>AiTodoService: AiTodoHistories
AiTodoService->>AiTodoService: build prompt via TodoDurationPromptBuilder
AiTodoService->>AiRequestRateLimiter: validate(userId, estimatedTokenCost)
AiRequestRateLimiter-->>AiTodoService: OK 또는 CustomException
AiTodoService->>GeminiService: generateJson(prompt)
GeminiService-->>AiTodoService: JSON 텍스트
AiTodoService->>AiTodoService: parse/validate/normalize recommendation
AiTodoService-->>AiTodoController: RecommendDurationResponse
AiTodoController-->>Client: BaseResponse(RecommendDurationResponse)
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
laura-jung
left a comment
There was a problem hiding this comment.
외부 api 연결이 번거로운데 깔끔하게 연결해주셨네요. 개인적으로 코드중에 궁금한 내용들이 많아서 코멘트 남겨두었으니 답변 부탁드립니다!
| private final AiTodoService aiTodoService; | ||
|
|
||
| @Override | ||
| @PostMapping("/recommend-duration") |
There was a problem hiding this comment.
p4) 흠 지금 url이 ai/todos/recommend-duration으로 길다는 느낌이 있는 것 같아서 더 좋은 url이 있다면 변경하면 좋을 것 같아요 중간에 ai를 없애도 괜찮을 것 같습니다!
There was a problem hiding this comment.
넹 요거는 그럼 ai를 없애도록 하겠습니다!
| public record GeminiDurationRecommendation( | ||
| Integer recommendedMinutes | ||
| ) { | ||
| } |
| @Schema(description = "추천 예상 소요 시간(분)", example = "45") | ||
| Integer recommendedMinutes | ||
| ) { | ||
| } |
| 현재 투두: | ||
| { | ||
| "title": "%s", | ||
| "tagId": %s | ||
| } |
There was a problem hiding this comment.
아래부분들도 응답에 필요한 json인지 설명해주시면 좋을 것 같습니다.
There was a problem hiding this comment.
저는 이 부분은 서버가 AI에게 전달하는 프롬프트여서 AI가 현재 어떤 투두를 기준으로 예상 소요 시간을 추천해야 하는지 판단하기 위해 필요한 정보 같습니다..!!
There was a problem hiding this comment.
현재 투두 부분은 Gemini가 추천을 계산할 때 참고하는 입력 컨텍스트이고 실제 응답으로 요구하는 JSON은 recommendedMinutes(예상소요시간)만 포함해용
다만 프롬프트상 입력 데이터와 응답 형식이 헷갈려 보이는것 같아서 더 잘 분리해서 작성해둘게용
There was a problem hiding this comment.
아래부분들도 응답에 필요한 json인지 설명해주시면 좋을 것 같습니다.
아래 JSON들은 응답값이 아니라 gemini한테 전달하는 과거 기록 컨텍스트입니당
| .replace("\\", "\\\\") | ||
| .replace("\"", "\\\"") | ||
| .replace("\n", " ") | ||
| .replace("\r", " ") | ||
| .replace("\t", " "); |
There was a problem hiding this comment.
이부분은 어떤 replace인지 설명 부탁드립니다.
There was a problem hiding this comment.
프롬프트 안에 사용자 입력 title이 문자열로 들어가다 보니 따옴표/역슬래시/개행/탭 때문에 JSON 형태가 깨지는 것을 막기 위한 escape 처리였어용 Gemini에게 전달하는 JSON 형태의 예시가 깨지지 않도록 특수문자를 치환했습니당
| lower(t.title) like lower(concat('%', :title, '%')) | ||
| or lower(:title) like lower(concat('%', t.title, '%')) |
There was a problem hiding this comment.
p3) or 양옆의 명령문이 동일한 것 같은데 확인 부탁드립니다.
There was a problem hiding this comment.
비슷한 투두 목록을 찾기 위한 조건이에용
예를 들어 사용자가 영어단어 공부하기로 입력했을 때 기존 투두 제목이 영어단어인 기록도 함께 찾기 위해서는 입력 제목이 기존 제목을 포함하는 경우와 기존 제목이 입력 제목을 포함하는 경우를 모두 확인하도록 했습니다.
근데 검색 품질이 떨어지는 것 같아서 full-text search 방식으로 변경하겠습니다! 그리고 추후에 데베가 많이 쓰일때를 고려해서 검색엔진까지 적용해보는것도 좋을것 같다고 생각이 드네용..
There was a problem hiding this comment.
현재 PR에서는 기존 LIKE 기반 검색 조건은 유지하되 정확히 일치하는 제목과 더 강한 포함 관계가 먼저 조회되도록 정렬 우선순위를 추가했습니다.
full-text search로 전환하고 싶었는데 DB 인덱스/DDL 변경과 검색 기준 조정이 함께 필요해서 이번 PR 범위를 넘어선다고 판단했습니당... 검색 품질 개선은 후속 이슈로 분리해서 다시 진행하겠습니다.
많관부
There was a problem hiding this comment.
full text search 보다는 더 강함 포함 관계로 조회되도록하는게 좋아보이네요...!
|
|
||
| private final EntityManager entityManager; | ||
|
|
||
| public List<TodoDurationHistory> findActualDurationHistoriesBySimilarTitle( |
There was a problem hiding this comment.
아래 tagid가 들어가는 함수와 비슷한 로직인 것 같은데 뭐가 다른건가요?
There was a problem hiding this comment.
두 함수 모두 타이머 기록 기반 실제 소요시간을 가져오는 공통 목적은 같아요 차이는 첫 번째 함수는 title 유사도를 기준으로 과거 투두 기록을 찾고
두 번째 함수는 사용자가 선택한 tagId 기준으로 최근 투두 기록을 찾는 용도입니다
There was a problem hiding this comment.
두가지다 필요한가요? tagId 기준으로 투두 기록을 찾는 함수가 필요한 이유가 뭔지 궁금합니다!(둥글둥글)
There was a problem hiding this comment.
넹 TL분께서 현재 투두와 유사한 과거 데이터, 사용자의 최근 소요 시간 경향도 함께 AI에게 전달해서 판단하는 로직이 있으면 좋겠다고 의견 주셔서 반영했습니다!
혹시 몰라서 Gemini에게 전달되는 데이터와 추천 판단 흐름을 아래에 정리해두었습니다.
제목 기반 조회
현재 투두와 작업 내용이 유사한 과거 투두를 조회하고 해당 투두들의 실제 소요 시간을 전달
태그 기반 조회
사용자가 입력한 태그를 기준으로 같은 태그에 속한 최근 투두들의 실제 소요 시간을 조회해서 사용자의 최근 작업 소요 시간 경향도 함께 반영
이렇게 두 가지 데이터를 함께 넘겨서 단순히 제목이 비슷한 투두만 보는 것이 아니라 사용자의 최근 작업 패턴까지 고려해 소요 시간을 추천하도록 구성했습니다. 그리고 해당 조회는 함께 호출되는 구조입니당
There was a problem hiding this comment.
p3) 이 친구 repository 폴더에 있는게 맞나요? dto 폴더에 맞는 파일인 것 같습니다.
| KEY_PREFIX + "tpm:global:" + minuteBucket | ||
| ); | ||
|
|
||
| Long allowed = redisTemplate.execute( |
There was a problem hiding this comment.
불러와야하는 값들은 redis에 있는거 좋은 것 같네요
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| public record RecommendDurationResponse(@Schema(description = "추천 예상 소요 시간(분)", example = "45") Integer recommendedMinutes) { |
There was a problem hiding this comment.
p4) 괄호 안에 들어간 친구들은 줄바꿈을 해줘야 가독성이 높아질 것 같아요 추가로 마지막 괄호는 여백 없애기!
| Integer actualSeconds, | ||
| LocalDate date | ||
| ) { | ||
| } |
|
|
||
| 아래 데이터는 응답에 포함할 값이 아니라 추천 계산에만 참고할 입력 데이터야. | ||
|
|
||
| 입력 데이터 - 현재 투두: |
There was a problem hiding this comment.
입력 데이터로 분류하니 더 이해하기 좋은 것 같네요!!
|
|
||
| private final EntityManager entityManager; | ||
|
|
||
| public List<TodoDurationHistory> findActualDurationHistoriesBySimilarTitle( |
There was a problem hiding this comment.
두가지다 필요한가요? tagId 기준으로 투두 기록을 찾는 함수가 필요한 이유가 뭔지 궁금합니다!(둥글둥글)
| lower(t.title) like lower(concat('%', :title, '%')) | ||
| or lower(:title) like lower(concat('%', t.title, '%')) |
There was a problem hiding this comment.
full text search 보다는 더 강함 포함 관계로 조회되도록하는게 좋아보이네요...!
There was a problem hiding this comment.
p1) application-local.yml에만 ai.* 설정이 추가됐고, application-prod.yml에는 없습니다. 그런데 GeminiService의 ${ai.gemini.model}와 AiRequestRateLimiter 이후 rate-limit 값들은 기본값 없이 주입됩니다. prod profile로 뜨면 AI API 호출 전이 아니라 Bean 생성 단계에서 placeholder 해석 실패로 서버가 안 뜰 수 있어요. 공통 application.yml 또는 application-prod.yml에도 env 기반 설정을 추가하는 게 좋아 보입니다!
배포시에도 안정적으로 가동될 수 있도록 수정해주세요!
There was a problem hiding this comment.
application-prod.yml에 추가했습니다!! 감사합니다
| .cors(cors -> cors.configurationSource(corsConfigurationSource())) | ||
| .sessionManagement(session -> session | ||
| .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) |
There was a problem hiding this comment.
확인해보니 AI 추천 API와 직접 관련된 변경이 아니고 이전에 Swagger 설정을 확인하는 과정에서 인증 관련 동작을 건들이다가 함께 커밋된 변경 같습니당.....
이번 PR 범위와 직접 관련이 없는 변경이고 제가 다른거 하다가 커밋된 거여서 기존 STATELESS 설정으로 되돌렸습니다.
| if (trimmed.startsWith("```json")) { | ||
| return trimmed.substring(7, trimmed.length() - 3).trim(); | ||
| } | ||
| if (trimmed.startsWith("```")) { | ||
| return trimmed.substring(3, trimmed.length() - 3).trim(); | ||
| } | ||
| return trimmed; |
There was a problem hiding this comment.
p2) 해당 부분에서 json이나 으로 시작하기만 하면 으로 끝나지 않아도 -3을 하는 상황이 발생할 수 있을 것 같습니다. 만약 Gemini가 실수로 이렇게 닫는 fence 없이 주면 json 끝부분이 잘려서 response가 망가질 수 있을 것 같아요! 따라서 자르기 전에 endsWith("")도 확인하는게 좋을 것 같습니다
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (5)
src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java (1)
100-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value미사용 메서드
toLong제거 권장.
toHistories는toInteger와toLocalDate만 사용하며toLong은 이 클래스 어디에서도 호출되지 않습니다. 데드 코드는 제거해 클래스 책임을 명확히 유지하는 것이 좋습니다.♻️ 제안 수정
- private Long toLong(Object value) { - if (value == null) { - return null; - } - return ((Number)value).longValue(); - } - private Integer toInteger(Object value) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java` around lines 100 - 105, Remove the unused toLong helper from AiTodoQueryRepository, since toHistories only relies on toInteger and toLocalDate and no other method in this class references toLong. Keep the change limited to the AiTodoQueryRepository class and delete the dead code without altering the remaining mapping helpers or repository behavior.src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java (1)
7-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
AiTodoHistories를dto패키지로 이동을 고려해 보세요.이 record는 순수 데이터 홀더이며 비즈니스 로직이 없습니다.
service패키지보다는TodoDurationHistory와 함께dto패키지에 위치하는 것이 응집성 측면에서 더 적절합니다. As per path instructions, 도메인 단위로 책임이 잘 분리되어 있는지 확인했습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java` around lines 7 - 10, AiTodoHistories is a pure data holder with no service logic, so move the record out of the service package and into the dto package alongside TodoDurationHistory. Update the AiTodoHistories declaration location and any imports/usages that reference it so callers still compile cleanly after the package change.Source: Path instructions
src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java (1)
57-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win수동 JSON 빌드 대신 ObjectMapper 사용을 권장합니다.
formatHistories에서 문자열 포맷팅으로 JSON을 수동 생성하고 있습니다. 필드가 추가되거나 구조가 변경될 때 누락이 발생하기 쉽습니다.ObjectMapper를 사용하면 더 안전하고 유지보수하기 쉽습니다.♻️ ObjectMapper 활용 제안
private String formatHistories(List<TodoDurationHistory> 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(); + try { + List<Map<String, Object>> items = histories.stream() + .map(history -> Map.of( + "title", history.title(), + "date", history.date(), + "actualMinutes", toMinutes(history.actualSeconds()) + )) + .toList(); + return objectMapper.writeValueAsString(items); + } catch (Exception e) { + return "[]"; + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java` around lines 57 - 72, The formatHistories method in TodoDurationPromptBuilder is manually assembling JSON-like output with string formatting and escaping, which is brittle and hard to maintain. Replace the manual formatting in formatHistories with ObjectMapper-based serialization of the TodoDurationHistory list, keeping the null/empty handling intact and ensuring the generated output remains the same shape expected by the prompt builder.src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java (1)
59-78: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winRedis 장애 시 예외 처리를 명시적으로 다루는 것을 권장합니다.
redisTemplate.execute()는 Redis 연결 실패 시null이 아닌RedisConnectionFailureException을 던질 수 있습니다. 현재allowed == null체크만 있어 연결 장애 시 미처리 예외가 전파되어 500 에러가 됩니다. Fail-closed 정책은 합리적이지만, 명시적 예외 처리로 일관된 에러 응답을 제공하는 것이 좋습니다.🛡️ Redis 연결 장애 예외 처리 제안
public void validate(Long userId, int estimatedTokenCost) { + try { String minuteBucket = currentMinuteBucket(); String dayBucket = currentDayBucket(); long minuteTtlSeconds = secondsUntilNextMinute(); long dayTtlSeconds = secondsUntilNextDay(); List<String> keys = List.of( KEY_PREFIX + "rpm:global:" + minuteBucket, KEY_PREFIX + "rpm:user:" + userId + ":" + minuteBucket, KEY_PREFIX + "rpd:global:" + dayBucket, KEY_PREFIX + "rpd:user:" + userId + ":" + dayBucket, KEY_PREFIX + "tpm:global:" + minuteBucket ); Long allowed = redisTemplate.execute( LIMIT_SCRIPT, keys, String.valueOf(globalRpmLimit), String.valueOf(userRpmLimit), String.valueOf(globalRpdLimit), String.valueOf(userRpdLimit), String.valueOf(globalTpmLimit), String.valueOf(REQUEST_COST), String.valueOf(REQUEST_COST), String.valueOf(REQUEST_COST), String.valueOf(REQUEST_COST), String.valueOf(estimatedTokenCost), String.valueOf(minuteTtlSeconds), String.valueOf(dayTtlSeconds) ); if (allowed == null || allowed == 0L) { throw new CustomException(AiErrorCode.AI_RATE_LIMIT_EXCEEDED); } + } catch (CustomException e) { + throw e; + } catch (Exception e) { + throw new CustomException(AiErrorCode.AI_RATE_LIMIT_EXCEEDED); + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java` around lines 59 - 78, `AiRequestRateLimiter`의 `redisTemplate.execute()` 호출은 Redis 연결 실패 시 `RedisConnectionFailureException`을 던질 수 있으므로, 현재 `allowed == null` / `0L` 체크만으로는 장애를 일관되게 처리하지 못합니다. `LIMIT_SCRIPT` 실행 구간을 명시적으로 감싸서 Redis 연결 실패 예외를 잡고, `CustomException(AiErrorCode.AI_RATE_LIMIT_EXCEEDED)`로 fail-closed 처리되도록 `AiRequestRateLimiter`의 허용 판정 로직을 수정하세요. 필요하면 `redisTemplate.execute()` 결과 검증과 Redis 장애 처리 흐름을 분리해, `allowed` 계산과 예외 매핑이 명확하게 보이도록 정리하세요.src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java (1)
88-90: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value한국어 프롬프트의 토큰 추정치가 낮을 수 있습니다.
TOKEN_ESTIMATE_CHAR_DIVISOR = 4는 영어 기준 휴리스틱입니다. 한국어는 문자당 약 2-3 토큰을 사용하므로, 한국어 중심 프롬프트에서 실제 토큰 수를 과소평가할 수 있습니다. TPM 제한이 의도보다 관대하게 적용될 수 있습니다. 정확한 토큰 계산이 필요하다면 tiktoken 또는 Gemini API의countTokens엔드포인트 사용을 고려해 보세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java` around lines 88 - 90, The token estimate in AiTodoService.estimateTokenCost is using a character-based English heuristic, so Korean prompts can be undercounted and bypass TPM limits too easily. Replace the current prompt.length() / TOKEN_ESTIMATE_CHAR_DIVISOR logic with a more accurate token-counting approach, such as Gemini API countTokens or a tokenizer library like tiktoken, and keep the existing ESTIMATED_RESPONSE_TOKEN_COST adjustment in the final cost calculation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java`:
- Around line 3-4: The Gemini response DTO still exposes recommendedMinutes even
though the rest of the duration flow should use seconds; update
GeminiDurationRecommendation to use a seconds-based field name such as
recommendedSeconds, and make sure any parsing/mapping code that consumes this
record converts Gemini’s value into seconds consistently before it reaches
RecommendDurationResponse or related duration logic.
In
`@src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java`:
- Around line 5-8: The duration response is still using minutes while the rest
of the system has been switched to seconds, so align this DTO and its callers to
the new unit. Update RecommendDurationResponse to expose a seconds-based field
instead of recommendedMinutes, and make AiTodoService.validate(),
GeminiDurationRecommendation, and TodoDurationPromptBuilder use the same seconds
contract end-to-end. Ensure any conversion logic currently producing minutes is
moved to seconds so the API response matches the updated
planned_seconds/durationSeconds model.
In `@src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java`:
- Around line 48-51: In GeminiService’s generationConfig map, the response
format key is using the wrong name; update the response MIME type entry from
response_mime_type to responseMimeType so the JSON response setting is applied
correctly. Make this change in the configuration block that builds the request
payload for GeminiService.
- Around line 36-63: The `GeminiService.generateJson` flow does not handle HTTP
failures from `restClient.post().retrieve()`, so 4xx/5xx responses bypass
`extractText` and surface as an unhandled 500. Update `generateJson` to catch
`RestClientResponseException` (or a broader client exception) around the Gemini
request, translate common statuses such as 429 and timeout-related failures into
appropriate application exceptions/statuses, and keep `extractText` focused on
parsing successful responses.
---
Nitpick comments:
In `@src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java`:
- Around line 57-72: The formatHistories method in TodoDurationPromptBuilder is
manually assembling JSON-like output with string formatting and escaping, which
is brittle and hard to maintain. Replace the manual formatting in
formatHistories with ObjectMapper-based serialization of the TodoDurationHistory
list, keeping the null/empty handling intact and ensuring the generated output
remains the same shape expected by the prompt builder.
In `@src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java`:
- Around line 100-105: Remove the unused toLong helper from
AiTodoQueryRepository, since toHistories only relies on toInteger and
toLocalDate and no other method in this class references toLong. Keep the change
limited to the AiTodoQueryRepository class and delete the dead code without
altering the remaining mapping helpers or repository behavior.
In `@src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java`:
- Around line 59-78: `AiRequestRateLimiter`의 `redisTemplate.execute()` 호출은 Redis
연결 실패 시 `RedisConnectionFailureException`을 던질 수 있으므로, 현재 `allowed == null` /
`0L` 체크만으로는 장애를 일관되게 처리하지 못합니다. `LIMIT_SCRIPT` 실행 구간을 명시적으로 감싸서 Redis 연결 실패 예외를
잡고, `CustomException(AiErrorCode.AI_RATE_LIMIT_EXCEEDED)`로 fail-closed 처리되도록
`AiRequestRateLimiter`의 허용 판정 로직을 수정하세요. 필요하면 `redisTemplate.execute()` 결과 검증과
Redis 장애 처리 흐름을 분리해, `allowed` 계산과 예외 매핑이 명확하게 보이도록 정리하세요.
In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java`:
- Around line 7-10: AiTodoHistories is a pure data holder with no service logic,
so move the record out of the service package and into the dto package alongside
TodoDurationHistory. Update the AiTodoHistories declaration location and any
imports/usages that reference it so callers still compile cleanly after the
package change.
In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java`:
- Around line 88-90: The token estimate in AiTodoService.estimateTokenCost is
using a character-based English heuristic, so Korean prompts can be undercounted
and bypass TPM limits too easily. Replace the current prompt.length() /
TOKEN_ESTIMATE_CHAR_DIVISOR logic with a more accurate token-counting approach,
such as Gemini API countTokens or a tokenizer library like tiktoken, and keep
the existing ESTIMATED_RESPONSE_TOKEN_COST adjustment in the final cost
calculation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: c8dfa994-a521-4f65-8b04-f8248276ccd7
⛔ Files ignored due to path filters (1)
src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.javais excluded by!**/docs/**
📒 Files selected for processing (16)
src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.javasrc/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.javasrc/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.javasrc/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.javasrc/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.javasrc/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.javasrc/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.javasrc/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.javasrc/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.javasrc/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.javasrc/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.javasrc/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.javasrc/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.javasrc/main/java/com/Timo/Timo/domain/ai/service/GeminiService.javasrc/main/resources/application-local.ymlsrc/main/resources/application-prod.yml
| public record GeminiDurationRecommendation(Integer recommendedMinutes) { | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
recommendedMinutes 필드명도 seconds 기준으로 변경이 필요합니다.
RecommendDurationResponse의 단위 불일치와 동일한 근원 이슈입니다. Gemini 응답을 파싱하는 내부 DTO이므로 필드명을 recommendedMinutes에서 recommendedSeconds로 변경하거나, Gemini가 분 단위로 반환하더라도 파싱 후 seconds로 변환하는 것이 좋습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java`
around lines 3 - 4, The Gemini response DTO still exposes recommendedMinutes
even though the rest of the duration flow should use seconds; update
GeminiDurationRecommendation to use a seconds-based field name such as
recommendedSeconds, and make sure any parsing/mapping code that consumes this
record converts Gemini’s value into seconds consistently before it reaches
RecommendDurationResponse or related duration logic.
| public record RecommendDurationResponse( | ||
| @Schema(description = "추천 예상 소요 시간(분)", example = "45") | ||
| Integer recommendedMinutes | ||
| ) {} No newline at end of file |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
시스텀 단위가 seconds로 변경되었으나 응답 DTO가 여전히 minutes 기준입니다.
PR 코멘트에서 duration 필드가 minutes에서 seconds로 변경되었고(durationMinutes → durationSeconds, planned_minutes → planned_seconds 등), AI 추천 로직이 여전히 분 단위를 가정하는지에 대한 지적이 있었습니다. 현재 코드를 확인하면:
RecommendDurationResponse가recommendedMinutes를 반환GeminiDurationRecommendation도recommendedMinutes사용TodoDurationPromptBuilder.toMinutes()가actualSeconds / 60.0으로 분 변환
시스템 전반이 seconds로 전환되었으므로, 이 API의 응답도 seconds 기준으로 통일하는 것이 클라이언트 측 혼란을 방지할 수 있습니다. 작성자님께서 업데이트하겠다고 답변하셨지만, 현재 코드에는 반영되지 않은 것으로 보입니다.
🔧 제안: seconds 기준으로 응답 DTO 변경
public record RecommendDurationResponse(
- `@Schema`(description = "추천 예상 소요 시간(분)", example = "45")
- Integer recommendedMinutes
+ `@Schema`(description = "추천 예상 소요 시간(초)", example = "2700")
+ Integer recommendedSeconds
) {}AiTodoService.validate()에서도 분→초 변환 후 반환하도록 수정이 필요합니다:
private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) {
if (recommendation == null
|| recommendation.recommendedMinutes() == null
) {
throw new IllegalArgumentException("Gemini recommendation has missing fields.");
}
- int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes());
- return new RecommendDurationResponse(recommendedMinutes);
+ int recommendedSeconds = normalizeMinutes(recommendation.recommendedMinutes()) * 60;
+ return new RecommendDurationResponse(recommendedSeconds);
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java`
around lines 5 - 8, The duration response is still using minutes while the rest
of the system has been switched to seconds, so align this DTO and its callers to
the new unit. Update RecommendDurationResponse to expose a seconds-based field
instead of recommendedMinutes, and make AiTodoService.validate(),
GeminiDurationRecommendation, and TodoDurationPromptBuilder use the same seconds
contract end-to-end. Ensure any conversion logic currently producing minutes is
moved to seconds so the API response matches the updated
planned_seconds/durationSeconds model.
| public String generateJson(String prompt) { | ||
| if (apiKey == null || apiKey.isBlank()) { | ||
| throw new IllegalStateException("Gemini API key is not configured."); | ||
| } | ||
| if (model == null || model.isBlank()) { | ||
| throw new IllegalStateException("Gemini model is not configured."); | ||
| } | ||
|
|
||
| Map<String, Object> request = Map.of( | ||
| "contents", List.of(Map.of( | ||
| "parts", List.of(Map.of("text", prompt)) | ||
| )), | ||
| "generationConfig", Map.of( | ||
| "temperature", 0.2, | ||
| "response_mime_type", "application/json" | ||
| ) | ||
| ); | ||
|
|
||
| String response = restClient.post() | ||
| .uri(GENERATE_CONTENT_URL, model) | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .header("x-goog-api-key", apiKey) | ||
| .body(request) | ||
| .retrieve() | ||
| .body(String.class); | ||
|
|
||
| return extractText(response); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Gemini API HTTP 오류 응답에 대한 예외 처리가 누락되어 있습니다.
restClient.post()...retrieve()는 4xx/5xx 응답 시 RestClientResponseException을 던지는데, 이는 extractText의 catch 블록 이전에 발생하여 미처리 상태로 전파됩니다. 결과적으로 Gemini 429(요금 제한)나 타임아웃이 발생해도 사용자에게 500 에러가 반환됩니다. 적절한 HTTP 상태 코드 매핑이 필요합니다.
As per coding guidelines, src/main/java/**/domain/**/service/**/*.java의 "예외 처리와 실패 케이스 처리가 충분한지 확인해 주세요"에 해당합니다.
🛡️ HTTP 오류 처리 제안
public String generateJson(String prompt) {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("Gemini API key is not configured.");
}
if (model == null || model.isBlank()) {
throw new IllegalStateException("Gemini model is not configured.");
}
Map<String, Object> request = Map.of(
"contents", List.of(Map.of(
"parts", List.of(Map.of("text", prompt))
)),
"generationConfig", Map.of(
"temperature", 0.2,
"responseMimeType", "application/json"
)
);
+ try {
String response = restClient.post()
.uri(GENERATE_CONTENT_URL, model)
.contentType(MediaType.APPLICATION_JSON)
.header("x-goog-api-key", apiKey)
.body(request)
.retrieve()
.body(String.class);
return extractText(response);
+ } catch (RestClientResponseException e) {
+ log.warn("Gemini API error: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString());
+ throw new IllegalStateException("Gemini API request failed with status: " + e.getStatusCode(), e);
+ } catch (RestClientException e) {
+ log.warn("Gemini API connection error", e);
+ throw new IllegalStateException("Gemini API connection failed.", e);
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java` around
lines 36 - 63, The `GeminiService.generateJson` flow does not handle HTTP
failures from `restClient.post().retrieve()`, so 4xx/5xx responses bypass
`extractText` and surface as an unhandled 500. Update `generateJson` to catch
`RestClientResponseException` (or a broader client exception) around the Gemini
request, translate common statuses such as 429 and timeout-related failures into
appropriate application exceptions/statuses, and keep `extractText` focused on
parsing successful responses.
Source: Path instructions
| "generationConfig", Map.of( | ||
| "temperature", 0.2, | ||
| "response_mime_type", "application/json" | ||
| ) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does Gemini API generateContent endpoint accept response_mime_type(snake_case) or requireresponseMimeType (camelCase) in generationConfig?
💡 Result:
The Gemini API generateContent endpoint uses the camelCase parameter responseMimeType in its GenerationConfig [1][2][3][4]. Official documentation and API references consistently define this configuration field as responseMimeType [1][3][4]. While some lower-level REST API requests or specific SDK abstractions may use snake_case for different parts of the request structure (e.g., in JSON bodies or specific object properties like inline_data or mime_type within content parts) [5], the specific parameter used to control the response format within GenerationConfig is responseMimeType [2][3][4].
Citations:
- 1: https://ai.google.dev/api/generate-content
- 2: https://googleapis.github.io/js-genai/release_docs/interfaces/types.GenerationConfig.html
- 3: https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/rest/v1beta1/GenerationConfig
- 4: https://firebase.google.com/docs/reference/js/ai.generationconfig
- 5: https://ai.google.dev/gemini-api/docs/generate-content/text-generation
response_mime_type를 responseMimeType으로 바꾸세요.
generationConfig의 응답 형식 필드는 responseMimeType입니다. 현재 키는 JSON 직렬화 후 적용되지 않아 JSON 응답 형식이 빠질 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java` around
lines 48 - 51, In GeminiService’s generationConfig map, the response format key
is using the wrong name; update the response MIME type entry from
response_mime_type to responseMimeType so the JSON response setting is applied
correctly. Make this change in the configuration block that builds the request
payload for GeminiService.
관련 이슈 🛠
작업 내용 요약 ✏️
투두명과 사용자의 과거 실제 소요시간 기록을 기반으로 예상 소요 시간을 추천하는 AI API를 추가했습니다.
Gemini API 호출 전 Redis 기반 quota 제한을 적용해 RPM/RPD/TPM 초과 상황을 방어하도록 구성했습니다.
주요 변경 사항 🛠️
트러블 슈팅 ⚽️
테스트 결과 📄
./gradlew compileJava
스크린샷 📷
리뷰 요구사항 📢
📎 참고 자료 (선택)
없음
Summary by CodeRabbit
New Features
Bug Fixes