Skip to content

[Feat] 자기소개서 분석 누락 역량 키워드 저장 및 응답 추가#117

Merged
whc9999 merged 2 commits into
devfrom
feat/refine-scoring
Jul 6, 2026
Merged

[Feat] 자기소개서 분석 누락 역량 키워드 저장 및 응답 추가#117
whc9999 merged 2 commits into
devfrom
feat/refine-scoring

Conversation

@whc9999

@whc9999 whc9999 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

✨ 어떤 이유로 PR를 하셨나요?

  • feature 병합
  • 버그 수정(아래에 issue #를 남겨주세요)
  • 코드 개선
  • 코드 수정
  • 배포
  • 기타(아래에 자세한 내용 기입해주세요)

📋 세부 내용 - 왜 해당 PR이 필요한지 작업 내용을 자세하게 설명해주세요

  • 자기소개서 분석 점수 가중치를 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 테스트 보완
  • 점수 계산 테스트 보완

📸 작업 화면 스크린샷

⚠️ PR하기 전에 확인해주세요

  • 로컬테스트를 진행하셨나요?
  • 머지할 브랜치를 확인하셨나요?
  • 관련 label을 선택하셨나요?

🚨 관련 이슈 번호 [#91 ]

Summary by CodeRabbit

  • New Features

    • Added support for showing “missing keywords” in analysis results.
    • Analysis responses can now include keyword/source details alongside existing feedback.
  • Bug Fixes

    • Ensures missing keyword data is always returned as an empty list when unavailable.
    • Improves handling of stored analysis data so malformed entries no longer break results.
    • Keeps analysis output consistent when saving and reloading results.

- 자기소개서 분석 점수 가중치를 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 테스트 보완
- 점수 계산 테스트 보완
@whc9999 whc9999 self-assigned this Jul 6, 2026
@whc9999 whc9999 added the ✨ feat New feature or request label Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@whc9999, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 62e34383-7727-4de9-ae08-5b4b016278e6

📥 Commits

Reviewing files that changed from the base of the PR and between e01fa80 and 0a07057.

📒 Files selected for processing (2)
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java
📝 Walkthrough

Walkthrough

Adds a missingKeywords feature to the analysis pipeline: new DTOs and enum, a persisted missingKeywordsJson field on Analysis with schema migration, extended LLM prompt rules, AnalysisService logic to build/normalize/serialize/read/sanitize missing keywords, and corresponding test coverage.

Changes

Missing keywords feature

Layer / File(s) Summary
DTO and response contracts
.../dto/llm/AnalysisLlmResponse.java, .../dto/response/AnalysisResponse.java, .../dto/response/MissingKeywordResponse.java, .../dto/response/MissingKeywordSource.java
Adds missingKeywords fields to LLM and API response records, a new MissingKeywordItem nested record, a new MissingKeywordResponse record, and a MissingKeywordSource Jackson enum.
Entity persistence and schema
.../entity/Analysis.java, src/main/resources/schema.sql
Adds missingKeywordsJson field/column with '[]' default and a new create(...) overload; adds matching conditional ALTER TABLE migration.
LLM prompt contract
.../service/AnalysisAiClient.java
Extends output schema, evaluation criteria, writing rules, and validation checks to require/limit missingKeywords generation.
Service logic and wiring
.../service/AnalysisService.java
Adds constants, ObjectMapper injection, and helper methods to build, normalize, dedupe, serialize, read, and sanitize missing keywords; wires them into analyze, getAnalysis, getAnalysisByJobPostingSequence, and toResponse; updates scoring weights.
Test coverage
.../service/AnalysisServiceTest.java
Adds tests for missing-keyword validation/filtering, null handling, persisted-value retrieval, and malformed-JSON fallback; refactors saveJobPosting test helper.

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

Possibly related PRs

  • JobDri-Developer/BackEnd#61: Extends the analysis DTOs and service introduced there by adding missingKeywords to AnalysisLlmResponse, AnalysisResponse, and AnalysisService.
  • JobDri-Developer/BackEnd#89: Both modify getAnalysis(...) / getAnalysisByJobPostingSequence(...) in AnalysisService, with this PR extending them to return missingKeywords.
  • JobDri-Developer/BackEnd#111: Both modify AnalysisAiClient prompt validation and AnalysisService scoring/validation logic in the same code paths.

Suggested labels: 🐛 fix

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly matches the main change: adding missing-keyword storage and response support.
Description check ✅ Passed The template sections are present and the PR details, checks, and issue reference are sufficiently filled in.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/refine-scoring

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.

@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: 1

🧹 Nitpick comments (1)
src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java (1)

309-344: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate validation logic between buildMissingKeywords and sanitizeStoredMissingKeywords.

Both methods independently re-implement keyword length checks, dedupe via normalizeKeyword, and the MAX_MISSING_KEYWORDS cap. 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

📥 Commits

Reviewing files that changed from the base of the PR and between a36909d and e01fa80.

📒 Files selected for processing (9)
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java
  • src/main/resources/schema.sql
  • src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java

Comment on lines +265 to +272
- missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다.
- missingKeywords는 최대 3개이며, 없으면 []로 출력한다.
- missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다.
- improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다.
- 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다.
- fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다.
- jobFit, impact, completeness는 0~100 정수로 출력한다.
- 총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다.

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 | 🔴 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.java

Repository: 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)
PY

Repository: 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.java

Repository: 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.

@whc9999 whc9999 merged commit ffef57e into dev Jul 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant