Skip to content

[Feat] AI 노트 제목 생성 API 엔드포인트 추가#197

Merged
gaeunee2 merged 3 commits intodevfrom
feat/196-generate-note-title-api-endpoint
Feb 18, 2026
Merged

[Feat] AI 노트 제목 생성 API 엔드포인트 추가#197
gaeunee2 merged 3 commits intodevfrom
feat/196-generate-note-title-api-endpoint

Conversation

@gaeunee2
Copy link
Copy Markdown
Collaborator

@gaeunee2 gaeunee2 commented Feb 18, 2026

📌 관련 이슈

🏷️ PR 타입

  • ✨ 기능 추가 (Feature)
  • 🐛 버그 수정 (Bug Fix)
  • ♻️ 리팩토링 (Refactoring)
  • 📝 문서 수정 (Documentation)
  • 🎨 스타일 변경 (Style)
  • ✅ 테스트 추가 (Test)

📝 작업 내용

  • AI 기반 노트 제목 생성 API 추가 (POST /api/notes/{noteId}/generate-title)
  • GenerateTitleRequest DTO 추가
  • GeminiClient를 사용하여 제목 생성 요청
  • 제목 생성 후 노트 제목 업데이트
  • 제목 생성 실패 시 기존 제목 유지

📸 스크린샷

스크린샷 2026-02-19 011410 스크린샷 2026-02-19 011423

✅ 체크리스트

  • 코드 리뷰를 받을 준비가 완료되었습니다
  • 테스트를 작성하고 모두 통과했습니다
  • 문서를 업데이트했습니다 (필요한 경우)
  • 코드 스타일 가이드를 준수했습니다
  • 셀프 리뷰를 완료했습니다

📎 기타 참고사항

  • Gemini API를 통해 AI 제목을 생성합니다. 실패 시 기존 제목을 유지하므로 안정성을 확보할 수 있습니다.

Summary by CodeRabbit

  • 새 기능
    • AI가 사용자의 질문을 분석해 노트 제목을 자동으로 생성해 노트에 즉시 반영하는 기능이 추가되었습니다.
  • 변경
    • 노트 제목 허용 길이가 기존보다 짧아져 최대 30자로 제한됩니다.
  • 오류 처리
    • AI 생성 실패 시 관련 오류 응답이 추가되어 문제 발생 시 안내가 개선되었습니다.

@gaeunee2 gaeunee2 requested a review from chowon442 February 18, 2026 16:09
@gaeunee2 gaeunee2 self-assigned this Feb 18, 2026
@gaeunee2 gaeunee2 added enhancement New feature or request 📑Notes labels Feb 18, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

AI 기반 노트 제목 자동 생성 API를 추가했습니다. 클라이언트가 첫 질문을 보내면 Gemini/OpenRouter를 호출해 생성된 제목을 노트에 저장합니다. 요청/응답 DTO, 서비스 메서드, Gemini 클라이언트, 설정 및 에러 코드가 함께 추가·수정되었습니다.

변경사항

Cohort / File(s) Summary
컨트롤러
src/main/java/com/proovy/domain/note/controller/NoteController.java
POST /api/notes/{noteId}/generate-title 엔드포인트 추가(중복 삽입 존재 — 매핑 충돌 주의). GenerateTitleRequest 수신 및 noteService.generateNoteTitle 호출. Swagger 응답 메타데이터 추가.
DTOs
src/main/java/com/proovy/domain/note/dto/request/GenerateTitleRequest.java, src/main/java/com/proovy/domain/note/dto/request/UpdateNoteTitleRequest.java
새 레코드 GenerateTitleRequest(text) 추가(@NotBlank, @Size(max=2000)). UpdateNoteTitleRequest의 제목 길이 검증 상한을 50→30자로 축소 및 메시지 갱신.
서비스 계층
src/main/java/com/proovy/domain/note/service/NoteService.java, src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java
generateNoteTitle(Long userId, Long noteId, GenerateTitleRequest) 시그니처 추가 및 구현: 사용자 인증/권한 확인, 노트 조회, GeminiClient 호출, 제목 업데이트/저장, 오류 처리 추가.
AI 클라이언트
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java
OpenRouter 연동용 GeminiClient 추가: WebClient 호출, 요청 페이로드(model, messages, max_tokens, temperature), 응답 파싱용 record들( OpenRouterResponse/Choice/Message ), 생성된 제목을 30자 내로 트리밍 및 검증.
에러 코드
src/main/java/com/proovy/global/response/ErrorCode.java
기존 NOTE4001 메시지 길이 안내를 50→30자로 변경. AI 실패용 NOTE5001(AI 노트 제목 생성에 실패했습니다.) 추가.
설정
src/main/resources/application.yaml
openrouter.api 블록 추가: key(env: OPENROUTER_API_KEY) 및 model(google/gemini-2.5-flash-lite-preview-09-2025).

