[Feat] 자기소개서 분석 누락 역량 키워드 저장 및 응답 추가#117
Conversation
- 자기소개서 분석 점수 가중치를 jobFit 50%, impact 30%, completeness 20%로 변경 - 분석 응답에 missingKeywords 필드 추가 - missingKeyword source enum 추가 - LLM missingKeywords 응답 검증 로직 추가 - 검증된 missingKeywords를 analyses.missing_keywords 컬럼에 JSON 배열 문자열로 저장 - POST 분석 응답과 GET 조회 응답 모두 저장된 missingKeywords 기준으로 반환 - 기존 GET 조회 시 JD/답변 기반 missingKeywords 재계산 로직 제거 - null, blank keyword, invalid source, 중복 keyword, 60자 초과 keyword 필터링 유지 - source 응답 값은 qualification, preference, mainTask로 유지 - JSON 직렬화/역직렬화 실패 시 [] fallback 및 warn 로그 처리 - missing status는 기존처럼 questionAnalysis에 저장하지 않도록 유지 - schema.sql에 analyses.missing_keywords 컬럼 추가 SQL 반영 - missingKeywords 검증, 저장, 조회 일관성, DB round-trip, malformed JSON fallback 테스트 보완 - 점수 계산 테스트 보완
|
Warning Review limit reached
Next review available in: 51 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds a ChangesMissing keywords feature
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant AnalysisService
participant AnalysisAiClient
participant Analysis
Client->>AnalysisService: analyze(...)
AnalysisService->>AnalysisAiClient: request analysis
AnalysisAiClient-->>AnalysisService: AnalysisLlmResponse (missingKeywords)
AnalysisService->>AnalysisService: buildMissingKeywords(llmResponse)
AnalysisService->>AnalysisService: serializeMissingKeywords(list)
AnalysisService->>Analysis: save(missingKeywordsJson)
AnalysisService->>AnalysisService: readMissingKeywords(analysis)
AnalysisService-->>Client: AnalysisResponse (missingKeywords)
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java (1)
309-344: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate validation logic between
buildMissingKeywordsandsanitizeStoredMissingKeywords.Both methods independently re-implement keyword length checks, dedupe via
normalizeKeyword, and theMAX_MISSING_KEYWORDScap. Consider extracting a shared helper that accepts a validated(keyword, source)pair plus the accumulator state, to keep the two validation paths (LLM response vs. persisted JSON) from silently diverging over time.♻️ Suggested consolidation
- private List<MissingKeywordResponse> buildMissingKeywords(AnalysisLlmResponse llmResponse) { + private List<MissingKeywordResponse> buildMissingKeywords(AnalysisLlmResponse llmResponse) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } - - List<MissingKeywordResponse> result = new ArrayList<>(); - Set<String> seenKeywords = new HashSet<>(); - - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { - if (item == null || !StringUtils.hasText(item.keyword())) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - Optional<MissingKeywordSource> source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; + return accumulateMissingKeywords(llmResponse.missingKeywords(), item -> + !StringUtils.hasText(item.keyword()) + ? Optional.empty() + : MissingKeywordSource.from(item.source()) + .map(source -> new MissingKeywordResponse(item.keyword().trim(), source))); } - private List<MissingKeywordResponse> sanitizeStoredMissingKeywords(List<MissingKeywordResponse> missingKeywords) { + private List<MissingKeywordResponse> sanitizeStoredMissingKeywords(List<MissingKeywordResponse> missingKeywords) { if (missingKeywords == null) { return List.of(); } - - List<MissingKeywordResponse> result = new ArrayList<>(); - Set<String> seenKeywords = new HashSet<>(); - - for (MissingKeywordResponse item : missingKeywords) { - if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, item.source())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; + return accumulateMissingKeywords(missingKeywords, item -> + item.source() == null || !StringUtils.hasText(item.keyword()) + ? Optional.empty() + : Optional.of(new MissingKeywordResponse(item.keyword().trim(), item.source()))); + } + + private <T> List<MissingKeywordResponse> accumulateMissingKeywords( + List<T> items, + Function<T, Optional<MissingKeywordResponse>> mapper + ) { + List<MissingKeywordResponse> result = new ArrayList<>(); + Set<String> seenKeywords = new HashSet<>(); + for (T item : items) { + if (item == null) { + continue; + } + Optional<MissingKeywordResponse> mapped = mapper.apply(item) + .filter(candidate -> candidate.keyword().length() <= MAX_MISSING_KEYWORD_LENGTH); + if (mapped.isEmpty() || !seenKeywords.add(normalizeKeyword(mapped.get().keyword()))) { + continue; + } + result.add(mapped.get()); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + return result; }Also applies to: 380-410
🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java` around lines 309 - 344, `buildMissingKeywords` duplicates the same validation and deduping rules that `sanitizeStoredMissingKeywords` also performs, so the two paths can drift over time. Extract the shared keyword validation/accumulation logic into a helper used by both methods, and have `buildMissingKeywords` and `sanitizeStoredMissingKeywords` call it with their respective inputs while preserving the `MAX_MISSING_KEYWORD_LENGTH`, `normalizeKeyword`, `MissingKeywordSource.from`, and `MAX_MISSING_KEYWORDS` checks.
🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java`:
- Around line 265-272: The formatted prompt in AnalysisAiClient.analyze()
contains unescaped percent signs, which will break the outer .formatted(...)
call at runtime. Update the prompt text so the percentage values used for the
server-side score weights are escaped as literal percent signs, and verify the
surrounding prompt string in the analyze() method remains valid after
formatting.
---
Nitpick comments:
In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java`:
- Around line 309-344: `buildMissingKeywords` duplicates the same validation and
deduping rules that `sanitizeStoredMissingKeywords` also performs, so the two
paths can drift over time. Extract the shared keyword validation/accumulation
logic into a helper used by both methods, and have `buildMissingKeywords` and
`sanitizeStoredMissingKeywords` call it with their respective inputs while
preserving the `MAX_MISSING_KEYWORD_LENGTH`, `normalizeKeyword`,
`MissingKeywordSource.from`, and `MAX_MISSING_KEYWORDS` checks.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: caa2de0b-77ec-4d79-8a81-a9201e0667ad
📒 Files selected for processing (9)
src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.javasrc/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.javasrc/main/resources/schema.sqlsrc/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java
| - missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다. | ||
| - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. | ||
| - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. | ||
| - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. | ||
| - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. | ||
| - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. | ||
| - jobFit, impact, completeness는 0~100 정수로 출력한다. | ||
| - 총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다. |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Confirm no test exercises buildPrompt()/AnalysisAiClient.analyze() with a real prompt build,
# and check for any other unescaped '%' occurrences in text-block templates that feed .formatted().
fd -e java --full-path 'analysis' | xargs rg -n 'AnalysisAiClient' -g '*Test*.java'
rg -n '%[^s%\n]' src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.javaRepository: JobDri-Developer/BackEnd
Length of output: 834
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant sections of AnalysisAiClient.java with line numbers.
sed -n '140,180p' src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
printf '\n----\n'
sed -n '225,280p' src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
printf '\n----\n'
# Show the exact line containing the formatted() call, if present nearby.
rg -n '\.formatted\(' src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
printf '\n----\n'
# Reproduce Java formatter behavior with the exact literal that appears on line 272.
python3 - <<'PY'
s = "총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다."
print(s)
# Show that Java-style formatting would treat this as invalid by using Python's percent formatter analogously.
# This is just a sanity check for literal percent parsing behavior.
try:
_ = s % ()
except Exception as e:
print(type(e).__name__, e)
PYRepository: JobDri-Developer/BackEnd
Length of output: 4464
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show where buildPrompt() is called relative to try/catch in AnalysisAiClient.analyze().
nl -ba src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java | sed -n '175,205p'
printf '\n----\n'
# Inspect the test to see whether a real prompt build path is exercised.
nl -ba src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java | sed -n '1,180p'Repository: JobDri-Developer/BackEnd
Length of output: 202
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the analyze() method boundary and the prompt-building helper with portable tools.
sed -n '175,215p' src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
printf '\n----\n'
sed -n '1,170p' src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java
printf '\n----\n'
# Find whether the test imports or mocks AnalysisAiClient or its dependency chain.
rg -n 'AnalysisAiClient|`@MockBean`|`@Mock`|analyze\(' src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.javaRepository: JobDri-Developer/BackEnd
Length of output: 14048
Escape the percent signs in this formatted prompt. Unescaped % in the outer .formatted(...) string will fail at runtime (50%, 30%, 20% need %%), so analyze() can crash before the OpenAI call is made.
🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java`
around lines 265 - 272, The formatted prompt in AnalysisAiClient.analyze()
contains unescaped percent signs, which will break the outer .formatted(...)
call at runtime. Update the prompt text so the percentage values used for the
server-side score weights are escaped as literal percent signs, and verify the
surrounding prompt string in the analyze() method remains valid after
formatting.
✨ 어떤 이유로 PR를 하셨나요?
📋 세부 내용 - 왜 해당 PR이 필요한지 작업 내용을 자세하게 설명해주세요
📸 작업 화면 스크린샷
🚨 관련 이슈 번호 [#91 ]
Summary by CodeRabbit
New Features
Bug Fixes