Skip to content

[feat] #35 - 소요시간 ai 추천#43

Merged
aneykrap merged 28 commits into
developfrom
feat/#35-recommend-duration-only
Jul 9, 2026
Merged

[feat] #35 - 소요시간 ai 추천#43
aneykrap merged 28 commits into
developfrom
feat/#35-recommend-duration-only

Conversation

@aneykrap

@aneykrap aneykrap commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

관련 이슈 🛠

작업 내용 요약 ✏️

투두명과 사용자의 과거 실제 소요시간 기록을 기반으로 예상 소요 시간을 추천하는 AI API를 추가했습니다.
Gemini API 호출 전 Redis 기반 quota 제한을 적용해 RPM/RPD/TPM 초과 상황을 방어하도록 구성했습니다.

주요 변경 사항 🛠️

  • [AI 예상 소요시간 추천 API 추가]: POST /api/v1/ai/todos/recommend-duration 엔드포인트를 추가했습니다.
  • [타이머 기록 기반 추천]: timer_records.actual_seconds를 기준으로 비슷한 투두명 기록과 지정 태그의 최근 소요시간 경향을 Gemini 프롬프트에 전달합니다.
  • [Gemini 연동]: Spring Boot 서버에서 Gemini API를 직접 호출하고 추천 예상 소요시간을 JSON 응답으로 반환합니다.
  • [Redis 기반 quota 제한]: AI API 호출 전 RPM/RPD/TPM 제한을 검사해 과도한 호출 시 429 응답을 반환하도록 구현했습니다.
  • [Swagger 문서 추가]: AI 예상 소요시간 추천 API 요청/응답 및 실패 케이스를 문서화했습니다.
  • [환경변수 기반 설정]: Gemini API Key, 모델명, quota 제한값을 환경변수로 관리하도록 구성했습니다.

트러블 슈팅 ⚽️

  • Gemini API에서 단순 호출 구현만으로는 다중 사용자 요청 시 quota 초과 가능성이 있었습니다.
  • Redis 기반 rate limiter를 추가해 Gemini 호출 전 서버에서 quota를 먼저 검사하도록 처리했습니다.
  • 프롬프트에 전달되는 데이터가 많아질 경우 TPM 부담이 커질 수 있어 일단 비슷한 투두명 기록과 지정 태그 최근 기록을 각각 최대 5개로 제한했습니다. -> 해당 내용은 이후 조정하도록 하겠습니다.

테스트 결과 📄

./gradlew compileJava

스크린샷 📷

스크린샷 2026-07-08 오전 6 48 49

리뷰 요구사항 📢

  • Redis 기반 RPM/RPD/TPM 제한 방식이 현재 서비스 규모에 적절한지 검토 부탁드립니다.
  • AI 도메인을 별도 패키지로 분리한 현재 구조가 전체 아키텍처 관점에서 적절한지 확인 부탁드립니다.

📎 참고 자료 (선택)

없음

Summary by CodeRabbit

  • New Features

    • 투두의 예상 소요 시간을 AI로 추천받을 수 있는 기능이 추가되었습니다.
    • 제목과 태그를 함께 참고해 더 적절한 추천을 제공합니다.
    • AI 추천 요청에 대한 사용량 제한이 적용되어 안정성이 향상되었습니다.
  • Bug Fixes

    • AI 응답 형식이 불안정할 때도 더 안전하게 처리하도록 개선되었습니다.
    • 추천 소요 시간이 비정상적으로 내려오는 경우를 보정해 최소값을 보장합니다.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

투두 제목/태그 기반 AI 소요시간 추천 기능이 새로 추가되었다. 신규 컨트롤러/서비스/DTO/예외코드가 정의되고, 히스토리 조회 리포지토리, 프롬프트 빌더, Redis 기반 레이트 리미터, Gemini API 연동 서비스가 구현되었으며, local/prod 설정 파일에 관련 환경변수가 추가되었다.

Changes

AI 소요시간 추천 기능

Layer / File(s) Summary
요청/응답 DTO 및 예외/성공 코드
dto/request/RecommendDurationRequest.java, dto/response/RecommendDurationResponse.java, dto/response/GeminiDurationRecommendation.java, dto/TodoDurationHistory.java, service/AiTodoHistories.java, exception/AiErrorCode.java, exception/AiSuccessCode.java
요청/응답, 히스토리 데이터 구조, 레이트리밋 초과 오류코드 및 추천 완료 성공코드가 정의된다.
컨트롤러 엔드포인트
controller/AiTodoController.java
POST /api/v1/todos/recommend-duration에서 인증 사용자와 요청을 받아 AiTodoService를 호출하고 BaseResponse로 응답한다.
히스토리 조회 리포지토리
repository/AiTodoQueryRepository.java
제목 유사도 및 태그 기반 네이티브 SQL 조회와 결과를 TodoDurationHistory로 변환하는 로직이 추가된다.
히스토리 조회 서비스
service/AiTodoHistoryService.java
제목 유사도 히스토리와 태그 히스토리를 조합해 AiTodoHistories를 반환한다.
프롬프트 빌더
prompt/TodoDurationPromptBuilder.java
히스토리 데이터를 JSON 프롬프트 문자열로 변환하고, 초→분 환산 및 JSON 이스케이프 처리를 수행한다.
레이트 리미터
service/AiRequestRateLimiter.java
Redis Lua 스크립트로 사용자/글로벌 RPM·RPD·TPM을 검증하고 초과 시 예외를 발생시킨다.
Gemini API 연동
service/GeminiService.java
Gemini generateContent 엔드포인트 호출 및 응답 JSON에서 텍스트 추출 로직이 추가된다.
서비스 오케스트레이션
service/AiTodoService.java
히스토리 조회, 프롬프트 생성, 레이트 검증, Gemini 호출, 응답 파싱/검증/정규화를 조합해 최종 추천을 반환한다.
환경 설정
application-local.yml, application-prod.yml
Gemini API 키/모델 및 레이트리밋 값을 환경변수로 주입하는 ai 설정 블록이 추가된다.

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)
Loading

Suggested reviewers: Jy000n

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 소요시간 AI 추천 기능의 핵심 변경을 정확하고 간결하게 요약합니다.
Linked Issues check ✅ Passed 투두 제목으로 AI 소요시간을 추천하는 API와 요청·응답 흐름이 구현되어 링크된 이슈의 핵심 요구를 충족합니다.
Out of Scope Changes check ✅ Passed 추가된 Gemini 연동, 레이트리밋, DTO, 설정 변경은 모두 해당 AI 추천 기능을 지원하는 범위 안입니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#35-recommend-duration-only

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@aneykrap

aneykrap commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@laura-jung laura-jung left a comment

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.

외부 api 연결이 번거로운데 깔끔하게 연결해주셨네요. 개인적으로 코드중에 궁금한 내용들이 많아서 코멘트 남겨두었으니 답변 부탁드립니다!

private final AiTodoService aiTodoService;

@Override
@PostMapping("/recommend-duration")

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.

p4) 흠 지금 url이 ai/todos/recommend-duration으로 길다는 느낌이 있는 것 같아서 더 좋은 url이 있다면 변경하면 좋을 것 같아요 중간에 ai를 없애도 괜찮을 것 같습니다!

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.

넹 요거는 그럼 ai를 없애도록 하겠습니다!

public record GeminiDurationRecommendation(
Integer recommendedMinutes
) {
}

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.

p4) 여백 없애 주세요

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.

공백 제거해서 반영하겠습니다!

@Schema(description = "추천 예상 소요 시간(분)", example = "45")
Integer recommendedMinutes
) {
}

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.

p4) 여기도 여백 없애 주세요

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.

공백 제거해서 반영하겠습니다!

Comment on lines +38 to +42
현재 투두:
{
"title": "%s",
"tagId": %s
}

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.

응답에 현재 투두도 들어가야하나요?

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.

아래부분들도 응답에 필요한 json인지 설명해주시면 좋을 것 같습니다.

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.

저는 이 부분은 서버가 AI에게 전달하는 프롬프트여서 AI가 현재 어떤 투두를 기준으로 예상 소요 시간을 추천해야 하는지 판단하기 위해 필요한 정보 같습니다..!!

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.

현재 투두 부분은 Gemini가 추천을 계산할 때 참고하는 입력 컨텍스트이고 실제 응답으로 요구하는 JSON은 recommendedMinutes(예상소요시간)만 포함해용
다만 프롬프트상 입력 데이터와 응답 형식이 헷갈려 보이는것 같아서 더 잘 분리해서 작성해둘게용

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.

아래부분들도 응답에 필요한 json인지 설명해주시면 좋을 것 같습니다.

아래 JSON들은 응답값이 아니라 gemini한테 전달하는 과거 기록 컨텍스트입니당

Comment on lines +87 to +91
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", " ")
.replace("\r", " ")
.replace("\t", " ");

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.

이부분은 어떤 replace인지 설명 부탁드립니다.

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.

프롬프트 안에 사용자 입력 title이 문자열로 들어가다 보니 따옴표/역슬래시/개행/탭 때문에 JSON 형태가 깨지는 것을 막기 위한 escape 처리였어용 Gemini에게 전달하는 JSON 형태의 예시가 깨지지 않도록 특수문자를 치환했습니당

Comment on lines +39 to +40
lower(t.title) like lower(concat('%', :title, '%'))
or lower(:title) like lower(concat('%', t.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.

p3) or 양옆의 명령문이 동일한 것 같은데 확인 부탁드립니다.

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.

비슷한 투두 목록을 찾기 위한 조건이에용
예를 들어 사용자가 영어단어 공부하기로 입력했을 때 기존 투두 제목이 영어단어인 기록도 함께 찾기 위해서는 입력 제목이 기존 제목을 포함하는 경우와 기존 제목이 입력 제목을 포함하는 경우를 모두 확인하도록 했습니다.

근데 검색 품질이 떨어지는 것 같아서 full-text search 방식으로 변경하겠습니다! 그리고 추후에 데베가 많이 쓰일때를 고려해서 검색엔진까지 적용해보는것도 좋을것 같다고 생각이 드네용..

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에서는 기존 LIKE 기반 검색 조건은 유지하되 정확히 일치하는 제목과 더 강한 포함 관계가 먼저 조회되도록 정렬 우선순위를 추가했습니다.

full-text search로 전환하고 싶었는데 DB 인덱스/DDL 변경과 검색 기준 조정이 함께 필요해서 이번 PR 범위를 넘어선다고 판단했습니당... 검색 품질 개선은 후속 이슈로 분리해서 다시 진행하겠습니다.

많관부

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.

full text search 보다는 더 강함 포함 관계로 조회되도록하는게 좋아보이네요...!


private final EntityManager entityManager;

public List<TodoDurationHistory> findActualDurationHistoriesBySimilarTitle(

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.

아래 tagid가 들어가는 함수와 비슷한 로직인 것 같은데 뭐가 다른건가요?

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.

두 함수 모두 타이머 기록 기반 실제 소요시간을 가져오는 공통 목적은 같아요 차이는 첫 번째 함수는 title 유사도를 기준으로 과거 투두 기록을 찾고

두 번째 함수는 사용자가 선택한 tagId 기준으로 최근 투두 기록을 찾는 용도입니다

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.

두가지다 필요한가요? tagId 기준으로 투두 기록을 찾는 함수가 필요한 이유가 뭔지 궁금합니다!(둥글둥글)

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.

넹 TL분께서 현재 투두와 유사한 과거 데이터, 사용자의 최근 소요 시간 경향도 함께 AI에게 전달해서 판단하는 로직이 있으면 좋겠다고 의견 주셔서 반영했습니다!

혹시 몰라서 Gemini에게 전달되는 데이터와 추천 판단 흐름을 아래에 정리해두었습니다.
제목 기반 조회
현재 투두와 작업 내용이 유사한 과거 투두를 조회하고 해당 투두들의 실제 소요 시간을 전달

태그 기반 조회
사용자가 입력한 태그를 기준으로 같은 태그에 속한 최근 투두들의 실제 소요 시간을 조회해서 사용자의 최근 작업 소요 시간 경향도 함께 반영

이렇게 두 가지 데이터를 함께 넘겨서 단순히 제목이 비슷한 투두만 보는 것이 아니라 사용자의 최근 작업 패턴까지 고려해 소요 시간을 추천하도록 구성했습니다. 그리고 해당 조회는 함께 호출되는 구조입니당

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) 이 친구 repository 폴더에 있는게 맞나요? dto 폴더에 맞는 파일인 것 같습니다.

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.

오모나... 반영하겟습니당

KEY_PREFIX + "tpm:global:" + minuteBucket
);

Long allowed = redisTemplate.execute(

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.

불러와야하는 값들은 redis에 있는거 좋은 것 같네요

@laura-jung laura-jung left a comment

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.

수정사항 확인한번 부탁드립니다!


import io.swagger.v3.oas.annotations.media.Schema;

public record RecommendDurationResponse(@Schema(description = "추천 예상 소요 시간(분)", example = "45") Integer recommendedMinutes) {

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.

p4) 괄호 안에 들어간 친구들은 줄바꿈을 해줘야 가독성이 높아질 것 같아요 추가로 마지막 괄호는 여백 없애기!

Integer actualSeconds,
LocalDate date
) {
}

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.

p5) 여기도 여백!!!!


아래 데이터는 응답에 포함할 값이 아니라 추천 계산에만 참고할 입력 데이터야.

입력 데이터 - 현재 투두:

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.

입력 데이터로 분류하니 더 이해하기 좋은 것 같네요!!


private final EntityManager entityManager;

public List<TodoDurationHistory> findActualDurationHistoriesBySimilarTitle(

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.

두가지다 필요한가요? tagId 기준으로 투두 기록을 찾는 함수가 필요한 이유가 뭔지 궁금합니다!(둥글둥글)

Comment on lines +39 to +40
lower(t.title) like lower(concat('%', :title, '%'))
or lower(:title) like lower(concat('%', t.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.

full text search 보다는 더 강함 포함 관계로 조회되도록하는게 좋아보이네요...!

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) 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 기반 설정을 추가하는 게 좋아 보입니다!
배포시에도 안정적으로 가동될 수 있도록 수정해주세요!

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.

application-prod.yml에 추가했습니다!! 감사합니다

.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))

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.

이부분은 왜 바뀐건가요?

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.

확인해보니 AI 추천 API와 직접 관련된 변경이 아니고 이전에 Swagger 설정을 확인하는 과정에서 인증 관련 동작을 건들이다가 함께 커밋된 변경 같습니당.....
이번 PR 범위와 직접 관련이 없는 변경이고 제가 다른거 하다가 커밋된 거여서 기존 STATELESS 설정으로 되돌렸습니다.

Comment on lines +94 to +100
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;

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) 해당 부분에서 json이나 으로 시작하기만 하면 으로 끝나지 않아도 -3을 하는 상황이 발생할 수 있을 것 같습니다. 만약 Gemini가 실수로 이렇게 닫는 fence 없이 주면 json 끝부분이 잘려서 response가 망가질 수 있을 것 같아요! 따라서 자르기 전에 endsWith("")도 확인하는게 좋을 것 같습니다

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.

넵 감사합니당 반영했습니다!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 제거 권장.

toHistoriestoIntegertoLocalDate만 사용하며 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

AiTodoHistoriesdto 패키지로 이동을 고려해 보세요.

이 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 win

Redis 장애 시 예외 처리를 명시적으로 다루는 것을 권장합니다.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 77fee04 and cf2b870.

⛔ Files ignored due to path filters (1)
  • src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java is excluded by !**/docs/**
📒 Files selected for processing (16)
  • src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java
  • src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java
  • src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java
  • src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java
  • src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java
  • src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java
  • src/main/resources/application-local.yml
  • src/main/resources/application-prod.yml

Comment on lines +3 to +4
public record GeminiDurationRecommendation(Integer recommendedMinutes) {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +5 to +8
public record RecommendDurationResponse(
@Schema(description = "추천 예상 소요 시간(분)", example = "45")
Integer recommendedMinutes
) {} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

시스텀 단위가 seconds로 변경되었으나 응답 DTO가 여전히 minutes 기준입니다.

PR 코멘트에서 duration 필드가 minutes에서 seconds로 변경되었고(durationMinutesdurationSeconds, planned_minutesplanned_seconds 등), AI 추천 로직이 여전히 분 단위를 가정하는지에 대한 지적이 있었습니다. 현재 코드를 확인하면:

  • RecommendDurationResponserecommendedMinutes를 반환
  • GeminiDurationRecommendationrecommendedMinutes 사용
  • 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.

Comment on lines +36 to +63
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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

Comment on lines +48 to +51
"generationConfig", Map.of(
"temperature", 0.2,
"response_mime_type", "application/json"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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:


response_mime_typeresponseMimeType으로 바꾸세요.
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.

@Jy000n Jy000n left a comment

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.

우와웅 고생 많으셨습니다 쨖쨕쨕쨕쨕👍👍👍👍

@aneykrap aneykrap merged commit 0c42f6f into develop Jul 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] ai 소요시간 추천

3 participants