시퀀스 다이어그램

sequenceDiagram
    participant Client
    participant Controller as NoteController
    participant Service as NoteServiceImpl
    participant Gemini as GeminiClient
    participant OpenRouter as OpenRouter
    participant DB as Database

    Client->>Controller: POST /api/notes/{noteId}/generate-title (GenerateTitleRequest)
    activate Controller
    Controller->>Service: generateNoteTitle(userId, noteId, request)
    activate Service

    Service->>DB: 조회 note by noteId
    DB-->>Service: note 데이터
    Service->>Service: 권한/소유자 검증

    Service->>Gemini: generateNoteTitle(questionText)
    activate Gemini
    Gemini->>OpenRouter: POST /v1/chat/completions (model, messages, max_tokens, temperature)
    activate OpenRouter
    OpenRouter-->>Gemini: completion response
    deactivate OpenRouter
    Gemini-->>Service: generatedTitle (validated, ≤30 chars)
    deactivate Gemini

    Service->>DB: note.title 업데이트 및 저장
    DB-->>Service: 업데이트 결과
    Service-->>Controller: UpdateNoteTitleResponse
    deactivate Service
    Controller-->>Client: ApiResponse<UpdateNoteTitleResponse>
    deactivate Controller
Loading

예상 코드 리뷰 노력

🎯 4 (Complex) | ⏱️ ~45 minutes

관련 가능성 있는 PR

추천 검토자

  • chowon442
  • haein45

개요

AI 기반 노트 제목 자동 생성 기능을 위한 새로운 API 엔드포인트, 서비스 계층, 그리고 OpenRouter 연동 클라이언트를 추가했습니다. 사용자의 첫 질문을 기반으로 AI가 노트 제목을 생성하고 업데이트합니다.

변경사항 (요약 블록 — 업데이트된 내용)

계층 / 파일 요약
컨트롤러
src/main/java/com/proovy/domain/note/controller/NoteController.java
POST /api/notes/{noteId}/generate-title 엔드포인트 추가. 요청 검증 및 서비스 위임 처리. 주의: 동일한 메서드가 중복 삽입되어 있어 매핑 충돌 가능성 있음.
DTO
src/main/java/com/proovy/domain/note/dto/request/GenerateTitleRequest.java
제목 생성 요청용 record 추가. text 필드는 @NotBlank@Size(max=2000) 검증 포함.
서비스 계층
src/main/java/com/proovy/domain/note/service/NoteService.java, src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java
generateNoteTitle 메서드 추가. 사용자 인증, 노트 조회, 제목 생성(GeminiClient 호출), 저장 및 응답 반환 처리. 오류(노트 없음, 권한 없음, AI 실패) 처리 포함.
AI 클라이언트
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java
OpenRouter API 연동 컴포넌트 추가. WebClient 기반 요청 처리, 응답 파싱용 record들, 최대 30자 제한 제목 생성 및 검증 로직 포함.
에러 코드
src/main/java/com/proovy/global/response/ErrorCode.java
AI 제목 생성 실패 시 사용할 NOTE5001 에러 코드 추가 및 NOTE4001 메시지 길이 제한을 30자로 변경.
설정
src/main/resources/application.yaml
OpenRouter API 키 및 모델(google/gemini-2.5-flash-lite-preview-09-2025) 설정 추가.

시 🐰

첫 질문 속에 빛이 보이네,
내 귀로 들려온 작은 아이디어 🐇✨
서른 글자 안에 담아 보낼게,
노트는 웃고, 사용자는 기뻐하리.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning NoteController에서 동일한 엔드포인트가 중복으로 두 번 추가되었으며, 이는 범위 내 기능 구현과 관계없는 결함입니다. NoteController에서 중복된 generateNoteTitle 엔드포인트 중 하나를 제거하여 매핑 충돌을 해결하세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 PR의 주요 변경 사항인 AI 기반 노트 제목 생성 API 엔드포인트 추가를 정확하고 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명이 템플릿의 대부분 필수 섹션을 포함하고 있으며, 관련 이슈, PR 타입, 작업 내용, 스크린샷, 체크리스트, 참고사항이 모두 기입되어 있습니다.
Linked Issues check ✅ Passed PR이 이슈 #196의 모든 주요 요구사항을 충족합니다: POST 엔드포인트 추가, GenerateTitleRequest DTO 생성, GeminiClient를 통한 AI 제목 생성, 성공 시 제목 업데이트, 실패 시 기존 제목 유지, gemini-2.5-flash-lite-preview 모델 사용.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/196-generate-note-title-api-endpoint

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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 (3)
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java (2)

48-54: 사용자 입력이 프롬프트에 직접 삽입됩니다 (프롬프트 인젝션).

String.format을 통해 사용자 질문 텍스트가 프롬프트에 직접 삽입되고 있습니다. 제목 생성이라 실제 악용 위험은 낮지만, 사용자가 "제목만 출력" 규칙을 무시하도록 유도하는 입력을 보낼 수 있습니다. 현재로서는 심각한 문제는 아니지만 인지해 두시기 바랍니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines
48 - 54, The prompt currently inserts raw user input via String.format in
generateNoteTitle using TITLE_PROMPT_TEMPLATE, risking prompt/format injection;
sanitize and normalize questionText before templating: trim and truncate to 500,
remove or replace newline/control sequences and common instruction tokens (e.g.,
lines starting with "system:", "assistant:", "###"), escape percent signs (%) to
avoid String.format interpolation, and/or stop using String.format by using a
safe placeholder replacement (e.g., replace("{user}", escapedText)) so
TITLE_PROMPT_TEMPLATE receives an escaped, instruction-free questionText.

16-27: @Value 필드를 final + 생성자 주입으로 변경하면 테스트가 용이해집니다.

현재 apiKeymodel은 필드 주입 방식이라 단위 테스트 시 리플렉션 없이 값을 설정하기 어렵습니다.

♻️ 생성자 주입 방식으로 개선 제안
 `@Slf4j`
 `@Component`
-@RequiredArgsConstructor
 public class GeminiClient {
 
     private final WebClient webClient;
-
-    `@Value`("${openrouter.api.key}")
-    private String apiKey;
-
-    `@Value`("${openrouter.api.model:google/gemini-2.5-flash-lite-preview-09-2025}")
-    private String model;
+    private final String apiKey;
+    private final String model;
+
+    public GeminiClient(
+            WebClient webClient,
+            `@Value`("${openrouter.api.key}") String apiKey,
+            `@Value`("${openrouter.api.model:google/gemini-2.5-flash-lite-preview-09-2025}") String model
+    ) {
+        this.webClient = webClient;
+        this.apiKey = apiKey;
+        this.model = model;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines
16 - 27, Convert the `@Value` field injections for apiKey and model in the
GeminiClient class to constructor injection: make the fields final (private
final String apiKey; private final String model;), remove the `@Value` annotations
from the fields, and add a constructor that accepts WebClient,
`@Value`("${openrouter.api.key}") String apiKey, and
`@Value`("${openrouter.api.model:...}") String model (or annotate the constructor
with `@Autowired` and annotate the apiKey/model parameters with `@Value`). Keep the
existing final WebClient and remove field injection so GeminiClient has all
three dependencies injected via the constructor (no reflective field access),
which makes unit testing easier.
src/main/resources/application.yaml (1)

142-148: 안정적인 GA 모델 버전으로 변경하세요.

google/gemini-2.5-flash-lite-preview-09-2025는 OpenRouter에서 유효하지만, 동일한 기능의 안정적인 GA 버전 google/gemini-2.5-flash-lite가 2025년 7월부터 사용 가능합니다. 프로덕션 환경에서는 안정성을 위해 GA 버전으로 변경하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/application.yaml` around lines 142 - 148, 현재
application.yaml의 openrouter.api.model 값이 불안정한 프리뷰 버전인
"google/gemini-2.5-flash-lite-preview-09-2025"로 설정되어 있습니다; 이를 프로덕션용 안정된 GA 버전인
"google/gemini-2.5-flash-lite"로 변경하세요 (참조: 설정 키 openrouter.api.model).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 308-315: The current catch for geminiClient.generateNoteTitle in
NoteServiceImpl incorrectly throws BusinessException(ErrorCode.NOTE5001); change
it so failures are non-fatal: log the warning (keep log.warn("Gemini 제목 생성 실패 -
noteId: {}, error: {}", noteId, e.getMessage())) but do NOT throw — instead
assign generatedTitle to the note's existing title (e.g., obtain current title
from the Note entity or request payload, e.g., note.getTitle() or
request.title()) and continue normal flow; if your intent was to expose errors,
update the controller/Swagger text instead of keeping the throw in
generateNoteTitle's catch.
- Around line 317-319: geminiClient.generateNoteTitle()의 반환값이 빈 문자열/공백일 수 있으므로
generatedTitle을 Note.updateTitle 호출 전에 검증하고 유효하지 않다면 대체 제목(예: 기존 제목 유지 또는 기본
제목)으로 처리하여 빈 제목이 저장되지 않도록 수정하세요; 구체적으로 NoteServiceImpl에서 generatedTitle 변수 검사(예:
null/trim().isEmpty())를 하고 유효하면 note.updateTitle(generatedTitle) 호출, 그렇지 않으면
note.updateTitle(...)를 건너뛰거나 기본 제목을 설정한 뒤 noteRepository.save(note)를 호출하도록
변경하세요.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 76-83: The null-check currently verifies response,
response.choices(), choices().isEmpty(), and message() but not
message().content(), so calling
response.choices().get(0).message().content().trim() can NPE when content is
null; update the validation in GeminiClient to also check that
response.choices().get(0).message().content() is non-null (and non-blank if
desired) before assigning to title, and if null provide a safe fallback or throw
a clear RuntimeException indicating empty content instead of allowing a
NullPointerException.
- Around line 67-74: The current blocking call that retrieves OpenRouterResponse
via webClient.post() uses .bodyToMono(...).block() with no timeout, risking
indefinite thread blocking; change the reactive chain to enforce a bounded wait
(for example, apply .timeout(Duration.ofSeconds(X)) on the Mono returned by
bodyToMono(OpenRouterResponse.class) or configure a WebClient TCP response
timeout) and handle the resulting TimeoutException so servlet threads are
protected; update the call site around
webClient.post()/.bodyToMono(OpenRouterResponse.class)/.block() and add
appropriate logging/error handling when the timeout triggers.

---

Nitpick comments:
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 48-54: The prompt currently inserts raw user input via
String.format in generateNoteTitle using TITLE_PROMPT_TEMPLATE, risking
prompt/format injection; sanitize and normalize questionText before templating:
trim and truncate to 500, remove or replace newline/control sequences and common
instruction tokens (e.g., lines starting with "system:", "assistant:", "###"),
escape percent signs (%) to avoid String.format interpolation, and/or stop using
String.format by using a safe placeholder replacement (e.g., replace("{user}",
escapedText)) so TITLE_PROMPT_TEMPLATE receives an escaped, instruction-free
questionText.
- Around line 16-27: Convert the `@Value` field injections for apiKey and model in
the GeminiClient class to constructor injection: make the fields final (private
final String apiKey; private final String model;), remove the `@Value` annotations
from the fields, and add a constructor that accepts WebClient,
`@Value`("${openrouter.api.key}") String apiKey, and
`@Value`("${openrouter.api.model:...}") String model (or annotate the constructor
with `@Autowired` and annotate the apiKey/model parameters with `@Value`). Keep the
existing final WebClient and remove field injection so GeminiClient has all
three dependencies injected via the constructor (no reflective field access),
which makes unit testing easier.

In `@src/main/resources/application.yaml`:
- Around line 142-148: 현재 application.yaml의 openrouter.api.model 값이 불안정한 프리뷰 버전인
"google/gemini-2.5-flash-lite-preview-09-2025"로 설정되어 있습니다; 이를 프로덕션용 안정된 GA 버전인
"google/gemini-2.5-flash-lite"로 변경하세요 (참조: 설정 키 openrouter.api.model).

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java (1)

84-86: RuntimeException 대신 커스텀 예외 사용을 고려해 주세요.

프로젝트에 이미 BusinessException/ErrorCode 패턴이 있으므로, 여기서도 해당 패턴을 사용하면 예외 처리 일관성이 향상됩니다. 현재 호출부(NoteServiceImpl)에서 catch (Exception e)로 잡고 있어 동작상 문제는 없습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines
84 - 86, Replace the RuntimeException thrown in GeminiClient where content==null
|| content.isBlank() with the project's BusinessException/ErrorCode pattern:
throw new BusinessException(ErrorCode.<NEW_OR_EXISTING_CODE>, "OpenRouter API
응답이 비어있습니다."); Add a new ErrorCode entry (e.g., EMPTY_OPENROUTER_RESPONSE or
OPENROUTER_EMPTY_RESPONSE) if none exists, and ensure GeminiClient's method
signature/behavior remains unchanged so callers like NoteServiceImpl continue to
catch as before.
src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java (1)

296-319: 외부 API 호출이 트랜잭션 내에서 수행되어 DB 커넥션을 불필요하게 점유합니다.

geminiClient.generateNoteTitle() 호출(최대 10초 블로킹)이 @Transactional 범위 안에서 실행되므로, API 응답을 기다리는 동안 DB 커넥션이 점유됩니다. 동시 요청이 많아지면 커넥션 풀 고갈로 이어질 수 있습니다.

외부 API 호출을 트랜잭션 밖으로 분리하는 것을 고려해 주세요:

♻️ 트랜잭션 분리 제안 (개념)
+    `@Override`
+    public UpdateNoteTitleResponse generateNoteTitle(Long userId, Long noteId, GenerateTitleRequest request) {
+        log.info("AI 노트 제목 생성 요청 - userId: {}, noteId: {}", userId, noteId);
+
+        // 1. 트랜잭션 내: 노트 조회 및 권한 확인
+        Note note = findNoteWithOwnerCheck(noteId, userId);
+
+        // 2. 트랜잭션 밖: 외부 API 호출
+        String generatedTitle;
+        try {
+            generatedTitle = geminiClient.generateNoteTitle(request.text());
+        } catch (Exception e) {
+            log.warn("Gemini 제목 생성 실패 - noteId: {}, 기존 제목 유지, error: {}", noteId, e.getMessage());
+            return UpdateNoteTitleResponse.builder()
+                    .noteId(note.getId())
+                    .title(note.getTitle())
+                    .updatedAt(note.getUpdatedAt())
+                    .build();
+        }
+
+        if (generatedTitle == null || generatedTitle.isBlank()) {
+            // 기존 제목 유지
+            ...
+        }
+
+        // 3. 트랜잭션 내: 제목 업데이트
+        return updateNoteTitleInternal(note.getId(), generatedTitle);
+    }

이 방식은 메서드에 @Transactional을 제거하고, 조회/저장 부분만 별도의 @Transactional 메서드로 분리하는 구조입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java` around
lines 296 - 319, The generateNoteTitle method currently calls
geminiClient.generateNoteTitle() while inside the transaction, which can hold DB
connections during the blocking external call; fix this by splitting
responsibilities: create a small `@Transactional` helper (e.g.,
fetchNoteForTitleUpdate or verifyOwnership) that uses
noteRepository.findById(...) to load the Note and check ownership (using
NoteServiceImpl::generateNoteTitle to call it), then return the Note DTO or
id/title, call geminiClient.generateNoteTitle(request.text()) outside any
transaction, and finally invoke another `@Transactional` helper (e.g.,
updateNoteTitle) that takes noteId and generatedTitle and performs the update +
save and returns the UpdateNoteTitleResponse; remove long-running external call
from the original transactional scope so only the short DB reads/writes run in
transactions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 49-55: The current generateNoteTitle method can throw formatting
errors if the user text contains percent-signs that are interpreted by
String.format; before calling String.format(TITLE_PROMPT_TEMPLATE,
truncatedText) escape percent signs in the user content (e.g. replace "%" with
"%%") so the input cannot be treated as format specifiers, then pass the escaped
string into String.format; update generateNoteTitle to perform this escape on
truncatedText prior to formatting with TITLE_PROMPT_TEMPLATE.

---

Duplicate comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 296-342: The generateNoteTitle method correctly handles note
lookup and permission checks (noteRepository.findById, NOTE4041/NOTE4031),
returns the existing title on Gemini failures (catch block around
geminiClient.generateNoteTitle) and defends against empty titles (generatedTitle
null/blank check); no code changes required—leave
NoteServiceImpl.generateNoteTitle as implemented.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 68-75: The .block(Duration.ofSeconds(10)) timeout fixed the
infinite-blocking issue, but replace the hard-coded Duration with a configurable
property and add null/timeout handling: make the timeout value configurable
(e.g., gemini.requestTimeout) and use it in the webClient call in GeminiClient
(the webClient.post()...bodyToMono(...).block(...)), and after the block call
check if response is null and throw or return a clear exception/error so callers
don't receive a NPE; also consider wrapping the call in try/catch to convert
WebClient exceptions into a meaningful GeminiClientException.
- Around line 77-86: The previous null-check issue around message().content() in
GeminiClient has been addressed but to make the code clearer and safer, extract
the first Choice's Message into a local variable (e.g., Message firstMessage =
response.choices().get(0).message()) and then set String content = firstMessage
== null ? null : firstMessage.content(); keep the existing content == null ||
content.isBlank() runtime check and throw; this avoids the complex ternary and
ensures message/content nullability is explicit in GeminiClient.

---

Nitpick comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 296-319: The generateNoteTitle method currently calls
geminiClient.generateNoteTitle() while inside the transaction, which can hold DB
connections during the blocking external call; fix this by splitting
responsibilities: create a small `@Transactional` helper (e.g.,
fetchNoteForTitleUpdate or verifyOwnership) that uses
noteRepository.findById(...) to load the Note and check ownership (using
NoteServiceImpl::generateNoteTitle to call it), then return the Note DTO or
id/title, call geminiClient.generateNoteTitle(request.text()) outside any
transaction, and finally invoke another `@Transactional` helper (e.g.,
updateNoteTitle) that takes noteId and generatedTitle and performs the update +
save and returns the UpdateNoteTitleResponse; remove long-running external call
from the original transactional scope so only the short DB reads/writes run in
transactions.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 84-86: Replace the RuntimeException thrown in GeminiClient where
content==null || content.isBlank() with the project's
BusinessException/ErrorCode pattern: throw new
BusinessException(ErrorCode.<NEW_OR_EXISTING_CODE>, "OpenRouter API 응답이
비어있습니다."); Add a new ErrorCode entry (e.g., EMPTY_OPENROUTER_RESPONSE or
OPENROUTER_EMPTY_RESPONSE) if none exists, and ensure GeminiClient's method
signature/behavior remains unchanged so callers like NoteServiceImpl continue to
catch as before.

Comment on lines +49 to +55
public String generateNoteTitle(String questionText) {
// 질문이 너무 길면 앞부분만 사용 (토큰 절약)
String truncatedText = questionText.length() > 500
? questionText.substring(0, 500)
: questionText;

String prompt = String.format(TITLE_PROMPT_TEMPLATE, truncatedText);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

String.format에 사용자 입력을 직접 전달하면 MissingFormatArgumentException이 발생할 수 있습니다.

questionText%s, %d, %n 등의 포맷 지정자가 포함되어 있으면 String.format이 이를 해석하려 시도하여 예외가 발생하거나 의도치 않은 결과가 반환됩니다. 사용자 입력은 포맷 인자로 전달하거나 문자열 치환 방식을 변경해야 합니다.

🐛 수정 제안
-    private static final String TITLE_PROMPT_TEMPLATE = """
-            다음 사용자 질문을 읽고, 노트 제목을 30자 이내로 만들어주세요.
-
-            규칙:
-            - 30자 이내
-            - 질문의 핵심 주제를 간결하게 표현
-            - 제목만 출력 (설명, 따옴표, 줄바꿈 없이)
-
-            질문: %s
-            """;
+    private static final String TITLE_PROMPT_TEMPLATE = """
+            다음 사용자 질문을 읽고, 노트 제목을 30자 이내로 만들어주세요.
+
+            규칙:
+            - 30자 이내
+            - 질문의 핵심 주제를 간결하게 표현
+            - 제목만 출력 (설명, 따옴표, 줄바꿈 없이)
+
+            질문: {{QUESTION}}
+            """;
-        String prompt = String.format(TITLE_PROMPT_TEMPLATE, truncatedText);
+        String prompt = TITLE_PROMPT_TEMPLATE.replace("{{QUESTION}}", truncatedText);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines
49 - 55, The current generateNoteTitle method can throw formatting errors if the
user text contains percent-signs that are interpreted by String.format; before
calling String.format(TITLE_PROMPT_TEMPLATE, truncatedText) escape percent signs
in the user content (e.g. replace "%" with "%%") so the input cannot be treated
as format specifiers, then pass the escaped string into String.format; update
generateNoteTitle to perform this escape on truncatedText prior to formatting
with TITLE_PROMPT_TEMPLATE.

Copy link
Copy Markdown
Member

@chowon442 chowon442 left a comment

Choose a reason for hiding this comment

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

좋아요~ 토큰 절약까지!! 🔥

@gaeunee2 gaeunee2 merged commit 62fa325 into dev Feb 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request 📑Notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] AI 기반 노트 제목 자동 생성 API 추가

2 participants