diff --git a/docs/evaluation/fewshot-static-dynamic-20260916.md b/docs/evaluation/fewshot-static-dynamic-20260916.md new file mode 100644 index 00000000..386cb4c7 --- /dev/null +++ b/docs/evaluation/fewshot-static-dynamic-20260916.md @@ -0,0 +1,127 @@ +# STATIC/DYNAMIC Few-shot 비교 평가 (2026-09-16) + +## 결론 + +DYNAMIC은 20건 중 12건 개선, 4건 동일, 4건 악화였습니다. Judge의 평균 overall usefulness는 +3.70에서 4.15로 0.45점(5점 척도) 상승했습니다. 품질 개선 신호는 분명하지만, +분석 평균 지연이 5,210ms에서 5,731ms로 약 10.0% 증가했고 4건이 악화됐으므로 +운영 활성화가 아니라 4번 임계값·top-k 튜닝으로 진행합니다. + +이번 결과만으로 기본 feature flag를 켜지 않습니다. 동일 조건 반복 평가와 악화 사례 분석 후 +최종 운영 적용 여부를 결정합니다. + +## 조건 + +- 평가일: 2026-09-16 (Asia/Seoul) +- holdout: `evaluation_cases_검수(2).csv`, EV-01~EV-20 총 20건 +- 후보: `fewshot-pm-reviewed-20260914-v2`, 승인 후보 5건 +- 후보와 holdout의 normalized input hash 정확 일치: 0건 +- 분석 모델: `gpt-4o-mini` +- 분석 temperature: 0.2 +- Judge 모델: `gpt-4o-mini` +- Judge temperature: 0.0 +- 분석 모드: single-pass +- DYNAMIC 설정: minSimilarity=-1.0, topK=5, minimumSelectedCount=1 +- STATIC/DYNAMIC 외 입력·모델·프롬프트 코드·Judge 조건 동일 +- 분석 및 Judge: 양쪽 각 20/20 성공 + +LLM 출력은 비결정적이므로 이 평가는 단일 실행 비교입니다. 같은 조건이어도 재실행 결과가 +달라질 수 있습니다. + +## 요약 + +| 항목 | STATIC | DYNAMIC | 변화 | +| --- | ---: | ---: | ---: | +| Overall usefulness | 3.70 | 4.15 | +0.45 | +| Relevance | 4.00 | 4.50 | +0.50 | +| Problem validity | 3.39 | 4.13 | +0.74 | +| Reason correctness | 3.92 | 4.35 | +0.43 | +| Context awareness | 3.32 | 4.13 | +0.81 | +| Faithfulness | 4.13 | 4.55 | +0.42 | +| Usability | 4.08 | 4.35 | +0.27 | +| Missing keyword precision | 3.55 | 4.55 | +1.00 | +| Missing keyword coverage | 3.65 | 4.55 | +0.90 | +| 평균 분석 지연 | 5,210ms | 5,731ms | +521ms (+10.0%) | +| P95 분석 지연 | 7,454ms | 8,273ms | +819ms (+11.0%) | +| Judge 평균 지연 | 4,584ms | 4,122ms | -462ms | +| Fatal error rate | 0% | 0% | 동일 | +| Unsupported fact rate | 0% | 0% | 동일 | +| False positive analysis rate | 0% | 0% | 동일 | + +Sentence type consistency는 4.11에서 4.50으로 상승했습니다. status별 정답률을 직접 계산할 +별도 정답 라벨은 holdout에 없으므로, 이 값과 Judge의 problem validity를 status 품질의 대리 +지표로 사용했습니다. + +## 케이스 분류 + +분류 기준은 케이스별 Judge overall usefulness의 DYNAMIC-STATIC 차이입니다. + +- 개선 12건: EV-01, EV-03, EV-04, EV-05, EV-08, EV-09, EV-10, EV-11, + EV-12, EV-13, EV-16, EV-20 +- 동일 4건: EV-06, EV-07, EV-17, EV-19 +- 악화 4건: EV-02, EV-14, EV-15, EV-18 + +가장 큰 개선은 EV-09·EV-11(+2), 가장 큰 악화는 EV-14(-2)였습니다. +EV-18은 STATIC의 `MISSED_MISSING_KEYWORD`가 DYNAMIC에서 `MISSED_ANALYSIS`로 바뀌었고, +DYNAMIC의 overall usefulness가 2점으로 내려갔습니다. 4번 튜닝에서 EV-02·14·15·18의 +선택 후보와 similarity를 우선 분석합니다. + +## 선택과 비용·호출량 + +- DYNAMIC 20건 모두 `EMBEDDING`, fallback 0건(0%). +- 전체 선택 similarity 평균: 0.4140 +- 케이스별 top similarity 평균: 0.4778 +- 케이스별 bottom similarity 평균: 0.3503 +- minSimilarity=-1.0, topK=5라서 모든 케이스에 후보 5건이 들어갔습니다. +- 최초 품질 실행에서 예상한 Cohere 호출 수는 STATIC 0회, DYNAMIC 21회였습니다. 아래 계측 + 재실행에서 sidecar의 논리 호출 수가 같은 값임을 확인했습니다. +- Judge 토큰: STATIC input 116,728 / output 9,226, + DYNAMIC input 115,906 / output 8,278. +- 분석 usage와 케이스별 논리적 Cohere 호출 수 계측을 추가한 뒤 동일 입력·설정으로 분석만 + 재실행했습니다(양쪽 20/20 성공). + +| 계측 재실행 | STATIC | DYNAMIC | 변화 | +| --- | ---: | ---: | ---: | +| 분석 input tokens | 160,941 | 311,081 | +150,140 (+93.3%) | +| 분석 output tokens | 10,438 | 9,903 | -535 (-5.1%) | +| 분석 total tokens | 171,379 | 320,984 | +149,605 (+87.3%) | +| Cohere 논리 호출 | 0 | 21 | +21 | +| 평균 분석 지연 | 5,511ms | 5,880ms | +369ms (+6.7%) | +| P95 분석 지연 | 8,344ms | 10,350ms | +2,006ms (+24.0%) | + +논리적 Cohere 호출은 애플리케이션의 embedding 요청 횟수이며 HTTP 계층의 내부 재전송은 +포함하지 않습니다. DYNAMIC의 첫 케이스는 query 1회와 document batch 1회, 이후 19건은 +query 1회씩 호출됐습니다. 재실행은 사용량 계측 목적이라 Judge를 다시 실행하지 않았으며, +품질 수치는 앞선 동일 조건 20건 비교 결과를 사용합니다. + +## 실행 중 발견하고 수정한 문제 + +1. Cohere v2 응답에는 `id`, `meta`, `response_type`, `texts`가 포함됩니다. DTO가 알 수 없는 + 필드를 거부해 모든 동적 선택이 LOCAL_FALLBACK 되던 문제를 수정했습니다. +2. single-pass 결과의 `sanitizedCandidateResponseJson` 값이 JSON `null`일 때 Judge 입력 생성이 + NullPointerException으로 실패하던 문제를 수정했습니다. + +각 문제에 실제 응답 형태 및 single-pass null 회귀 테스트를 추가했습니다. + +## 산출물 + +로컬 원본 결과는 `build/evaluation/fewshot-comparison-20260916/full/`에 있습니다. + +- `static-analysis.csv`, `dynamic-analysis.csv` +- 각 분석 CSV의 `*.fewshot..jsonl` 선택 메타데이터 +- `static-judge.csv`, `dynamic-judge.csv` +- `judge-comparison.csv` +- 각 실행 로그 + +사용량 계측 재실행 결과는 `build/evaluation/fewshot-comparison-20260916/usage-metrics/`의 +`static-analysis.csv`, `dynamic-analysis.csv`, 각 sidecar와 로그에 있습니다. + +`build/`는 Git 추적 대상이 아닙니다. 결과 재현이 필요하면 동일 holdout과 설정으로 다시 +실행하거나, 개인정보 보관 정책을 확인한 후 별도 안전한 저장소에 보관해야 합니다. + +## 다음 결정 + +3번의 품질·지연·토큰·Cohere 호출량 비교와 계측을 완료했습니다. DYNAMIC은 품질이 좋아졌지만 +분석 input token이 93.3% 증가했으므로 그대로 운영 활성화하지 않습니다. 다음은 4번 +`min-similarity`, `top-k`, `minimum-selected-count` 튜닝으로 품질 이득을 유지하면서 낮은 유사도 +후보와 토큰 비용을 줄이는 실험입니다. diff --git a/docs/evaluation/fewshot-tuning-20260916.md b/docs/evaluation/fewshot-tuning-20260916.md new file mode 100644 index 00000000..e20cb903 --- /dev/null +++ b/docs/evaluation/fewshot-tuning-20260916.md @@ -0,0 +1,74 @@ +# 동적 Few-shot 선택값 튜닝 (2026-09-16) + +## 결론 + +권장값은 다음과 같습니다. + +```yaml +analysis: + few-shot: + search: + top-k: 5 + min-similarity: 0.40 + minimum-selected-count: 2 +``` + +동적 선택 feature flag의 기본값은 계속 비활성입니다. 이 설정은 품질 평가와 단계적 운영 적용에 +사용할 권장 검색값이며, 운영 활성화를 의미하지 않습니다. + +## 평가 조건 + +- holdout: `evaluation_cases_검수(2).csv` 20건 +- 후보 데이터셋: `fewshot-pm-reviewed-20260914-v2` 5건 +- 분석·Judge 모델: `gpt-4o-mini` +- 분석 temperature: 0.2, Judge temperature: 0.0 +- 분석 모드: single-pass +- 각 실험의 분석·Judge 성공: 20/20 +- STATIC 품질과 TOPK5 품질은 기존 비교 평가 결과를 사용 +- 튜닝 3개 설정은 각각 새 분석·Judge 실행 + +LLM 출력은 비결정적이므로 단일 실행 결과입니다. 운영 활성화 전에는 권장 설정을 같은 holdout과 +신규 holdout에서 반복 평가해야 합니다. + +## similarity 분포 + +TOPK5 실행에서 선택된 100개 점수의 범위는 0.2763~0.6075, 평균은 0.4140, +중앙값은 0.4115였습니다. + +| 임계값 | 평균 통과 후보 수 | 0건 | 2건 미만 | +| ---: | ---: | ---: | ---: | +| 0.30 | 4.90 | 0 | 0 | +| 0.35 | 4.45 | 0 | 0 | +| 0.40 | 2.80 | 1 | 3 | +| 0.45 | 1.45 | 3 | 12 | +| 0.50 | 0.35 | 13 | 20 | + +0.35는 비용 절감이 작고, 0.45부터는 fallback 의존도가 지나치게 높아 0.40을 실험값으로 +선택했습니다. + +## 결과 + +| 설정 | 품질 평균 | STATIC 대비 개선/동일/악화 | 평균 선택 수 | 총 분석 토큰 | 평균 지연 | P95 지연 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| STATIC | 3.70 | 기준 | 4.00 | 171,379 | 5,511ms | 8,344ms | +| TOPK5, 임계값 없음 | 4.15 | 12/4/4 | 5.00 | 320,984 | 5,880ms | 10,350ms | +| TOPK3, 임계값 없음 | 3.70 | 6/9/5 | 3.00 | 251,195 | 5,266ms | 6,663ms | +| TOPK5, 0.40, 최소 1 | 3.95 | 7/10/3 | 3.05 | 251,825 | 5,149ms | 6,955ms | +| **TOPK5, 0.40, 최소 2** | **4.10** | **10/8/2** | **3.45** | **265,266** | **5,469ms** | **7,471ms** | + +권장값은 임계값 없는 TOPK5보다 총 분석 토큰을 55,718개(17.4%) 줄이면서 품질 평균은 +0.05만 낮았습니다. STATIC 대비 품질은 0.40 높고, 악화 사례는 2건입니다. + +`minimum-selected-count=2`에서는 17건이 EMBEDDING, 3건이 LOCAL_FALLBACK이었습니다. +Cohere 논리 호출은 모든 동적 설정에서 21회로 동일했습니다. 임계값은 프롬프트에 넣을 후보를 +줄이지만 query/document embedding 호출 자체를 줄이지는 않습니다. + +## 결정 + +- `top-k=3` 단독 적용은 품질 하락으로 제외합니다. +- `min-similarity=0.40`으로 낮은 유사도 후보를 제거합니다. +- 통과 후보가 2개 미만이면 로컬 fallback하여 지나치게 작은 예시 집합을 피합니다. +- 동적 선택 feature flag는 비활성으로 유지합니다. +- 신규 후보 추가 또는 embedding 모델 변경 시 분포와 임계값을 다시 평가합니다. + +로컬 원본은 `build/evaluation/fewshot-tuning-20260916/`에 있으며 Git 추적 대상이 아닙니다. diff --git a/docs/fewshot-cache-policy.md b/docs/fewshot-cache-policy.md new file mode 100644 index 00000000..a02716dd --- /dev/null +++ b/docs/fewshot-cache-policy.md @@ -0,0 +1,45 @@ +# Few-shot 메모리 캐시 정책 + +## 기본값 + +| 캐시 | 최대 크기 | TTL | +| --- | ---: | ---: | +| selection | 1,000 | 30분 | +| query embedding | 1,000 | 30분 | +| document embedding | 5,000 | 30분 | + +환경변수로 조정할 수 있습니다. + +```text +ANALYSIS_FEW_SHOT_CACHE_TTL=30m +ANALYSIS_FEW_SHOT_SELECTION_CACHE_MAX_SIZE=1000 +ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_CACHE_MAX_SIZE=1000 +ANALYSIS_FEW_SHOT_DOCUMENT_EMBEDDING_CACHE_MAX_SIZE=5000 +ANALYSIS_FEW_SHOT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT=20s +``` + +모든 최대 크기는 1 이상으로 보정합니다. selection과 document embedding 캐시는 접근할 때마다 +만료 항목을 먼저 제거하고, 상한을 넘으면 마지막 접근 시각이 오래된 항목부터 제거합니다. +query embedding 캐시는 기존 주기적 정리와 LRU 제거를 유지합니다. + +selection과 document embedding 생성이 진행 중인 키는 제거 대상에서 제외합니다. 생성이 끝나 in-flight 상태가 +해제된 직후 다시 상한을 적용하므로, 동시에 한 batch가 완료되는 짧은 구간에는 상한을 일시적으로 +넘을 수 있지만 장시간 초과 상태로 남지 않습니다. selection/query/document의 in-flight future는 +캐시 제거와 별도로 유지되므로 동일 키의 동시 요청이 외부 API를 중복 호출하지 않습니다. + +## 관측 + +`fewshot_cache_events_total`을 `cache`, `outcome` 태그로 나눠 확인합니다. + +```promql +sum(rate(fewshot_cache_events_total[10m])) by (cache, outcome) + +sum(rate(fewshot_cache_events_total{outcome="hit"}[10m])) by (cache) +/ +sum(rate(fewshot_cache_events_total{outcome=~"hit|miss"}[10m])) by (cache) + +sum(increase(fewshot_cache_events_total{outcome="evicted"}[1h])) by (cache) +``` + +eviction이 지속 증가하면서 hit 비율이 낮으면 캐시 상한을 늘리기 전에 고유 질의 수, 데이터셋 변경 +빈도와 실제 메모리 사용량을 함께 확인합니다. diff --git a/docs/fewshot-evaluation-metadata.md b/docs/fewshot-evaluation-metadata.md new file mode 100644 index 00000000..7198f4cb --- /dev/null +++ b/docs/fewshot-evaluation-metadata.md @@ -0,0 +1,79 @@ +# Few-shot 평가 메타데이터 + +후속 이슈 2번의 선택 이력 기록입니다. 운영 API·DB·기존 평가 CSV 컬럼은 변경하지 않습니다. +`EvaluationAnalysisRunner`가 사용하는 배치 서비스에서 실행마다 별도 JSONL 파일을 생성합니다. + +## 출력 및 연결 + +평가 출력이 `evaluation_ai_results.csv`라면 같은 폴더에 +`evaluation_ai_results.csv.fewshot..jsonl`이 생성됩니다. +정확한 경로는 배치 시작 로그의 `Few-shot evaluation metadata output`에 표시됩니다. +실행별 새 파일이므로 이전 메타데이터를 덮어쓰지 않습니다. + +- 한 줄은 입력 CSV의 데이터 행 하나에 대응합니다. +- `rowIndex`: 헤더를 제외한 1부터 시작하는 행 번호. +- `caseId`: 평가 입력의 ID. 중복 ID는 `rowIndex`로 구분합니다. +- `runId`: 한 실행 내에서 동일한 UUID. +- `outcome`: SUCCESS 또는 FAILED. +- `metadataStatus`: RECORDED 또는 UNAVAILABLE. +- `captureStage`: SELECTION_OBSERVED 또는 UNAVAILABLE. +- `selections`: 이 행에서 관측한 선택 스냅샷 배열. +- `schemaVersion=1`, `recordedAt`: sidecar 형식 버전 및 UTC 기록 시각. + +행 처리 직후 flush하므로 뒤 행에서 실패해도 앞 행의 메타데이터는 남습니다. +비정상 종료 시 sidecar는 부분 결과일 수 있으며, CSV 생성까지 완료되었다는 뜻은 아닙니다. +sidecar 저장 실패는 평가 실행 실패로 전파됩니다. + +## 선택 스냅샷 + +| selectionMode | 의미 | scoreType | +| --- | --- | --- | +| STATIC | 동적 선택 비활성, 기존 정적 전체 예시 | NONE | +| EMBEDDING | 임베딩 검색 후보 사용(캐시 반환 포함) | COSINE_SIMILARITY | +| LOCAL_FALLBACK | 로컬 기준으로 선택한 후보 사용 | LOCAL_HEURISTIC | +| STATIC_FALLBACK | 빈 결과·검색 예외 등으로 정적 전체 예시 복귀 | NONE | +| NOT_APPLIED | two-pass 경로에는 Few-shot 미적용 | NONE | + +- `selectedCases`: 실제 조립한 예시의 ID, source, score, 후보 datasetVersion. +- `topScore/bottomScore/avgScore`: 최종 선택 후보의 최대·최소·평균 점수. + 프롬프트 순서와 최대·최소 점수 순서는 다를 수 있습니다. +- `datasetVersion/minSimilarity/topK/minimumSelectedCount`: 실행의 선택 설정. + topK는 설정한 요청 개수이며 실제 개수는 selectedCases 배열 길이입니다. +- `cohereApiCallCount`: 해당 프롬프트 선택 중 발생한 논리적 Cohere embedding 호출 수. + 캐시 적중 시 0이며, SDK/HTTP 계층의 내부 재전송 횟수와는 구분합니다. +- `reason`: 정적 선택·fallback·미적용 사유 코드. 예외 메시지 원문은 넣지 않습니다. + +정적 예시 ID는 기존 로더와 같은 `FS-FIXED-1..N`입니다. +정적 후보의 datasetVersion은 `static-resource`로 표시하며, 검색 설정의 datasetVersion과 구분합니다. +정적 선택은 유사도 계산이 없으므로 후보 점수와 점수 통계는 **0이 아니라 null**입니다. +LOCAL_HEURISTIC 점수는 코사인 유사도와 섞어 분포를 비교하면 안 됩니다. + +## 실제 호출과의 관계 + +프롬프트 조립 시 검색을 한 번 수행하고 그 선택 결과를 기록합니다. +메타데이터 수집을 위한 추가 Cohere/OpenAI 호출은 없습니다. +기록은 외부 AI 호출 전에 전달되므로, 시간 초과나 응답 검증 실패 행에도 선택 이력이 남습니다. +따라서 RECORDED는 **프롬프트 선택을 관측했다는 뜻**이며, API 호출 성공이나 토큰 소비를 보장하지 않습니다. + +single-pass는 한 선택을, hybrid-exact는 single-pass 하위 호출의 선택을 기록합니다. +two-pass는 NOT_APPLIED를 기록합니다. +선택 전에 실패했거나 메타데이터를 지원하지 않는 다른 generator는 UNAVAILABLE로 기록하며 +STATIC으로 추정하지 않습니다. + +선택 스냅샷에는 자소서 원문, JD, 프롬프트 본문, 임베딩 벡터를 넣지 않습니다. +기존 평가 CSV의 원문 보존 동작은 바꾸지 않았으므로 CSV 접근 권한은 기존대로 관리해야 합니다. + +## 분석 사용량 + +평가 CSV의 `candidateInputTokens`, `candidateOutputTokens`, `finalInputTokens`, +`finalOutputTokens`, `totalInputTokens`, `totalOutputTokens`에는 OpenAI 응답 usage를 기록합니다. +single-pass 사용량은 final 컬럼에 기록되고 candidate 컬럼은 비어 있습니다. two-pass는 후보와 +최종 검토를 나눠 기록하며, 선택적으로 실행되는 recheck 사용량은 final에 합산합니다. + +## 검증 + +`./gradlew test --tests '*AnalysisAiClientTest' --tests '*Evaluation*' --tests '*fewshot.*' --tests '*FewShotMetadataPromptTest' --tests '*FewShotPromptProviderTest'` + +192개 통과(실패·건너뜀 0). 선택 이력과 프롬프트의 일치, 로컬 점수 구분, +정적 fallback, 모드별 실패 전 기록, 실패 행 보존, 중복 caseId 행 구분, +기존 CSV 컬럼 유지 및 실행별 파일 분리를 확인했습니다. 외부 AI 호출은 수행하지 않았습니다. diff --git a/docs/fewshot-operations.md b/docs/fewshot-operations.md new file mode 100644 index 00000000..7f6bf697 --- /dev/null +++ b/docs/fewshot-operations.md @@ -0,0 +1,95 @@ +# 동적 Few-shot 단계적 운영 가이드 + +## 운영 전제 + +- 기본값 `ANALYSIS_FEW_SHOT_DYNAMIC_SELECTION_ENABLED=false`를 유지합니다. +- 권장 검색값은 `top-k=5`, `min-similarity=0.40`, `minimum-selected-count=2`입니다. +- 승인 데이터셋 버전을 명시하고, 후보 변경 시 새 버전으로 배포합니다. +- 개인정보 정책은 `docs/fewshot-privacy-masking.md`를 따릅니다. + +## 관측 지표 + +Prometheus endpoint는 관리 포트의 `/actuator/prometheus`입니다. + +| 지표 | 태그 | 용도 | +| --- | --- | --- | +| `fewshot_selection_count_total` | `mode`, `cache_hit` | EMBEDDING·LOCAL_FALLBACK·STATIC_FALLBACK 비율 | +| `fewshot_selection_duration_seconds` | `mode`, `cache_hit` | 검색 평균·P95 지연 | +| `fewshot_selection_selected_candidates_count/_sum` | `mode`, `cache_hit` | 선택 후보 수 분포 | +| `fewshot_cohere_logical_calls_total` | 없음 | Few-shot이 발생시킨 Cohere 논리 호출량 | +| `fewshot_cohere_failure_count_total` | `reason` | Cohere 검색 실패 유형 | +| `fewshot_cache_events_total` | `cache`, `outcome` | 캐시별 hit·hit_after_claim·miss·expired·evicted 횟수 | + +원문 JD·답변·검색 텍스트·embedding은 지표 태그나 로그에 넣지 않습니다. `reason`은 정해진 예외 +분류만 허용하고 그 외 값은 `Other`로 묶어 tag cardinality 증가를 방지합니다. + +대표 PromQL: + +```promql +sum(rate(fewshot_selection_count_total[10m])) by (mode) + +sum(rate(fewshot_selection_count_total{mode=~"LOCAL_FALLBACK|STATIC_FALLBACK"}[10m])) +/ +sum(rate(fewshot_selection_count_total[10m])) + +histogram_quantile( + 0.95, + sum(rate(fewshot_selection_duration_seconds_bucket[10m])) by (le) +) + +sum(rate(fewshot_cohere_failure_count_total[10m])) by (reason) + +sum(increase(fewshot_cohere_logical_calls_total[1h])) +``` + +## 단계적 활성화 + +애플리케이션 feature flag는 boolean이므로 트래픽 비율은 배포 플랫폼의 인스턴스 또는 라우팅 +단위로 나눕니다. + +1. 평가·내부 인스턴스에서만 활성화하고 최소 1일 관측합니다. +2. 운영 canary 인스턴스 5%에서 활성화합니다. +3. 이상이 없으면 25% → 50% → 100% 순으로 확대합니다. +4. 각 단계에서 최소 하나의 일간 피크 구간을 포함해 관측합니다. +5. 단계 변경 시 datasetVersion·설정값·배포 시각을 운영 기록에 남깁니다. + +활성화 환경변수: + +```text +ANALYSIS_FEW_SHOT_DYNAMIC_SELECTION_ENABLED=true +ANALYSIS_FEW_SHOT_DATASET_VERSION=fewshot-pm-reviewed-20260914-v2 +ANALYSIS_FEW_SHOT_TOP_K=5 +ANALYSIS_FEW_SHOT_MIN_SIMILARITY=0.40 +ANALYSIS_FEW_SHOT_MINIMUM_SELECTED_COUNT=2 +``` + +## 중단 판단 기준 + +초기 canary에서는 다음 중 하나면 확대를 멈추고 원인을 확인합니다. + +- 10분간 LOCAL_FALLBACK + STATIC_FALLBACK 비율이 10% 초과 +- 10분간 Cohere 실패가 Few-shot 선택 요청의 5% 초과 +- Few-shot 선택 P95가 2초 초과 또는 기존 기준 대비 30% 이상 증가 +- 분석 전체 P95가 기존 기준 대비 20% 이상 증가 +- 시간당 Cohere 호출량이나 OpenAI 입력 토큰이 예상 범위를 20% 이상 초과 +- 회귀 샘플에서 unsupported fact 또는 false positive 증가 + +트래픽이 적으면 짧은 비율만으로 판단하지 않고 최소 20건 이상의 표본을 함께 확인합니다. + +## 즉시 복귀 + +1. 활성 인스턴스의 `ANALYSIS_FEW_SHOT_DYNAMIC_SELECTION_ENABLED=false`로 재배포합니다. +2. 선택 모드가 STATIC으로 돌아왔는지 로그와 지표로 확인합니다. +3. fallback이 아니라 feature flag 비활성으로 복귀했는지 확인합니다. +4. datasetVersion, 오류 시각, 실패 reason, 지연과 호출량을 장애 기록에 남깁니다. +5. 승인 데이터나 캐시를 삭제하지 않습니다. 원인 수정 후 평가 환경에서 재검증합니다. + +flag를 끄면 기존 정적 Few-shot 프롬프트로 돌아가며 API 응답·DB 스키마는 바뀌지 않습니다. + +## 활성화 승인 조건 + +- 승인 후보 데이터와 개인정보 검수 완료 +- STATIC/DYNAMIC 품질 비교와 튜닝 회귀 평가 완료 +- fallback·Cohere 실패·검색 P95·호출량 대시보드 확인 가능 +- canary 단계에서 중단 기준 미충족 +- 장애 시 flag 비활성 재배포 절차를 담당자가 실행 가능 diff --git a/docs/fewshot-privacy-masking.md b/docs/fewshot-privacy-masking.md new file mode 100644 index 00000000..382ab417 --- /dev/null +++ b/docs/fewshot-privacy-masking.md @@ -0,0 +1,44 @@ +# Few-shot 검색 개인정보 마스킹 정책 + +## 적용 범위 + +마스킹은 Cohere embedding으로 보내는 다음 검색 텍스트에 적용합니다. + +- 실시간 분석 질의의 JD, 문항, 답변 +- 승인 Few-shot 후보의 직무 정보, 문항, `sanitizedAnswer`, 태그 + +HTML 제거 후 마스킹하고, 그다음 공백 정규화와 길이 제한을 적용합니다. 동일한 마스커를 질의와 +후보 양쪽에 사용하므로 대체 토큰도 동일합니다. 원래 분석 프롬프트와 DB 원문을 변경하지 않습니다. + +## 자동 마스킹 + +| 유형 | 대체값 | +| --- | --- | +| 이메일 | `[EMAIL]` | +| 국내 전화번호·휴대전화 | `[PHONE]` | +| HTTP(S)·www URL | `[URL]` | +| 주민등록번호 형식 | `[RESIDENT_ID]` | +| `이름:`, `성명:`, `name:` 라벨 값 | `[NAME]` | +| `주소:`, `address:` 라벨 값 | `[ADDRESS]` | +| 계정·아이디·username 라벨 값 | `[ACCOUNT]` | +| 사번·학번·직원번호 라벨 값 | `[INTERNAL_ID]` | + +검색 및 Cohere 호출 로그에는 원문 검색 텍스트나 embedding 벡터를 기록하지 않습니다. 선택 로그와 +평가 sidecar에는 case ID, 후보 ID, source, 점수와 호출 횟수만 기록합니다. + +## 자동 마스킹하지 않는 항목 + +회사명·학교명은 직무와 경험의 의미를 크게 바꿀 수 있고, 일반 명사와의 구분도 불확실하므로 +일괄 제거하지 않습니다. 라벨 없는 사람 이름과 자유 형식 주소도 정규식만으로 안전하게 식별할 수 +없어 자동 탐지 범위 밖입니다. + +따라서 승인 후보는 PM 검수 단계에서 다음을 계속 확인해야 합니다. + +- 사람 이름과 자유 형식 주소 제거 +- 비공개 회사·학교 내부 프로젝트명 및 조직명 일반화 +- 고객명, 거래처명, 계정명, 사번·학번 등 내부 식별자 제거 +- 개인 포트폴리오·SNS·저장소 URL 제거 +- 여러 비식별 정보의 조합으로 특정인을 재식별할 수 있는지 확인 + +자동 마스킹은 PM 승인과 비식별 검수를 대체하지 않습니다. 새로운 개인정보 유형이 발견되면 원문을 +로그에 남기지 않은 채 대표 테스트를 먼저 추가하고 정책 범위를 갱신합니다. diff --git a/docs/fewshot-reviewed-loader.md b/docs/fewshot-reviewed-loader.md new file mode 100644 index 00000000..247f840a --- /dev/null +++ b/docs/fewshot-reviewed-loader.md @@ -0,0 +1,90 @@ +# PM 검수 Few-shot 적재 + +기존 `fix/fewshot-selection-safety-cache` 작업 브랜치에 로더 보강을 추가했습니다. +기존 dev에 후보 및 질의 임베딩 캐시가 있으므로 캐시는 중복 구현하지 않았습니다. + +## 변경 + +- CSV의 주요 업무·자격요건·우대사항 중 하나 이상 있으면 JD를 허용합니다. + 세 항목이 모두 빈 사례는 제외합니다. 누락된 원문 정보를 생성하지 않습니다. +- jobTitle을 보존하고, 없는 기존 CSV만 jobCategorySmall로 대체합니다. +- 우대사항을 예시 프롬프트에 포함합니다. +- approvedAnalysisJson은 단일 JSON 객체여야 합니다. 잘못된 행만 제외하고 다음 행을 읽습니다. + 검수 CSV와 JSON 리소스 모두 아래 부분 분석 계약을 검증합니다. 분석 의미의 최종 승인은 PM에게 있습니다. +- 활성·승인·caseId·문항·비식별 답변 검증을 유지합니다. + +## 로컬 평가 설정 + +검수 후보 CSV는 평가 입력(holdout)과 분리해야 합니다. +후보 JSON과 CSV는 동일 데이터이므로 동시에 로딩하지 않습니다. + +```properties +analysis.few-shot.dynamic-selection-enabled=true +analysis.few-shot.dataset-version=fewshot-pm-reviewed-20260914-v2 +analysis.few-shot.source.reviewed-evaluation-enabled=true +analysis.few-shot.reviewed-evaluation-resource= +analysis.few-shot.reviewed-evaluation-csv-path=/absolute/path/fewshot_candidates_approved.csv +``` + +운영 기본 활성화 설정은 변경하지 않았습니다. Python 워커 연결과 실제 API 비용이 +발생하는 평가는 이 변경에 포함하지 않습니다. + +## 검수 확인 + +사용자가 PM에게 재확인한 내용: FS-02-S1의 fabricated 변경은 의도된 것이며, +우선순위 1이 최상위입니다. FS-02-S1은 상태 변경 의도 때문에 보류한 것이 아니라 +최종 판정 이유와 정책 정합성 보완이 남아 활성 예시에서 제외한 상태입니다. + +## 검증 + +`./gradlew test --tests '*fewshot.*'`: 27개 성공. +선택 서비스의 기존 캐시·입력 제외 회귀 테스트와 CSV 로더의 선택 JD 필드, +직무명 호환, 우대사항 보존, 잘못된 JSON 행 이후 정상 행 적재를 확인했습니다. + +## 2026-09-15 승인 데이터 연결 및 검증 보강 + +- 기존 변환본 `fewshot-pm-reviewed-20260914-v2`의 FS-02·03·05·08·09, 총 5개를 + `analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json`에 반영했습니다. + 새 승인을 만들어낸 것이 아니라 기존 승인·비식별 통과 데이터만 연결했습니다. + 동일한 데이터이므로 버전은 v2를 유지합니다. 후속 데이터 변경 시 새 버전을 사용합니다. +- PM 우선순위 1이 최상위라는 확인은 변환본의 `10-rank` 값으로 이미 반영되어 있습니다. + 검색기는 큰 priority를 우선하므로 추가로 역전하지 않습니다. +- 기존 점수는 예시에 넣지 않았으며 보류 사례·문장도 다시 활성화하지 않았습니다. +- 데이터 및 프롬프트의 이메일·국내 휴대전화·HTTP URL 패턴 검사에서 일치 항목이 없었습니다. + 이는 이름·소속·경험 조합을 통한 재식별 위험까지 보장하는 개인정보 정책 검증은 아닙니다. + 원본 승인 범위는 유지합니다. 검색 입력 자동 마스킹과 수동 검수 범위는 + `docs/fewshot-privacy-masking.md`에 정의했습니다. + +### 부분 분석 계약 + +- `keyStrengths`, `missingKeywords`, `questionAnalyses`: 필수 배열. 빈 배열 허용. +- 강점 항목: 비어 있지 않은 문자열 `title`, `quote`. +- 누락 항목: 비어 있지 않은 `keyword`, 출처는 `mainTask|qualification|preference`. +- 문장 항목: 양의 정수 `questionId`, 비어 있지 않은 `sentence`, `reason`, + `status=proven|mentioned|fabricated`, `improvement`는 null 또는 비어 있지 않은 문자열. +- 점수·feedback은 부분 Few-shot 계약의 필수 필드가 아닙니다. +- CSV의 필수 헤더, 중복 헤더, 열 개수, 닫히지 않은 따옴표도 검사합니다. + 구조상 행 경계를 신뢰할 수 없는 CSV는 파일 단위로 제외합니다. +- JSON 리소스 내부 source로 승인 데이터 검증을 우회할 수 없도록 로딩 경로의 source를 적용합니다. +- 검증 통과 후 ID와 정규화 입력을 등록합니다. 같은 입력의 첫 유효 후보만 유지합니다. + 기존 검색 단계와 동일한 NFKC·공백·대소문자 정규화 및 SHA-256을 재사용합니다. + 입력 지문은 기존 호환성을 위해 주요 업무·자격요건·문항·답변을 기준으로 하며 + 우대사항만 다른 입력은 같은 입력으로 보수적으로 취급합니다. +- 파싱 오류 로그에는 답변이 포함될 수 있는 예외 메시지를 남기지 않습니다. + +### 평가 프로필 + +`analysis-eval,fewshot-pm-eval`을 명시적으로 함께 선택하면 검수 후보 5개만 사용합니다. +이 전용 프로필은 `analysis.mode=single-pass`로 지정합니다. two-pass에는 Few-shot이 적용되지 않습니다. +운영 기본 프로필이나 기본 feature flag는 변경하지 않았습니다. + +STATIC 비교 시 동일 프로필에서 `analysis.few-shot.dynamic-selection-enabled=false`만 덮어씁니다. +STATIC 또는 동적 실패 시 기존 정적 전체 블록이 사용되므로, 사용 후보 추적과 fallback 구분은 +후속 2번 메타데이터 기록 작업이 완료된 뒤 품질 평가에서 확인해야 합니다. +holdout은 후보 원본 및 단순 비식별 변형과 분리하세요. 해시 검사는 유사문·비식별 전후 변형까지 +탐지하지 못하므로 데이터 분할 검토를 대체하지 않습니다. + +이번 작업에서는 외부 AI 호출, 운영 활성화, 실제 품질 비교 평가를 수행하지 않았습니다. + +검증: `./gradlew test --tests '*fewshot.*' --tests '*AnalysisPromptBuilderTest' --tests '*FewShotPromptProviderTest' --tests '*Evaluation*'` +149개 통과(실패·건너뜀 0). `git diff --check` 통과. diff --git a/docs/job-application-archive-api.md b/docs/job-application-archive-api.md new file mode 100644 index 00000000..6682d235 --- /dev/null +++ b/docs/job-application-archive-api.md @@ -0,0 +1,20 @@ +# 지원관리 보관함 API + +기본 경로는 `/api/job-applications`이며 모든 API는 로그인 사용자가 소유한 카드만 처리한다. + +## 카드 보관과 복원 + +- `POST /{jobApplicationId}/archive`: 카드에 보관 시각을 기록하고 활성 보드에서 제외한다. 빠진 카드가 있던 단계의 수동 순서는 0부터 연속으로 다시 정리한다. +- `POST /{jobApplicationId}/restore`: 카드가 보관 전에 속했던 단계를 유지하며 해당 단계의 마지막 순서로 복원한다. + +이미 보관된 카드를 다시 보관하거나 활성 카드를 복원하면 `409 JOB_APPLICATION_4091`을 반환한다. + +## 보관함 조회 + +`GET /archive?page=0&size=10`은 현재 사용자의 보관 카드만 `archivedAt DESC, id DESC` 순서로 페이지 조회한다. `page`는 최소 0으로, `size`는 1부터 공통 최대 페이지 크기까지 보정한다. + +## 영구 삭제 + +`DELETE /{jobApplicationId}`는 지원 카드와 카드가 직접 소유한 기술 태그·체크리스트·정량 스펙·실제 제출용 자기소개서만 삭제한다. `sourceJobPostingId`가 가리키는 저장 공고와 `mockApplyId`가 가리키는 모의지원 및 분석 결과는 삭제하지 않는다. + +반대로 출처 공고나 연결된 모의지원이 먼저 삭제되면 연결 ID만 `null`이 되며 카드의 독립 스냅샷은 유지된다. diff --git a/docs/job-application-details-api.md b/docs/job-application-details-api.md new file mode 100644 index 00000000..9827ca11 --- /dev/null +++ b/docs/job-application-details-api.md @@ -0,0 +1,22 @@ +# 지원관리 상세 API + +기본 경로는 `/api/job-applications`이며 모든 요청은 로그인 사용자의 카드만 조회하거나 수정할 수 있다. 카드의 공고 정보와 상세 항목은 원본 `JobPosting` 및 `MockApply`와 자동 동기화되지 않는 독립 스냅샷이다. + +## 상세 조회 + +`GET /api/job-applications/{jobApplicationId}`는 공고 스냅샷, 현재 일정/라벨, 메모, 학점과 체크리스트·정량 스펙·실제 제출용 자기소개서를 반환한다. 각 목록은 저장된 `displayOrder` 오름차순이다. + +## 상세 전체 저장 + +`PUT /api/job-applications/{jobApplicationId}`는 아래 데이터를 한 트랜잭션에서 저장한다. + +- 공고 스냅샷: 회사명, 공고명, 직무명, 회사 규모, 직무 분류, JD 상세, 기술 태그, 마감 일시 +- 진행 정보: 자유 형식 라벨과 예정 일시, 지원 및 면접 메모 +- 정량 정보: `gpa`, `maxGpa`, `metrics` +- 세부 목록: `checklistItems`, `metrics`, `essays` + +목록 필드가 누락되거나 `null`이면 빈 목록으로 간주하며, 전달된 목록은 기존 목록을 완전히 교체한다. 응답의 `updatedAt`을 다음 요청의 필수 `lastKnownUpdatedAt`으로 전달해야 한다. 서버의 수정 시각이 더 최신이면 `409 JOB_APPLICATION_4091`을 반환한다. + +정량 스펙의 `type`은 `CERTIFICATE`, `LANGUAGE`, `AWARD`, `CUSTOM` 중 하나다. 학점과 만점은 둘 다 입력하거나 둘 다 비워야 하며 `0 <= gpa <= maxGpa`여야 한다. + +기본 제한은 기술 태그 20개(각 50자), 체크리스트 100개(각 200자), 자기소개서 20개(질문 1,000자·답변 10,000자), 메모 10,000자, 진행 라벨 100자다. 모든 일시는 ISO `LocalDateTime` 형식이며 Asia/Seoul 기준으로 해석한다. diff --git a/docs/job-application-kanban-api.md b/docs/job-application-kanban-api.md new file mode 100644 index 00000000..fa225c45 --- /dev/null +++ b/docs/job-application-kanban-api.md @@ -0,0 +1,40 @@ +# 지원관리 칸반 API (#303) + +선행 브랜치: `feat/#301-job-application-domain`. 모든 API는 로그인 사용자 소유 카드만 처리한다. + +## 보드 조회 + +`GET /api/job-applications/board?query=&sort=MANUAL` + +- `sort`: `MANUAL`(기본), `CREATED_DESC`. 알 수 없는 값은 400. +- `query`: 앞뒤 공백을 제거하고 회사명·공고명·직무명을 대소문자 구분 없이 부분 검색한다. `%`, `_`, `!`도 일반 문자로 검색한다. +- 응답은 공통 `ApiResponse.result` 안의 `sort`, `columns`이다. +- `columns`는 항상 `PLANNED`, `DOCUMENT`, `INTERVIEW`, `COMPLETED` 순서이며 각 열은 `stage`, `count`, `cards`를 가진다. 빈 열도 반환한다. +- 보관된 카드는 제외하고 `count`는 검색된 카드 수다. +- MANUAL은 저장된 `stageOrder`, ID 순서다. CREATED_DESC는 생성 시각, ID 내림차순이며 저장 순서를 변경하지 않는다. +- 카드 필드: `jobApplicationId`, `companyName`, `postingName`, `jobTitle`, `requiredSkills`, `essayQuestionCount`, `deadlineAt`, `currentLabel`, `currentAt`, `stage`, `stageOrder`, `createdAt`, `updatedAt`. +- 일시는 ISO LocalDateTime, Asia/Seoul 기준이며 D-day와 표시 색상은 클라이언트에서 계산한다. +- 자소서 개수는 지원 카드 소유 `job_application_essays`의 실제 개수다. 상세 편집 API는 #302에서 추가한다. MockApply 문항은 집계하지 않는다. + +## 카드 이동 + +`PATCH /api/job-applications/{jobApplicationId}/position` + +```json +{"targetStage":"INTERVIEW","targetIndex":0} +``` + +- targetIndex는 이동 카드를 제거한 뒤 도착 열에 삽입할 0 기반 위치다. 같은 열에서 맨 뒤는 원래 카드 수 - 1, 다른 열에서 맨 뒤는 도착 열의 카드 수다. +- 검색으로 감춰진 카드까지 포함한 **전체 열** 기준 위치다. 필터된 목록의 인덱스를 그대로 전송하면 안 된다. +- 클라이언트는 CREATED_DESC에서 드래그를 비활성화한다. +- 저장 후 전체 MANUAL 보드를 반환한다. 양쪽 열 순서와 갱신된 updatedAt을 함께 반영할 수 있다. +- 잘못된 단계·누락/음수/범위 초과 인덱스: 400. 존재하지 않는 카드: JOB_APPLICATION_4041. 다른 사용자 카드: 403. 보관된 카드: JOB_APPLICATION_4091 (409). +- 이동은 사용자 잠금 → 출발/도착 열의 활성 카드 잠금 → 양쪽 열 재정렬 → flush 순서의 단일 트랜잭션이다. 사용자 잠금은 빈 열과 #301의 동시 등록을 보호한다. +- 후속 보관·복원·삭제 구현에서도 순서 변경 전에 동일 사용자 잠금을 획득해야 한다. +- JOB_APPLICATION_MOVE 감사 이벤트를 기록한다. + +## 조회와 DB 적용 + +기술 태그는 fetch join, 문항 개수는 사용자별 GROUP BY 한 번으로 조회한다. 사용자 인증 조회를 포함해 카드가 늘어도 조회 쿼리는 최대 3개다. + +운영에는 #301 마이그레이션 적용 후 `ops/db/migrations/20260914_job_application_essays.sql`을 적용한다. 지원 카드 삭제 시 자소서는 함께 삭제되며 공고와 모의지원은 영향을 받지 않는다. diff --git a/ops/db/migrations/20260914_job_application_details.sql b/ops/db/migrations/20260914_job_application_details.sql new file mode 100644 index 00000000..d908fd12 --- /dev/null +++ b/ops/db/migrations/20260914_job_application_details.sql @@ -0,0 +1,37 @@ +ALTER TABLE job_applications + ADD COLUMN IF NOT EXISTS gpa NUMERIC(6, 3), + ADD COLUMN IF NOT EXISTS max_gpa NUMERIC(6, 3); + +ALTER TABLE job_applications + DROP CONSTRAINT IF EXISTS ck_job_applications_gpa; + +ALTER TABLE job_applications + ADD CONSTRAINT ck_job_applications_gpa CHECK ( + (gpa IS NULL AND max_gpa IS NULL) + OR (gpa >= 0 AND max_gpa >= 0 AND gpa <= max_gpa) + ); + +CREATE TABLE IF NOT EXISTS job_application_checklist_items ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + content VARCHAR(200) NOT NULL, + completed BOOLEAN NOT NULL, + display_order INTEGER NOT NULL CHECK (display_order >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_checklist_application + ON job_application_checklist_items (job_application_id, display_order); + +CREATE TABLE IF NOT EXISTS job_application_metrics ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + type VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + metric_value VARCHAR(200) NOT NULL, + display_order INTEGER NOT NULL CHECK (display_order >= 0), + CONSTRAINT ck_job_application_metrics_type + CHECK (type IN ('CERTIFICATE', 'LANGUAGE', 'AWARD', 'CUSTOM')) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_metrics_application + ON job_application_metrics (job_application_id, display_order); diff --git a/ops/db/migrations/20260914_job_application_essays.sql b/ops/db/migrations/20260914_job_application_essays.sql new file mode 100644 index 00000000..3e3f260f --- /dev/null +++ b/ops/db/migrations/20260914_job_application_essays.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS job_application_essays ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + question VARCHAR(1000) NOT NULL, + answer TEXT, + display_order INTEGER NOT NULL CHECK (display_order >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_essays_application + ON job_application_essays (job_application_id, display_order); diff --git a/ops/db/migrations/20260914_job_applications.sql b/ops/db/migrations/20260914_job_applications.sql new file mode 100644 index 00000000..e00ebfd7 --- /dev/null +++ b/ops/db/migrations/20260914_job_applications.sql @@ -0,0 +1,48 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS job_applications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_job_posting_id BIGINT REFERENCES job_postings(id) ON DELETE SET NULL, + mock_apply_id BIGINT UNIQUE REFERENCES mock_applies(id) ON DELETE SET NULL, + detail_classification_id BIGINT REFERENCES detail_classifications(id) ON DELETE SET NULL, + company_name VARCHAR(255) NOT NULL, + posting_name VARCHAR(255) NOT NULL, + job_title VARCHAR(255) NOT NULL, + company_size VARCHAR(20), + task TEXT, + requirement TEXT, + preferred TEXT, + deadline_at TIMESTAMP, + stage VARCHAR(20) NOT NULL, + stage_order INTEGER NOT NULL, + current_label VARCHAR(100), + current_at TIMESTAMP, + memo TEXT, + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_job_applications_stage + CHECK (stage IN ('PLANNED', 'DOCUMENT', 'INTERVIEW', 'COMPLETED')), + CONSTRAINT ck_job_applications_stage_order CHECK (stage_order >= 0) +); + +CREATE TABLE IF NOT EXISTS job_application_required_skills ( + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + skill_name VARCHAR(50) NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (job_application_id, display_order) +); + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_stage_order + ON job_applications (user_id, stage, stage_order) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_archived + ON job_applications (user_id, archived_at DESC) + WHERE archived_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_search + ON job_applications USING gin ( + (company_name || ' ' || posting_name || ' ' || job_title) gin_trgm_ops + ); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java index 49ba7b6f..697f5104 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java @@ -7,6 +7,7 @@ import com.openai.core.RequestOptions; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.ResponseUsage; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -33,6 +34,15 @@ public T createStructuredResponse( String prompt, Class responseType, Duration timeout + ) { + return createStructuredResponseWithUsage(operationName, prompt, responseType, timeout).content(); + } + + public StructuredCallResult createStructuredResponseWithUsage( + String operationName, + String prompt, + Class responseType, + Duration timeout ) { var params = ResponseCreateParams.builder() .model(analysisModel) @@ -53,8 +63,13 @@ public T createStructuredResponse( : openAIClient.responses().create(params, requestOptions) ); T structuredContent = analysisResponseParser.extractStructuredContent(response); + ResponseUsage usage = response.usage().orElse(null); success = true; - return structuredContent; + return new StructuredCallResult<>( + structuredContent, + toIntegerTokenCount(usage == null ? null : usage.inputTokens()), + toIntegerTokenCount(usage == null ? null : usage.outputTokens()) + ); } finally { asyncMetricsRecorder.recordLlmRequest( operationName, @@ -64,6 +79,16 @@ public T createStructuredResponse( } } + private Integer toIntegerTokenCount(Long tokens) { + if (tokens == null) { + return null; + } + return tokens > Integer.MAX_VALUE ? Integer.MAX_VALUE : tokens.intValue(); + } + + public record StructuredCallResult(T content, Integer inputTokens, Integer outputTokens) { + } + private long elapsedMillis(long startedAt) { return (System.nanoTime() - startedAt) / 1_000_000; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index 56e72fbb..0afcad0c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -1,5 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMetadata; +import java.util.function.Consumer; + import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; @@ -168,8 +171,20 @@ public AnalysisAiCallResult analyzeForEvaluationResult( AnalysisPromptInput promptInput, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, Instant deadline + ) { + return analyzeForEvaluationResult(promptInput, jobCategoryEvaluationCriteria, deadline, ignored -> {}); + } + + public AnalysisAiCallResult analyzeForEvaluationResult( + AnalysisPromptInput promptInput, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + Instant deadline, + Consumer recorder ) { try { + if (resolveAnalysisMode() == AnalysisMode.TWO_PASS) { + recorder.accept(analysisPromptBuilder.fewShotNotApplied()); + } return switch (resolveAnalysisMode()) { case TWO_PASS -> analyzeTwoPass( promptInput, @@ -183,14 +198,16 @@ public AnalysisAiCallResult analyzeForEvaluationResult( emptyContext(), jobCategoryEvaluationCriteria, "cover-letter-analysis-evaluation", - deadline + deadline, + recorder ); case SINGLE_PASS -> analyzeSinglePass( promptInput, emptyContext(), jobCategoryEvaluationCriteria, "cover-letter-analysis-evaluation", - deadline + deadline, + recorder ); }; } catch (GeneralException e) { @@ -210,16 +227,33 @@ private AnalysisAiCallResult analyzeSinglePass( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, String operationName, Instant deadline + ) { + return analyzeSinglePass(promptInput, referenceContext, jobCategoryEvaluationCriteria, operationName, deadline, ignored -> {}); + } + + private AnalysisAiCallResult analyzeSinglePass( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + String operationName, + Instant deadline, + Consumer recorder ) { long startedAt = System.nanoTime(); - AnalysisLlmResponse response = createStructuredResponse( + var callResult = createStructuredResponseWithUsage( operationName, - buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), + analysisPromptBuilder.buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, recorder), AnalysisLlmResponse.class, deadline ); + AnalysisLlmResponse response = callResult.content(); response = analysisResponseParser.sanitizeSinglePassSubheadings(promptInput, response); - return AnalysisAiCallResult.singlePass(response, elapsedMillis(startedAt)); + return AnalysisAiCallResult.singlePass( + response, + elapsedMillis(startedAt), + callResult.inputTokens(), + callResult.outputTokens() + ); } private AnalysisAiCallResult analyzeTwoPass( @@ -230,12 +264,13 @@ private AnalysisAiCallResult analyzeTwoPass( Instant deadline ) { long candidateStartedAt = System.nanoTime(); - AnalysisCandidateResponse rawCandidates = createStructuredResponse( + var candidateCallResult = createStructuredResponseWithUsage( operationName + "-candidates", buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), AnalysisCandidateResponse.class, deadline ); + AnalysisCandidateResponse rawCandidates = candidateCallResult.content(); long candidateLatencyMs = elapsedMillis(candidateStartedAt); AnalysisCandidateResponse sanitizedCandidates = sanitizeCandidates(promptInput, rawCandidates); log.debug( @@ -252,12 +287,13 @@ private AnalysisAiCallResult analyzeTwoPass( ); long finalStartedAt = System.nanoTime(); - CandidateReviewResponse reviewResponse = createStructuredResponse( + var finalCallResult = createStructuredResponseWithUsage( operationName + "-final", buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates), CandidateReviewResponse.class, deadline ); + CandidateReviewResponse reviewResponse = finalCallResult.content(); CandidateReviewResponse validatedReviewResponse = validateCandidateReview( promptInput, sanitizedCandidates, @@ -272,7 +308,7 @@ private AnalysisAiCallResult analyzeTwoPass( rejectedDecisionCount(validatedReviewResponse), rejectionCodeCounts(validatedReviewResponse) ); - CandidateReviewResponse recheckedReviewResponse = recheckWhenAllCandidatesRejected( + RecheckCallResult recheckCallResult = recheckWhenAllCandidatesRejected( promptInput, referenceContext, jobCategoryEvaluationCriteria, @@ -281,6 +317,7 @@ private AnalysisAiCallResult analyzeTwoPass( operationName, deadline ); + CandidateReviewResponse recheckedReviewResponse = recheckCallResult.reviewResponse(); AnalysisLlmResponse response = buildFinalResponse(promptInput, sanitizedCandidates, recheckedReviewResponse); long finalLatencyMs = elapsedMillis(finalStartedAt); logQuestionFlowStats(sanitizedCandidates, recheckedReviewResponse, response); @@ -308,7 +345,11 @@ private AnalysisAiCallResult analyzeTwoPass( sanitizedCandidates, recheckedReviewResponse, candidateLatencyMs, - finalLatencyMs + finalLatencyMs, + candidateCallResult.inputTokens(), + candidateCallResult.outputTokens(), + sumTokens(finalCallResult.inputTokens(), recheckCallResult.inputTokens()), + sumTokens(finalCallResult.outputTokens(), recheckCallResult.outputTokens()) ); } @@ -318,13 +359,25 @@ private AnalysisAiCallResult analyzeHybridExact( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, String operationName, Instant deadline + ) { + return analyzeHybridExact(promptInput, referenceContext, jobCategoryEvaluationCriteria, operationName, deadline, ignored -> {}); + } + + private AnalysisAiCallResult analyzeHybridExact( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + String operationName, + Instant deadline, + Consumer recorder ) { AnalysisAiCallResult singlePassResult = analyzeSinglePass( promptInput, referenceContext, jobCategoryEvaluationCriteria, operationName + "-single-pass", - deadline + deadline, + recorder ); AnalysisAiCallResult twoPassResult = analyzeTwoPass( promptInput, @@ -352,22 +405,22 @@ private AnalysisAiCallResult analyzeHybridExact( twoPassResult.sanitizedCandidateResponse(), twoPassResult.candidateReviewResponse(), twoPassResult.candidateCallLatencyMs(), - singlePassResult.finalCallLatencyMs() + twoPassResult.finalCallLatencyMs() + singlePassResult.finalCallLatencyMs() + twoPassResult.finalCallLatencyMs(), + twoPassResult.candidateInputTokens(), + twoPassResult.candidateOutputTokens(), + sumTokens(singlePassResult.finalInputTokens(), twoPassResult.finalInputTokens()), + sumTokens(singlePassResult.finalOutputTokens(), twoPassResult.finalOutputTokens()) ); } - private T createStructuredResponse(String operationName, String prompt, Class responseType) { - return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); - } - - private T createStructuredResponse( + private OpenAiAnalysisAdapter.StructuredCallResult createStructuredResponseWithUsage( String operationName, String prompt, Class responseType, Instant deadline ) { if (deadline == null) { - return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + return openAiAnalysisAdapter.createStructuredResponseWithUsage(operationName, prompt, responseType, null); } Duration remaining = Duration.between(Instant.now(), deadline); if (remaining.isZero() || remaining.isNegative()) { @@ -376,11 +429,15 @@ private T createStructuredResponse( "평가 사례 처리 시간이 제한을 초과했습니다." ); } - return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType, remaining); + return openAiAnalysisAdapter.createStructuredResponseWithUsage(operationName, prompt, responseType, remaining); } - private T createStructuredResponse(String operationName, String prompt, Class responseType, Duration timeout) { - return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + private Integer sumTokens(Integer left, Integer right) { + if (left == null && right == null) { + return null; + } + long sum = (left == null ? 0L : left.longValue()) + (right == null ? 0L : right.longValue()); + return sum > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) sum; } String buildPrompt( @@ -595,7 +652,7 @@ AnalysisLlmResponse buildFinalResponse( ); } - CandidateReviewResponse recheckWhenAllCandidatesRejected( + private RecheckCallResult recheckWhenAllCandidatesRejected( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, @@ -609,7 +666,7 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( : sanitizedCandidates.analysisCandidates().size(); int acceptedCandidates = acceptedDecisionCount(reviewResponse); if (firstPassCandidates == 0 || acceptedCandidates > 0) { - return reviewResponse; + return new RecheckCallResult(reviewResponse, null, null); } log.debug( @@ -619,12 +676,13 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( rejectedDecisionCount(reviewResponse), rejectionCodeCounts(reviewResponse) ); - CandidateRecheckResponse recheckResponse = createStructuredResponse( + var recheckCallResult = createStructuredResponseWithUsage( operationName + "-recheck", buildRecheckPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates, reviewResponse), CandidateRecheckResponse.class, deadline ); + CandidateRecheckResponse recheckResponse = recheckCallResult.content(); CandidateReviewResponse rechecked = applyRecheckResponse(promptInput, sanitizedCandidates, reviewResponse, recheckResponse); int recoveredMentionedCount = recoveredDecisionCount(rechecked, QuestionAnalysisStatus.MENTIONED); int recoveredFabricatedCount = recoveredDecisionCount(rechecked, QuestionAnalysisStatus.FABRICATED); @@ -642,7 +700,11 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( recheckResponse != null && StringUtils.hasText(recheckResponse.candidateId()), rejectedDecisionCount(rechecked) ); - return rechecked; + return new RecheckCallResult( + rechecked, + recheckCallResult.inputTokens(), + recheckCallResult.outputTokens() + ); } CandidateReviewResponse applyRecheckResponse( @@ -1719,6 +1781,13 @@ private enum RecheckValidationFailureReason { INVALID_FABRICATED } + private record RecheckCallResult( + CandidateReviewResponse reviewResponse, + Integer inputTokens, + Integer outputTokens + ) { + } + public record AnalysisAiCallResult( AnalysisLlmResponse response, AnalysisCandidateResponse rawCandidateResponse, @@ -1726,10 +1795,22 @@ public record AnalysisAiCallResult( CandidateReviewResponse candidateReviewResponse, boolean twoPassEnabled, long candidateCallLatencyMs, - long finalCallLatencyMs + long finalCallLatencyMs, + Integer candidateInputTokens, + Integer candidateOutputTokens, + Integer finalInputTokens, + Integer finalOutputTokens ) { - static AnalysisAiCallResult singlePass(AnalysisLlmResponse response, long latencyMs) { - return new AnalysisAiCallResult(response, null, null, null, false, 0, latencyMs); + static AnalysisAiCallResult singlePass( + AnalysisLlmResponse response, + long latencyMs, + Integer inputTokens, + Integer outputTokens + ) { + return new AnalysisAiCallResult( + response, null, null, null, false, 0, latencyMs, + null, null, inputTokens, outputTokens + ); } static AnalysisAiCallResult twoPass( @@ -1738,7 +1819,11 @@ static AnalysisAiCallResult twoPass( AnalysisCandidateResponse sanitizedCandidateResponse, CandidateReviewResponse candidateReviewResponse, long candidateCallLatencyMs, - long finalCallLatencyMs + long finalCallLatencyMs, + Integer candidateInputTokens, + Integer candidateOutputTokens, + Integer finalInputTokens, + Integer finalOutputTokens ) { return new AnalysisAiCallResult( response, @@ -1747,7 +1832,11 @@ static AnalysisAiCallResult twoPass( candidateReviewResponse, true, candidateCallLatencyMs, - finalCallLatencyMs + finalCallLatencyMs, + candidateInputTokens, + candidateOutputTokens, + finalInputTokens, + finalOutputTokens ); } @@ -1757,7 +1846,11 @@ static AnalysisAiCallResult hybridExact( AnalysisCandidateResponse sanitizedCandidateResponse, CandidateReviewResponse candidateReviewResponse, long candidateCallLatencyMs, - long finalCallLatencyMs + long finalCallLatencyMs, + Integer candidateInputTokens, + Integer candidateOutputTokens, + Integer finalInputTokens, + Integer finalOutputTokens ) { return new AnalysisAiCallResult( response, @@ -1766,7 +1859,11 @@ static AnalysisAiCallResult hybridExact( candidateReviewResponse, true, candidateCallLatencyMs, - finalCallLatencyMs + finalCallLatencyMs, + candidateInputTokens, + candidateOutputTokens, + finalInputTokens, + finalOutputTokens ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java index ce3e740a..5f46c8ba 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java @@ -8,7 +8,9 @@ import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMode; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMetadata; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; @@ -515,12 +517,23 @@ String buildSinglePassPrompt( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, ignored -> {}); + } + + String buildSinglePassPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + java.util.function.Consumer recorder ) { String questionText = formatQuestions(promptInput); String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); + FewShotPromptSelection selection = resolveFewShotPromptBlock(promptInput); + String fewShotPromptBlock = selection.prompt(); + recorder.accept(selection.metadata()); return """ [시스템 지시] @@ -604,10 +617,11 @@ String buildSinglePassPrompt( ); } - private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { + private FewShotPromptSelection resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { - return fewShotPromptProvider.getPrompt(); + return staticSelection("STATIC", "dynamic_disabled"); } + long cohereCallsBefore = fewShotSearchService.cohereApiCallCount(); try { List selectedFewShots = fewShotSearchService.searchRelevantFewShots( FewShotSearchQuery.from(promptInput), @@ -615,11 +629,13 @@ private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { ); if (selectedFewShots.isEmpty()) { log.warn( - "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", + "dynamic few-shot selection returned empty result. selectionMode={}, caseId={}, datasetVersion={}", + FewShotSelectionMode.STATIC_FALLBACK, promptInput.caseId(), fewShotProperties.getDatasetVersion() ); - return fewShotPromptProvider.getPrompt(); + return staticSelection("STATIC_FALLBACK", "empty_selection", + fewShotSearchService.cohereApiCallCount() - cohereCallsBefore); } log.debug( "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", @@ -629,20 +645,47 @@ private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), fewShotProperties.getDatasetVersion() ); - return fewShotPromptProvider.buildPromptBlock(selectedFewShots); + if (selectedFewShots.stream().anyMatch(item -> item == null || item.fewShotCase() == null + || !org.springframework.util.StringUtils.hasText(item.fewShotCase().promptBlock()) + || !Double.isFinite(item.score()))) { + return staticSelection("STATIC_FALLBACK", "invalid_selection", + fewShotSearchService.cohereApiCallCount() - cohereCallsBefore); + } + return new FewShotPromptSelection(fewShotPromptProvider.buildPromptBlock(selectedFewShots), + FewShotSelectionMetadata.selected(selectedFewShots, fewShotProperties, + fewShotSearchService.cohereApiCallCount() - cohereCallsBefore)); } catch (Exception e) { log.warn( - "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", + "dynamic few-shot selection failed. selectionMode={}, caseId={}, datasetVersion={}, reason={}, message={}", + FewShotSelectionMode.STATIC_FALLBACK, promptInput.caseId(), fewShotProperties.getDatasetVersion(), e.getClass().getSimpleName(), e.getMessage() ); log.debug("dynamic few-shot selection exception", e); - return fewShotPromptProvider.getPrompt(); + return staticSelection("STATIC_FALLBACK", "selection_exception", + fewShotSearchService.cohereApiCallCount() - cohereCallsBefore); } } + private FewShotPromptSelection staticSelection(String mode, String reason) { + return staticSelection(mode, reason, 0L); + } + + private FewShotPromptSelection staticSelection(String mode, String reason, long cohereApiCallCount) { + return new FewShotPromptSelection(fewShotPromptProvider.getPrompt(), + FewShotSelectionMetadata.staticSelection(mode, reason, + fewShotPromptProvider.getFixedExampleBlocks().size(), fewShotProperties, cohereApiCallCount)); + } + + FewShotSelectionMetadata fewShotNotApplied() { + return FewShotSelectionMetadata.staticSelection("NOT_APPLIED", "two_pass", 0, fewShotProperties, 0L); + } + + private record FewShotPromptSelection(String prompt, FewShotSelectionMetadata metadata) { + } + private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { if (criteria == null) { return ""; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchService.java index fc199397..628dbd61 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -17,30 +18,56 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Pattern; @Service @Slf4j public class DefaultFewShotSearchService implements FewShotSearchService { + private static final long QUERY_EMBEDDING_CACHE_CLEANUP_INTERVAL_MILLIS = 60_000L; + private static final long DEFAULT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT_MILLIS = 20_000L; + private static final long DEFAULT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT_MILLIS = 20_000L; private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^\\p{IsAlphabetic}\\p{IsDigit}가-힣]+"); private final FewShotCaseStore caseStore; private final FewShotSearchTextBuilder textBuilder; private final CohereEmbeddingClient cohereEmbeddingClient; private final FewShotProperties properties; - private final Map cache = new ConcurrentHashMap<>(); + private final FewShotMetricsRecorder metricsRecorder; + private final Map selectionCache = new ConcurrentHashMap<>(); + private final Map> selectionInFlight = new ConcurrentHashMap<>(); + private final AtomicBoolean selectionCacheCleanupInProgress = new AtomicBoolean(); + private final AtomicBoolean selectionCacheCleanupRequested = new AtomicBoolean(); + private final Map queryEmbeddingCache = new ConcurrentHashMap<>(); + private final Map> queryEmbeddingInFlight = + new ConcurrentHashMap<>(); + private final AtomicLong queryEmbeddingCacheNextCleanupAt = new AtomicLong(); + private final AtomicBoolean queryEmbeddingCacheCleanupInProgress = new AtomicBoolean(); + private final Map documentEmbeddingCache = new ConcurrentHashMap<>(); + private final Map> documentEmbeddingInFlight = + new ConcurrentHashMap<>(); + private final AtomicBoolean documentEmbeddingCacheCleanupInProgress = new AtomicBoolean(); + private final AtomicBoolean documentEmbeddingCacheCleanupRequested = new AtomicBoolean(); + @Autowired public DefaultFewShotSearchService( FewShotCaseStore caseStore, FewShotSearchTextBuilder textBuilder, CohereEmbeddingClient cohereEmbeddingClient, - FewShotProperties properties + FewShotProperties properties, + FewShotMetricsRecorder metricsRecorder ) { this.caseStore = caseStore; this.textBuilder = textBuilder; this.cohereEmbeddingClient = cohereEmbeddingClient; this.properties = properties; + this.metricsRecorder = metricsRecorder; } @Override @@ -50,33 +77,116 @@ public List searchRelevantFewShots(FewShotSearchQuery query return List.of(); } int requestedTopK = topK > 0 ? topK : properties.getSearch().getTopK(); - String cacheKey = cacheKey(query, requestedTopK); - CacheEntry cached = readCache(cacheKey); + long startedAt = System.nanoTime(); + List activeCases = caseStore.loadActiveCases(); + String datasetFingerprint = datasetFingerprint(activeCases); + String cacheKey = selectionCacheKey(query, requestedTopK, datasetFingerprint); + SelectionCacheEntry cached = readSelectionCache(cacheKey); if (cached != null) { - log.debug("few-shot selection cache hit. selectedCount={}, datasetVersion={}", cached.selectedCases().size(), properties.getDatasetVersion()); - return cached.selectedCases(); + return cachedSelection(cached, startedAt, "cache hit"); } - long startedAt = System.nanoTime(); - List activeCases = caseStore.loadActiveCases(); - List candidates = localPrefilter(activeCases, query); - List selected = selectWithCohere(query, candidates, requestedTopK); - if (selected.isEmpty() && properties.isFallbackEnabled()) { - selected = selectLocally(query, candidates, requestedTopK, "local-fallback"); + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = selectionInFlight.putIfAbsent(cacheKey, created); + if (existing != null) { + return cachedSelection(awaitSelection(existing), startedAt, "in-flight reuse"); } - if (properties.isCacheEnabled()) { - cache.put(cacheKey, new CacheEntry(selected, Instant.now().plus(properties.getCacheTtl()))); + SelectionCacheEntry cachedAfterClaim = readSelectionCache(cacheKey, false); + if (cachedAfterClaim != null) { + created.complete(cachedAfterClaim); + selectionInFlight.remove(cacheKey, created); + return cachedSelection(cachedAfterClaim, startedAt, "cache hit after claim"); } - log.info( - "dynamic few-shot selection completed. enabled=true, totalCandidates={}, filteredCandidates={}, selectedIds={}, sources={}, scores={}, latencyMs={}", - activeCases.size(), - candidates.size(), - selected.stream().map(item -> item.fewShotCase().id()).toList(), - selected.stream().map(item -> item.fewShotCase().source()).toList(), - selected.stream().map(item -> "%.4f".formatted(item.score())).toList(), + + try { + List candidates = localPrefilter(activeCases, query); + List selected = selectWithCohere(query, candidates, requestedTopK); + FewShotSelectionMode selectionMode = selected.isEmpty() + ? FewShotSelectionMode.STATIC_FALLBACK + : FewShotSelectionMode.EMBEDDING; + int minimumSelectedCount = Math.max( + 1, + Math.min(properties.getSearch().getMinimumSelectedCount(), requestedTopK) + ); + if (selected.size() < minimumSelectedCount && properties.isFallbackEnabled()) { + selected = selectLocally(query, candidates, requestedTopK, "local-fallback"); + if (!selected.isEmpty()) { + selectionMode = FewShotSelectionMode.LOCAL_FALLBACK; + } + } + SelectionCacheEntry entry = new SelectionCacheEntry(selected, selectionMode, expiresAt()); + if (properties.isCacheEnabled()) { + selectionCache.put(cacheKey, entry); + maintainSelectionCache(true); + } + created.complete(entry); + recordMetrics(selectionMode, false, selected.size(), startedAt); + log.info( + "dynamic few-shot selection completed. enabled=true, selectionMode={}, totalCandidates={}, filteredCandidates={}, selectedIds={}, sources={}, scores={}, latencyMs={}", + selectionMode, + activeCases.size(), + candidates.size(), + selected.stream().map(item -> item.fewShotCase().id()).toList(), + selected.stream().map(item -> item.fewShotCase().source()).toList(), + selected.stream().map(item -> "%.4f".formatted(item.score())).toList(), + (System.nanoTime() - startedAt) / 1_000_000 + ); + return selected; + } catch (RuntimeException | Error e) { + created.completeExceptionally(e); + throw e; + } finally { + selectionInFlight.remove(cacheKey, created); + if (properties.isCacheEnabled()) { + maintainSelectionCache(true); + } + } + } + + private List cachedSelection( + SelectionCacheEntry cached, + long startedAt, + String source + ) { + recordMetrics(cached.selectionMode(), true, cached.selectedCases().size(), startedAt); + log.debug( + "few-shot selection {}. selectionMode={}, selectedCount={}, datasetVersion={}", + source, + cached.selectionMode(), + cached.selectedCases().size(), + properties.getDatasetVersion() + ); + return cached.selectedCases(); + } + + private SelectionCacheEntry awaitSelection(CompletableFuture existing) { + long timeoutMillis = properties.getSelectionInFlightWaitTimeout() == null + ? DEFAULT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT_MILLIS + : Math.max(1L, properties.getSelectionInFlightWaitTimeout().toMillis()); + try { + return existing.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("공유된 Few-shot selection 대기 중 인터럽트되었습니다.", e); + } catch (ExecutionException e) { + throw new IllegalStateException("공유된 Few-shot selection 생성에 실패했습니다.", e.getCause()); + } catch (TimeoutException e) { + throw new IllegalStateException("공유된 Few-shot selection 대기 시간이 초과되었습니다.", e); + } + } + + private void recordMetrics( + FewShotSelectionMode selectionMode, + boolean cacheHit, + int selectedCount, + long startedAt + ) { + metricsRecorder.recordSelection( + selectionMode, + cacheHit, + selectedCount, (System.nanoTime() - startedAt) / 1_000_000 ); - return selected; } private List selectWithCohere( @@ -92,31 +202,343 @@ private List selectWithCohere( List documents = candidates.stream() .map(textBuilder::buildCandidateDocument) .toList(); - float[] queryEmbedding = cohereEmbeddingClient.embedQuery(queryText); - List documentEmbeddings = cohereEmbeddingClient.embedDocuments(documents); + float[] queryEmbedding = resolveQueryEmbedding(queryText); + List documentEmbeddings = resolveDocumentEmbeddings(candidates, documents); List ranked = new ArrayList<>(); + List similarityScores = new ArrayList<>(); for (int i = 0; i < candidates.size(); i++) { double score = cosineSimilarity(queryEmbedding, documentEmbeddings.get(i)); - if (score >= properties.getSearch().getMinRerankScore()) { + similarityScores.add(score); + if (score >= properties.getSearch().getMinSimilarity()) { ranked.add(new SelectedFewShotCase(candidates.get(i), score, "cohere-embedding")); } } + logSimilarityDistribution(similarityScores, ranked.size()); ranked.sort(Comparator .comparingDouble(SelectedFewShotCase::score).reversed() .thenComparingInt(item -> -item.fewShotCase().priority()) .thenComparing(item -> item.fewShotCase().id())); return diversify(ranked, topK); } catch (Exception e) { + metricsRecorder.recordCohereFailure(e.getClass().getSimpleName()); log.warn("dynamic few-shot Cohere selection failed. fallback=local, reason={}, message={}", e.getClass().getSimpleName(), e.getMessage()); log.debug("dynamic few-shot Cohere exception", e); return List.of(); } } + private void logSimilarityDistribution(List scores, int passedThresholdCount) { + if (scores.isEmpty()) { + return; + } + double topScore = scores.stream().mapToDouble(Double::doubleValue).max().orElse(0.0); + double bottomScore = scores.stream().mapToDouble(Double::doubleValue).min().orElse(0.0); + double avgScore = scores.stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + log.info( + "few-shot embedding similarity distribution. candidateCount={}, passedThresholdCount={}, minSimilarity={}, topScore={}, bottomScore={}, avgScore={}", + scores.size(), + passedThresholdCount, + formatScore(properties.getSearch().getMinSimilarity()), + formatScore(topScore), + formatScore(bottomScore), + formatScore(avgScore) + ); + } + + private static String formatScore(double score) { + return String.format(Locale.ROOT, "%.4f", score); + } + + private float[] resolveQueryEmbedding(String queryText) { + if (!properties.isCacheEnabled()) { + return embedQuery(queryText); + } + Instant now = Instant.now(); + maintainQueryEmbeddingCache(now); + String key = sha256(queryText); + QueryEmbeddingCacheEntry cached = readQueryEmbeddingCache(key, now); + if (cached != null) { + log.debug("few-shot query embedding cache hit."); + return cached.embedding(); + } + + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = queryEmbeddingInFlight.putIfAbsent(key, created); + if (existing != null) { + log.debug("few-shot query embedding in-flight request reused."); + return awaitQueryEmbedding(existing).embedding(); + } + + QueryEmbeddingCacheEntry cachedAfterClaim = readQueryEmbeddingCache(key, Instant.now(), false); + if (cachedAfterClaim != null) { + created.complete(cachedAfterClaim); + queryEmbeddingInFlight.remove(key, created); + log.debug("few-shot query embedding cache hit after in-flight claim."); + return cachedAfterClaim.embedding(); + } + + try { + QueryEmbeddingCacheEntry initialized = new QueryEmbeddingCacheEntry( + embedQuery(queryText), + expiresAt() + ); + queryEmbeddingCache.put(key, initialized); + maintainQueryEmbeddingCache(Instant.now()); + created.complete(initialized); + log.debug("few-shot query embedding cache initialized."); + return initialized.embedding(); + } catch (RuntimeException | Error e) { + created.completeExceptionally(e); + throw e; + } finally { + queryEmbeddingInFlight.remove(key, created); + } + } + + private QueryEmbeddingCacheEntry readQueryEmbeddingCache(String key, Instant now) { + return readQueryEmbeddingCache(key, now, true); + } + + private QueryEmbeddingCacheEntry readQueryEmbeddingCache(String key, Instant now, boolean recordEvent) { + QueryEmbeddingCacheEntry cached = queryEmbeddingCache.get(key); + if (cached == null) { + if (recordEvent) { + metricsRecorder.recordCacheEvent("query_embedding", "miss", 1L); + } + return null; + } + if (cached.expiresAt().isBefore(now)) { + queryEmbeddingCache.remove(key, cached); + if (recordEvent) { + metricsRecorder.recordCacheEvent("query_embedding", "expired", 1L); + } + return null; + } + QueryEmbeddingCacheEntry accessed = cached.accessedAt(now); + queryEmbeddingCache.replace(key, cached, accessed); + if (recordEvent) { + metricsRecorder.recordCacheEvent("query_embedding", "hit", 1L); + } + return accessed; + } + + private QueryEmbeddingCacheEntry awaitQueryEmbedding( + CompletableFuture existing + ) { + long timeoutMillis = properties.getQueryEmbeddingInFlightWaitTimeout() == null + ? DEFAULT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT_MILLIS + : Math.max(1L, properties.getQueryEmbeddingInFlightWaitTimeout().toMillis()); + try { + return existing.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Few-shot query embedding 대기 중 인터럽트되었습니다.", e); + } catch (ExecutionException e) { + throw new IllegalStateException("공유된 Few-shot query embedding 생성에 실패했습니다.", e.getCause()); + } catch (TimeoutException e) { + throw new IllegalStateException("공유된 Few-shot query embedding 대기 시간이 초과되었습니다.", e); + } + } + + private void maintainQueryEmbeddingCache(Instant now) { + int maxSize = Math.max(1, properties.getQueryEmbeddingCacheMaxSize()); + long nowMillis = now.toEpochMilli(); + if (queryEmbeddingCache.size() < maxSize + && nowMillis < queryEmbeddingCacheNextCleanupAt.get()) { + return; + } + if (!queryEmbeddingCacheCleanupInProgress.compareAndSet(false, true)) { + return; + } + try { + int sizeBefore = queryEmbeddingCache.size(); + queryEmbeddingCache.entrySet().removeIf(entry -> entry.getValue().expiresAt().isBefore(now)); + int sizeAfterExpiration = queryEmbeddingCache.size(); + metricsRecorder.recordCacheEvent("query_embedding", "expired", sizeBefore - sizeAfterExpiration); + if (sizeAfterExpiration >= maxSize) { + int trimTarget = Math.max(1, maxSize - Math.max(1, maxSize / 10)); + int removalCount = sizeAfterExpiration - trimTarget; + queryEmbeddingCache.entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getValue().lastAccessedAt())) + .limit(removalCount) + .forEach(entry -> queryEmbeddingCache.remove(entry.getKey(), entry.getValue())); + } + int evictedCount = sizeAfterExpiration - queryEmbeddingCache.size(); + metricsRecorder.recordCacheEvent("query_embedding", "evicted", evictedCount); + queryEmbeddingCacheNextCleanupAt.set(nowMillis + QUERY_EMBEDDING_CACHE_CLEANUP_INTERVAL_MILLIS); + log.debug( + "few-shot query embedding cache maintained. sizeBefore={}, sizeAfter={}, maxSize={}", + sizeBefore, + queryEmbeddingCache.size(), + maxSize + ); + } finally { + queryEmbeddingCacheCleanupInProgress.set(false); + } + } + + private List resolveDocumentEmbeddings( + List candidates, + List documents + ) { + if (!properties.isCacheEnabled()) { + return embedDocuments(documents); + } + Instant now = Instant.now(); + maintainDocumentEmbeddingCache(false); + + List result = new ArrayList<>(java.util.Collections.nCopies(candidates.size(), null)); + List pending = new ArrayList<>(); + int cacheHitCount = 0; + int inFlightReuseCount = 0; + + for (int i = 0; i < candidates.size(); i++) { + String key = documentEmbeddingCacheKey(candidates.get(i), documents.get(i)); + DocumentEmbeddingCacheEntry cached = documentEmbeddingCache.get(key); + if (cached != null && cached.expiresAt().isBefore(now)) { + if (documentEmbeddingCache.remove(key, cached)) { + metricsRecorder.recordCacheEvent("document_embedding", "expired", 1L); + } + cached = null; + } + if (cached != null) { + DocumentEmbeddingCacheEntry accessed = cached.accessedAt(now); + documentEmbeddingCache.replace(key, cached, accessed); + result.set(i, accessed.embedding()); + cacheHitCount++; + metricsRecorder.recordCacheEvent("document_embedding", "hit", 1L); + continue; + } + metricsRecorder.recordCacheEvent("document_embedding", "miss", 1L); + + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = documentEmbeddingInFlight.putIfAbsent(key, created); + boolean owner = existing == null; + if (owner) { + DocumentEmbeddingCacheEntry cachedAfterClaim = documentEmbeddingCache.get(key); + if (cachedAfterClaim != null && cachedAfterClaim.expiresAt().isBefore(Instant.now())) { + if (documentEmbeddingCache.remove(key, cachedAfterClaim)) { + metricsRecorder.recordCacheEvent("document_embedding", "expired", 1L); + } + cachedAfterClaim = null; + } + if (cachedAfterClaim != null) { + created.complete(cachedAfterClaim); + documentEmbeddingInFlight.remove(key, created); + result.set(i, cachedAfterClaim.embedding()); + cacheHitCount++; + metricsRecorder.recordCacheEvent("document_embedding", "hit_after_claim", 1L); + continue; + } + } + if (!owner) { + inFlightReuseCount++; + } + pending.add(new PendingDocumentEmbedding( + i, + key, + documents.get(i), + owner ? created : existing, + owner + )); + } + + initializeMissingDocumentEmbeddings(pending); + for (PendingDocumentEmbedding item : pending) { + result.set(item.index(), item.future().join().embedding()); + } + log.debug( + "few-shot document embedding cache resolved. hitCount={}, initializedCount={}, inFlightReuseCount={}, candidateCount={}, datasetVersion={}", + cacheHitCount, + pending.stream().filter(PendingDocumentEmbedding::owner).count(), + inFlightReuseCount, + candidates.size(), + properties.getDatasetVersion() + ); + return List.copyOf(result); + } + + private void maintainDocumentEmbeddingCache(boolean requestFollowUpIfBusy) { + int maxSize = Math.max(1, properties.getDocumentEmbeddingCacheMaxSize()); + if (!documentEmbeddingCacheCleanupInProgress.compareAndSet(false, true)) { + if (requestFollowUpIfBusy) { + documentEmbeddingCacheCleanupRequested.set(true); + } + return; + } + try { + do { + documentEmbeddingCacheCleanupRequested.set(false); + Instant cleanupTime = Instant.now(); + int sizeBefore = documentEmbeddingCache.size(); + documentEmbeddingCache.entrySet().removeIf(entry -> + entry.getValue().expiresAt().isBefore(cleanupTime) + && !documentEmbeddingInFlight.containsKey(entry.getKey())); + int sizeAfterExpiration = documentEmbeddingCache.size(); + metricsRecorder.recordCacheEvent("document_embedding", "expired", sizeBefore - sizeAfterExpiration); + if (sizeAfterExpiration > maxSize) { + int removalCount = sizeAfterExpiration - maxSize; + documentEmbeddingCache.entrySet().stream() + .filter(entry -> !documentEmbeddingInFlight.containsKey(entry.getKey())) + .sorted(Comparator.comparing(entry -> entry.getValue().lastAccessedAt())) + .limit(removalCount) + .forEach(entry -> documentEmbeddingCache.remove(entry.getKey(), entry.getValue())); + } + metricsRecorder.recordCacheEvent( + "document_embedding", "evicted", sizeAfterExpiration - documentEmbeddingCache.size()); + } while (documentEmbeddingCacheCleanupRequested.getAndSet(false)); + } finally { + documentEmbeddingCacheCleanupInProgress.set(false); + if (documentEmbeddingCacheCleanupRequested.getAndSet(false)) { + maintainDocumentEmbeddingCache(true); + } + } + } + + private void initializeMissingDocumentEmbeddings(List pending) { + List owned = pending.stream() + .filter(PendingDocumentEmbedding::owner) + .toList(); + if (owned.isEmpty()) { + return; + } + try { + List embeddedDocuments = embedDocuments( + owned.stream().map(PendingDocumentEmbedding::document).toList() + ); + if (embeddedDocuments.size() != owned.size()) { + throw new IllegalStateException("Cohere document embedding count does not match candidate count."); + } + Instant expiresAt = expiresAt(); + for (int i = 0; i < owned.size(); i++) { + PendingDocumentEmbedding item = owned.get(i); + DocumentEmbeddingCacheEntry entry = new DocumentEmbeddingCacheEntry( + embeddedDocuments.get(i), + expiresAt + ); + documentEmbeddingCache.put(item.key(), entry); + item.future().complete(entry); + } + } catch (RuntimeException | Error e) { + owned.forEach(item -> item.future().completeExceptionally(e)); + throw e; + } finally { + owned.forEach(item -> documentEmbeddingInFlight.remove(item.key(), item.future())); + maintainDocumentEmbeddingCache(true); + } + } + private List localPrefilter(List activeCases, FewShotSearchQuery query) { int limit = Math.max(1, properties.getSearch().getCandidateLimit()); + String queryInputHash = FewShotInputHash.of( + query.mainTasks(), + query.qualifications(), + query.question(), + query.answer() + ); return activeCases.stream() .filter(fewShotCase -> !sameCase(query.caseId(), fewShotCase.id())) + .filter(fewShotCase -> !sameNormalizedInput(queryInputHash, fewShotCase)) .map(fewShotCase -> new LocalScore(fewShotCase, localScore(query, fewShotCase))) .sorted(Comparator .comparingDouble(LocalScore::score).reversed() @@ -179,29 +601,132 @@ private double localScore(FewShotSearchQuery query, FewShotCase fewShotCase) { return jaccard + fewShotCase.priority() / 1000.0; } - private CacheEntry readCache(String key) { + private SelectionCacheEntry readSelectionCache(String key) { + return readSelectionCache(key, true); + } + + private SelectionCacheEntry readSelectionCache(String key, boolean recordEvent) { if (!properties.isCacheEnabled()) { return null; } - CacheEntry entry = cache.get(key); - if (entry == null) { + Instant now = Instant.now(); + maintainSelectionCache(false); + SelectionCacheEntry cached = selectionCache.get(key); + if (cached == null) { + if (recordEvent) { + metricsRecorder.recordCacheEvent("selection", "miss", 1L); + } return null; } - if (entry.expiresAt().isBefore(Instant.now())) { - cache.remove(key); + if (cached.expiresAt().isBefore(now)) { + selectionCache.remove(key, cached); + if (recordEvent) { + metricsRecorder.recordCacheEvent("selection", "expired", 1L); + } return null; } - return entry; + SelectionCacheEntry accessed = cached.accessedAt(now); + selectionCache.replace(key, cached, accessed); + if (recordEvent) { + metricsRecorder.recordCacheEvent("selection", "hit", 1L); + } + return accessed; + } + + private void maintainSelectionCache(boolean requestFollowUpIfBusy) { + int maxSize = Math.max(1, properties.getSelectionCacheMaxSize()); + if (!selectionCacheCleanupInProgress.compareAndSet(false, true)) { + if (requestFollowUpIfBusy) { + selectionCacheCleanupRequested.set(true); + } + return; + } + try { + do { + selectionCacheCleanupRequested.set(false); + Instant cleanupTime = Instant.now(); + int sizeBefore = selectionCache.size(); + selectionCache.entrySet().removeIf(entry -> entry.getValue().expiresAt().isBefore(cleanupTime)); + int sizeAfterExpiration = selectionCache.size(); + metricsRecorder.recordCacheEvent("selection", "expired", sizeBefore - sizeAfterExpiration); + if (sizeAfterExpiration > maxSize) { + int removalCount = sizeAfterExpiration - maxSize; + selectionCache.entrySet().stream() + .filter(entry -> !selectionInFlight.containsKey(entry.getKey())) + .sorted(Comparator.comparing(entry -> entry.getValue().lastAccessedAt())) + .limit(removalCount) + .forEach(entry -> selectionCache.remove(entry.getKey(), entry.getValue())); + } + metricsRecorder.recordCacheEvent("selection", "evicted", sizeAfterExpiration - selectionCache.size()); + } while (selectionCacheCleanupRequested.getAndSet(false)); + } finally { + selectionCacheCleanupInProgress.set(false); + if (selectionCacheCleanupRequested.getAndSet(false)) { + maintainSelectionCache(true); + } + } + } + + private String selectionCacheKey(FewShotSearchQuery query, int topK, String datasetFingerprint) { + return sha256( + datasetFingerprint + + "\n" + topK + + "\n" + properties.getSearch().getMinSimilarity() + + "\n" + properties.getSearch().getMinimumSelectedCount() + + "\n" + properties.isFallbackEnabled() + + "\n" + properties.getSearch().isDiversityEnabled() + + "\n" + defaultString(query.caseId()) + + "\n" + textBuilder.buildQueryText(query) + ); + } + + private String datasetFingerprint(List activeCases) { + StringBuilder source = new StringBuilder(properties.getDatasetVersion()); + for (FewShotCase fewShotCase : activeCases) { + source.append('\n') + .append(defaultString(fewShotCase.id())).append('\u001f') + .append(fewShotCase.source()).append('\u001f') + .append(fewShotCase.priority()).append('\u001f') + .append(textBuilder.buildCandidateDocument(fewShotCase)).append('\u001f') + .append(defaultString(fewShotCase.promptBlock())); + } + return sha256(source.toString()); } - private String cacheKey(FewShotSearchQuery query, int topK) { - return sha256(properties.getDatasetVersion() + "\n" + topK + "\n" + textBuilder.buildQueryText(query)); + private String documentEmbeddingCacheKey(FewShotCase fewShotCase, String document) { + return sha256( + properties.getDatasetVersion() + + "\n" + defaultString(fewShotCase.id()) + + "\n" + document + ); + } + + private Instant expiresAt() { + return Instant.now().plus(properties.getCacheTtl()); } private static boolean sameCase(String queryCaseId, String candidateId) { return StringUtils.hasText(queryCaseId) && queryCaseId.equals(candidateId); } + private static boolean sameNormalizedInput(String queryInputHash, FewShotCase fewShotCase) { + if (!StringUtils.hasText(queryInputHash)) { + return false; + } + String candidateInputHash = FewShotInputHash.of( + fewShotCase.mainTasks(), + fewShotCase.qualifications(), + fewShotCase.question(), + fewShotCase.sanitizedAnswer() + ); + return queryInputHash.equals(candidateInputHash); + } + + + private static String defaultString(String value) { + return value == null ? "" : value; + } + private static Set tokens(String text) { if (!StringUtils.hasText(text)) { return Set.of(); @@ -250,9 +775,100 @@ private static String sha256(String value) { private record LocalScore(FewShotCase fewShotCase, double score) { } - private record CacheEntry(List selectedCases, Instant expiresAt) { - private CacheEntry { + private record PendingDocumentEmbedding( + int index, + String key, + String document, + CompletableFuture future, + boolean owner + ) { + } + + private record SelectionCacheEntry( + List selectedCases, + FewShotSelectionMode selectionMode, + Instant expiresAt, + Instant lastAccessedAt + ) { + private SelectionCacheEntry( + List selectedCases, + FewShotSelectionMode selectionMode, + Instant expiresAt + ) { + this(selectedCases, selectionMode, expiresAt, Instant.now()); + } + + private SelectionCacheEntry { selectedCases = selectedCases == null ? List.of() : List.copyOf(selectedCases); } + + private SelectionCacheEntry accessedAt(Instant accessedAt) { + return new SelectionCacheEntry(selectedCases, selectionMode, expiresAt, accessedAt); + } + } + + private record DocumentEmbeddingCacheEntry(float[] embedding, Instant expiresAt, Instant lastAccessedAt) { + private DocumentEmbeddingCacheEntry(float[] embedding, Instant expiresAt) { + this(embedding, expiresAt, Instant.now()); + } + + private DocumentEmbeddingCacheEntry { + embedding = embedding == null ? new float[0] : embedding.clone(); + } + + private DocumentEmbeddingCacheEntry accessedAt(Instant accessedAt) { + return new DocumentEmbeddingCacheEntry(embedding, expiresAt, accessedAt); + } + + @Override + public float[] embedding() { + return embedding.clone(); + } + } + + private record QueryEmbeddingCacheEntry(float[] embedding, Instant expiresAt, Instant lastAccessedAt) { + private QueryEmbeddingCacheEntry(float[] embedding, Instant expiresAt) { + this(embedding, expiresAt, Instant.now()); + } + + private QueryEmbeddingCacheEntry { + embedding = embedding == null ? new float[0] : embedding.clone(); + } + + private QueryEmbeddingCacheEntry accessedAt(Instant accessedAt) { + return new QueryEmbeddingCacheEntry(embedding, expiresAt, accessedAt); + } + + @Override + public float[] embedding() { + return embedding.clone(); + } + } + + @Override + public long cohereApiCallCount() { + return cohereEmbeddingClient.apiCallCount(); + } + + private float[] embedQuery(String queryText) { + metricsRecorder.recordCohereLogicalCalls(1L); + return cohereEmbeddingClient.embedQuery(queryText); + } + + private List embedDocuments(List documents) { + metricsRecorder.recordCohereLogicalCalls(1L); + return cohereEmbeddingClient.embedDocuments(documents); + } + + int selectionCacheSize() { + return selectionCache.size(); + } + + int queryEmbeddingCacheSize() { + return queryEmbeddingCache.size(); + } + + int documentEmbeddingCacheSize() { + return documentEmbeddingCache.size(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java index 9a431816..9c9da1d8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java @@ -103,14 +103,15 @@ private List loadJsonCases(String resourcePath, FewShotSource expec List cases = objectMapper.readValue(json, new TypeReference<>() { }); return cases.stream() + .filter(java.util.Objects::nonNull) .map(fewShotCase -> normalizeSource(fewShotCase, expectedSource)) .toList(); } catch (IOException | RuntimeException e) { log.warn( - "few-shot resource parse failed. source={}, resource={}, message={}", + "few-shot resource parse failed. source={}, resource={}, errorType={}", expectedSource, resourcePath, - e.getMessage() + e.getClass().getSimpleName() ); return List.of(); } @@ -119,14 +120,11 @@ private List loadJsonCases(String resourcePath, FewShotSource expec private List filterSearchable(List cases) { List result = new ArrayList<>(); Set ids = new HashSet<>(); + Set inputHashes = new HashSet<>(); for (FewShotCase fewShotCase : cases) { if (fewShotCase == null || !StringUtils.hasText(fewShotCase.id())) { continue; } - if (!ids.add(fewShotCase.id())) { - log.warn("few-shot case skipped. reason=duplicate_case_id, id={}, source={}", fewShotCase.id(), fewShotCase.source()); - continue; - } if (!fewShotCase.searchable()) { log.debug("few-shot case skipped. reason=not_searchable, id={}, source={}", fewShotCase.id(), fewShotCase.source()); continue; @@ -135,6 +133,25 @@ private List filterSearchable(List cases) { log.debug("few-shot case skipped. reason=blank_prompt_block, id={}, source={}", fewShotCase.id(), fewShotCase.source()); continue; } + if (fewShotCase.source() == FewShotSource.REVIEWED_EVALUATION + && (!StringUtils.hasText(fewShotCase.question()) + || !StringUtils.hasText(fewShotCase.sanitizedAnswer()) + || !validReviewedAnalysis(fewShotCase.approvedAnalysisJson(), fewShotCase.id()))) { + log.warn("few-shot case skipped. reason=invalid_reviewed_case, id={}", fewShotCase.id()); + continue; + } + if (ids.contains(fewShotCase.id())) { + log.warn("few-shot case skipped. reason=duplicate_case_id, id={}, source={}", fewShotCase.id(), fewShotCase.source()); + continue; + } + String inputHash = FewShotInputHash.of(fewShotCase.mainTasks(), fewShotCase.qualifications(), + fewShotCase.question(), fewShotCase.sanitizedAnswer()); + if (!inputHash.isEmpty() && !inputHashes.add(inputHash)) { + log.warn("few-shot case skipped. reason=duplicate_normalized_input, id={}, source={}", + fewShotCase.id(), fewShotCase.source()); + continue; + } + ids.add(fewShotCase.id()); result.add(fewShotCase); } return List.copyOf(result); @@ -152,13 +169,8 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { try { List> rows = readCsv(path); List result = new ArrayList<>(); - Set ids = new HashSet<>(); for (Map row : rows) { String id = value(row, "caseId"); - if (!ids.add(id)) { - log.warn("reviewed evaluation few-shot row skipped. reason=duplicate_case_id, caseId={}", id); - continue; - } if (!"true".equalsIgnoreCase(value(row, "fewShotEnabled"))) { continue; } @@ -169,14 +181,31 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { String sanitizedAnswer = value(row, "sanitizedAnswer"); String approvedAnalysisJson = value(row, "approvedAnalysisJson"); if (!StringUtils.hasText(id) - || !StringUtils.hasText(value(row, "mainTasks")) - || !StringUtils.hasText(value(row, "qualifications")) + || !(StringUtils.hasText(value(row, "mainTasks")) + || StringUtils.hasText(value(row, "qualifications")) + || StringUtils.hasText(value(row, "preferences"))) || !StringUtils.hasText(value(row, "question")) || !StringUtils.hasText(sanitizedAnswer) || !StringUtils.hasText(approvedAnalysisJson)) { log.warn("reviewed evaluation few-shot row skipped. reason=missing_required_field, caseId={}", id); continue; } + try { + var analysis = objectMapper.reader() + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(approvedAnalysisJson); + if (analysis == null || !analysis.isObject()) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_object, caseId={}", id); + continue; + } + if (!ReviewedFewShotAnalysisValidator.isValid(analysis)) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_schema, caseId={}", id); + continue; + } + } catch (IOException e) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_json, caseId={}", id); + continue; + } result.add(new FewShotCase( id, FewShotSource.REVIEWED_EVALUATION, @@ -184,7 +213,8 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { true, parseInt(value(row, "fewShotPriority")), value(row, "jobCategorySmall"), - value(row, "jobCategorySmall"), + StringUtils.hasText(value(row, "jobTitle")) + ? value(row, "jobTitle") : value(row, "jobCategorySmall"), splitLines(value(row, "mainTasks")), splitLines(value(row, "qualifications")), value(row, "question"), @@ -195,18 +225,33 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { buildReviewedPromptBlock(id, row, sanitizedAnswer, approvedAnalysisJson) )); } - log.info("reviewed evaluation few-shot CSV loaded. rows={}, accepted={}, path={}", rows.size(), result.size(), csvPath); + log.info("reviewed evaluation few-shot CSV validated before deduplication. rows={}, validRows={}, path={}", rows.size(), result.size(), csvPath); return result; } catch (IOException | RuntimeException e) { - log.warn("reviewed evaluation few-shot CSV parse failed. path={}, message={}", csvPath, e.getMessage()); + log.warn("reviewed evaluation few-shot CSV parse failed. path={}, errorType={}", csvPath, e.getClass().getSimpleName()); return List.of(); } } + private boolean validReviewedAnalysis(String json, String id) { + try { + var analysis = objectMapper.reader() + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(json); + if (ReviewedFewShotAnalysisValidator.isValid(analysis)) { + return true; + } + log.warn("reviewed few-shot case skipped. reason=invalid_analysis_schema, caseId={}", id); + } catch (IOException e) { + log.warn("reviewed few-shot case skipped. reason=invalid_analysis_json, caseId={}", id); + } + return false; + } + private FewShotCase normalizeSource(FewShotCase fewShotCase, FewShotSource expectedSource) { return new FewShotCase( fewShotCase.id(), - fewShotCase.source() == null ? expectedSource : fewShotCase.source(), + expectedSource, fewShotCase.reviewStatus(), fewShotCase.enabled(), fewShotCase.priority(), @@ -250,6 +295,7 @@ private static String buildReviewedPromptBlock( 관련 JD 요구사항: - mainTask: %s - qualification: %s + - preference: %s 평가 대상 답변: - questionId: 1 @@ -262,6 +308,7 @@ private static String buildReviewedPromptBlock( id, value(row, "mainTasks"), value(row, "qualifications"), + value(row, "preferences"), value(row, "question"), sanitizedAnswer, approvedAnalysisJson @@ -313,11 +360,23 @@ private static List> readCsv(Path path) throws IOException { if (!headers.isEmpty() && !headers.getFirst().isEmpty() && headers.getFirst().charAt(0) == '\uFEFF') { headers.set(0, headers.getFirst().substring(1)); } + if (new HashSet<>(headers).size() != headers.size() + || !headers.containsAll(List.of("caseId", "question", "sanitizedAnswer", + "approvedAnalysisJson", "fewShotEnabled", "reviewStatus")) + || headers.stream().noneMatch(Set.of("mainTasks", "qualifications", "preferences")::contains)) { + log.warn("reviewed evaluation few-shot CSV skipped. reason=invalid_csv_headers"); + return List.of(); + } List> result = new ArrayList<>(); for (List row : rows.subList(1, rows.size())) { if (row.stream().allMatch(String::isBlank)) { continue; } + if (row.size() != headers.size()) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_column_count, expected={}, actual={}", + headers.size(), row.size()); + continue; + } Map values = new LinkedHashMap<>(); for (int i = 0; i < headers.size(); i++) { values.put(headers.get(i), i < row.size() ? row.get(i) : ""); @@ -359,6 +418,9 @@ private static List> parseRows(String content) { field.append(current); } } + if (inQuotes) { + throw new IllegalArgumentException("Unclosed quoted CSV field"); + } row.add(field.toString()); if (!row.isEmpty() && row.stream().anyMatch(value -> !value.isBlank())) { rows.add(row); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotInputHash.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotInputHash.java new file mode 100644 index 00000000..0aaa1e8f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotInputHash.java @@ -0,0 +1,70 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +final class FewShotInputHash { + private static final Pattern NORMALIZED_INPUT_WHITESPACE_PATTERN = Pattern.compile("[\\p{Z}\\s]+"); + + private FewShotInputHash() { + } + + static String of( + List mainTasks, + List qualifications, + String question, + String answer + ) { + String normalizedMainTasks = normalizeInputSection(mainTasks == null ? "" : String.join("\n", mainTasks)); + String normalizedQualifications = normalizeInputSection( + qualifications == null ? "" : String.join("\n", qualifications) + ); + String normalizedQuestion = normalizeInputSection(question); + String normalizedAnswer = normalizeInputSection(answer); + if (normalizedMainTasks.isEmpty() + && normalizedQualifications.isEmpty() + && normalizedQuestion.isEmpty() + && normalizedAnswer.isEmpty()) { + return ""; + } + return sha256( + normalizedMainTasks + '\u001f' + + normalizedQualifications + '\u001f' + + normalizedQuestion + '\u001f' + + normalizedAnswer + ); + } + + private static String normalizeInputSection(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + String unicodeNormalized = Normalizer.normalize(value, Normalizer.Form.NFKC); + return NORMALIZED_INPUT_WHITESPACE_PATTERN.matcher(unicodeNormalized) + .replaceAll(" ") + .trim() + .toLowerCase(Locale.ROOT); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + for (byte b : hash) { + result.append("%02x".formatted(b)); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available", e); + } + } + +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorder.java new file mode 100644 index 00000000..0d8881da --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorder.java @@ -0,0 +1,77 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Component +public class FewShotMetricsRecorder { + private static final Duration[] SELECTION_SLOS = { + Duration.ofMillis(10), Duration.ofMillis(50), Duration.ofMillis(100), + Duration.ofMillis(250), Duration.ofMillis(500), Duration.ofSeconds(1), + Duration.ofSeconds(3), Duration.ofSeconds(5), Duration.ofSeconds(10) + }; + + private final MeterRegistry meterRegistry; + + public FewShotMetricsRecorder(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void recordSelection(FewShotSelectionMode mode, boolean cacheHit, int selectedCount, long durationMillis) { + Tags tags = Tags.of("mode", mode.name(), "cache_hit", Boolean.toString(cacheHit)); + Counter.builder("fewshot.selection.count").tags(tags).register(meterRegistry).increment(); + Timer.builder("fewshot.selection.duration") + .tags(tags) + .publishPercentileHistogram() + .serviceLevelObjectives(SELECTION_SLOS) + .register(meterRegistry) + .record(durationMillis, TimeUnit.MILLISECONDS); + DistributionSummary.builder("fewshot.selection.selected.candidates") + .tags(tags) + .register(meterRegistry) + .record(selectedCount); + } + + public void recordCohereLogicalCalls(long count) { + if (count > 0) { + Counter.builder("fewshot.cohere.logical.calls") + .register(meterRegistry) + .increment(count); + } + } + + public void recordCohereFailure(String reason) { + Counter.builder("fewshot.cohere.failure.count") + .tag("reason", normalizeReason(reason)) + .register(meterRegistry) + .increment(); + } + + public void recordCacheEvent(String cache, String outcome, long count) { + if (count <= 0) { + return; + } + Counter.builder("fewshot.cache.events") + .tags("cache", cache, "outcome", outcome) + .register(meterRegistry) + .increment(count); + } + + private String normalizeReason(String reason) { + if (reason == null || reason.isBlank()) { + return "Unknown"; + } + return switch (reason) { + case "GeneralException", "IllegalArgumentException", "IllegalStateException", + "TimeoutException", "ExecutionException" -> reason; + default -> "Other"; + }; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java new file mode 100644 index 00000000..baedebf7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java @@ -0,0 +1,48 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.regex.Pattern; + +@Component +public class FewShotPrivacyMasker { + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "(?i)(?]+" + ); + private static final Pattern KOREAN_PHONE_PATTERN = Pattern.compile( + "(? searchRelevantFewShots(FewShotSearchQuery query, int topK); + + default long cohereApiCallCount() { + return 0L; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilder.java index 27e1bc36..1f344f92 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilder.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilder.java @@ -1,12 +1,44 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; +import org.springframework.web.util.HtmlUtils; import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; @Component public class FewShotSearchTextBuilder { + static final int MAX_IDENTIFIER_LENGTH = 100; + static final int MAX_SHORT_FIELD_LENGTH = 200; + static final int MAX_REQUIREMENTS_LENGTH = 1_200; + static final int MAX_QUESTION_LENGTH = 600; + static final int MAX_ANSWER_LENGTH = 2_000; + static final int MAX_TAGS_LENGTH = 500; + + private static final Pattern SCRIPT_STYLE_PATTERN = Pattern.compile( + "(?is)<(script|style)\\b[^>]*>.*?" + ); + private static final Pattern BLOCK_HTML_TAG_PATTERN = Pattern.compile( + "(?is)]*>" + ); + private static final Pattern HTML_COMMENT_PATTERN = Pattern.compile("(?s)"); + private static final Pattern HTML_TAG_PATTERN = Pattern.compile("(?is)]*>"); + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("[\\p{Z}\\s]+"); + + private final FewShotPrivacyMasker privacyMasker; + + FewShotSearchTextBuilder() { + this(new FewShotPrivacyMasker()); + } + + @Autowired + public FewShotSearchTextBuilder(FewShotPrivacyMasker privacyMasker) { + this.privacyMasker = privacyMasker; + } + public String buildQueryText(FewShotSearchQuery query) { return """ [JOB_CATEGORY] @@ -27,12 +59,12 @@ public String buildQueryText(FewShotSearchQuery query) { [ANSWER] %s """.formatted( - value(query.jobCategory()), - value(query.jobTitle()), - lines(query.mainTasks()), - lines(query.qualifications()), - value(query.question()), - value(query.answer()) + value(query.jobCategory(), MAX_SHORT_FIELD_LENGTH), + value(query.jobTitle(), MAX_SHORT_FIELD_LENGTH), + lines(query.mainTasks(), MAX_REQUIREMENTS_LENGTH), + lines(query.qualifications(), MAX_REQUIREMENTS_LENGTH), + value(query.question(), MAX_QUESTION_LENGTH), + value(query.answer(), MAX_ANSWER_LENGTH) ); } @@ -63,31 +95,64 @@ public String buildCandidateDocument(FewShotCase fewShotCase) { [TAGS] %s """.formatted( - value(fewShotCase.id()), + value(fewShotCase.id(), MAX_IDENTIFIER_LENGTH), fewShotCase.source(), - value(fewShotCase.jobCategory()), - value(fewShotCase.jobTitle()), - lines(fewShotCase.mainTasks()), - lines(fewShotCase.qualifications()), - value(fewShotCase.question()), - value(fewShotCase.sanitizedAnswer()), - String.join(", ", fewShotCase.tags()) + value(fewShotCase.jobCategory(), MAX_SHORT_FIELD_LENGTH), + value(fewShotCase.jobTitle(), MAX_SHORT_FIELD_LENGTH), + lines(fewShotCase.mainTasks(), MAX_REQUIREMENTS_LENGTH), + lines(fewShotCase.qualifications(), MAX_REQUIREMENTS_LENGTH), + value(fewShotCase.question(), MAX_QUESTION_LENGTH), + value(fewShotCase.sanitizedAnswer(), MAX_ANSWER_LENGTH), + values(fewShotCase.tags(), MAX_TAGS_LENGTH) ); } - private static String lines(List values) { + private String lines(List values, int maxLength) { if (values == null || values.isEmpty()) { return ""; } - return values.stream() + String joined = values.stream() + .filter(StringUtils::hasText) + .map(this::normalize) .filter(StringUtils::hasText) - .map(String::trim) .map(line -> line.startsWith("-") ? line : "- " + line) - .reduce((left, right) -> left + "\n" + right) - .orElse(""); + .collect(Collectors.joining("\n")); + return truncate(joined, maxLength); } - private static String value(String value) { - return StringUtils.hasText(value) ? value.trim() : ""; + private String values(List values, int maxLength) { + if (values == null || values.isEmpty()) { + return ""; + } + String joined = values.stream() + .filter(StringUtils::hasText) + .map(this::normalize) + .filter(StringUtils::hasText) + .collect(Collectors.joining(", ")); + return truncate(joined, maxLength); + } + + private String value(String value, int maxLength) { + return truncate(normalize(value), maxLength); + } + + private String normalize(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + String decoded = HtmlUtils.htmlUnescape(value); + String withoutExecutableContent = SCRIPT_STYLE_PATTERN.matcher(decoded).replaceAll(" "); + String withoutComments = HTML_COMMENT_PATTERN.matcher(withoutExecutableContent).replaceAll(" "); + String withBlockSeparators = BLOCK_HTML_TAG_PATTERN.matcher(withoutComments).replaceAll(" "); + String withoutTags = HTML_TAG_PATTERN.matcher(withBlockSeparators).replaceAll(""); + String privacyMasked = privacyMasker.mask(withoutTags); + return WHITESPACE_PATTERN.matcher(privacyMasked).replaceAll(" ").trim(); + } + + private static String truncate(String value, int maxLength) { + if (value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength).trim(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMetadata.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMetadata.java new file mode 100644 index 00000000..65224910 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMetadata.java @@ -0,0 +1,55 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import java.util.List; +import java.util.stream.IntStream; + +/** Prompt-selection snapshot only: never contains answers, JD text or embeddings. */ +public record FewShotSelectionMetadata( + String selectionMode, + String reason, + String datasetVersion, + double minSimilarity, + int topK, + int minimumSelectedCount, + String scoreType, + long cohereApiCallCount, + List selectedCases, + Double topScore, + Double bottomScore, + Double avgScore +) { + public FewShotSelectionMetadata { + selectedCases = List.copyOf(selectedCases); + } + + public record Candidate(String id, String source, Double score, String datasetVersion) { + } + + public static FewShotSelectionMetadata selected( + List selected, + FewShotProperties properties, + long cohereApiCallCount + ) { + boolean embedding = selected.stream().allMatch(item -> "cohere-embedding".equals(item.selectionMethod())); + var scores = selected.stream().mapToDouble(SelectedFewShotCase::score).summaryStatistics(); + return new FewShotSelectionMetadata( + embedding ? "EMBEDDING" : "LOCAL_FALLBACK", "", properties.getDatasetVersion(), + properties.getSearch().getMinSimilarity(), properties.getSearch().getTopK(), + properties.getSearch().getMinimumSelectedCount(), embedding ? "COSINE_SIMILARITY" : "LOCAL_HEURISTIC", + cohereApiCallCount, + selected.stream().map(item -> new Candidate(item.fewShotCase().id(), + item.fewShotCase().source().name(), item.score(), item.fewShotCase().datasetVersion())).toList(), + scores.getMax(), scores.getMin(), scores.getAverage()); + } + + public static FewShotSelectionMetadata staticSelection(String mode, String reason, int exampleCount, + FewShotProperties configured, long cohereApiCallCount) { + var properties = configured == null ? new FewShotProperties() : configured; + return new FewShotSelectionMetadata(mode, reason, properties.getDatasetVersion(), + properties.getSearch().getMinSimilarity(), properties.getSearch().getTopK(), + properties.getSearch().getMinimumSelectedCount(), "NONE", cohereApiCallCount, + IntStream.rangeClosed(1, exampleCount) + .mapToObj(i -> new Candidate("FS-FIXED-" + i, "FIXED", null, "static-resource")).toList(), + null, null, null); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMode.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMode.java new file mode 100644 index 00000000..64c90450 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSelectionMode.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +public enum FewShotSelectionMode { + EMBEDDING, + LOCAL_FALLBACK, + STATIC_FALLBACK +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/ReviewedFewShotAnalysisValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/ReviewedFewShotAnalysisValidator.java new file mode 100644 index 00000000..9cfdc49c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/ReviewedFewShotAnalysisValidator.java @@ -0,0 +1,52 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Set; + +/** + * Validates the partial analysis contract used by reviewed prompt examples. + * Scores and feedback are deliberately not required; semantic approval remains a PM task. + */ +final class ReviewedFewShotAnalysisValidator { + private static final Set STATUSES = Set.of("proven", "mentioned", "fabricated"); + private static final Set SOURCES = Set.of("mainTask", "qualification", "preference"); + + private ReviewedFewShotAnalysisValidator() { + } + + static boolean isValid(JsonNode analysis) { + if (analysis == null || !analysis.isObject() + || !analysis.path("keyStrengths").isArray() + || !analysis.path("missingKeywords").isArray() + || !analysis.path("questionAnalyses").isArray()) { + return false; + } + for (JsonNode strength : analysis.path("keyStrengths")) { + if (!text(strength, "title") || !text(strength, "quote")) { + return false; + } + } + for (JsonNode missing : analysis.path("missingKeywords")) { + if (!text(missing, "keyword") || !text(missing, "source") + || !SOURCES.contains(missing.path("source").textValue())) { + return false; + } + } + for (JsonNode sentence : analysis.path("questionAnalyses")) { + JsonNode questionId = sentence.path("questionId"); + if (!questionId.isIntegralNumber() || !questionId.canConvertToLong() || questionId.longValue() <= 0 + || !text(sentence, "sentence") || !text(sentence, "reason") + || !text(sentence, "status") || !STATUSES.contains(sentence.path("status").textValue()) + || !sentence.has("improvement") + || !(sentence.path("improvement").isNull() || text(sentence, "improvement"))) { + return false; + } + } + return true; + } + + private static boolean text(JsonNode node, String field) { + return node.path(field).isTextual() && !node.path(field).textValue().isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index 4a8aaec1..f814eae0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -114,6 +114,8 @@ private Map fewShotPolicy() { fewShotPolicy.put("datasetVersion", fewShotProperties.getDatasetVersion()); fewShotPolicy.put("topK", fewShotProperties.getSearch().getTopK()); fewShotPolicy.put("candidateLimit", fewShotProperties.getSearch().getCandidateLimit()); + fewShotPolicy.put("minSimilarity", fewShotProperties.getSearch().getMinSimilarity()); + fewShotPolicy.put("minimumSelectedCount", fewShotProperties.getSearch().getMinimumSelectedCount()); fewShotPolicy.put("reviewedEvaluationEnabled", fewShotProperties.getSource().isReviewedEvaluationEnabled()); return fewShotPolicy; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index 5c77371b..c2476e91 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -75,23 +75,36 @@ public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOExce List results = new ArrayList<>(); int successCount = 0; - for (int i = 0; i < cases.size(); i++) { - EvaluationAnalysisCase evaluationCase = cases.get(i); - log.info("[{}/{}] {} analyzing...", i + 1, cases.size(), evaluationCase.caseId()); - - try { - EvaluationAnalysisResult result = analyzeCase(evaluationCase); - results.add(result); - if (!StringUtils.hasText(result.errorMessage())) { - successCount++; + try (var sidecar = new EvaluationFewShotSidecar(outputPath, objectMapper)) { + log.info("Few-shot evaluation metadata output: {}", sidecar.path()); + for (int i = 0; i < cases.size(); i++) { + EvaluationAnalysisCase evaluationCase = cases.get(i); + log.info("[{}/{}] {} analyzing...", i + 1, cases.size(), evaluationCase.caseId()); + + List selections = new ArrayList<>(); + boolean success = false; + try { + EvaluationAnalysisResult result = analyzeCase(evaluationCase, json -> { + try { + selections.add(objectMapper.readTree(json)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid few-shot metadata JSON", e); + } + }); + results.add(result); + if (!StringUtils.hasText(result.errorMessage())) { + successCount++; + success = true; + } + } catch (Exception e) { + log.warn("[{}/{}] {} failed. message={}", i + 1, cases.size(), evaluationCase.caseId(), e.getMessage()); + results.add(EvaluationAnalysisResult.failed( + evaluationCase, + safeErrorMessage(e), + createdAt() + )); } - } catch (Exception e) { - log.warn("[{}/{}] {} failed. message={}", i + 1, cases.size(), evaluationCase.caseId(), e.getMessage()); - results.add(EvaluationAnalysisResult.failed( - evaluationCase, - safeErrorMessage(e), - createdAt() - )); + sidecar.write(i + 1, evaluationCase.caseId(), success, selections); } } @@ -125,7 +138,8 @@ private void validateHeaders(List headers) { } } - private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) { + private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase, + java.util.function.Consumer recorder) { EvaluationGeneratedResult generatedResult = evaluationAnalysisGenerator.generate(new EvaluationAnalysisCommand( evaluationCase.caseId(), evaluationCase.jobCategoryMiddle(), @@ -134,7 +148,8 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa evaluationCase.qualifications(), evaluationCase.preferences(), evaluationCase.question(), - evaluationCase.answer() + evaluationCase.answer(), + recorder )); EvaluationLlmSnapshot llmResponse = generatedResult.responseSnapshot(); @@ -187,18 +202,26 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa generatedResult.finalCallLatencyMs(), generatedResult.candidateCallLatencyMs(), generatedResult.finalCallLatencyMs(), - null, - null, - null, - null, - null, - null, + generatedResult.candidateInputTokens(), + generatedResult.candidateOutputTokens(), + generatedResult.finalInputTokens(), + generatedResult.finalOutputTokens(), + sumTokens(generatedResult.candidateInputTokens(), generatedResult.finalInputTokens()), + sumTokens(generatedResult.candidateOutputTokens(), generatedResult.finalOutputTokens()), "", "", createdAt() ); } + private Integer sumTokens(Integer left, Integer right) { + if (left == null && right == null) { + return null; + } + long sum = (left == null ? 0L : left.longValue()) + (right == null ? 0L : right.longValue()); + return sum > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) sum; + } + private List buildMissingKeywords( EvaluationAnalysisCase evaluationCase, EvaluationLlmSnapshot llmResponse diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationFewShotSidecar.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationFewShotSidecar.java new file mode 100644 index 00000000..99fe0c9d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationFewShotSidecar.java @@ -0,0 +1,52 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** One JSON line per input row, including failures. Contains no answer or prompt text. */ +final class EvaluationFewShotSidecar implements AutoCloseable { + private final String runId = UUID.randomUUID().toString(); + private final ObjectMapper mapper; + private final BufferedWriter writer; + private final Path path; + + EvaluationFewShotSidecar(Path outputCsv, ObjectMapper mapper) throws IOException { + this.mapper = mapper; + path = outputCsv.toAbsolutePath().resolveSibling(outputCsv.getFileName() + ".fewshot." + runId + ".jsonl"); + Files.createDirectories(path.getParent()); + writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } + + Path path() { + return path; + } + + void write(int rowIndex, String caseId, boolean success, List selections) throws IOException { + writer.write(mapper.writeValueAsString(new Entry(1, runId, rowIndex, caseId, + success ? "SUCCESS" : "FAILED", selections.isEmpty() ? "UNAVAILABLE" : "RECORDED", + selections.isEmpty() ? "UNAVAILABLE" : "SELECTION_OBSERVED", + Instant.now().toString(), List.copyOf(selections)))); + writer.newLine(); + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } + + private record Entry(int schemaVersion, String runId, int rowIndex, String caseId, + String outcome, String metadataStatus, String captureStage, String recordedAt, + List selections) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index 60205300..b0a5a26f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -616,6 +616,9 @@ private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJs try { Map values = objectMapper.readValue(sanitizedCandidateResponseJson, new TypeReference<>() { }); + if (values == null) { + return 0; + } Object candidates = values.get("missingKeywordCandidates"); if (candidates instanceof List list) { return list.size(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java index 847f40d3..afe0946e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java @@ -8,6 +8,16 @@ public record EvaluationAnalysisCommand( String qualifications, String preferences, String question, - String answer + String answer, + java.util.function.Consumer fewShotMetadataRecorder ) { + public EvaluationAnalysisCommand { + fewShotMetadataRecorder = fewShotMetadataRecorder == null ? ignored -> {} : fewShotMetadataRecorder; + } + + public EvaluationAnalysisCommand(String caseId, String jobCategoryMiddle, String jobCategorySmall, + String mainTasks, String qualifications, String preferences, + String question, String answer) { + this(caseId, jobCategoryMiddle, jobCategorySmall, mainTasks, qualifications, preferences, question, answer, ignored -> {}); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java index 4e761130..ccbd51ba 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java @@ -9,6 +9,29 @@ public record EvaluationGeneratedResult( String candidateReviewResponseJson, EvaluationCandidateReviewSnapshot candidateReviewSnapshot, long candidateCallLatencyMs, - long finalCallLatencyMs + long finalCallLatencyMs, + Integer candidateInputTokens, + Integer candidateOutputTokens, + Integer finalInputTokens, + Integer finalOutputTokens ) { + public EvaluationGeneratedResult( + EvaluationLlmSnapshot responseSnapshot, + String rawLlmResponseJson, + String rawCandidateResponseJson, + String sanitizedCandidateResponseJson, + EvaluationCandidateSnapshot sanitizedCandidateSnapshot, + String candidateReviewResponseJson, + EvaluationCandidateReviewSnapshot candidateReviewSnapshot, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) { + this( + responseSnapshot, rawLlmResponseJson, rawCandidateResponseJson, + sanitizedCandidateResponseJson, sanitizedCandidateSnapshot, + candidateReviewResponseJson, candidateReviewSnapshot, + candidateCallLatencyMs, finalCallLatencyMs, + null, null, null, null + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java index 0bcf9edf..68449bf1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java @@ -74,7 +74,8 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { jobCategoryEvaluationCriteriaProvider .findByMiddleName(command.jobCategoryMiddle()) .orElse(null), - deadline + deadline, + metadata -> command.fewShotMetadataRecorder().accept(writeJson(metadata)) ); String rawLlmResponseJson = writeJson(aiCallResult.response()); String rawCandidateResponseJson = writeJson(aiCallResult.rawCandidateResponse()); @@ -93,7 +94,11 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { candidateReviewResponseJson, reviewSnapshotParser.parse(candidateReviewResponseJson), aiCallResult.candidateCallLatencyMs(), - aiCallResult.finalCallLatencyMs() + aiCallResult.finalCallLatencyMs(), + aiCallResult.candidateInputTokens(), + aiCallResult.candidateOutputTokens(), + aiCallResult.finalInputTokens(), + aiCallResult.finalOutputTokens() ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java new file mode 100644 index 00000000..36d29f22 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/controller/JobApplicationController.java @@ -0,0 +1,166 @@ +package com.jobdri.jobdri_api.domain.jobapplication.controller; + +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationDetailUpdateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationPositionRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationBoardResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationDetailResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationArchiveService; +import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationBoardService; +import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationDetailService; +import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationService; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-applications") +@Tag(name = "JobApplication", description = "실제 지원관리 카드 API") +public class JobApplicationController { + private final JobApplicationService jobApplicationService; + private final UserService userService; + private final JobApplicationBoardService jobApplicationBoardService; + private final JobApplicationDetailService jobApplicationDetailService; + private final JobApplicationArchiveService jobApplicationArchiveService; + + @Operation(summary = "지원관리 칸반 조회", description = "활성 카드를 4개 열로 반환합니다. 회사명·공고명·직무명을 검색하며 count는 검색 결과 기준입니다. CREATED_DESC에서는 드래그를 비활성화합니다.") + @GetMapping("/board") + public ApiResponse board( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(defaultValue = "") String query, + @RequestParam(defaultValue = "MANUAL") JobApplicationSort sort + ) { + return ApiResponse.onSuccess("지원관리 칸반 조회에 성공했습니다.", + jobApplicationBoardService.getBoard(validateAuthenticatedUser(userDetails), query, sort)); + } + + @Operation(summary = "지원 카드 이동", description = "카드를 제거한 도착 열의 0 기반 targetIndex에 삽입합니다. 필터링된 화면의 인덱스가 아닌 전체 열 기준이며, 저장 후 전체 MANUAL 보드를 반환합니다.") + @PatchMapping("/{jobApplicationId}/position") + public ApiResponse move( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId, + @Valid @RequestBody JobApplicationPositionRequest request + ) { + return ApiResponse.onSuccess("지원 카드 이동에 성공했습니다.", + jobApplicationBoardService.move(validateAuthenticatedUser(userDetails), jobApplicationId, request)); + } + + @Operation(summary = "지원 카드 보관", description = "활성 보드에서 카드를 제외하고 보관 시각을 기록합니다. 남은 열의 수동 순서는 연속으로 정리합니다.") + @PostMapping("/{jobApplicationId}/archive") + public ApiResponse archive( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + return ApiResponse.onSuccess( + "지원 카드 보관에 성공했습니다.", + jobApplicationArchiveService.archive(validateAuthenticatedUser(userDetails), jobApplicationId) + ); + } + + @Operation(summary = "지원 카드 복원", description = "보관 전 단계를 유지하며 해당 단계의 마지막 순서로 복원합니다.") + @PostMapping("/{jobApplicationId}/restore") + public ApiResponse restore( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + return ApiResponse.onSuccess( + "지원 카드 복원에 성공했습니다.", + jobApplicationArchiveService.restore(validateAuthenticatedUser(userDetails), jobApplicationId) + ); + } + + @Operation(summary = "지원 카드 보관함 조회", description = "보관 시각과 ID 내림차순으로 현재 사용자의 보관 카드를 페이지 조회합니다.") + @GetMapping("/archive") + public ApiResponse> archivePage( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return ApiResponse.onSuccess( + "지원 카드 보관함 조회에 성공했습니다.", + jobApplicationArchiveService.getArchive(validateAuthenticatedUser(userDetails), page, size) + ); + } + + @Operation(summary = "지원 카드 영구 삭제", description = "지원 카드와 카드가 직접 소유한 상세 데이터만 삭제합니다. 출처 공고와 연결된 모의지원은 유지합니다.") + @DeleteMapping("/{jobApplicationId}") + public ApiResponse delete( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + jobApplicationArchiveService.delete(validateAuthenticatedUser(userDetails), jobApplicationId); + return ApiResponse.onSuccess("지원 카드 영구 삭제에 성공했습니다.", null); + } + + @Operation(summary = "지원 카드 수동 등록", description = "분석을 시작하지 않고 독립적인 지원관리 카드를 생성합니다.") + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody JobApplicationCreateRequest request + ) { + var user = validateAuthenticatedUser(userDetails); + return ApiResponse.onSuccess("지원 카드 등록에 성공했습니다.", jobApplicationService.create(user, request)); + } + + @Operation(summary = "저장 공고에서 지원 카드 등록", description = "내 저장 공고의 현재 값을 복사해 이후 독립적으로 관리되는 지원 카드를 생성합니다.") + @PostMapping("/from-job-posting") + public ApiResponse createFromJobPosting( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody JobApplicationFromJobPostingRequest request + ) { + var user = validateAuthenticatedUser(userDetails); + return ApiResponse.onSuccess( + "저장 공고에서 지원 카드 등록에 성공했습니다.", + jobApplicationService.createFromJobPosting(user, request) + ); + } + + @Operation(summary = "지원 카드 상세 조회", description = "공고 스냅샷과 체크리스트·정량 스펙·실제 제출용 자기소개서를 전달된 순서대로 조회합니다.") + @GetMapping("/{jobApplicationId}") + public ApiResponse get( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId + ) { + var user = validateAuthenticatedUser(userDetails); + return ApiResponse.onSuccess("지원 카드 상세 조회에 성공했습니다.", jobApplicationDetailService.get(user, jobApplicationId)); + } + + @Operation(summary = "지원 카드 상세 전체 저장", description = "공고 스냅샷과 세 탭 데이터를 한 트랜잭션에서 전체 교체합니다. 마지막 조회 수정 시각이 오래된 경우 409를 반환합니다.") + @PutMapping("/{jobApplicationId}") + public ApiResponse update( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long jobApplicationId, + @Valid @RequestBody JobApplicationDetailUpdateRequest request + ) { + var user = validateAuthenticatedUser(userDetails); + return ApiResponse.onSuccess( + "지원 카드 상세 저장에 성공했습니다.", + jobApplicationDetailService.update(user, jobApplicationId, request) + ); + } + + private com.jobdri.jobdri_api.domain.user.entity.User validateAuthenticatedUser(UserDetailsImpl userDetails) { + return userService.validateUser(userDetails == null ? null : userDetails.getUser()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationChecklistItemRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationChecklistItemRequest.java new file mode 100644 index 00000000..407f5ef7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationChecklistItemRequest.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record JobApplicationChecklistItemRequest( + @NotBlank(message = "체크리스트 내용은 필수입니다.") + @Size(max = 200, message = "체크리스트 내용은 최대 200자까지 입력할 수 있습니다.") + String content, + boolean completed +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationCreateRequest.java new file mode 100644 index 00000000..1c7ef55e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationCreateRequest.java @@ -0,0 +1,46 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record JobApplicationCreateRequest( + @NotBlank(message = "회사명은 필수입니다.") + @Size(max = 255, message = "회사명은 최대 255자까지 입력할 수 있습니다.") + String companyName, + + @NotBlank(message = "공고명은 필수입니다.") + @Size(max = 255, message = "공고명은 최대 255자까지 입력할 수 있습니다.") + String postingName, + + @NotBlank(message = "직무명은 필수입니다.") + @Size(max = 255, message = "직무명은 최대 255자까지 입력할 수 있습니다.") + String jobTitle, + + CompanySize companySize, + Long detailClassificationId, + + @Size(max = 4000, message = "주요 업무는 최대 4000자까지 입력할 수 있습니다.") + String task, + + @Size(max = 4000, message = "자격 요건은 최대 4000자까지 입력할 수 있습니다.") + String requirement, + + @Size(max = 4000, message = "우대 사항은 최대 4000자까지 입력할 수 있습니다.") + String preferred, + + @Size(max = 20, message = "기술 태그는 최대 20개까지 입력할 수 있습니다.") + List<@NotBlank(message = "기술 태그는 비어 있을 수 없습니다.") @Size(max = 50, message = "기술 태그는 각각 최대 50자까지 입력할 수 있습니다.") String> requiredSkills, + + LocalDateTime deadlineAt, + JobApplicationStage initialStage, + + @Size(max = 100, message = "진행 라벨은 최대 100자까지 입력할 수 있습니다.") + String currentLabel, + LocalDateTime currentAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationDetailUpdateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationDetailUpdateRequest.java new file mode 100644 index 00000000..c6f685ef --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationDetailUpdateRequest.java @@ -0,0 +1,79 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record JobApplicationDetailUpdateRequest( + @NotNull(message = "마지막 조회 수정 시각은 필수입니다.") + LocalDateTime lastKnownUpdatedAt, + + @NotBlank(message = "회사명은 필수입니다.") + @Size(max = 255, message = "회사명은 최대 255자까지 입력할 수 있습니다.") + String companyName, + + @NotBlank(message = "공고명은 필수입니다.") + @Size(max = 255, message = "공고명은 최대 255자까지 입력할 수 있습니다.") + String postingName, + + @NotBlank(message = "직무명은 필수입니다.") + @Size(max = 255, message = "직무명은 최대 255자까지 입력할 수 있습니다.") + String jobTitle, + + CompanySize companySize, + Long detailClassificationId, + + @Size(max = 4000, message = "주요 업무는 최대 4000자까지 입력할 수 있습니다.") + String task, + + @Size(max = 4000, message = "자격 요건은 최대 4000자까지 입력할 수 있습니다.") + String requirement, + + @Size(max = 4000, message = "우대 사항은 최대 4000자까지 입력할 수 있습니다.") + String preferred, + + @Size(max = 20, message = "기술 태그는 최대 20개까지 입력할 수 있습니다.") + List<@NotBlank(message = "기술 태그는 비어 있을 수 없습니다.") @Size(max = 50, message = "기술 태그는 각각 최대 50자까지 입력할 수 있습니다.") String> requiredSkills, + + LocalDateTime deadlineAt, + + @Size(max = 100, message = "진행 라벨은 최대 100자까지 입력할 수 있습니다.") + String currentLabel, + LocalDateTime currentAt, + + @Size(max = 10000, message = "메모는 최대 10000자까지 입력할 수 있습니다.") + String memo, + + @DecimalMin(value = "0", message = "학점은 0 이상이어야 합니다.") + @Digits(integer = 3, fraction = 3, message = "학점은 정수 3자리, 소수 3자리까지 입력할 수 있습니다.") + BigDecimal gpa, + + @DecimalMin(value = "0", message = "학점 만점은 0 이상이어야 합니다.") + @Digits(integer = 3, fraction = 3, message = "학점 만점은 정수 3자리, 소수 3자리까지 입력할 수 있습니다.") + BigDecimal maxGpa, + + @Size(max = 100, message = "체크리스트는 최대 100개까지 입력할 수 있습니다.") + List<@Valid JobApplicationChecklistItemRequest> checklistItems, + + List<@Valid JobApplicationMetricRequest> metrics, + + @Size(max = 20, message = "자기소개서는 최대 20개까지 입력할 수 있습니다.") + List<@Valid JobApplicationEssayRequest> essays +) { + @AssertTrue(message = "학점과 만점은 모두 입력하거나 모두 비워야 하며, 학점은 만점 이하여야 합니다.") + public boolean isGpaRangeValid() { + if (gpa == null || maxGpa == null) { + return gpa == null && maxGpa == null; + } + return gpa.compareTo(maxGpa) <= 0; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationEssayRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationEssayRequest.java new file mode 100644 index 00000000..aeb8f9c7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationEssayRequest.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record JobApplicationEssayRequest( + @NotBlank(message = "자기소개서 질문은 필수입니다.") + @Size(max = 1000, message = "자기소개서 질문은 최대 1000자까지 입력할 수 있습니다.") + String question, + + @Size(max = 10000, message = "자기소개서 답변은 최대 10000자까지 입력할 수 있습니다.") + String answer +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationFromJobPostingRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationFromJobPostingRequest.java new file mode 100644 index 00000000..4596ede7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationFromJobPostingRequest.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import jakarta.validation.constraints.NotNull; + +public record JobApplicationFromJobPostingRequest( + @NotNull(message = "공고 ID는 필수입니다.") + Long jobPostingId, + JobApplicationStage initialStage +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationMetricRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationMetricRequest.java new file mode 100644 index 00000000..a68a3af3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationMetricRequest.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationMetricType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record JobApplicationMetricRequest( + @NotNull(message = "정량 스펙 유형은 필수입니다.") + JobApplicationMetricType type, + + @NotBlank(message = "정량 스펙 이름은 필수입니다.") + @Size(max = 100, message = "정량 스펙 이름은 최대 100자까지 입력할 수 있습니다.") + String name, + + @NotBlank(message = "정량 스펙 값은 필수입니다.") + @Size(max = 200, message = "정량 스펙 값은 최대 200자까지 입력할 수 있습니다.") + String value +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationPositionRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationPositionRequest.java new file mode 100644 index 00000000..25f4b0af --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationPositionRequest.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record JobApplicationPositionRequest( + @NotNull JobApplicationStage targetStage, + @NotNull @Min(0) Integer targetIndex +) {} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationSort.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationSort.java new file mode 100644 index 00000000..52c10b0f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/request/JobApplicationSort.java @@ -0,0 +1,5 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.request; + +public enum JobApplicationSort { + MANUAL, CREATED_DESC +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java new file mode 100644 index 00000000..4a801e75 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationArchiveItemResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; + +import java.time.LocalDateTime; + +public record JobApplicationArchiveItemResponse( + Long jobApplicationId, + String companyName, + String postingName, + String jobTitle, + LocalDateTime deadlineAt, + String currentLabel, + LocalDateTime currentAt, + JobApplicationStage stage, + LocalDateTime archivedAt, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationBoardResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationBoardResponse.java new file mode 100644 index 00000000..b8477b11 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationBoardResponse.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import java.util.List; + +public record JobApplicationBoardResponse(JobApplicationSort sort, List columns) { + public record Column(JobApplicationStage stage, int count, List cards) {} +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationCardResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationCardResponse.java new file mode 100644 index 00000000..2ed21af4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationCardResponse.java @@ -0,0 +1,21 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import java.time.LocalDateTime; +import java.util.List; + +public record JobApplicationCardResponse( + Long jobApplicationId, String companyName, String postingName, String jobTitle, + List requiredSkills, long essayQuestionCount, LocalDateTime deadlineAt, + String currentLabel, LocalDateTime currentAt, JobApplicationStage stage, + int stageOrder, LocalDateTime createdAt, LocalDateTime updatedAt +) { + public static JobApplicationCardResponse from(JobApplication card, long essayQuestionCount) { + return new JobApplicationCardResponse( + card.getId(), card.getCompanyName(), card.getPostingName(), card.getJobTitle(), + List.copyOf(card.getRequiredSkills()), essayQuestionCount, card.getDeadlineAt(), + card.getCurrentLabel(), card.getCurrentAt(), card.getStage(), card.getStageOrder(), + card.getCreatedAt(), card.getUpdatedAt()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationDetailResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationDetailResponse.java new file mode 100644 index 00000000..73d29607 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationDetailResponse.java @@ -0,0 +1,105 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationMetricType; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class JobApplicationDetailResponse { + private Long jobApplicationId; + private Long sourceJobPostingId; + private Long mockApplyId; + private String companyName; + private String postingName; + private String jobTitle; + private CompanySize companySize; + private Long detailClassificationId; + private String detailClassificationName; + private String task; + private String requirement; + private String preferred; + private List requiredSkills; + private LocalDateTime deadlineAt; + private JobApplicationStage stage; + private int stageOrder; + private String currentLabel; + private LocalDateTime currentAt; + private String memo; + private BigDecimal gpa; + private BigDecimal maxGpa; + private List checklistItems; + private List metrics; + private List essays; + private LocalDateTime archivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static JobApplicationDetailResponse from(JobApplication application) { + return JobApplicationDetailResponse.builder() + .jobApplicationId(application.getId()) + .sourceJobPostingId(application.getSourceJobPosting() == null ? null : application.getSourceJobPosting().getId()) + .mockApplyId(application.getMockApply() == null ? null : application.getMockApply().getId()) + .companyName(application.getCompanyName()) + .postingName(application.getPostingName()) + .jobTitle(application.getJobTitle()) + .companySize(application.getCompanySize()) + .detailClassificationId(application.getDetailClassification() == null ? null : application.getDetailClassification().getId()) + .detailClassificationName(application.getDetailClassification() == null ? null : application.getDetailClassification().getDetailName()) + .task(application.getTask()) + .requirement(application.getRequirement()) + .preferred(application.getPreferred()) + .requiredSkills(List.copyOf(application.getRequiredSkills())) + .deadlineAt(application.getDeadlineAt()) + .stage(application.getStage()) + .stageOrder(application.getStageOrder()) + .currentLabel(application.getCurrentLabel()) + .currentAt(application.getCurrentAt()) + .memo(application.getMemo()) + .gpa(application.getGpa()) + .maxGpa(application.getMaxGpa()) + .checklistItems(application.getChecklistItems().stream().map(ChecklistItem::from).toList()) + .metrics(application.getMetrics().stream().map(Metric::from).toList()) + .essays(application.getEssays().stream().map(Essay::from).toList()) + .archivedAt(application.getArchivedAt()) + .createdAt(application.getCreatedAt()) + .updatedAt(application.getUpdatedAt()) + .build(); + } + + public record ChecklistItem(Long checklistItemId, String content, boolean completed, int displayOrder) { + private static ChecklistItem from(com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationChecklistItem item) { + return new ChecklistItem(item.getId(), item.getContent(), item.isCompleted(), item.getDisplayOrder()); + } + } + + public record Metric( + Long metricId, + JobApplicationMetricType type, + String name, + String value, + int displayOrder + ) { + private static Metric from(com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationMetric metric) { + return new Metric(metric.getId(), metric.getType(), metric.getName(), metric.getValue(), metric.getDisplayOrder()); + } + } + + public record Essay(Long essayId, String question, String answer, int displayOrder) { + private static Essay from(com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationEssay essay) { + return new Essay(essay.getId(), essay.getQuestion(), essay.getAnswer(), essay.getDisplayOrder()); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationResponse.java new file mode 100644 index 00000000..d0f1a550 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/dto/response/JobApplicationResponse.java @@ -0,0 +1,69 @@ +package com.jobdri.jobdri_api.domain.jobapplication.dto.response; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class JobApplicationResponse { + private Long jobApplicationId; + private Long sourceJobPostingId; + private Long mockApplyId; + private String companyName; + private String postingName; + private String jobTitle; + private CompanySize companySize; + private Long detailClassificationId; + private String detailClassificationName; + private String task; + private String requirement; + private String preferred; + private List requiredSkills; + private LocalDateTime deadlineAt; + private JobApplicationStage stage; + private int stageOrder; + private String currentLabel; + private LocalDateTime currentAt; + private String memo; + private LocalDateTime archivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static JobApplicationResponse from(JobApplication application) { + return JobApplicationResponse.builder() + .jobApplicationId(application.getId()) + .sourceJobPostingId(application.getSourceJobPosting() == null ? null : application.getSourceJobPosting().getId()) + .mockApplyId(application.getMockApply() == null ? null : application.getMockApply().getId()) + .companyName(application.getCompanyName()) + .postingName(application.getPostingName()) + .jobTitle(application.getJobTitle()) + .companySize(application.getCompanySize()) + .detailClassificationId(application.getDetailClassification() == null ? null : application.getDetailClassification().getId()) + .detailClassificationName(application.getDetailClassification() == null ? null : application.getDetailClassification().getDetailName()) + .task(application.getTask()) + .requirement(application.getRequirement()) + .preferred(application.getPreferred()) + .requiredSkills(List.copyOf(application.getRequiredSkills())) + .deadlineAt(application.getDeadlineAt()) + .stage(application.getStage()) + .stageOrder(application.getStageOrder()) + .currentLabel(application.getCurrentLabel()) + .currentAt(application.getCurrentAt()) + .memo(application.getMemo()) + .archivedAt(application.getArchivedAt()) + .createdAt(application.getCreatedAt()) + .updatedAt(application.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java new file mode 100644 index 00000000..c5c13fd7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplication.java @@ -0,0 +1,252 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.entity.BaseEntity; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderBy; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; +import jakarta.persistence.CascadeType; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table( + name = "job_applications", + indexes = { + @Index(name = "idx_job_applications_user_stage_order", columnList = "user_id,stage,stage_order"), + @Index(name = "idx_job_applications_user_archived", columnList = "user_id,archived_at") + } +) +public class JobApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_job_posting_id") + private JobPosting sourceJobPosting; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mock_apply_id", unique = true) + private MockApply mockApply; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "detail_classification_id") + private DetailClassification detailClassification; + + @Column(nullable = false, length = 255) + private String companyName; + + @Column(nullable = false, length = 255) + private String postingName; + + @Column(nullable = false, length = 255) + private String jobTitle; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private CompanySize companySize; + + @Column(columnDefinition = "TEXT") + private String task; + + @Column(columnDefinition = "TEXT") + private String requirement; + + @Column(columnDefinition = "TEXT") + private String preferred; + + @ElementCollection + @CollectionTable(name = "job_application_required_skills", joinColumns = @JoinColumn(name = "job_application_id")) + @OrderColumn(name = "display_order") + @Column(name = "skill_name", nullable = false, length = 50) + @Builder.Default + private List requiredSkills = new ArrayList<>(); + + private LocalDateTime deadlineAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private JobApplicationStage stage; + + @Column(name = "stage_order", nullable = false) + private int stageOrder; + + @Column(length = 100) + private String currentLabel; + + private LocalDateTime currentAt; + + @Column(columnDefinition = "TEXT") + private String memo; + + @Column(precision = 6, scale = 3) + private BigDecimal gpa; + + @Column(name = "max_gpa", precision = 6, scale = 3) + private BigDecimal maxGpa; + + private LocalDateTime archivedAt; + + @OneToMany(mappedBy = "jobApplication", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("displayOrder ASC") + @Builder.Default + private List checklistItems = new ArrayList<>(); + + @OneToMany(mappedBy = "jobApplication", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("displayOrder ASC") + @Builder.Default + private List metrics = new ArrayList<>(); + + @OneToMany(mappedBy = "jobApplication", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("displayOrder ASC") + @Builder.Default + private List essays = new ArrayList<>(); + + public void moveTo(JobApplicationStage stage, int stageOrder) { + if (stage == null || stageOrder < 0) { + throw new IllegalArgumentException("단계와 0 이상의 순서가 필요합니다."); + } + this.stage = stage; + this.stageOrder = stageOrder; + } + + public void archive(LocalDateTime archivedAt) { + if (archivedAt == null || this.archivedAt != null) { + throw new IllegalStateException("활성 지원 카드만 보관할 수 있습니다."); + } + this.archivedAt = archivedAt; + } + + public void restore(int stageOrder) { + if (this.archivedAt == null || stageOrder < 0) { + throw new IllegalStateException("보관된 지원 카드와 0 이상의 순서가 필요합니다."); + } + this.archivedAt = null; + this.stageOrder = stageOrder; + } + + public void updateDetails( + DetailClassification detailClassification, + String companyName, + String postingName, + String jobTitle, + CompanySize companySize, + String task, + String requirement, + String preferred, + List requiredSkills, + LocalDateTime deadlineAt, + String currentLabel, + LocalDateTime currentAt, + String memo, + BigDecimal gpa, + BigDecimal maxGpa + ) { + this.detailClassification = detailClassification; + this.companyName = companyName; + this.postingName = postingName; + this.jobTitle = jobTitle; + this.companySize = companySize; + this.task = task; + this.requirement = requirement; + this.preferred = preferred; + this.requiredSkills.clear(); + this.requiredSkills.addAll(requiredSkills); + this.deadlineAt = deadlineAt; + this.currentLabel = currentLabel; + this.currentAt = currentAt; + this.memo = memo; + this.gpa = gpa; + this.maxGpa = maxGpa; + } + + public void replaceChecklistItems(List items) { + checklistItems.clear(); + checklistItems.addAll(items); + } + + public void replaceMetrics(List newMetrics) { + metrics.clear(); + metrics.addAll(newMetrics); + } + + public void replaceEssays(List newEssays) { + essays.clear(); + essays.addAll(newEssays); + } + + public static JobApplication create( + User user, + JobPosting sourceJobPosting, + DetailClassification detailClassification, + String companyName, + String postingName, + String jobTitle, + CompanySize companySize, + String task, + String requirement, + String preferred, + List requiredSkills, + LocalDateTime deadlineAt, + JobApplicationStage stage, + int stageOrder, + String currentLabel, + LocalDateTime currentAt + ) { + return JobApplication.builder() + .user(user) + .sourceJobPosting(sourceJobPosting) + .detailClassification(detailClassification) + .companyName(companyName) + .postingName(postingName) + .jobTitle(jobTitle) + .companySize(companySize) + .task(task) + .requirement(requirement) + .preferred(preferred) + .requiredSkills(requiredSkills == null ? new ArrayList<>() : new ArrayList<>(requiredSkills)) + .deadlineAt(deadlineAt) + .stage(stage) + .stageOrder(stageOrder) + .currentLabel(currentLabel) + .currentAt(currentAt) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationChecklistItem.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationChecklistItem.java new file mode 100644 index 00000000..5b47a871 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationChecklistItem.java @@ -0,0 +1,53 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "job_application_checklist_items", indexes = + @Index(name = "idx_job_application_checklist_application", columnList = "job_application_id,display_order")) +public class JobApplicationChecklistItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "job_application_id", nullable = false) + private JobApplication jobApplication; + + @Column(nullable = false, length = 200) + private String content; + + @Column(nullable = false) + private boolean completed; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + public static JobApplicationChecklistItem create( + JobApplication application, + String content, + boolean completed, + int displayOrder + ) { + JobApplicationChecklistItem item = new JobApplicationChecklistItem(); + item.jobApplication = application; + item.content = content; + item.completed = completed; + item.displayOrder = displayOrder; + return item; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationEssay.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationEssay.java new file mode 100644 index 00000000..b68b6d23 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationEssay.java @@ -0,0 +1,39 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "job_application_essays", indexes = + @Index(name = "idx_job_application_essays_application", columnList = "job_application_id,display_order")) +public class JobApplicationEssay { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "job_application_id", nullable = false) + private JobApplication jobApplication; + + @Column(nullable = false, length = 1000) + private String question; + + @Column(columnDefinition = "TEXT") + private String answer; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + public static JobApplicationEssay create(JobApplication application, String question, String answer, int displayOrder) { + JobApplicationEssay essay = new JobApplicationEssay(); + essay.jobApplication = application; + essay.question = question; + essay.answer = answer; + essay.displayOrder = displayOrder; + return essay; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetric.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetric.java new file mode 100644 index 00000000..830992d6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetric.java @@ -0,0 +1,61 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "job_application_metrics", indexes = + @Index(name = "idx_job_application_metrics_application", columnList = "job_application_id,display_order")) +public class JobApplicationMetric { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "job_application_id", nullable = false) + private JobApplication jobApplication; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private JobApplicationMetricType type; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "metric_value", nullable = false, length = 200) + private String value; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + public static JobApplicationMetric create( + JobApplication application, + JobApplicationMetricType type, + String name, + String value, + int displayOrder + ) { + JobApplicationMetric metric = new JobApplicationMetric(); + metric.jobApplication = application; + metric.type = type; + metric.name = name; + metric.value = value; + metric.displayOrder = displayOrder; + return metric; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetricType.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetricType.java new file mode 100644 index 00000000..8a91fee8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationMetricType.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +public enum JobApplicationMetricType { + CERTIFICATE, + LANGUAGE, + AWARD, + CUSTOM +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationStage.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationStage.java new file mode 100644 index 00000000..b322e526 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/entity/JobApplicationStage.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobapplication.entity; + +public enum JobApplicationStage { + PLANNED, + DOCUMENT, + INTERVIEW, + COMPLETED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationEssayRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationEssayRepository.java new file mode 100644 index 00000000..1de58c09 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationEssayRepository.java @@ -0,0 +1,22 @@ +package com.jobdri.jobdri_api.domain.jobapplication.repository; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationEssay; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; + +public interface JobApplicationEssayRepository extends JpaRepository { + interface Count { + Long getJobApplicationId(); + long getQuestionCount(); + } + + @Query(""" + select e.jobApplication.id as jobApplicationId, count(e) as questionCount + from JobApplicationEssay e + where e.jobApplication.user.id = :userId and e.jobApplication.archivedAt is null + group by e.jobApplication.id + """) + List countActiveByUser(@Param("userId") Long userId); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java new file mode 100644 index 00000000..f267ca46 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/repository/JobApplicationRepository.java @@ -0,0 +1,87 @@ +package com.jobdri.jobdri_api.domain.jobapplication.repository; + +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface JobApplicationRepository extends JpaRepository { + + @Query(""" + select distinct ja from JobApplication ja left join fetch ja.requiredSkills + where ja.user.id = :userId and ja.archivedAt is null + and (lower(ja.companyName) like :pattern escape '!' + or lower(ja.postingName) like :pattern escape '!' + or lower(ja.jobTitle) like :pattern escape '!') + """) + List findBoard(@Param("userId") Long userId, @Param("pattern") String pattern); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select ja from JobApplication ja + where ja.user.id = :userId and ja.archivedAt is null + and ja.stage in :stages + order by ja.stage, ja.stageOrder, ja.id + """) + List lockActiveColumns(@Param("userId") Long userId, + @Param("stages") List stages); + + @Query(""" + select coalesce(max(ja.stageOrder), -1) + from JobApplication ja + where ja.user.id = :userId + and ja.stage = :stage + and ja.archivedAt is null + """) + int findMaxStageOrder(@Param("userId") Long userId, @Param("stage") JobApplicationStage stage); + + @EntityGraph(attributePaths = {"detailClassification", "requiredSkills"}) + Optional findDetailedById(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select ja from JobApplication ja where ja.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query( + value = """ + select new com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse( + ja.id, ja.companyName, ja.postingName, ja.jobTitle, ja.deadlineAt, + ja.currentLabel, ja.currentAt, ja.stage, ja.archivedAt, ja.createdAt, ja.updatedAt + ) + from JobApplication ja + where ja.user.id = :userId and ja.archivedAt is not null + """, + countQuery = """ + select count(ja) + from JobApplication ja + where ja.user.id = :userId and ja.archivedAt is not null + """ + ) + Page findArchivePageByUserId( + @Param("userId") Long userId, + Pageable pageable + ); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update JobApplication ja set ja.sourceJobPosting = null where ja.sourceJobPosting.id = :jobPostingId") + int clearSourceJobPosting(@Param("jobPostingId") Long jobPostingId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.jobPosting.id = :jobPostingId") + int clearMockAppliesForJobPosting(@Param("jobPostingId") Long jobPostingId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.id = :mockApplyId") + int clearMockApply(@Param("mockApplyId") Long mockApplyId); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java new file mode 100644 index 00000000..c5dcef53 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationArchiveService.java @@ -0,0 +1,130 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.pagination.PaginationPolicy; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobApplicationArchiveService { + public static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE; + + private final JobApplicationRepository jobApplicationRepository; + private final UserService userService; + private final UserRepository userRepository; + private final EntityManager entityManager; + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_ARCHIVE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationResponse archive(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() != null) { + throw conflict("이미 보관된 지원 카드입니다."); + } + + List column = jobApplicationRepository.lockActiveColumns( + validatedUser.getId(), List.of(application.getStage())); + application.archive(LocalDateTime.now()); + reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage()); + return flushAndReload(application.getId()); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_RESTORE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationResponse restore(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() == null) { + throw conflict("보관되지 않은 지원 카드는 복원할 수 없습니다."); + } + + int lastOrder = jobApplicationRepository.findMaxStageOrder(validatedUser.getId(), application.getStage()) + 1; + application.restore(lastOrder); + return flushAndReload(application.getId()); + } + + public Page getArchive(User user, int page, int size) { + User validatedUser = userService.validateUser(user); + PageRequest pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), MAX_PAGE_SIZE), + Sort.by(Sort.Order.desc("archivedAt"), Sort.Order.desc("id")) + ); + return jobApplicationRepository.findArchivePageByUserId(validatedUser.getId(), pageable); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_DELETE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public void delete(User user, Long jobApplicationId) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + if (application.getArchivedAt() == null) { + List column = jobApplicationRepository.lockActiveColumns( + validatedUser.getId(), List.of(application.getStage())); + reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage()); + } + jobApplicationRepository.delete(application); + jobApplicationRepository.flush(); + } + + private JobApplicationResponse flushAndReload(Long jobApplicationId) { + jobApplicationRepository.flush(); + entityManager.clear(); + JobApplication application = jobApplicationRepository.findDetailedById(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + return JobApplicationResponse.from(application); + } + + private void reorder(List cards, JobApplicationStage stage) { + for (int index = 0; index < cards.size(); index++) { + cards.get(index).moveTo(stage, index); + } + } + + private JobApplication getOwnedApplicationForUpdate(User user, Long jobApplicationId) { + JobApplication application = jobApplicationRepository.findByIdForUpdate(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + if (!application.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다."); + } + return application; + } + + private User lockValidatedUser(User user) { + User validatedUser = userService.validateUser(user); + return userRepository.findByIdForUpdate(validatedUser.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } + + private GeneralException notFound(Long jobApplicationId) { + return new GeneralException( + GeneralErrorCode.JOB_APPLICATION_NOT_FOUND, + "해당 지원 카드를 찾을 수 없습니다. jobApplicationId=" + jobApplicationId + ); + } + + private GeneralException conflict(String message) { + return new GeneralException(GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT, message); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationBoardService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationBoardService.java new file mode 100644 index 00000000..ee594f71 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationBoardService.java @@ -0,0 +1,108 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationPositionRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationBoardResponse; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationCardResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationEssayRepository; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobApplicationBoardService { + private final JobApplicationRepository applications; + private final JobApplicationEssayRepository essays; + private final UserService userService; + private final UserRepository users; + private final EntityManager entityManager; + + public JobApplicationBoardResponse getBoard(User principal, String query, JobApplicationSort sort) { + User user = userService.validateUser(principal); + JobApplicationSort effectiveSort = sort == null ? JobApplicationSort.MANUAL : sort; + List cards = applications.findBoard(user.getId(), searchPattern(query)); + Map counts = cards.isEmpty() ? Map.of() : essays.countActiveByUser(user.getId()).stream() + .collect(Collectors.toMap(JobApplicationEssayRepository.Count::getJobApplicationId, + JobApplicationEssayRepository.Count::getQuestionCount)); + Comparator comparator = effectiveSort == JobApplicationSort.CREATED_DESC + ? Comparator.comparing(JobApplication::getCreatedAt).reversed() + .thenComparing(JobApplication::getId, Comparator.reverseOrder()) + : Comparator.comparingInt(JobApplication::getStageOrder).thenComparing(JobApplication::getId); + List columns = Arrays.stream(JobApplicationStage.values()) + .map(stage -> { + List items = cards.stream() + .filter(card -> card.getStage() == stage).sorted(comparator) + .map(card -> JobApplicationCardResponse.from(card, counts.getOrDefault(card.getId(), 0L))) + .toList(); + return new JobApplicationBoardResponse.Column(stage, items.size(), items); + }).toList(); + return new JobApplicationBoardResponse(effectiveSort, columns); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_MOVE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationBoardResponse move(User principal, Long id, JobApplicationPositionRequest request) { + if (request == null || request.targetStage() == null || request.targetIndex() == null + || request.targetIndex() < 0) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "단계와 0 이상의 targetIndex가 필요합니다."); + } + User user = userService.validateUser(principal); + // Registration uses the same user lock. It protects empty columns and prevents + // concurrent inserts while either column is being reordered. + users.findByIdForUpdate(user.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + JobApplication card = applications.findById(id) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.JOB_APPLICATION_NOT_FOUND)); + if (!card.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다."); + } + if (card.getArchivedAt() != null) { + throw new GeneralException(GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT, "보관된 지원 카드는 이동할 수 없습니다."); + } + JobApplicationStage source = card.getStage(); + JobApplicationStage target = request.targetStage(); + List locked = applications.lockActiveColumns(user.getId(), + source == target ? List.of(source) : List.of(source, target)); + List destination = new ArrayList<>(locked.stream() + .filter(item -> item.getStage() == target && !item.getId().equals(id)).toList()); + if (request.targetIndex() > destination.size()) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "targetIndex가 도착 열의 범위를 벗어났습니다."); + } + if (source != target) { + reorder(locked.stream().filter(item -> item.getStage() == source && !item.getId().equals(id)).toList(), source); + } + destination.add(request.targetIndex(), card); + reorder(destination, target); + // Flush auditing timestamps and reload their database-normalized precision + // before building the response. + applications.flush(); + entityManager.clear(); + return getBoard(user, null, JobApplicationSort.MANUAL); + } + + private static void reorder(List cards, JobApplicationStage stage) { + for (int i = 0; i < cards.size(); i++) { + cards.get(i).moveTo(stage, i); + } + } + + private static String searchPattern(String query) { + String normalized = query == null ? "" : query.strip().toLowerCase(Locale.ROOT); + return "%" + normalized.replace("!", "!!").replace("%", "!%").replace("_", "!_") + "%"; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailService.java new file mode 100644 index 00000000..70599a7d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationDetailService.java @@ -0,0 +1,189 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationChecklistItemRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationDetailUpdateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationEssayRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationMetricRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationDetailResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationChecklistItem; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationEssay; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationMetric; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobApplicationDetailService { + private final JobApplicationRepository jobApplicationRepository; + private final DetailClassificationRepository detailClassificationRepository; + private final UserService userService; + private final UserRepository userRepository; + private final EntityManager entityManager; + + public JobApplicationDetailResponse get(User user, Long jobApplicationId) { + User validatedUser = userService.validateUser(user); + return JobApplicationDetailResponse.from(getOwnedApplication(validatedUser, jobApplicationId)); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_UPDATE", targetType = "JOB_APPLICATION", targetId = "#arg1") + public JobApplicationDetailResponse update( + User user, + Long jobApplicationId, + JobApplicationDetailUpdateRequest request + ) { + User validatedUser = lockValidatedUser(user); + JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId); + validateNotStale(application, request.lastKnownUpdatedAt()); + DetailClassification detailClassification = findOptionalClassification(request.detailClassificationId()); + + application.updateDetails( + detailClassification, + request.companyName().trim(), + request.postingName().trim(), + request.jobTitle().trim(), + request.companySize(), + request.task(), + request.requirement(), + request.preferred(), + normalizeSkills(request.requiredSkills()), + request.deadlineAt(), + request.currentLabel(), + request.currentAt(), + request.memo(), + request.gpa(), + request.maxGpa() + ); + application.replaceChecklistItems(toChecklistItems(application, request.checklistItems())); + application.replaceMetrics(toMetrics(application, request.metrics())); + application.replaceEssays(toEssays(application, request.essays())); + + jobApplicationRepository.flush(); + entityManager.clear(); + return JobApplicationDetailResponse.from(getOwnedApplication(validatedUser, jobApplicationId)); + } + + private JobApplication getOwnedApplication(User user, Long jobApplicationId) { + JobApplication application = jobApplicationRepository.findDetailedById(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + validateOwner(application, user); + return application; + } + + private JobApplication getOwnedApplicationForUpdate(User user, Long jobApplicationId) { + JobApplication application = jobApplicationRepository.findByIdForUpdate(jobApplicationId) + .orElseThrow(() -> notFound(jobApplicationId)); + validateOwner(application, user); + return application; + } + + private void validateOwner(JobApplication application, User user) { + if (!application.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다."); + } + } + + private void validateNotStale(JobApplication application, LocalDateTime lastKnownUpdatedAt) { + if (application.getUpdatedAt() != null && application.getUpdatedAt().isAfter(lastKnownUpdatedAt)) { + throw new GeneralException( + GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT, + "지원 카드가 이미 수정되었습니다. 최신 정보를 다시 조회한 뒤 저장해주세요." + ); + } + } + + private DetailClassification findOptionalClassification(Long detailClassificationId) { + if (detailClassificationId == null) { + return null; + } + return detailClassificationRepository.findById(detailClassificationId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId + )); + } + + private List normalizeSkills(List skills) { + return safeList(skills).stream().map(String::trim).toList(); + } + + private List toChecklistItems( + JobApplication application, + List requests + ) { + List items = safeList(requests); + return IntStream.range(0, items.size()) + .mapToObj(index -> JobApplicationChecklistItem.create( + application, + items.get(index).content().trim(), + items.get(index).completed(), + index + )) + .toList(); + } + + private List toMetrics( + JobApplication application, + List requests + ) { + List metrics = safeList(requests); + return IntStream.range(0, metrics.size()) + .mapToObj(index -> JobApplicationMetric.create( + application, + metrics.get(index).type(), + metrics.get(index).name().trim(), + metrics.get(index).value().trim(), + index + )) + .toList(); + } + + private List toEssays( + JobApplication application, + List requests + ) { + List essays = safeList(requests); + return IntStream.range(0, essays.size()) + .mapToObj(index -> JobApplicationEssay.create( + application, + essays.get(index).question().trim(), + essays.get(index).answer(), + index + )) + .toList(); + } + + private List safeList(List values) { + return values == null ? List.of() : values; + } + + private User lockValidatedUser(User user) { + User validatedUser = userService.validateUser(user); + return userRepository.findByIdForUpdate(validatedUser.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } + + private GeneralException notFound(Long jobApplicationId) { + return new GeneralException( + GeneralErrorCode.JOB_APPLICATION_NOT_FOUND, + "해당 지원 카드를 찾을 수 없습니다. jobApplicationId=" + jobApplicationId + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationService.java new file mode 100644 index 00000000..830ef96f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobapplication/service/JobApplicationService.java @@ -0,0 +1,139 @@ +package com.jobdri.jobdri_api.domain.jobapplication.service; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest; +import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication; +import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobApplicationService { + private final JobApplicationRepository jobApplicationRepository; + private final DetailClassificationRepository detailClassificationRepository; + private final JobPostingService jobPostingService; + private final UserService userService; + private final UserRepository userRepository; + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_CREATE", targetType = "JOB_APPLICATION", targetId = "#result.jobApplicationId") + public JobApplicationResponse create(User user, JobApplicationCreateRequest request) { + User validatedUser = lockValidatedUser(user); + JobApplicationStage stage = request.initialStage() == null + ? JobApplicationStage.PLANNED + : request.initialStage(); + DetailClassification detailClassification = findOptionalClassification(request.detailClassificationId()); + + JobApplication application = JobApplication.create( + validatedUser, + null, + detailClassification, + request.companyName().trim(), + request.postingName().trim(), + request.jobTitle().trim(), + request.companySize(), + request.task(), + request.requirement(), + request.preferred(), + normalizeSkills(request.requiredSkills()), + request.deadlineAt(), + stage, + nextStageOrder(validatedUser.getId(), stage), + request.currentLabel(), + request.currentAt() + ); + return JobApplicationResponse.from(jobApplicationRepository.save(application)); + } + + @Transactional + @AuditLogEvent(action = "JOB_APPLICATION_CREATE_FROM_JOB_POSTING", targetType = "JOB_APPLICATION", targetId = "#result.jobApplicationId") + public JobApplicationResponse createFromJobPosting(User user, JobApplicationFromJobPostingRequest request) { + User validatedUser = lockValidatedUser(user); + JobPosting source = jobPostingService.getOwnedJobPostingForUpdate(validatedUser, request.jobPostingId()); + JobApplicationStage stage = request.initialStage() == null + ? JobApplicationStage.PLANNED + : request.initialStage(); + + JobApplication application = JobApplication.create( + validatedUser, + source, + source.getDetailClassification(), + source.getCompany().getName(), + source.getPostingName(), + source.getJobTitle(), + source.getCompany().getSize(), + source.getTask(), + source.getRequirement(), + source.getPreferred(), + List.of(), + null, + stage, + nextStageOrder(validatedUser.getId(), stage), + null, + null + ); + return JobApplicationResponse.from(jobApplicationRepository.save(application)); + } + + public JobApplicationResponse get(User user, Long jobApplicationId) { + User validatedUser = userService.validateUser(user); + return JobApplicationResponse.from(getOwnedApplication(validatedUser, jobApplicationId)); + } + + public JobApplication getOwnedApplication(User user, Long jobApplicationId) { + JobApplication application = jobApplicationRepository.findDetailedById(jobApplicationId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_APPLICATION_NOT_FOUND, + "해당 지원 카드를 찾을 수 없습니다. jobApplicationId=" + jobApplicationId + )); + if (!application.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다."); + } + return application; + } + + private DetailClassification findOptionalClassification(Long detailClassificationId) { + if (detailClassificationId == null) { + return null; + } + return detailClassificationRepository.findById(detailClassificationId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId + )); + } + + private int nextStageOrder(Long userId, JobApplicationStage stage) { + return jobApplicationRepository.findMaxStageOrder(userId, stage) + 1; + } + + private List normalizeSkills(List skills) { + if (skills == null) { + return List.of(); + } + return skills.stream().map(String::trim).toList(); + } + + private User lockValidatedUser(User user) { + User validatedUser = userService.validateUser(user); + return userRepository.findByIdForUpdate(validatedUser.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java index a9c3d2f7..ad777a9e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java @@ -1,18 +1,26 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface JobPostingRepository extends JpaRepository { List findAllByOrderByIdAsc(); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = {"company", "user", "detailClassification"}) + @Query("select jp from JobPosting jp where jp.id = :jobPostingId") + Optional findByIdForUpdate(@Param("jobPostingId") Long jobPostingId); + @EntityGraph(attributePaths = { "company", "user", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java index 3d1a7f85..47633fec 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingUpdateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; @@ -16,6 +17,7 @@ import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplySequenceRepository; import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -46,6 +48,8 @@ public class JobPostingService { private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; + private final JobApplicationRepository jobApplicationRepository; + private final UserRepository userRepository; @Transactional @AuditLogEvent(action = "JOB_POSTING_CREATE", targetType = "JOB_POSTING", targetId = "#result.getJobPostingId()") @@ -135,14 +139,16 @@ private void validateNotStale(JobPosting jobPosting, LocalDateTime lastKnownUpda @Transactional @AuditLogEvent(action = "JOB_POSTING_DELETE", targetType = "JOB_POSTING", targetId = "#arg1") public void deleteJobPosting(User user, Long jobPostingId) { - User validatedUser = userService.validateUser(user); - JobPosting jobPosting = getOwnedJobPosting(validatedUser, jobPostingId); + User validatedUser = lockValidatedUser(user); + JobPosting jobPosting = getOwnedJobPostingForUpdate(validatedUser, jobPostingId); + jobApplicationRepository.clearMockAppliesForJobPosting(jobPostingId); questionAnalysisRepository.deleteAllByJobPostingId(jobPostingId); questionRepository.deleteAllByJobPostingId(jobPostingId); analysisRepository.deleteAllByJobPostingId(jobPostingId); mockApplyRepository.deleteAllByJobPostingId(jobPostingId); mockApplySequenceRepository.deleteAllByUserIdAndJobPostingId(validatedUser.getId(), jobPostingId); + jobApplicationRepository.clearSourceJobPosting(jobPostingId); jobPostingRepository.delete(jobPosting); } @@ -160,6 +166,24 @@ public JobPosting getOwnedJobPosting(User user, Long jobPostingId) { return jobPosting; } + public JobPosting getOwnedJobPostingForUpdate(User user, Long jobPostingId) { + JobPosting jobPosting = jobPostingRepository.findByIdForUpdate(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + if (!jobPosting.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 공고에 접근할 수 없습니다."); + } + return jobPosting; + } + + private User lockValidatedUser(User user) { + User validatedUser = userService.validateUser(user); + return userRepository.findByIdForUpdate(validatedUser.getId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } + private Company findOrCreateCompany(String companyName, com.jobdri.jobdri_api.domain.company.entity.CompanySize companySize) { return companyRepository.findByName(companyName) .orElseGet(() -> companyRepository.save(Company.create(companyName, companySize))); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index ee090335..f4923063 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; @@ -68,6 +69,7 @@ public class MockApplyService { private final MockApplyPersistenceService mockApplyPersistenceService; private final MockApplySequenceService mockApplySequenceService; private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final JobApplicationRepository jobApplicationRepository; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -298,6 +300,7 @@ public void deleteMockApply(User user, Long mockApplyId) { questionAnalysisRepository.deleteAllByMockApplyId(mockApplyId); analysisRepository.deleteByMockApplyId(mockApplyId); questionRepository.deleteAllByMockApplyId(mockApplyId); + jobApplicationRepository.clearMockApply(mockApplyId); mockApplyRepository.deleteByMockApplyId(mockApplyId); } diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 2611dae3..e7c22552 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -41,6 +41,11 @@ public enum GeneralErrorCode implements BaseErrorCode { JOB_POSTING_UPDATE_CONFLICT(HttpStatus.CONFLICT, "JOB_POSTING_4091", "채용 공고가 이미 수정되었습니다."), WORKER_TASK_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "WORKER_RESULT_4041", "worker 결과를 찾을 수 없습니다."), + // 지원관리 에러 + JOB_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_APPLICATION_4041", "지원 카드를 찾을 수 없습니다."), + JOB_APPLICATION_UPDATE_CONFLICT(HttpStatus.CONFLICT, "JOB_APPLICATION_4091", "지원 카드가 이미 수정되었습니다."), + JOB_APPLICATION_NOT_READY(HttpStatus.UNPROCESSABLE_ENTITY, "JOB_APPLICATION_NOT_READY", "모의지원 전환에 필요한 정보가 부족합니다."), + // 모의 서류 지원 에러 MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."), QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java index 42d4b6de..1545b26f 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java @@ -29,6 +29,7 @@ import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; @Component @Slf4j @@ -45,6 +46,7 @@ public class CohereEmbeddingClient { private final CohereProperties properties; private final RestClient restClient; + private final AtomicLong apiCallCount = new AtomicLong(); public CohereEmbeddingClient(CohereProperties properties, RestClient.Builder restClientBuilder) { this.properties = properties; @@ -94,7 +96,7 @@ private CohereEmbeddingResponse callCohere(CohereEmbeddingRequest request) { if (attempt == MAX_TRANSIENT_ATTEMPTS) { throw unavailable("Cohere Embed API가 일시적으로 응답할 수 없습니다.", e); } - Duration delay = e.retryAfter() != null ? e.retryAfter() : backoff; + Duration delay = boundedRetryDelay(e.retryAfter(), backoff); log.warn( "Cohere Embed API transient failure. attempt={}, maxAttempts={}, retryAfterMs={}, message={}", attempt, @@ -110,6 +112,7 @@ private CohereEmbeddingResponse callCohere(CohereEmbeddingRequest request) { } private CohereEmbeddingResponse callCohereOnce(CohereEmbeddingRequest request) { + apiCallCount.incrementAndGet(); try { return restClient.post() .uri("/v2/embed") @@ -150,6 +153,10 @@ private CohereEmbeddingResponse callCohereOnce(CohereEmbeddingRequest request) { } } + public long apiCallCount() { + return apiCallCount.get(); + } + private List validateResponse(CohereEmbeddingResponse response, int expectedCount) { if (response == null || response.embeddings() == null || response.embeddings().floatValues() == null || response.embeddings().floatValues().isEmpty()) { @@ -260,6 +267,11 @@ private static Duration nextBackoff(Duration current) { return next.compareTo(MAX_RETRY_BACKOFF) > 0 ? MAX_RETRY_BACKOFF : next; } + static Duration boundedRetryDelay(Duration retryAfter, Duration backoff) { + Duration requested = retryAfter != null ? retryAfter : backoff; + return requested.compareTo(MAX_RETRY_BACKOFF) > 0 ? MAX_RETRY_BACKOFF : requested; + } + private static void sleepBeforeRetry(Duration delay) { try { Thread.sleep(delay.toMillis()); diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java index 72aac3f0..930376b9 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java @@ -1,12 +1,15 @@ package com.jobdri.jobdri_api.global.cohere.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +@JsonIgnoreProperties(ignoreUnknown = true) public record CohereEmbeddingResponse( Embeddings embeddings ) { + @JsonIgnoreProperties(ignoreUnknown = true) public record Embeddings( @JsonProperty("float") List> floatValues diff --git a/src/main/resources/analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json b/src/main/resources/analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json new file mode 100644 index 00000000..7c8a0aa4 --- /dev/null +++ b/src/main/resources/analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json @@ -0,0 +1,173 @@ +[ + { + "id": "FS-02", + "source": "REVIEWED_EVALUATION", + "reviewStatus": "APPROVED", + "enabled": true, + "priority": 6, + "jobCategory": "회계 관리자", + "jobTitle": "재무/회계 Sr.", + "mainTasks": [ + "- 회계 정보 " + ], + "qualifications": [ + "- 학사 이상", + "- 회계 관련 전공자", + "- (최소 5년) 10년 미만 관련 업무 경험자", + "- 연결재무제표 작성 경력 최소 2년 이상", + "- SAP FI 활용 경험이 있는 분", + "- 외부감사 수검 2회이상 경험을 보유한 분", + "- 결산 조정 가능한 분", + "- 팀 내/간 구성원 커뮤니케이션이 원활한 분", + "- 성실하고 기민하여 꼼꼼한 업무수행이 가능한 분" + ], + "question": "지원 동기와 입사 후 포부를 기술하시오", + "sanitizedAnswer": "자기소개\n\n영화 인터스텔라의 ‘밀러 행성’ 장면에서 1시간의 체류 시간이 지구에서는 7년의 시간이 흐른다는 설정을 보며 겉으로는 같은 숫자라도, 어떤 기준에서 바라보느냐에 따라 전혀 다른 결과로 해석될 수 있음을 느꼈습니다.\n\n회계는 기업의 성과를 기준에 따라 기록하고 이를 통해 사업의 실질을 정확하게 전달하는 역할을 합니다. 기준이 명확하지 않으면 동일한 거래도 왜곡된 결과로 나타날 수 있으며 이는 재무정보의 신뢰성을 저해할 수 있습니다.\n\n저는 ○○○에서 11년간 근무하며 별도 결산, 세무, 자산 관리 및 내부회계 운영 등 재무 전반의 실무 경험을 쌓아왔습니다. 결산 과정에서 손익 변동의 원인을 분석하고 회계 기준과 거래의 실질이 일치하는지 지속적으로 점검해 왔습니다. 또한 재무 결과를 사업 활동과 연결하여 이해하고 설명하는 데 집중해 왔으며 이를 통해 재무정보의 정확성과 신뢰성을 유지해 왔습니다.\n\n이러한 경험을 바탕으로 재무 데이터를 통해 사업의 현재를 왜곡 없이 투영하고 엄격한 기준과 원칙 위에서 재무정보의 정확성을 책임지는 재무 담당자가 되겠습니다.\n\n지원동기\n\n콘텐츠 산업은 하나의 IP가 음악, 공연, 영상, 플랫폼 등 다양한 형태로 확장되며 글로벌 시장에서 지속적으로 성장하는 산업이라고 생각합니다. 이러한 산업 구조에서는 다양한 국가와 사업 영역에서 발생하는 거래를 정확하게 반영하고 사업의 재무 흐름을 안정적으로 관리하는 재무 역할이 중요하다고 생각합니다.\n\n하이브는 아티스트 IP를 기반으로 음악, 공연, 플랫폼 등 다양한 콘텐츠 사업을 글로벌 시장으로 확장하며 새로운 비즈니스 구조를 만들어가고 있는 기업이라고 생각합니다. 특히 하나의 IP가 다양한 사업으로 확장되는 구조에서는 각 사업 활동에서 발생하는 거래를 재무적으로 정확하게 반영하고 사업의 성과를 일관된 기준으로 관리하는 재무 기능이 중요하다고 생각했습니다.\n\n저는 ○○○에서 결산과 재무 분석을 수행하며 사업 활동과 재무 결과를 연결해 설명하는 경험을 쌓아왔습니다. 이러한 경험을 바탕으로 다양한 사업 구조 속에서 발생하는 거래를 정확하게 반영하고 글로벌 사업의 재무 흐름을 안정적으로 관리하는 역할을 수행하고자 지원했습니다.\n\n직무적합성 및 강점\n\n○○○에서 11년간 근무하며 결산을 중심으로 회계, 세무, 내부회계 운영 등 재무 전반의 실무 경험을 쌓아왔으며 특히 주요 거래와 회계 이슈에 대한 기준을 검토하고 재무제표의 신뢰성을 확보하는 역할을 수행해 왔습니다. 이러한 경험은 다음과 같은 성과로 이어졌습니다.\n\n첫째, 자산 거버넌스 및 자본 구조 개선 경험입니다. 전사 토지 재평가 프로젝트를 수행하며 약 7.8조 원 규모의 자산 재평가를 진행하였고 부채비율을 약 35.4%p 개선하여 재무 안정성을 확보했습니다. 공정가치 산정 기준 검토와 재무적 영향 분석을 수행하였으며 이후 손상평가 등 후속 검토까지 관리하며 자산 회계의 전 과정을 경험했습니다.\n\n둘째, 세무 리스크 관리 및 조세 실익 확보 경험입니다. 부가가치세 조세불복을 수행하여 약 320억 원 규모의 세액 환급을 이끌어냈으며 약 800억 원 규모의 장기 미수금을 정비하여 재무 건전성 개선에 기여했습니다. 회계와 세무를 분리하지 않고 재무 전반의 관점에서 리스크를 관리한 경험입니다.\n\n셋째, 시스템 고도화를 통한 재무 프로세스 개선 경험입니다. SAP S/4 HANA 고도화 프로젝트에 참여하여 현금흐름 추출 로직을 설계하고 결산 프로세스를 개선하였습니다. 수기 업무를 시스템 통제로 전환하며 데이터 정합성을 강화하였고 결산 리드타임을 약 30% 단축하였습니다. 이는 디지털 기반으로 재무 프로세스를 고도화하고 재무보고의 효율성을 높인 경험입니다.\n\n입사 후 포부\n\n입사 초기에는 하이브의 결산 프로세스와 재무보고 체계를 빠르게 이해하고 맡은 업무를 정확하고 안정적으로 수행하는 데 집중하겠습니다. 특히 다양한 국가와 사업 구조에서 발생하는 거래를 정확하게 반영하고 글로벌 사업의 재무 흐름을 이해하며 결산 업무의 전문성을 넓혀 나가겠습니다.\n\n중장기적으로는 복잡한 사업 구조 속에서 발생하는 회계 이슈에 대해 기준을 검토하고 합리적인 처리 방향을 제시할 수 있는 재무 담당자로 성장하고 싶습니다. 또한 글로벌 사업 확장 과정에서 재무 데이터의 정확성과 일관성을 유지함으로써 하이브의 안정적인 재무 운영을 뒷받침하겠습니다.", + "approvedAnalysisJson": "{\"keyStrengths\": [], \"missingKeywords\": [{\"keyword\": \"연결 재무제표 작성\", \"source\": \"qualification\"}], \"questionAnalyses\": [{\"questionId\": 1, \"sentence\": \"SAP S/4 HANA 고도화 프로젝트에 참여하여 현금흐름 추출 로직을 설계하고 결산 프로세스를 개선하였습니다.\", \"status\": \"proven\", \"reason\": \"채용공고의 SAP FI 활용 경험과 결산 프로세스 개선 역량에 직접 맞닿는 문장입니다. 단순 사용이 아니라 시스템 로직 설계와 프로세스 개선까지 수행한 점이 드러나 직무 적합성을 뒷받침합니다.\", \"improvement\": null}, {\"questionId\": 1, \"sentence\": \"수기 업무를 시스템 통제로 전환하며 데이터 정합성을 강화하였고 결산 리드타임을 약 30% 단축하였습니다.\", \"status\": \"proven\", \"reason\": \"프로세스 개선의 결과가 수치로 제시되어 있어 성과의 검증 가능성이 높습니다. 효율성과 정확성 두 측면의 개선 효과가 함께 드러나 재무/회계 운영 기여도를 보여줍니다.\", \"improvement\": null}]}", + "tags": [ + "회계·세무", + "회계 관리자", + "mentioned", + "proven", + "missing", + "completeness", + "jobFit", + "impact" + ], + "datasetVersion": "fewshot-pm-reviewed-20260914-v2", + "promptBlock": "## 예시 FS-02: PM 검수 사례\n관련 JD 요구사항:\n- 회계 정보 \n- 학사 이상\n- 회계 관련 전공자\n- (최소 5년) 10년 미만 관련 업무 경험자\n- 연결재무제표 작성 경력 최소 2년 이상\n- SAP FI 활용 경험이 있는 분\n- 외부감사 수검 2회이상 경험을 보유한 분\n- 결산 조정 가능한 분\n- 팀 내/간 구성원 커뮤니케이션이 원활한 분\n- 성실하고 기민하여 꼼꼼한 업무수행이 가능한 분\n우대사항:\n- 엔터테인먼트/미디어 혹은 IT업계에서 회계 관련 경험을 보유한 분\n- 공인회계사 혹은 회계 관련 자격증을 보유한 분\n- 영어나 일본어 커뮤니케이션에 원활한 분\n평가 대상 답변:\n- questionId: 1\n- question: 지원 동기와 입사 후 포부를 기술하시오\n- answer: 자기소개\n\n영화 인터스텔라의 ‘밀러 행성’ 장면에서 1시간의 체류 시간이 지구에서는 7년의 시간이 흐른다는 설정을 보며 겉으로는 같은 숫자라도, 어떤 기준에서 바라보느냐에 따라 전혀 다른 결과로 해석될 수 있음을 느꼈습니다.\n\n회계는 기업의 성과를 기준에 따라 기록하고 이를 통해 사업의 실질을 정확하게 전달하는 역할을 합니다. 기준이 명확하지 않으면 동일한 거래도 왜곡된 결과로 나타날 수 있으며 이는 재무정보의 신뢰성을 저해할 수 있습니다.\n\n저는 ○○○에서 11년간 근무하며 별도 결산, 세무, 자산 관리 및 내부회계 운영 등 재무 전반의 실무 경험을 쌓아왔습니다. 결산 과정에서 손익 변동의 원인을 분석하고 회계 기준과 거래의 실질이 일치하는지 지속적으로 점검해 왔습니다. 또한 재무 결과를 사업 활동과 연결하여 이해하고 설명하는 데 집중해 왔으며 이를 통해 재무정보의 정확성과 신뢰성을 유지해 왔습니다.\n\n이러한 경험을 바탕으로 재무 데이터를 통해 사업의 현재를 왜곡 없이 투영하고 엄격한 기준과 원칙 위에서 재무정보의 정확성을 책임지는 재무 담당자가 되겠습니다.\n\n지원동기\n\n콘텐츠 산업은 하나의 IP가 음악, 공연, 영상, 플랫폼 등 다양한 형태로 확장되며 글로벌 시장에서 지속적으로 성장하는 산업이라고 생각합니다. 이러한 산업 구조에서는 다양한 국가와 사업 영역에서 발생하는 거래를 정확하게 반영하고 사업의 재무 흐름을 안정적으로 관리하는 재무 역할이 중요하다고 생각합니다.\n\n하이브는 아티스트 IP를 기반으로 음악, 공연, 플랫폼 등 다양한 콘텐츠 사업을 글로벌 시장으로 확장하며 새로운 비즈니스 구조를 만들어가고 있는 기업이라고 생각합니다. 특히 하나의 IP가 다양한 사업으로 확장되는 구조에서는 각 사업 활동에서 발생하는 거래를 재무적으로 정확하게 반영하고 사업의 성과를 일관된 기준으로 관리하는 재무 기능이 중요하다고 생각했습니다.\n\n저는 ○○○에서 결산과 재무 분석을 수행하며 사업 활동과 재무 결과를 연결해 설명하는 경험을 쌓아왔습니다. 이러한 경험을 바탕으로 다양한 사업 구조 속에서 발생하는 거래를 정확하게 반영하고 글로벌 사업의 재무 흐름을 안정적으로 관리하는 역할을 수행하고자 지원했습니다.\n\n직무적합성 및 강점\n\n○○○에서 11년간 근무하며 결산을 중심으로 회계, 세무, 내부회계 운영 등 재무 전반의 실무 경험을 쌓아왔으며 특히 주요 거래와 회계 이슈에 대한 기준을 검토하고 재무제표의 신뢰성을 확보하는 역할을 수행해 왔습니다. 이러한 경험은 다음과 같은 성과로 이어졌습니다.\n\n첫째, 자산 거버넌스 및 자본 구조 개선 경험입니다. 전사 토지 재평가 프로젝트를 수행하며 약 7.8조 원 규모의 자산 재평가를 진행하였고 부채비율을 약 35.4%p 개선하여 재무 안정성을 확보했습니다. 공정가치 산정 기준 검토와 재무적 영향 분석을 수행하였으며 이후 손상평가 등 후속 검토까지 관리하며 자산 회계의 전 과정을 경험했습니다.\n\n둘째, 세무 리스크 관리 및 조세 실익 확보 경험입니다. 부가가치세 조세불복을 수행하여 약 320억 원 규모의 세액 환급을 이끌어냈으며 약 800억 원 규모의 장기 미수금을 정비하여 재무 건전성 개선에 기여했습니다. 회계와 세무를 분리하지 않고 재무 전반의 관점에서 리스크를 관리한 경험입니다.\n\n셋째, 시스템 고도화를 통한 재무 프로세스 개선 경험입니다. SAP S/4 HANA 고도화 프로젝트에 참여하여 현금흐름 추출 로직을 설계하고 결산 프로세스를 개선하였습니다. 수기 업무를 시스템 통제로 전환하며 데이터 정합성을 강화하였고 결산 리드타임을 약 30% 단축하였습니다. 이는 디지털 기반으로 재무 프로세스를 고도화하고 재무보고의 효율성을 높인 경험입니다.\n\n입사 후 포부\n\n입사 초기에는 하이브의 결산 프로세스와 재무보고 체계를 빠르게 이해하고 맡은 업무를 정확하고 안정적으로 수행하는 데 집중하겠습니다. 특히 다양한 국가와 사업 구조에서 발생하는 거래를 정확하게 반영하고 글로벌 사업의 재무 흐름을 이해하며 결산 업무의 전문성을 넓혀 나가겠습니다.\n\n중장기적으로는 복잡한 사업 구조 속에서 발생하는 회계 이슈에 대해 기준을 검토하고 합리적인 처리 방향을 제시할 수 있는 재무 담당자로 성장하고 싶습니다. 또한 글로벌 사업 확장 과정에서 재무 데이터의 정확성과 일관성을 유지함으로써 하이브의 안정적인 재무 운영을 뒷받침하겠습니다.\n출력 중 강점/문장/누락 관련 필드:\n{\n \"keyStrengths\": [],\n \"missingKeywords\": [\n {\n \"keyword\": \"연결 재무제표 작성\",\n \"source\": \"qualification\"\n }\n ],\n \"questionAnalyses\": [\n {\n \"questionId\": 1,\n \"sentence\": \"SAP S/4 HANA 고도화 프로젝트에 참여하여 현금흐름 추출 로직을 설계하고 결산 프로세스를 개선하였습니다.\",\n \"status\": \"proven\",\n \"reason\": \"채용공고의 SAP FI 활용 경험과 결산 프로세스 개선 역량에 직접 맞닿는 문장입니다. 단순 사용이 아니라 시스템 로직 설계와 프로세스 개선까지 수행한 점이 드러나 직무 적합성을 뒷받침합니다.\",\n \"improvement\": null\n },\n {\n \"questionId\": 1,\n \"sentence\": \"수기 업무를 시스템 통제로 전환하며 데이터 정합성을 강화하였고 결산 리드타임을 약 30% 단축하였습니다.\",\n \"status\": \"proven\",\n \"reason\": \"프로세스 개선의 결과가 수치로 제시되어 있어 성과의 검증 가능성이 높습니다. 효율성과 정확성 두 측면의 개선 효과가 함께 드러나 재무/회계 운영 기여도를 보여줍니다.\",\n \"improvement\": null\n }\n ]\n}" + }, + { + "id": "FS-03", + "source": "REVIEWED_EVALUATION", + "reviewStatus": "APPROVED", + "enabled": true, + "priority": 1, + "jobCategory": "공공기관 인턴", + "jobTitle": "청년인턴(체험형)", + "mainTasks": [ + "- 해외시장 개척, 해외 투자진출 지원 및 시장정보 조사, 경영관리 보조 등", + "- 행정업무 보조" + ], + "qualifications": [ + "- 가. 국적 : 대한민국 국적 소지자", + "- 나. 응시연령 : 만 34세 이하 청년", + "- 다. 학력 : 고등학교 졸업 이상(‘26. 6. 17. 공고일 기준)", + "- 라. 국가공무원법 제33조에 의한 결격사유가 없는 자", + "- 마. 최종 합격 후 근로계약 체결일(채용일)부터 근무 가능한 자", + "- 바. KOTRA 청년인턴으로 근무했던 자 또는 근무 중인 자는 채용 제외", + "- 유효한 영어 TOEIC 성적 보유자" + ], + "question": "지원 동기와 입사 후 포부를 기술하시오", + "sanitizedAnswer": "기업의 해외 진출을 지원하는 KOTRA에서 기업지원 실무를 직접 경험하고 싶어 지원했습니다. 무역학개론을 수강하며 기업의 해외 진출은 제품의 경쟁력뿐 아니라 국가별 시장환경과 고객을 이해하고 그에 맞는 전략을 수립하는 과정이 중요하다는 점을 배웠습니다. 이를 배우며 해외시장 분석과 전략 수립을 지원하는 업무에 관심을 갖게 되었고, 교환학생 기간 중 창업 프로젝트를 통해 미국 시장에서 이를 직접 검증해 볼 수 있었습니다.\n한국에서 구상한 아이디어를 미국 시장에 적용하는 과정에서 한국에서 효과적이라고 생각했던 전략이 그대로 통하지 않는다는 점을 몸소 경험했습니다. 고객 특성과 경쟁 환경을 다시 분석하며 운영 전략을 수정했고 시장을 이해하는 과정이 전략을 바꾸기도 한다는 점을 직접 확인할 수 있었습니다. 이 경험을 통해 기업이 해외시장에 진출하기 위해서는 시장을 이해하고 전략을 세우는 과정이 중요하다는 점을 느꼈습니다. 그래서 이러한 과정을 지원하는 KOTRA의 기업지원 업무에 관심을 갖게 되었습니다.\n체험형 인턴으로 근무하게 된다면 자료조사와 행정지원 등 맡은 업무를 책임감 있게 수행하겠습니다. 실무를 통해 기업이 해외시장을 분석하고 진출을 준비하는 과정을 배우며 기업지원 업무에 대한 이해를 넓히고 싶습니다.\n\n직무와 관련해 가장 자신 있는 역량은 고객과 시장을 분석하고 이를 바탕으로 전략을 보완하는 능력입니다. 이를 키우기 위해 미국 교환학생 기간 동안 온라인 영어 교육 플랫폼 창업 프로젝트를 수행했습니다.\n영어학원에서 근무하며 학부모들은 성적 뿐만 아니라 자녀가 실제로 얼마나 이해했는지 확인하는 것 또한 중요하게 생각한다는 점을 알게 되었습니다. 그러나 기존 온라인 교육 플랫폼은 강의 시청 여부만 확인할 수 있어 학생의 실제 이해 수준을 확인하기에는 한계가 있었습니다. 이를 해결하기 위해 강의 수강 후 피드백 세션에서 학생이 배운 내용을 직접 설명하고 이해가 확인되어야 다음 단계로 학습할 수 있는 구조를 기획했습니다.\n초기에는 영어학원에서 학부모들이 교사의 밀착 관리와 개별 피드백을 선호한다는 점을 고려해 1:1 피드백 중심으로 서비스를 설계했습니다. 그러나 미국에서 토론과 협업 중심의 수업을 직접 경험하며 학생 간 상호작용을 통해 학습 효과를 높이는 교육 방식을 직접 경험했고 현지 교육 전문가의 조언을 통해 이러한 방식이 미국 시장에 더 적합하다는 점을 확인했습니다. 이에 그룹 피드백 중심으로 운영 방식을 전환해 1:1 강사 운영 비용을 줄이면서도 교육 효과와 서비스 확장성을 확보했습니다.\n이 경험을 통해 시장이 달라지면 전략도 달라져야 한다는 점을 직접 경험했고 고객과 시장에 맞게 전략을 보완하는 역량을 기를 수 있었습니다.", + "approvedAnalysisJson": "{\"keyStrengths\": [], \"missingKeywords\": [], \"questionAnalyses\": [{\"questionId\": 1, \"sentence\": \"고객 특성과 경쟁 환경을 다시 분석하며 운영 전략을 수정했고 시장을 이해하는 과정이 전략을 바꾸기도 한다는 점을 직접 확인할 수 있었습니다.\", \"status\": \"proven\", \"reason\": \"해외시장 진출 지원 직무의 핵심인 시장정보 조사와 전략 보완 역량을 보여주는 문장입니다. 단순 관심 표명이 아니라 시장과 경쟁 환경을 재분석하고 전략을 조정한 행동이 드러나 직무 연관성이 높습니다.\", \"improvement\": null}, {\"questionId\": 1, \"sentence\": \"체험형 인턴으로 근무하게 된다면 자료조사와 행정지원 등 맡은 업무를 책임감 있게 수행하겠습니다.\", \"status\": \"mentioned\", \"reason\": \"입사 후 포부가 공고상 업무와 직접 연결된 점은 좋지만, 책임감 있게 수행하겠다는 표현이 다소 일반적입니다. 어떤 방식으로 자료조사 정확도, 보고 정리, 행정업무 지원에 기여할지 실행 계획이 보이면 더 설득력 있습니다.\", \"improvement\": \"체험형 인턴으로 근무하게 된다면 해외시장 관련 자료를 목적에 맞게 빠르게 정리하고, 행정업무 보조 시에도 일정·문서·데이터를 정확하게 관리해 실무자들이 바로 활용할 수 있는 지원을 하겠습니다.\"}]}", + "tags": [ + "공공·복지", + "공공기관 인턴", + "mentioned", + "proven", + "impact", + "jobFit", + "completeness" + ], + "datasetVersion": "fewshot-pm-reviewed-20260914-v2", + "promptBlock": "## 예시 FS-03: PM 검수 사례\n관련 JD 요구사항:\n- 해외시장 개척, 해외 투자진출 지원 및 시장정보 조사, 경영관리 보조 등\n- 행정업무 보조\n- 가. 국적 : 대한민국 국적 소지자\n- 나. 응시연령 : 만 34세 이하 청년\n- 다. 학력 : 고등학교 졸업 이상(‘26. 6. 17. 공고일 기준)\n- 라. 국가공무원법 제33조에 의한 결격사유가 없는 자\n- 마. 최종 합격 후 근로계약 체결일(채용일)부터 근무 가능한 자\n- 바. KOTRA 청년인턴으로 근무했던 자 또는 근무 중인 자는 채용 제외\n- 유효한 영어 TOEIC 성적 보유자\n우대사항:\n\n평가 대상 답변:\n- questionId: 1\n- question: 지원 동기와 입사 후 포부를 기술하시오\n- answer: 기업의 해외 진출을 지원하는 KOTRA에서 기업지원 실무를 직접 경험하고 싶어 지원했습니다. 무역학개론을 수강하며 기업의 해외 진출은 제품의 경쟁력뿐 아니라 국가별 시장환경과 고객을 이해하고 그에 맞는 전략을 수립하는 과정이 중요하다는 점을 배웠습니다. 이를 배우며 해외시장 분석과 전략 수립을 지원하는 업무에 관심을 갖게 되었고, 교환학생 기간 중 창업 프로젝트를 통해 미국 시장에서 이를 직접 검증해 볼 수 있었습니다.\n한국에서 구상한 아이디어를 미국 시장에 적용하는 과정에서 한국에서 효과적이라고 생각했던 전략이 그대로 통하지 않는다는 점을 몸소 경험했습니다. 고객 특성과 경쟁 환경을 다시 분석하며 운영 전략을 수정했고 시장을 이해하는 과정이 전략을 바꾸기도 한다는 점을 직접 확인할 수 있었습니다. 이 경험을 통해 기업이 해외시장에 진출하기 위해서는 시장을 이해하고 전략을 세우는 과정이 중요하다는 점을 느꼈습니다. 그래서 이러한 과정을 지원하는 KOTRA의 기업지원 업무에 관심을 갖게 되었습니다.\n체험형 인턴으로 근무하게 된다면 자료조사와 행정지원 등 맡은 업무를 책임감 있게 수행하겠습니다. 실무를 통해 기업이 해외시장을 분석하고 진출을 준비하는 과정을 배우며 기업지원 업무에 대한 이해를 넓히고 싶습니다.\n\n직무와 관련해 가장 자신 있는 역량은 고객과 시장을 분석하고 이를 바탕으로 전략을 보완하는 능력입니다. 이를 키우기 위해 미국 교환학생 기간 동안 온라인 영어 교육 플랫폼 창업 프로젝트를 수행했습니다.\n영어학원에서 근무하며 학부모들은 성적 뿐만 아니라 자녀가 실제로 얼마나 이해했는지 확인하는 것 또한 중요하게 생각한다는 점을 알게 되었습니다. 그러나 기존 온라인 교육 플랫폼은 강의 시청 여부만 확인할 수 있어 학생의 실제 이해 수준을 확인하기에는 한계가 있었습니다. 이를 해결하기 위해 강의 수강 후 피드백 세션에서 학생이 배운 내용을 직접 설명하고 이해가 확인되어야 다음 단계로 학습할 수 있는 구조를 기획했습니다.\n초기에는 영어학원에서 학부모들이 교사의 밀착 관리와 개별 피드백을 선호한다는 점을 고려해 1:1 피드백 중심으로 서비스를 설계했습니다. 그러나 미국에서 토론과 협업 중심의 수업을 직접 경험하며 학생 간 상호작용을 통해 학습 효과를 높이는 교육 방식을 직접 경험했고 현지 교육 전문가의 조언을 통해 이러한 방식이 미국 시장에 더 적합하다는 점을 확인했습니다. 이에 그룹 피드백 중심으로 운영 방식을 전환해 1:1 강사 운영 비용을 줄이면서도 교육 효과와 서비스 확장성을 확보했습니다.\n이 경험을 통해 시장이 달라지면 전략도 달라져야 한다는 점을 직접 경험했고 고객과 시장에 맞게 전략을 보완하는 역량을 기를 수 있었습니다.\n출력 중 강점/문장/누락 관련 필드:\n{\n \"keyStrengths\": [],\n \"missingKeywords\": [],\n \"questionAnalyses\": [\n {\n \"questionId\": 1,\n \"sentence\": \"고객 특성과 경쟁 환경을 다시 분석하며 운영 전략을 수정했고 시장을 이해하는 과정이 전략을 바꾸기도 한다는 점을 직접 확인할 수 있었습니다.\",\n \"status\": \"proven\",\n \"reason\": \"해외시장 진출 지원 직무의 핵심인 시장정보 조사와 전략 보완 역량을 보여주는 문장입니다. 단순 관심 표명이 아니라 시장과 경쟁 환경을 재분석하고 전략을 조정한 행동이 드러나 직무 연관성이 높습니다.\",\n \"improvement\": null\n },\n {\n \"questionId\": 1,\n \"sentence\": \"체험형 인턴으로 근무하게 된다면 자료조사와 행정지원 등 맡은 업무를 책임감 있게 수행하겠습니다.\",\n \"status\": \"mentioned\",\n \"reason\": \"입사 후 포부가 공고상 업무와 직접 연결된 점은 좋지만, 책임감 있게 수행하겠다는 표현이 다소 일반적입니다. 어떤 방식으로 자료조사 정확도, 보고 정리, 행정업무 지원에 기여할지 실행 계획이 보이면 더 설득력 있습니다.\",\n \"improvement\": \"체험형 인턴으로 근무하게 된다면 해외시장 관련 자료를 목적에 맞게 빠르게 정리하고, 행정업무 보조 시에도 일정·문서·데이터를 정확하게 관리해 실무자들이 바로 활용할 수 있는 지원을 하겠습니다.\"\n }\n ]\n}" + }, + { + "id": "FS-05", + "source": "REVIEWED_EVALUATION", + "reviewStatus": "APPROVED", + "enabled": true, + "priority": 8, + "jobCategory": "프로젝트 어시스턴트", + "jobTitle": "Project Assistant", + "mainTasks": [], + "qualifications": [ + "- 프로젝트 전체를 보고 판단 및 결정할 수 있는 능력", + "- 부드럽고 정확한 커뮤니케이션 능력", + "- VFX 작업 프로세스를 알고 있거나 영화 제작 프로세스를 이해하고 있는 분", + "- 해외 여행 결격사유 없는 분", + "- 배려하며 긍정적인 분위기로 협업이 가능한 분" + ], + "question": "1. 자기소개 및 진로계획", + "sanitizedAnswer": "1. 자기소개 및 진로계획\n\n여러 사람과 협업하여 하나의 결과물을 만들어낼 때 가장 큰 즐거움을 느낍니다. 학부 시절 영화를 전공하며 전공하며 연출, 조연출, PD, 편집 등 다양한 역할을 맡아 영화 프로세스 전 과정을 경험했습니다. 여러 역할을 넘나들며 깨달은 건, 제가 가장 뿌듯함을 느끼는 순간은 혼자 무언가를 완성했을 때가 아니라, 여럿이 모여 각자의 역할을 다해 하나의 결과물로 완성되는 그 과정이었습니다.\n\n이 확신은 3D 포트폴리오를 제작하며 컴프 아티스트와 협업할 때 더욱 굳어졌습니다. 혼자서만 작업하던 영상을 처음으로 타인에게 설명하려니, 기획 의도와 추상적인 이미지를 전달하는 데 어려움을 느꼈습니다. 이를 해결하기 위해 레퍼런스 보드를 제작해 수조의 탁도, 포그 농도, 글레어 강도 등 톤앤매너를 시각적으로 수치화했습니다. 또한 렌더링 소요 시간과 작업 스케줄을 함께 조율하고, 단계마다 결과물을 체크하며 방향성을 맞춰나갔습니다. 마침내 의도했던 이미지가 실제결과물로 구현되었을 때, 단순한 작업 이상의 협업이 만들어낸 시너지에 깊은 성취감을 느꼈습니다. 이와 같은 경험을 바탕으로, 저는 단일 작업자에 머무르기보다 협업의 중심에 서고자 합니다. 한국 VFX 1세대로서 한국영화를 이끌어온 디지털아이디어는, 영화에 그치지 않고 VR/AR, 전시 영상 등 영역을 확장해나가고 있습니다. 새로운 도전을 두려워하지 않고 계속해서 뻗어나가는 모습이 새로운 도전을 즐기는 제 지향점과 꼭 닮아 있습니다. 이곳에서 프리프로덕션부터 현장, 후반 작업을 두루 경험하며 쌓은 높은 이해도와 원활한 소통 능력을 기반으로 프로젝트를 완성도 높은 결과물로 끝까지 만들고 싶습니다.\n\n2. 커뮤니케이션 역량\n\n학부 시절 단편영화를 찍고 상업 영상 현장에서 조연출과 PD로 일하며, 예측 불가능한 돌발 변수 속에서도 우선순위를 재설정하고 스케줄을 효율적으로 관리하는 능력을 얻었습니다. 촬영 하루 전 매니지먼트 측의 갑작스런 일정 변경이나 기상 악화로 로케이션이 취소되는 위기를 여러 번 겪었습니다. 이러한 상황에서 당황하는 대신 대체 가능한 씬을 파악해 우선순위를 빠르게 재설정하고, 24시간 내에 전체 일정표를 다시 짜 조명, 미술, 배우 등 관련 이해관계자들에게 즉각 전달하여 현장의 혼선을 막았습니다.\n\n파리 교환학생 시절 다국적 학생들과 단편영화를 제작하며, 언어와 문화가 다른 사람들과 하나의 결과물을 만들어내는 경험도 했습니다. 의견 충돌이 생겼을 때 중재자의 역할을 하며, 모두가 같은 그림을 볼 수 있도록 직접 레퍼런스를 모으고 콘티를 그려 공유했습니다. 남은 기한을 고려해 업무를 재배분했고 그 결과 마감 기한 내에 팀원 전부가 동의하는 영화를 만들 수 있었습니다. 팀이 하나의 방향으로 맞춰지는 순간이 저는 늘 좋았고, 아무리 혁신적인 기술이라도 결국 사람이 하는 일이기에 원활한 소통과 조율 없이는 완성될 수 없다는 것을 알고 있습니다.\n\n영상 스튜디오 PD와 프리랜서 편집자로 일하며, 클라이언트의 요구사항을 정확히 파악하고 작업물로 구현해내는 과정을 수없이 반복했습니다. 때로는 구성을 통으로 바꿔야 하는 수정 요청이 오기도 했고, 컨셉에 맞춰 준비한 내용이 클라이언트의 머릿속과 전혀 다른 방향을 향하고 있다는 걸 뒤늦게 알게 되는 상황도 있었습니다. 그럴 때마다 소통의 오류가 어디서 생겼는지 재검토하고 다시 영점을 맞춰 클라이언트가 진짜 원하는 게 뭔지 파악하는 데 집중했습니다. 이를 통해 추상적인 피드백을 구체적인 방향으로 번역하고 문서화하는 능력은 길렀습니다. 이 감각은 감독과 클라이언트의 의도를 아티스트에게 정확히 전달해야 하는 PA의 역할에서 실질적인 무기가 될 것입니다.\n\n3. VFX와 영화 제작 프로세스 경험\n\n스크립터로서 촬영 현장의 정보를 정리해 본 경험은, 현장과 후반 작업의 연결 고리가 얼마나 중요한지 깨닫는 계기가 되었습니다. 뒤섞인 촬영 순서 속에서 의상 및 소품 컨티뉴이티 오류를 바로 포착하여 재촬영으로 인한 손실을 방지했고, 카메라 렌즈 데이터와 조명 세팅 같은 현장 정보를 꼼꼼히 기록하여 후반 작업자들의 시행착오를 최소화하는 데 집중했습니다. 개인 VFX 프로젝트에서는 이 감각을 직접 실천했습니다. Maya에서 먼저 조명 환경을 구현한 뒤 이를 촬영 현장의 라이팅 가이드로 활용했고, 촬영 당시의 메타데이터와 조명 및 카메라 위치를 꼼꼼히 텍스트와 사진으로 아카이빙했습니다. 이 기록들은 누크에서 카메라 생성과 컬러코렉팅 과정에서 가이드라인이 되어, 실사와 3D 사이의 이질감을 최소화하고 테스트 과정을 줄이는 데 실질적인 도움이 되었습니다. 하지만 소규모 인원으로 촬영과 스크립트 업무를 병행하다 특정 클립에서 렌즈 미리수를 누락하는 실수가 발생했습니다. 이후 카메라 매칭 과정에서 단 하나의 데이터 부재가 작업 난이도를 얼마나 높이고 시간을 낭비하게 만드는지 직접 겪었습니다. 작업자가 겪을 고충을 미리 이해하고, 데이터 누락 없이 정확하게 전달하는 것이 PA의 핵심 본분이라고 생각합니다. 현장의 상황을 후반 작업자에게 꼼꼼히 전달함으로써, 프로젝트가 끝까지 무사히 완주될 수 있도록 뒷받침하는 사람이 되겠습니다.", + "approvedAnalysisJson": "{\"keyStrengths\": [], \"missingKeywords\": [{\"keyword\": \"adobe, microsoft\", \"source\": \"preference\"}], \"questionAnalyses\": [{\"questionId\": 1, \"sentence\": \"파리 교환학생 시절 다국적 학생들과 단편영화를 제작하며, 언어와 문화가 다른 사람들과 하나의 결과물을 만들어내는 경험도 했습니다.\", \"status\": \"mentioned\", \"reason\": \"채용공고의 '해외 여행 결격사유 없는 분' 요건에 대해 이 문장은 해외 체류·협업 경험이라는 간접 근거만 제공합니다. 결격사유가 없다는 명시적 확인은 아니므로 proven이 아닌 mentioned에 머뭅니다. 다만 답변 내 다른 서술이나 JD와 모순되는 정황이 전혀 없어, 주장 자체의 신뢰가 깨지는 fabricated와는 구분됩니다 — 근거의 '부족'과 '모순'의 경계를 보여주는 판정입니다.\", \"improvement\": \"파리 교환학생 시절 다국적 팀과 단편영화를 제작하며 의견 충돌이 생겼을 때 중재 역할을 맡아 레퍼런스와 콘티를 직접 공유해 기준을 맞췄고, 남은 기한에 맞춰 업무를 재배분해 팀원 전원이 동의하는 결과물을 기한 내에 완성했습니다. 이러한 해외 현장 협업 경험을 바탕으로 해외 로케이션과 출장이 포함된 프로젝트에도 안정적으로 참여할 수 있습니다.\"}, {\"questionId\": 1, \"sentence\": \"이를 해결하기 위해 레퍼런스 보드를 제작해 수조의 탁도, 포그 농도, 글레어 강도 등 톤앤매너를 시각적으로 수치화했습니다.\", \"status\": \"proven\", \"reason\": \"추상적인 톤앤매너를 탁도·농도·강도 값으로 수치화해 협업자와 공유한 구체 행동이 드러납니다. '부드럽고 정확한 커뮤니케이션' 요건을 실행 방식 수준에서 입증하며, 감독·클라이언트의 의도를 아티스트에게 번역해 전달해야 하는 PA 직무와 직결되는 강점 근거입니다.\", \"improvement\": null}, {\"questionId\": 1, \"sentence\": \"이러한 상황에서 당황하는 대신 대체 가능한 씬을 파악해 우선순위를 빠르게 재설정하고, 24시간 내에 전체 일정표를 다시 짜 조명, 미술, 배우 등 관련 이해관계자들에게 즉각 전달하여 현장의 혼선을 막았습니다.\", \"status\": \"proven\", \"reason\": \"돌발 상황에서 대체안 파악 → 24시간 내 일정 재편 → 이해관계자 즉각 전파 → 현장 혼선 방지까지 상황-행동-결과가 완결적으로 서술됩니다. 수치는 없지만 행동과 결과가 구체적이어서 감점 요인이 되지 않으며, 프로젝트 전체를 보고 판단·결정하는 능력 요건과도 연결됩니다.\", \"improvement\": null}]}", + "tags": [ + "미디어·문화·스포츠", + "프로젝트 어시스턴트", + "missing", + "mentioned", + "proven", + "completeness", + "jobFit", + "impact" + ], + "datasetVersion": "fewshot-pm-reviewed-20260914-v2", + "promptBlock": "## 예시 FS-05: PM 검수 사례\n관련 JD 요구사항:\n- 프로젝트 전체를 보고 판단 및 결정할 수 있는 능력\n- 부드럽고 정확한 커뮤니케이션 능력\n- VFX 작업 프로세스를 알고 있거나 영화 제작 프로세스를 이해하고 있는 분\n- 해외 여행 결격사유 없는 분\n- 배려하며 긍정적인 분위기로 협업이 가능한 분\n우대사항:\n- adobe, microsoft 프로그램 사용 가능 자 우대\n평가 대상 답변:\n- questionId: 1\n- question: 1. 자기소개 및 진로계획\n- answer: 1. 자기소개 및 진로계획\n\n여러 사람과 협업하여 하나의 결과물을 만들어낼 때 가장 큰 즐거움을 느낍니다. 학부 시절 영화를 전공하며 전공하며 연출, 조연출, PD, 편집 등 다양한 역할을 맡아 영화 프로세스 전 과정을 경험했습니다. 여러 역할을 넘나들며 깨달은 건, 제가 가장 뿌듯함을 느끼는 순간은 혼자 무언가를 완성했을 때가 아니라, 여럿이 모여 각자의 역할을 다해 하나의 결과물로 완성되는 그 과정이었습니다.\n\n이 확신은 3D 포트폴리오를 제작하며 컴프 아티스트와 협업할 때 더욱 굳어졌습니다. 혼자서만 작업하던 영상을 처음으로 타인에게 설명하려니, 기획 의도와 추상적인 이미지를 전달하는 데 어려움을 느꼈습니다. 이를 해결하기 위해 레퍼런스 보드를 제작해 수조의 탁도, 포그 농도, 글레어 강도 등 톤앤매너를 시각적으로 수치화했습니다. 또한 렌더링 소요 시간과 작업 스케줄을 함께 조율하고, 단계마다 결과물을 체크하며 방향성을 맞춰나갔습니다. 마침내 의도했던 이미지가 실제결과물로 구현되었을 때, 단순한 작업 이상의 협업이 만들어낸 시너지에 깊은 성취감을 느꼈습니다. 이와 같은 경험을 바탕으로, 저는 단일 작업자에 머무르기보다 협업의 중심에 서고자 합니다. 한국 VFX 1세대로서 한국영화를 이끌어온 디지털아이디어는, 영화에 그치지 않고 VR/AR, 전시 영상 등 영역을 확장해나가고 있습니다. 새로운 도전을 두려워하지 않고 계속해서 뻗어나가는 모습이 새로운 도전을 즐기는 제 지향점과 꼭 닮아 있습니다. 이곳에서 프리프로덕션부터 현장, 후반 작업을 두루 경험하며 쌓은 높은 이해도와 원활한 소통 능력을 기반으로 프로젝트를 완성도 높은 결과물로 끝까지 만들고 싶습니다.\n\n2. 커뮤니케이션 역량\n\n학부 시절 단편영화를 찍고 상업 영상 현장에서 조연출과 PD로 일하며, 예측 불가능한 돌발 변수 속에서도 우선순위를 재설정하고 스케줄을 효율적으로 관리하는 능력을 얻었습니다. 촬영 하루 전 매니지먼트 측의 갑작스런 일정 변경이나 기상 악화로 로케이션이 취소되는 위기를 여러 번 겪었습니다. 이러한 상황에서 당황하는 대신 대체 가능한 씬을 파악해 우선순위를 빠르게 재설정하고, 24시간 내에 전체 일정표를 다시 짜 조명, 미술, 배우 등 관련 이해관계자들에게 즉각 전달하여 현장의 혼선을 막았습니다.\n\n파리 교환학생 시절 다국적 학생들과 단편영화를 제작하며, 언어와 문화가 다른 사람들과 하나의 결과물을 만들어내는 경험도 했습니다. 의견 충돌이 생겼을 때 중재자의 역할을 하며, 모두가 같은 그림을 볼 수 있도록 직접 레퍼런스를 모으고 콘티를 그려 공유했습니다. 남은 기한을 고려해 업무를 재배분했고 그 결과 마감 기한 내에 팀원 전부가 동의하는 영화를 만들 수 있었습니다. 팀이 하나의 방향으로 맞춰지는 순간이 저는 늘 좋았고, 아무리 혁신적인 기술이라도 결국 사람이 하는 일이기에 원활한 소통과 조율 없이는 완성될 수 없다는 것을 알고 있습니다.\n\n영상 스튜디오 PD와 프리랜서 편집자로 일하며, 클라이언트의 요구사항을 정확히 파악하고 작업물로 구현해내는 과정을 수없이 반복했습니다. 때로는 구성을 통으로 바꿔야 하는 수정 요청이 오기도 했고, 컨셉에 맞춰 준비한 내용이 클라이언트의 머릿속과 전혀 다른 방향을 향하고 있다는 걸 뒤늦게 알게 되는 상황도 있었습니다. 그럴 때마다 소통의 오류가 어디서 생겼는지 재검토하고 다시 영점을 맞춰 클라이언트가 진짜 원하는 게 뭔지 파악하는 데 집중했습니다. 이를 통해 추상적인 피드백을 구체적인 방향으로 번역하고 문서화하는 능력은 길렀습니다. 이 감각은 감독과 클라이언트의 의도를 아티스트에게 정확히 전달해야 하는 PA의 역할에서 실질적인 무기가 될 것입니다.\n\n3. VFX와 영화 제작 프로세스 경험\n\n스크립터로서 촬영 현장의 정보를 정리해 본 경험은, 현장과 후반 작업의 연결 고리가 얼마나 중요한지 깨닫는 계기가 되었습니다. 뒤섞인 촬영 순서 속에서 의상 및 소품 컨티뉴이티 오류를 바로 포착하여 재촬영으로 인한 손실을 방지했고, 카메라 렌즈 데이터와 조명 세팅 같은 현장 정보를 꼼꼼히 기록하여 후반 작업자들의 시행착오를 최소화하는 데 집중했습니다. 개인 VFX 프로젝트에서는 이 감각을 직접 실천했습니다. Maya에서 먼저 조명 환경을 구현한 뒤 이를 촬영 현장의 라이팅 가이드로 활용했고, 촬영 당시의 메타데이터와 조명 및 카메라 위치를 꼼꼼히 텍스트와 사진으로 아카이빙했습니다. 이 기록들은 누크에서 카메라 생성과 컬러코렉팅 과정에서 가이드라인이 되어, 실사와 3D 사이의 이질감을 최소화하고 테스트 과정을 줄이는 데 실질적인 도움이 되었습니다. 하지만 소규모 인원으로 촬영과 스크립트 업무를 병행하다 특정 클립에서 렌즈 미리수를 누락하는 실수가 발생했습니다. 이후 카메라 매칭 과정에서 단 하나의 데이터 부재가 작업 난이도를 얼마나 높이고 시간을 낭비하게 만드는지 직접 겪었습니다. 작업자가 겪을 고충을 미리 이해하고, 데이터 누락 없이 정확하게 전달하는 것이 PA의 핵심 본분이라고 생각합니다. 현장의 상황을 후반 작업자에게 꼼꼼히 전달함으로써, 프로젝트가 끝까지 무사히 완주될 수 있도록 뒷받침하는 사람이 되겠습니다.\n출력 중 강점/문장/누락 관련 필드:\n{\n \"keyStrengths\": [],\n \"missingKeywords\": [\n {\n \"keyword\": \"adobe, microsoft\",\n \"source\": \"preference\"\n }\n ],\n \"questionAnalyses\": [\n {\n \"questionId\": 1,\n \"sentence\": \"파리 교환학생 시절 다국적 학생들과 단편영화를 제작하며, 언어와 문화가 다른 사람들과 하나의 결과물을 만들어내는 경험도 했습니다.\",\n \"status\": \"mentioned\",\n \"reason\": \"채용공고의 '해외 여행 결격사유 없는 분' 요건에 대해 이 문장은 해외 체류·협업 경험이라는 간접 근거만 제공합니다. 결격사유가 없다는 명시적 확인은 아니므로 proven이 아닌 mentioned에 머뭅니다. 다만 답변 내 다른 서술이나 JD와 모순되는 정황이 전혀 없어, 주장 자체의 신뢰가 깨지는 fabricated와는 구분됩니다 — 근거의 '부족'과 '모순'의 경계를 보여주는 판정입니다.\",\n \"improvement\": \"파리 교환학생 시절 다국적 팀과 단편영화를 제작하며 의견 충돌이 생겼을 때 중재 역할을 맡아 레퍼런스와 콘티를 직접 공유해 기준을 맞췄고, 남은 기한에 맞춰 업무를 재배분해 팀원 전원이 동의하는 결과물을 기한 내에 완성했습니다. 이러한 해외 현장 협업 경험을 바탕으로 해외 로케이션과 출장이 포함된 프로젝트에도 안정적으로 참여할 수 있습니다.\"\n },\n {\n \"questionId\": 1,\n \"sentence\": \"이를 해결하기 위해 레퍼런스 보드를 제작해 수조의 탁도, 포그 농도, 글레어 강도 등 톤앤매너를 시각적으로 수치화했습니다.\",\n \"status\": \"proven\",\n \"reason\": \"추상적인 톤앤매너를 탁도·농도·강도 값으로 수치화해 협업자와 공유한 구체 행동이 드러납니다. '부드럽고 정확한 커뮤니케이션' 요건을 실행 방식 수준에서 입증하며, 감독·클라이언트의 의도를 아티스트에게 번역해 전달해야 하는 PA 직무와 직결되는 강점 근거입니다.\",\n \"improvement\": null\n },\n {\n \"questionId\": 1,\n \"sentence\": \"이러한 상황에서 당황하는 대신 대체 가능한 씬을 파악해 우선순위를 빠르게 재설정하고, 24시간 내에 전체 일정표를 다시 짜 조명, 미술, 배우 등 관련 이해관계자들에게 즉각 전달하여 현장의 혼선을 막았습니다.\",\n \"status\": \"proven\",\n \"reason\": \"돌발 상황에서 대체안 파악 → 24시간 내 일정 재편 → 이해관계자 즉각 전파 → 현장 혼선 방지까지 상황-행동-결과가 완결적으로 서술됩니다. 수치는 없지만 행동과 결과가 구체적이어서 감점 요인이 되지 않으며, 프로젝트 전체를 보고 판단·결정하는 능력 요건과도 연결됩니다.\",\n \"improvement\": null\n }\n ]\n}" + }, + { + "id": "FS-08", + "source": "REVIEWED_EVALUATION", + "reviewStatus": "APPROVED", + "enabled": true, + "priority": 7, + "jobCategory": "공공기관 인턴", + "jobTitle": "국민건강보험공단 인턴", + "mainTasks": [ + "- 가입자 자격 및 보험료 산정", + "- 보험료 징수 및 체납 관리", + "- 의료비 급여 지급", + "- 노인 돌봄(장기요양) 보험 운영" + ], + "qualifications": [ + "- 책임감 있는 인재", + "- 소통과 협력 인재", + "- 변화와 혁신 인재", + "- 전문성 있는 인재" + ], + "question": "타인의 불편이나 불만을 듣고 이를 해소한 경험을 기술하시오.", + "sanitizedAnswer": "자기소개서(질문1)– 타인의 불편이나 불만을 듣고 이를 해소한 경험 (600자 이내)\n[맞춤형 대응으로 문제 해결 경험]\n 독거노인 정서지원 프로그램에서 한 참여자가 봉사자와 타인에 대한 경계로 집단 활동 참여를 거부한 경험이 있습니다. 담당 파트너로서 상담을 통해 참여자의 두려움과 성향, 선호를 파악하고, 참여자가 표출한 불편과 실제 요구를 구분했습니다. 분석 결과, 참여자는 타인과 함께 활동하는 것은 원하지만 타인과 비교되지 않는 활동을 원한다는 핵심 요구를 확인했습니다. 이를 바탕으로 경쟁이 요구되는 게임을 제외하되 함께 참여할 수 있는 요리 프로그램을 통해 활동 난이도를 조정하고, 단계적 격려와 지속 참여 유도를 통해 작은 성취 경험을 제공했습니다. 그 결과 참여자는 집단 활동에 점차 참여하고 다른 참여자와 관계를 형성하며 자기 효능감이 향상되었습니다. 이러한 경험은 가입자가 보험료, 서비스, 급여 관련 민원을 제기할 때, 불만과 요구를 분리하고 맞춤 안내 및 단계적 지원을 제공하며 신뢰와 만족도를 높이는 공단 업무에 직접 적용 가능합니다.\n\n자기소개서(질문2)– 규칙이나 원칙을 지키기 어려웠던 상황에서 노력한 경험 (600자 이내)\n[규칙 준수를 통한 정확한 데이터 확보 경험]\n 사회복지현장실습 중, 여름철 야외 캠페인에서 이용률 증가 및 개선방안 만족도 조사를 진행하던 중, 팀원이 빠른 철수를 위해 임의로 조사 결과를 통계에 포함해 목표 응답률을 달성하자고 제안했습니다. 규칙과 원칙에 따라 객관적이고 신뢰성 있는 데이터를 확보해야했지만, 더위와 장시간 활동으로 모두 대충 끝내고 싶은 유혹이 있었습니다. 저는 팀원과 로테이션 근무를 제안해 체력 부담을 분산시키고, 어르신에게는 일상용어로 설명하여 솔직한 의견을 유도했습니다. 임의 기록은 통계적 오류와 왜곡을 초래할 수 있음을 설득하며 원칙 준수를 유지했습니다. 그 결과 시간을 더 소요했지만, 객관적 지표를 확보하고 팀워크와 체력 분배 효율을 높일 수 있었습니다. 이러한 경험을 통해 원칙 준수가 신뢰와 정확성을 확보하는 핵심임을 배웠으며, 인턴 근무 시, 가입자 정보와 보험료, 급여 데이터를 처리할 때 원칙을 지키며 세밀하게 확인하고, 업무 과정에서 오류를 최소화하고, 공정하고 안정적인 서비스를 제공하는 데 기여하겠습니다.\n\n자기소개서(질문3)– 조직이나 단체의 업무 개선을 위한 아이디어 제안 경험 (600자 이내)\n[운영 효율화와 참여율 개선 경험]\n 봉사 동아리 운영 당시, 부원 참여율 저조와 의견 수집 어려움을 반복적·구조적 문제로 인식했습니다. 기존에는 표준화 매뉴얼 부재와 정기 회의 방식으로 모든 부원의 의견을 수집하기 어려웠습니다. 저는 상시 온라인 익명 설문을 도입해 요구와 불편을 지속적으로 파악하고, 활동 안내서를 제작·배포하여 사업 개요, 목적과 주의사항을 안내했습니다. 또한 다양한 봉사 대상자의 선택 범위를 넓히기 위해 새로운 기관 3곳을 연계, 총 5곳에서 활동 가능하도록 계획을 개선했습니다. 실행 과정에서는 일정 조율과 안내 설득을 통해 부원의 초기 저항을 해소하고 원활히 적용했습니다. 그 결과 참여율과 이해도가 상승하고, 기관 담당자로부터 활동 질 향상을 호평받았으며, 운영 전 과정을 표준화해 재현성과 효율성을 확보하고 도지사상을 수상했습니다. 이 경험을 통해 문제를 구조적으로 분석하고 근거 기반으로 개선하면 조직 신뢰와 효율성을 높일 수 있다는 점을 배웠습니다. 인턴 근무 시 가입자 요구를 분석하고 맞춤형 행정 효율성을 강화하며, 데이터와 절차를 준수한 신뢰도 높은 서비스 제공에 기여하겠습니다.\n\n자기소개서(질문4)– 본인의 전문성을 활용해 문제를 해결한 경험 (600자 이내)\n[데이터 기반 문제 분석과 프로그램 설계 경험]\n 저의 전문성은 사회복지학 전공을 통해 함양한 “사회복지제도 이해”와 “문제 분석·구조 설계 능력”입니다. 신규 사업 아이디어 공모전에서, 학교 밖 청소년의 문제해결능력 부족으로 인한 사회적 위험 노출 문제를 발견했습니다. 통계 자료와 사회문제해결척도(SPSI, 참여 청소년 50명 대상) 검사를 활용해 문제의 원인을 분석하고, 지역 자원, 전달체계, 전문인력, 예산안을 포함한 프로그램 구조를 설계했습니다. 설계 과정에서는 지표별 목표 달성 기준을 설정하고, 프로그램 효과성을 검증하며 개선안을 반복 적용했습니다. 그 결과 신규 사업 아이디어 공모전에서 입상하며 실현 가능성과 효과성을 객관적으로 입증했습니다. 이 경험을 통해 데이터 기반 분석과 체계적 설계를 통해 문제를 해결하는 능력을 배웠습니다. 국민건강보험공단 인턴으로서도 가입자 민원, 보험료·급여 데이터를 분석하고 문제의 구조적 원인을 도출하여, 행정 효율을 높이고 신뢰도 높은 서비스 제공에 기여하겠습니다.", + "approvedAnalysisJson": "{\"keyStrengths\": [], \"missingKeywords\": [], \"questionAnalyses\": [{\"questionId\": 1, \"sentence\": \"이러한 경험은 가입자가 보험료, 서비스, 급여 관련 민원을 제기할 때, 불만과 요구를 분리하고 맞춤 안내 및 단계적 지원을 제공하며 신뢰와 만족도를 높이는 공단 업무에 직접 적용 가능합니다.\", \"status\": \"proven\", \"reason\": \"단순 경험 서술에 그치지 않고 공단의 보험료·급여·민원 업무와 연결해 직무 적합성을 명확히 드러냈습니다. 특히 맞춤 안내와 단계적 지원이라는 표현이 공공기관의 민원 응대 방식과 자연스럽게 맞닿아 있습니다.\", \"improvement\": null}]}", + "tags": [ + "공공·복지", + "공공기관 인턴", + "proven", + "mentioned", + "completeness", + "jobFit", + "impact" + ], + "datasetVersion": "fewshot-pm-reviewed-20260914-v2", + "promptBlock": "## 예시 FS-08: PM 검수 사례\n관련 JD 요구사항:\n- 가입자 자격 및 보험료 산정\n- 보험료 징수 및 체납 관리\n- 의료비 급여 지급\n- 노인 돌봄(장기요양) 보험 운영\n- 책임감 있는 인재\n- 소통과 협력 인재\n- 변화와 혁신 인재\n- 전문성 있는 인재\n우대사항:\n\n평가 대상 답변:\n- questionId: 1\n- question: 타인의 불편이나 불만을 듣고 이를 해소한 경험을 기술하시오.\n- answer: 자기소개서(질문1)– 타인의 불편이나 불만을 듣고 이를 해소한 경험 (600자 이내)\n[맞춤형 대응으로 문제 해결 경험]\n 독거노인 정서지원 프로그램에서 한 참여자가 봉사자와 타인에 대한 경계로 집단 활동 참여를 거부한 경험이 있습니다. 담당 파트너로서 상담을 통해 참여자의 두려움과 성향, 선호를 파악하고, 참여자가 표출한 불편과 실제 요구를 구분했습니다. 분석 결과, 참여자는 타인과 함께 활동하는 것은 원하지만 타인과 비교되지 않는 활동을 원한다는 핵심 요구를 확인했습니다. 이를 바탕으로 경쟁이 요구되는 게임을 제외하되 함께 참여할 수 있는 요리 프로그램을 통해 활동 난이도를 조정하고, 단계적 격려와 지속 참여 유도를 통해 작은 성취 경험을 제공했습니다. 그 결과 참여자는 집단 활동에 점차 참여하고 다른 참여자와 관계를 형성하며 자기 효능감이 향상되었습니다. 이러한 경험은 가입자가 보험료, 서비스, 급여 관련 민원을 제기할 때, 불만과 요구를 분리하고 맞춤 안내 및 단계적 지원을 제공하며 신뢰와 만족도를 높이는 공단 업무에 직접 적용 가능합니다.\n\n자기소개서(질문2)– 규칙이나 원칙을 지키기 어려웠던 상황에서 노력한 경험 (600자 이내)\n[규칙 준수를 통한 정확한 데이터 확보 경험]\n 사회복지현장실습 중, 여름철 야외 캠페인에서 이용률 증가 및 개선방안 만족도 조사를 진행하던 중, 팀원이 빠른 철수를 위해 임의로 조사 결과를 통계에 포함해 목표 응답률을 달성하자고 제안했습니다. 규칙과 원칙에 따라 객관적이고 신뢰성 있는 데이터를 확보해야했지만, 더위와 장시간 활동으로 모두 대충 끝내고 싶은 유혹이 있었습니다. 저는 팀원과 로테이션 근무를 제안해 체력 부담을 분산시키고, 어르신에게는 일상용어로 설명하여 솔직한 의견을 유도했습니다. 임의 기록은 통계적 오류와 왜곡을 초래할 수 있음을 설득하며 원칙 준수를 유지했습니다. 그 결과 시간을 더 소요했지만, 객관적 지표를 확보하고 팀워크와 체력 분배 효율을 높일 수 있었습니다. 이러한 경험을 통해 원칙 준수가 신뢰와 정확성을 확보하는 핵심임을 배웠으며, 인턴 근무 시, 가입자 정보와 보험료, 급여 데이터를 처리할 때 원칙을 지키며 세밀하게 확인하고, 업무 과정에서 오류를 최소화하고, 공정하고 안정적인 서비스를 제공하는 데 기여하겠습니다.\n\n자기소개서(질문3)– 조직이나 단체의 업무 개선을 위한 아이디어 제안 경험 (600자 이내)\n[운영 효율화와 참여율 개선 경험]\n 봉사 동아리 운영 당시, 부원 참여율 저조와 의견 수집 어려움을 반복적·구조적 문제로 인식했습니다. 기존에는 표준화 매뉴얼 부재와 정기 회의 방식으로 모든 부원의 의견을 수집하기 어려웠습니다. 저는 상시 온라인 익명 설문을 도입해 요구와 불편을 지속적으로 파악하고, 활동 안내서를 제작·배포하여 사업 개요, 목적과 주의사항을 안내했습니다. 또한 다양한 봉사 대상자의 선택 범위를 넓히기 위해 새로운 기관 3곳을 연계, 총 5곳에서 활동 가능하도록 계획을 개선했습니다. 실행 과정에서는 일정 조율과 안내 설득을 통해 부원의 초기 저항을 해소하고 원활히 적용했습니다. 그 결과 참여율과 이해도가 상승하고, 기관 담당자로부터 활동 질 향상을 호평받았으며, 운영 전 과정을 표준화해 재현성과 효율성을 확보하고 도지사상을 수상했습니다. 이 경험을 통해 문제를 구조적으로 분석하고 근거 기반으로 개선하면 조직 신뢰와 효율성을 높일 수 있다는 점을 배웠습니다. 인턴 근무 시 가입자 요구를 분석하고 맞춤형 행정 효율성을 강화하며, 데이터와 절차를 준수한 신뢰도 높은 서비스 제공에 기여하겠습니다.\n\n자기소개서(질문4)– 본인의 전문성을 활용해 문제를 해결한 경험 (600자 이내)\n[데이터 기반 문제 분석과 프로그램 설계 경험]\n 저의 전문성은 사회복지학 전공을 통해 함양한 “사회복지제도 이해”와 “문제 분석·구조 설계 능력”입니다. 신규 사업 아이디어 공모전에서, 학교 밖 청소년의 문제해결능력 부족으로 인한 사회적 위험 노출 문제를 발견했습니다. 통계 자료와 사회문제해결척도(SPSI, 참여 청소년 50명 대상) 검사를 활용해 문제의 원인을 분석하고, 지역 자원, 전달체계, 전문인력, 예산안을 포함한 프로그램 구조를 설계했습니다. 설계 과정에서는 지표별 목표 달성 기준을 설정하고, 프로그램 효과성을 검증하며 개선안을 반복 적용했습니다. 그 결과 신규 사업 아이디어 공모전에서 입상하며 실현 가능성과 효과성을 객관적으로 입증했습니다. 이 경험을 통해 데이터 기반 분석과 체계적 설계를 통해 문제를 해결하는 능력을 배웠습니다. 국민건강보험공단 인턴으로서도 가입자 민원, 보험료·급여 데이터를 분석하고 문제의 구조적 원인을 도출하여, 행정 효율을 높이고 신뢰도 높은 서비스 제공에 기여하겠습니다.\n출력 중 강점/문장/누락 관련 필드:\n{\n \"keyStrengths\": [],\n \"missingKeywords\": [],\n \"questionAnalyses\": [\n {\n \"questionId\": 1,\n \"sentence\": \"이러한 경험은 가입자가 보험료, 서비스, 급여 관련 민원을 제기할 때, 불만과 요구를 분리하고 맞춤 안내 및 단계적 지원을 제공하며 신뢰와 만족도를 높이는 공단 업무에 직접 적용 가능합니다.\",\n \"status\": \"proven\",\n \"reason\": \"단순 경험 서술에 그치지 않고 공단의 보험료·급여·민원 업무와 연결해 직무 적합성을 명확히 드러냈습니다. 특히 맞춤 안내와 단계적 지원이라는 표현이 공공기관의 민원 응대 방식과 자연스럽게 맞닿아 있습니다.\",\n \"improvement\": null\n }\n ]\n}" + }, + { + "id": "FS-09", + "source": "REVIEWED_EVALUATION", + "reviewStatus": "APPROVED", + "enabled": true, + "priority": 4, + "jobCategory": "브랜드 경험 디자이너", + "jobTitle": "브랜드 경험 디자이너", + "mainTasks": [ + "- 브랜드 IP(기업 브랜드, 캐릭터) 활용 커뮤니케이션", + "- 브랜드 IP 기반 굿즈 기획 및 운영", + "- 브랜드 IP 확장을 위한 콜라보레이션 기획/실행", + "- 브랜드 IP 자산 관리" + ], + "qualifications": [], + "question": "오뚜기에 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 설명하시오", + "sanitizedAnswer": "**[자기소개서]**\n\n- **본인이 경험한 식문화 또는 음식 관련된 경험 서술 / 깨달은 점 및 의미**\n: 식문화는 사람의 취향과 라이프스타일이 드러나는 경험이라고 생각합니다.\n\n라이프스타일 브랜드에서 근무하며 고객들이 제품보다 자신의 생활 방식에 어울리는 경험을 먼저 고른다는 것을 관찰했습니다.\n먹는 행위를 넘어 어떻게 차려내고 어떤 분위기를 만드는지가 곧 그 사람의 취향이라는 것을 배웠습니다.\n또한 제품 배치가 바뀔 때마다 고객 반응이 달라지는 것을 보고 같은 제품이어도 어떤 맥락에 놓이느냐에 따라 경험이 달라진다는 것을 배웠습니다.\n\n이는 행동과 감각을 세심하게 관찰하는 습관으로 이어졌습니다.\n\n습관을 바탕으로 ○○○를 활용해 하나의 흐름을 설계하고 싶습니다.\n예를 들어 제품 출시 시 캐릭터 에피소드와 연동된 체험형 콘텐츠를 기획하여 구매 후에도 상호작용이 이어지는 경험을 만들겠습니다.\n\n- **지원 직무 수행하기 위해 필요한 역량 또는 태도는 무엇이라고 생각하는지 본인의 경험을 바탕으로 설명하십시오 (직무 관련 경험, 단체활동, 전공, 수강과목, 자격증 등을 포함)**\n: 브랜드 핵심 상징을 소비자가 체감할 수 있는 접점으로 번역하는 역량이 필요하다고 생각합니다.\n\n○○○광역시 도시 캐릭터 리브랜딩 프로젝트에서 BX 플래너로 참여하며 기존 캐릭터 '도달쑤'의 낮은 인지도와 아이덴티티 부재 문제를 발견했습니다. \n대구 시민 105명을 대상으로 설문조사와 T검정을 진행해 '분지'와 '산'을 도시의 상징으로 인식한다는 인사이트를 도출하고 이를 캐릭터 IP로 번역하였습니다. \n캐릭터를 Illustrator로 디자인하며 색상값, 표정 및 포즈 응용 규칙을 담은 IP 가이드라인을 제작해 팀 내 브랜드 자산의 일관성을 유지했고 Photoshop으로 펀딩 굿즈까지 제작하였습니다.\n그 결과 목표 금액의 352%를 달성하고 누적 후원액 2,200만원 이상을 기록했으며, 한국디자인학회에 논문으로도 게재되었습니다.\n\n인턴십에서도 같은 관점으로 문제를 바라봤습니다. 이미지 나열에 머물던 SNS 콘텐츠로 인해 처음 방문한 사람들이 브랜드와 제품을 이해하기 어렵다는 점을 발견했습니다.\n이를 해결하기 위해 ‘디자인 설명, 브랜드 스토리, 3D 프로토타입 제작 과정’을 릴스 시리즈로 기획했습니다.\n이 과정에서 영상 소스가 부족하여 Image-to-Video AI 워크플로우를 설계해 해결했습니다.\n그 결과 SNS 팔로워가 약 5배 증가하고 조회수는 기존 대비 2~4배 상승했습니다.\n\n○○○는 친근한 캐릭터성과 식품 브랜드로서의 일상 접점을 동시에 가진 IP입니다. 현재 팝업 및 굿즈 등 다양한 접점이 있지만 해당 컨텐츠들이 하나의 흐름으로 연결되면 소비자가 제품 구매 이후에도 캐릭터를 반복적으로 만나는 구조로 발전할 가능성이 크다고 생각합니다. ○○○ BX에서 이 접점들을 하나의 IP 경험 흐름으로 설계해 소비자가 브랜드와 지속적으로 상호작용하는 경험을 만들겠습니다.\n\n- **○○○에 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 설명**\n: ○○○가 정직한 원료와 신뢰를 기반으로 소비자의 일상 속 식문화 경험까지 확장해온 방향처럼 브랜드 경험은 화려한 캠페인보다 소비자의 일상 속에서 꾸준히 쌓여야 한다고 생각합니다.\n최근 숏폼 중심 시장에서 반복 접점 없이 소비되는 콘텐츠가 많은 만큼 ○○○에서는 경험이 이어지는 구조를 설계해 브랜드 자산이 누적되는 BX를 만들고 싶습니다.\n○○○는 ○○○의 헤리티지와 따뜻한 철학을 담은 친근한 IP입니다. 굿즈, 브랜드 공간 등 다양한 접점이 강점입니다.\n저는 여기에서 캐릭터 간 관계성과 서사가 더해진다면 소비자가 다음 이야기를 기다리는 구조가 만들어질 수 있다고 보았습니다.\n○○○광역시 도시 캐릭터 리브랜딩 프로젝트에서 캐릭터에 관계성과 맥락을 부여하며 IP를 설계했던 경험이 이 가능성을 더 선명하게 보이게 했습니다.\n입사 후에는 ○○○ 세계관 구축, 공간과 SNS 연결 구조 설계, 패키지와 콘텐츠 경험 연동을 통해\n소비자가 ○○○의 이야기를 일상 속에서 자연스럽게 만나고 공유하는 BX 기획자로 성장할 것입니다.", + "approvedAnalysisJson": "{\"keyStrengths\": [], \"missingKeywords\": [], \"questionAnalyses\": [{\"questionId\": 1, \"sentence\": \"캐릭터를 Illustrator로 디자인하며 색상값, 표정 및 포즈 응용 규칙을 담은 IP 가이드라인을 제작해 팀 내 브랜드 자산의 일관성을 유지했고 Photoshop으로 펀딩 굿즈까지 제작하였습니다.\", \"status\": \"proven\", \"reason\": \"브랜드 IP 자산 관리와 굿즈 제작, 디자인 툴 활용이 한 문장 안에서 모두 구체적으로 드러납니다. 채용공고의 브랜드 IP 자산 관리와 포토샵/일러스트레이터 활용 우대요건을 직접적으로 입증하는 강한 근거입니다.\", \"improvement\": null}, {\"questionId\": 1, \"sentence\": \"최근 숏폼 중심 시장에서 반복 접점 없이 소비되는 콘텐츠가 많은 만큼 ○○○에서는 경험이 이어지는 구조를 설계해 브랜드 자산이 누적되는 BX를 만들고 싶습니다.\", \"status\": \"mentioned\", \"reason\": \"지원 동기와 입사 후 방향성은 보이지만, 왜 하필 오뚜기여야 하는지에 대한 기업 고유 분석이나 차별화된 근거는 다소 추상적입니다. '경험이 이어지는 구조'가 어떤 사업/브랜드 접점에서 구현될지도 구체성이 부족합니다.\", \"improvement\": \"오뚜기는 식품 브랜드이면서도 옐로우즈, 팝업, 굿즈 등 생활밀착형 접점을 이미 보유하고 있어 IP 경험을 확장하기에 적합하다고 보았습니다. 입사 후에는 카레·케첩 등 대표 제품과 캐릭터 서사를 연결한 시즌형 굿즈와 SNS 연계 캠페인을 기획해 반복 접점을 만들고 싶습니다.\"}]}", + "tags": [ + "디자인", + "브랜드 경험 디자이너", + "proven", + "mentioned", + "completeness", + "jobFit", + "impact" + ], + "datasetVersion": "fewshot-pm-reviewed-20260914-v2", + "promptBlock": "## 예시 FS-09: PM 검수 사례\n관련 JD 요구사항:\n- 브랜드 IP(기업 브랜드, 캐릭터) 활용 커뮤니케이션\n- 브랜드 IP 기반 굿즈 기획 및 운영\n- 브랜드 IP 확장을 위한 콜라보레이션 기획/실행\n- 브랜드 IP 자산 관리\n우대사항:\n- 포토샵/일러스트레이터 등 활용 가능자\n- 브랜드 IP를 활용한 콘텐츠/굿즈 기획에 관심이 있는 자\n- 기획 의도를 시각 요소/콘텐츠로 구체화할 수 있는 자\n- 브랜드에 대한 높은 관심과 호기심을 바탕으로 적극적으로 탐구하는 자\n평가 대상 답변:\n- questionId: 1\n- question: 오뚜기에 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 설명하시오\n- answer: **[자기소개서]**\n\n- **본인이 경험한 식문화 또는 음식 관련된 경험 서술 / 깨달은 점 및 의미**\n: 식문화는 사람의 취향과 라이프스타일이 드러나는 경험이라고 생각합니다.\n\n라이프스타일 브랜드에서 근무하며 고객들이 제품보다 자신의 생활 방식에 어울리는 경험을 먼저 고른다는 것을 관찰했습니다.\n먹는 행위를 넘어 어떻게 차려내고 어떤 분위기를 만드는지가 곧 그 사람의 취향이라는 것을 배웠습니다.\n또한 제품 배치가 바뀔 때마다 고객 반응이 달라지는 것을 보고 같은 제품이어도 어떤 맥락에 놓이느냐에 따라 경험이 달라진다는 것을 배웠습니다.\n\n이는 행동과 감각을 세심하게 관찰하는 습관으로 이어졌습니다.\n\n습관을 바탕으로 ○○○를 활용해 하나의 흐름을 설계하고 싶습니다.\n예를 들어 제품 출시 시 캐릭터 에피소드와 연동된 체험형 콘텐츠를 기획하여 구매 후에도 상호작용이 이어지는 경험을 만들겠습니다.\n\n- **지원 직무 수행하기 위해 필요한 역량 또는 태도는 무엇이라고 생각하는지 본인의 경험을 바탕으로 설명하십시오 (직무 관련 경험, 단체활동, 전공, 수강과목, 자격증 등을 포함)**\n: 브랜드 핵심 상징을 소비자가 체감할 수 있는 접점으로 번역하는 역량이 필요하다고 생각합니다.\n\n○○○광역시 도시 캐릭터 리브랜딩 프로젝트에서 BX 플래너로 참여하며 기존 캐릭터 '도달쑤'의 낮은 인지도와 아이덴티티 부재 문제를 발견했습니다. \n대구 시민 105명을 대상으로 설문조사와 T검정을 진행해 '분지'와 '산'을 도시의 상징으로 인식한다는 인사이트를 도출하고 이를 캐릭터 IP로 번역하였습니다. \n캐릭터를 Illustrator로 디자인하며 색상값, 표정 및 포즈 응용 규칙을 담은 IP 가이드라인을 제작해 팀 내 브랜드 자산의 일관성을 유지했고 Photoshop으로 펀딩 굿즈까지 제작하였습니다.\n그 결과 목표 금액의 352%를 달성하고 누적 후원액 2,200만원 이상을 기록했으며, 한국디자인학회에 논문으로도 게재되었습니다.\n\n인턴십에서도 같은 관점으로 문제를 바라봤습니다. 이미지 나열에 머물던 SNS 콘텐츠로 인해 처음 방문한 사람들이 브랜드와 제품을 이해하기 어렵다는 점을 발견했습니다.\n이를 해결하기 위해 ‘디자인 설명, 브랜드 스토리, 3D 프로토타입 제작 과정’을 릴스 시리즈로 기획했습니다.\n이 과정에서 영상 소스가 부족하여 Image-to-Video AI 워크플로우를 설계해 해결했습니다.\n그 결과 SNS 팔로워가 약 5배 증가하고 조회수는 기존 대비 2~4배 상승했습니다.\n\n○○○는 친근한 캐릭터성과 식품 브랜드로서의 일상 접점을 동시에 가진 IP입니다. 현재 팝업 및 굿즈 등 다양한 접점이 있지만 해당 컨텐츠들이 하나의 흐름으로 연결되면 소비자가 제품 구매 이후에도 캐릭터를 반복적으로 만나는 구조로 발전할 가능성이 크다고 생각합니다. ○○○ BX에서 이 접점들을 하나의 IP 경험 흐름으로 설계해 소비자가 브랜드와 지속적으로 상호작용하는 경험을 만들겠습니다.\n\n- **○○○에 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 설명**\n: ○○○가 정직한 원료와 신뢰를 기반으로 소비자의 일상 속 식문화 경험까지 확장해온 방향처럼 브랜드 경험은 화려한 캠페인보다 소비자의 일상 속에서 꾸준히 쌓여야 한다고 생각합니다.\n최근 숏폼 중심 시장에서 반복 접점 없이 소비되는 콘텐츠가 많은 만큼 ○○○에서는 경험이 이어지는 구조를 설계해 브랜드 자산이 누적되는 BX를 만들고 싶습니다.\n○○○는 ○○○의 헤리티지와 따뜻한 철학을 담은 친근한 IP입니다. 굿즈, 브랜드 공간 등 다양한 접점이 강점입니다.\n저는 여기에서 캐릭터 간 관계성과 서사가 더해진다면 소비자가 다음 이야기를 기다리는 구조가 만들어질 수 있다고 보았습니다.\n○○○광역시 도시 캐릭터 리브랜딩 프로젝트에서 캐릭터에 관계성과 맥락을 부여하며 IP를 설계했던 경험이 이 가능성을 더 선명하게 보이게 했습니다.\n입사 후에는 ○○○ 세계관 구축, 공간과 SNS 연결 구조 설계, 패키지와 콘텐츠 경험 연동을 통해\n소비자가 ○○○의 이야기를 일상 속에서 자연스럽게 만나고 공유하는 BX 기획자로 성장할 것입니다.\n출력 중 강점/문장/누락 관련 필드:\n{\n \"keyStrengths\": [],\n \"missingKeywords\": [],\n \"questionAnalyses\": [\n {\n \"questionId\": 1,\n \"sentence\": \"캐릭터를 Illustrator로 디자인하며 색상값, 표정 및 포즈 응용 규칙을 담은 IP 가이드라인을 제작해 팀 내 브랜드 자산의 일관성을 유지했고 Photoshop으로 펀딩 굿즈까지 제작하였습니다.\",\n \"status\": \"proven\",\n \"reason\": \"브랜드 IP 자산 관리와 굿즈 제작, 디자인 툴 활용이 한 문장 안에서 모두 구체적으로 드러납니다. 채용공고의 브랜드 IP 자산 관리와 포토샵/일러스트레이터 활용 우대요건을 직접적으로 입증하는 강한 근거입니다.\",\n \"improvement\": null\n },\n {\n \"questionId\": 1,\n \"sentence\": \"최근 숏폼 중심 시장에서 반복 접점 없이 소비되는 콘텐츠가 많은 만큼 ○○○에서는 경험이 이어지는 구조를 설계해 브랜드 자산이 누적되는 BX를 만들고 싶습니다.\",\n \"status\": \"mentioned\",\n \"reason\": \"지원 동기와 입사 후 방향성은 보이지만, 왜 하필 오뚜기여야 하는지에 대한 기업 고유 분석이나 차별화된 근거는 다소 추상적입니다. '경험이 이어지는 구조'가 어떤 사업/브랜드 접점에서 구현될지도 구체성이 부족합니다.\",\n \"improvement\": \"오뚜기는 식품 브랜드이면서도 옐로우즈, 팝업, 굿즈 등 생활밀착형 접점을 이미 보유하고 있어 IP 경험을 확장하기에 적합하다고 보았습니다. 입사 후에는 카레·케첩 등 대표 제품과 캐릭터 서사를 연결한 시즌형 굿즈와 SNS 연계 캠페인을 기획해 반복 접점을 만들고 싶습니다.\"\n }\n ]\n}" + } +] diff --git a/src/main/resources/application-analysis-eval.yaml b/src/main/resources/application-analysis-eval.yaml index 6da33b6c..968e20e4 100644 --- a/src/main/resources/application-analysis-eval.yaml +++ b/src/main/resources/application-analysis-eval.yaml @@ -95,6 +95,11 @@ analysis: fallback-enabled: ${ANALYSIS_FEW_SHOT_FALLBACK_ENABLED:true} cache-enabled: ${ANALYSIS_FEW_SHOT_CACHE_ENABLED:true} cache-ttl: ${ANALYSIS_FEW_SHOT_CACHE_TTL:30m} + selection-cache-max-size: ${ANALYSIS_FEW_SHOT_SELECTION_CACHE_MAX_SIZE:1000} + query-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_CACHE_MAX_SIZE:1000} + document-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_DOCUMENT_EMBEDDING_CACHE_MAX_SIZE:5000} + selection-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT:20s} + query-embedding-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT:20s} source: fixed-enabled: ${ANALYSIS_FEW_SHOT_FIXED_ENABLED:true} curated-enabled: ${ANALYSIS_FEW_SHOT_CURATED_ENABLED:true} @@ -103,7 +108,8 @@ analysis: search: candidate-limit: ${ANALYSIS_FEW_SHOT_CANDIDATE_LIMIT:30} top-k: ${ANALYSIS_FEW_SHOT_TOP_K:5} - min-rerank-score: ${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:-1.0} + min-similarity: ${ANALYSIS_FEW_SHOT_MIN_SIMILARITY:${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:0.40}} + minimum-selected-count: ${ANALYSIS_FEW_SHOT_MINIMUM_SELECTED_COUNT:2} diversity-enabled: ${ANALYSIS_FEW_SHOT_DIVERSITY_ENABLED:true} cohere: diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 7505ae0b..01eeb8f8 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -169,6 +169,11 @@ analysis: fallback-enabled: ${ANALYSIS_FEW_SHOT_FALLBACK_ENABLED:true} cache-enabled: ${ANALYSIS_FEW_SHOT_CACHE_ENABLED:true} cache-ttl: ${ANALYSIS_FEW_SHOT_CACHE_TTL:30m} + selection-cache-max-size: ${ANALYSIS_FEW_SHOT_SELECTION_CACHE_MAX_SIZE:1000} + query-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_CACHE_MAX_SIZE:1000} + document-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_DOCUMENT_EMBEDDING_CACHE_MAX_SIZE:5000} + selection-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT:20s} + query-embedding-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT:20s} source: fixed-enabled: ${ANALYSIS_FEW_SHOT_FIXED_ENABLED:true} curated-enabled: ${ANALYSIS_FEW_SHOT_CURATED_ENABLED:true} @@ -177,7 +182,8 @@ analysis: search: candidate-limit: ${ANALYSIS_FEW_SHOT_CANDIDATE_LIMIT:30} top-k: ${ANALYSIS_FEW_SHOT_TOP_K:5} - min-rerank-score: ${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:-1.0} + min-similarity: ${ANALYSIS_FEW_SHOT_MIN_SIMILARITY:${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:0.40}} + minimum-selected-count: ${ANALYSIS_FEW_SHOT_MINIMUM_SELECTED_COUNT:2} diversity-enabled: ${ANALYSIS_FEW_SHOT_DIVERSITY_ENABLED:true} cohere: diff --git a/src/main/resources/application-fewshot-pm-eval.yaml b/src/main/resources/application-fewshot-pm-eval.yaml new file mode 100644 index 00000000..3fd575f7 --- /dev/null +++ b/src/main/resources/application-fewshot-pm-eval.yaml @@ -0,0 +1,17 @@ +# Explicit opt-in, used together with analysis-eval. Never add to default profiles. +analysis: + mode: single-pass + few-shot: + dynamic-selection-enabled: true + dataset-version: fewshot-pm-reviewed-20260914-v2 + reviewed-evaluation-resource: analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json + reviewed-evaluation-csv-path: "" + search: + top-k: 5 + min-similarity: 0.40 + minimum-selected-count: 2 + source: + fixed-enabled: false + curated-enabled: false + reviewed-evaluation-enabled: true + reviewed-production-enabled: false diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index b351123f..15609e40 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -171,6 +171,11 @@ analysis: fallback-enabled: ${ANALYSIS_FEW_SHOT_FALLBACK_ENABLED:true} cache-enabled: ${ANALYSIS_FEW_SHOT_CACHE_ENABLED:true} cache-ttl: ${ANALYSIS_FEW_SHOT_CACHE_TTL:30m} + selection-cache-max-size: ${ANALYSIS_FEW_SHOT_SELECTION_CACHE_MAX_SIZE:1000} + query-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_CACHE_MAX_SIZE:1000} + document-embedding-cache-max-size: ${ANALYSIS_FEW_SHOT_DOCUMENT_EMBEDDING_CACHE_MAX_SIZE:5000} + selection-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_SELECTION_IN_FLIGHT_WAIT_TIMEOUT:20s} + query-embedding-in-flight-wait-timeout: ${ANALYSIS_FEW_SHOT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT:20s} source: fixed-enabled: ${ANALYSIS_FEW_SHOT_FIXED_ENABLED:true} curated-enabled: ${ANALYSIS_FEW_SHOT_CURATED_ENABLED:true} @@ -179,7 +184,8 @@ analysis: search: candidate-limit: ${ANALYSIS_FEW_SHOT_CANDIDATE_LIMIT:30} top-k: ${ANALYSIS_FEW_SHOT_TOP_K:5} - min-rerank-score: ${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:-1.0} + min-similarity: ${ANALYSIS_FEW_SHOT_MIN_SIMILARITY:${ANALYSIS_FEW_SHOT_MIN_RERANK_SCORE:0.40}} + minimum-selected-count: ${ANALYSIS_FEW_SHOT_MINIMUM_SELECTED_COUNT:2} diversity-enabled: ${ANALYSIS_FEW_SHOT_DIVERSITY_ENABLED:true} cohere: diff --git a/src/main/resources/db/migration/V202609141200__create_job_applications.sql b/src/main/resources/db/migration/V202609141200__create_job_applications.sql new file mode 100644 index 00000000..e00ebfd7 --- /dev/null +++ b/src/main/resources/db/migration/V202609141200__create_job_applications.sql @@ -0,0 +1,48 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS job_applications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_job_posting_id BIGINT REFERENCES job_postings(id) ON DELETE SET NULL, + mock_apply_id BIGINT UNIQUE REFERENCES mock_applies(id) ON DELETE SET NULL, + detail_classification_id BIGINT REFERENCES detail_classifications(id) ON DELETE SET NULL, + company_name VARCHAR(255) NOT NULL, + posting_name VARCHAR(255) NOT NULL, + job_title VARCHAR(255) NOT NULL, + company_size VARCHAR(20), + task TEXT, + requirement TEXT, + preferred TEXT, + deadline_at TIMESTAMP, + stage VARCHAR(20) NOT NULL, + stage_order INTEGER NOT NULL, + current_label VARCHAR(100), + current_at TIMESTAMP, + memo TEXT, + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_job_applications_stage + CHECK (stage IN ('PLANNED', 'DOCUMENT', 'INTERVIEW', 'COMPLETED')), + CONSTRAINT ck_job_applications_stage_order CHECK (stage_order >= 0) +); + +CREATE TABLE IF NOT EXISTS job_application_required_skills ( + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + skill_name VARCHAR(50) NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (job_application_id, display_order) +); + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_stage_order + ON job_applications (user_id, stage, stage_order) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_archived + ON job_applications (user_id, archived_at DESC) + WHERE archived_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_search + ON job_applications USING gin ( + (company_name || ' ' || posting_name || ' ' || job_title) gin_trgm_ops + ); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 5da74226..e0ba58d5 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,6 +1,99 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS vector; +CREATE TABLE IF NOT EXISTS job_applications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_job_posting_id BIGINT REFERENCES job_postings(id) ON DELETE SET NULL, + mock_apply_id BIGINT UNIQUE REFERENCES mock_applies(id) ON DELETE SET NULL, + detail_classification_id BIGINT REFERENCES detail_classifications(id) ON DELETE SET NULL, + company_name VARCHAR(255) NOT NULL, + posting_name VARCHAR(255) NOT NULL, + job_title VARCHAR(255) NOT NULL, + company_size VARCHAR(20), + task TEXT, + requirement TEXT, + preferred TEXT, + deadline_at TIMESTAMP, + stage VARCHAR(20) NOT NULL, + stage_order INTEGER NOT NULL, + current_label VARCHAR(100), + current_at TIMESTAMP, + memo TEXT, + gpa NUMERIC(6, 3), + max_gpa NUMERIC(6, 3), + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_job_applications_stage + CHECK (stage IN ('PLANNED', 'DOCUMENT', 'INTERVIEW', 'COMPLETED')), + CONSTRAINT ck_job_applications_stage_order CHECK (stage_order >= 0), + CONSTRAINT ck_job_applications_gpa CHECK ( + (gpa IS NULL AND max_gpa IS NULL) + OR (gpa >= 0 AND max_gpa >= 0 AND gpa <= max_gpa) + ) +); + +ALTER TABLE IF EXISTS job_applications + ADD COLUMN IF NOT EXISTS gpa NUMERIC(6, 3), + ADD COLUMN IF NOT EXISTS max_gpa NUMERIC(6, 3); + +CREATE TABLE IF NOT EXISTS job_application_required_skills ( + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + skill_name VARCHAR(50) NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (job_application_id, display_order) +); + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_stage_order + ON job_applications (user_id, stage, stage_order) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_user_archived + ON job_applications (user_id, archived_at DESC) + WHERE archived_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_job_applications_search + ON job_applications USING gin ( + (company_name || ' ' || posting_name || ' ' || job_title) gin_trgm_ops + ); + +CREATE TABLE IF NOT EXISTS job_application_essays ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + question VARCHAR(1000) NOT NULL, + answer TEXT, + display_order INTEGER NOT NULL CHECK (display_order >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_essays_application + ON job_application_essays (job_application_id, display_order); + +CREATE TABLE IF NOT EXISTS job_application_checklist_items ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + content VARCHAR(200) NOT NULL, + completed BOOLEAN NOT NULL, + display_order INTEGER NOT NULL CHECK (display_order >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_checklist_application + ON job_application_checklist_items (job_application_id, display_order); + +CREATE TABLE IF NOT EXISTS job_application_metrics ( + id BIGSERIAL PRIMARY KEY, + job_application_id BIGINT NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + type VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + metric_value VARCHAR(200) NOT NULL, + display_order INTEGER NOT NULL CHECK (display_order >= 0), + CONSTRAINT ck_job_application_metrics_type + CHECK (type IN ('CERTIFICATE', 'LANGUAGE', 'AWARD', 'CUSTOM')) +); + +CREATE INDEX IF NOT EXISTS idx_job_application_metrics_application + ON job_application_metrics (job_application_id, display_order); + CREATE TABLE IF NOT EXISTS mock_job_posting_embeddings ( id BIGSERIAL PRIMARY KEY, corpus_id BIGINT NOT NULL UNIQUE REFERENCES mock_job_posting_corpus(id) ON DELETE CASCADE, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java index e4905c2d..1fb04ea5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java @@ -6,12 +6,14 @@ import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.openai.client.OpenAIClient; import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.ResponseUsage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -57,6 +59,26 @@ void createStructuredResponseRecordsSuccessMetric() throws Exception { verify(asyncMetricsRecorder, never()).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); } + @Test + @DisplayName("구조화 응답의 입력·출력 토큰 사용량을 함께 반환한다") + void createStructuredResponseWithUsageReturnsTokenCounts() throws Exception { + StructuredResponse response = mock(StructuredResponse.class); + ResponseUsage usage = mock(ResponseUsage.class); + when(usage.inputTokens()).thenReturn(123L); + when(usage.outputTokens()).thenReturn(45L); + when(response.usage()).thenReturn(Optional.of(usage)); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + when(analysisResponseParser.extractStructuredContent(response)).thenReturn("ok"); + + var result = openAiAnalysisAdapter.createStructuredResponseWithUsage( + "analysis", "prompt", String.class, null + ); + + assertThat(result.content()).isEqualTo("ok"); + assertThat(result.inputTokens()).isEqualTo(123); + assertThat(result.outputTokens()).isEqualTo(45); + } + @Test @DisplayName("limiter 실행 중 RuntimeException이 나면 그대로 전파하고 error 메트릭을 기록한다") void createStructuredResponsePropagatesLimiterFailure() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 0488bca6..c8a39cab 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -40,6 +40,21 @@ class AnalysisAiClientTest { + @Test + void evaluationRecordsSelectionBeforeFailureAcrossModes() { + for (String mode : List.of("single-pass", "hybrid-exact", "two-pass")) { + ReflectionTestUtils.setField(analysisAiClient, "analysisMode", mode); + var recorded = new java.util.ArrayList(); + var input = new AnalysisPromptInput("EV", "회사", "개발", "API", "Java", "", + List.of(new AnalysisPromptInput.QuestionAnswer(1L, "질문", "답변"))); + assertThatThrownBy(() -> analysisAiClient.analyzeForEvaluationResult( + input, null, java.time.Instant.EPOCH, recorded::add)).isInstanceOf(RuntimeException.class); + assertThat(recorded).hasSize(1); + assertThat(recorded.getFirst().selectionMode()).isEqualTo(mode.equals("two-pass") ? "NOT_APPLIED" : "STATIC"); + } + org.mockito.Mockito.verifyNoInteractions(openAiAnalysisAdapter); + } + private final FewShotSearchService fewShotSearchService = mock(FewShotSearchService.class); private final FewShotProperties fewShotProperties = new FewShotProperties(); private final ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotMetadataPromptTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotMetadataPromptTest.java new file mode 100644 index 00000000..e0fde988 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotMetadataPromptTest.java @@ -0,0 +1,101 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.*; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +class FewShotMetadataPromptTest { + private final FewShotSearchService search = mock(FewShotSearchService.class); + private final FewShotProperties properties = new FewShotProperties(); + private final FewShotPromptProvider provider = new FewShotPromptProvider(); + private final AnalysisPromptBuilder builder = new AnalysisPromptBuilder(provider, search, properties, new ObjectMapper()); + private final List recorded = new ArrayList<>(); + + private String build() { + return builder.buildSinglePassPrompt( + new AnalysisPromptInput("EV", "회사", "개발", "API", "Java", "", + List.of(new AnalysisPromptInput.QuestionAnswer(1L, "질문", "비밀 원문"))), + new RetrievalContext(List.of(), List.of()), null, recorded::add); + } + + @Test + void staticReportsActualFixedIdsAndNoSimilarity() { + assertThat(build()).contains(provider.getPrompt()); + var meta = recorded.getFirst(); + assertThat(meta.selectionMode()).isEqualTo("STATIC"); + assertThat(meta.selectedCases()).hasSize(provider.getFixedExampleBlocks().size()); + assertThat(meta.selectedCases().getFirst().id()).isEqualTo("FS-FIXED-1"); + assertThat(meta.topScore()).isNull(); + verifyNoInteractions(search); + } + + @Test + void embeddingSnapshotMatchesPromptAndIsRecordedOnce() throws Exception { + properties.setDynamicSelectionEnabled(true); + properties.setDatasetVersion("version-2"); + properties.getSearch().setTopK(2); + properties.getSearch().setMinSimilarity(0.3); + when(search.cohereApiCallCount()).thenReturn(10L, 12L); + when(search.searchRelevantFewShots(any(), anyInt())).thenReturn(List.of( + selected("A", 0.4, "cohere-embedding"), selected("B", 0.8, "cohere-embedding"))); + assertThat(build()).contains("BLOCK-A", "BLOCK-B").doesNotContain(provider.getPrompt()); + assertThat(recorded).hasSize(1); + var meta = recorded.getFirst(); + assertThat(meta.selectionMode()).isEqualTo("EMBEDDING"); + assertThat(meta.topScore()).isEqualTo(0.8); + assertThat(meta.bottomScore()).isEqualTo(0.4); + assertThat(meta.avgScore()).isCloseTo(0.6, org.assertj.core.data.Offset.offset(0.00001)); + assertThat(meta.datasetVersion()).isEqualTo("version-2"); + assertThat(meta.minSimilarity()).isEqualTo(0.3); + assertThat(meta.topK()).isEqualTo(2); + assertThat(meta.cohereApiCallCount()).isEqualTo(2); + assertThat(meta.selectedCases()).extracting(FewShotSelectionMetadata.Candidate::id).containsExactly("A", "B"); + assertThat(new ObjectMapper().writeValueAsString(meta)).doesNotContain("비밀 원문", "BLOCK-", "sanitizedAnswer", "promptBlock"); + verify(search, times(1)).searchRelevantFewShots(any(), anyInt()); + } + + @Test + void localScoreIsNotLabeledAsCosineSimilarity() { + properties.setDynamicSelectionEnabled(true); + when(search.searchRelevantFewShots(any(), anyInt())).thenReturn(List.of(selected("L", 1.2, "local-fallback"))); + build(); + assertThat(recorded.getFirst().selectionMode()).isEqualTo("LOCAL_FALLBACK"); + assertThat(recorded.getFirst().scoreType()).isEqualTo("LOCAL_HEURISTIC"); + } + + @Test + void emptyAndExceptionFallbackRecordStaticExamplesNotEmptyCandidates() { + properties.setDynamicSelectionEnabled(true); + when(search.searchRelevantFewShots(any(), anyInt())).thenReturn(List.of()).thenThrow(new RuntimeException("failure")); + assertThat(build()).contains(provider.getPrompt()); + assertThat(build()).contains(provider.getPrompt()); + assertThat(recorded).allSatisfy(meta -> { + assertThat(meta.selectionMode()).isEqualTo("STATIC_FALLBACK"); + assertThat(meta.selectedCases()).hasSize(provider.getFixedExampleBlocks().size()); + assertThat(meta.avgScore()).isNull(); + }); + assertThat(recorded.get(0).reason()).isEqualTo("empty_selection"); + assertThat(recorded.get(1).reason()).isEqualTo("selection_exception"); + } + + @Test + void twoPassIsNotReportedAsStatic() { + assertThat(builder.fewShotNotApplied().selectionMode()).isEqualTo("NOT_APPLIED"); + assertThat(builder.fewShotNotApplied().selectedCases()).isEmpty(); + } + + private SelectedFewShotCase selected(String id, double score, String method) { + return new SelectedFewShotCase(new FewShotCase(id, FewShotSource.REVIEWED_EVALUATION, + FewShotReviewStatus.APPROVED, true, 1, "", "", List.of(), List.of(), "질문", + "비밀 원문", "{}", List.of(), "version-2", "BLOCK-" + id), score, method); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchServiceTest.java index 48d7c9d6..7829cbc8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/DefaultFewShotSearchServiceTest.java @@ -3,13 +3,29 @@ import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,12 +33,14 @@ class DefaultFewShotSearchServiceTest { private final FewShotCaseStore caseStore = mock(FewShotCaseStore.class); private final FewShotSearchTextBuilder textBuilder = new FewShotSearchTextBuilder(); private final CohereEmbeddingClient cohereEmbeddingClient = mock(CohereEmbeddingClient.class); + private final FewShotMetricsRecorder metricsRecorder = mock(FewShotMetricsRecorder.class); private final FewShotProperties properties = new FewShotProperties(); private final DefaultFewShotSearchService service = new DefaultFewShotSearchService( caseStore, textBuilder, cohereEmbeddingClient, - properties + properties, + metricsRecorder ); @Test @@ -51,6 +69,24 @@ void excludesSelfReferenceCandidate() { assertThat(result).extracting(item -> item.fewShotCase().id()) .containsExactly("EV-02"); + assertThat(result.getFirst().selectionMethod()).isEqualTo("cohere-embedding"); + } + + @Test + @DisplayName("caseId가 달라도 정규화된 JD, 문항, 답변이 같으면 후보에서 제외한다") + void excludesCandidateWithSameNormalizedInput() { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-SAME", " spring BOOT API를 개발했습니다. ", 10), + caseItem("FS-OTHER", "Spring Boot API를 운영하고 장애를 개선했습니다.", 5) + )); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + List result = service.searchRelevantFewShots(query("EV-01"), 3); + + assertThat(result).extracting(item -> item.fewShotCase().id()) + .containsExactly("FS-OTHER"); } @Test @@ -67,9 +103,651 @@ void fallsBackToLocalSelectionWhenCohereFails() { assertThat(result).hasSize(1); assertThat(result.getFirst().selectionMethod()).isEqualTo("local-fallback"); + verify(metricsRecorder).recordCohereLogicalCalls(1L); + verify(metricsRecorder).recordCohereFailure("RuntimeException"); + verify(metricsRecorder).recordSelection(eq(FewShotSelectionMode.LOCAL_FALLBACK), eq(false), eq(1), anyLong()); + } + + @Test + @DisplayName("임계값을 통과한 후보가 최소 개수보다 적으면 로컬 선택으로 fallback한다") + void fallsBackToLocalSelectionWhenEmbeddingResultsAreBelowMinimumCount() { + properties.setDynamicSelectionEnabled(true); + properties.getSearch().setMinSimilarity(0.5); + properties.getSearch().setMinimumSelectedCount(2); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0) + )); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1} + )); + + List result = service.searchRelevantFewShots(query("EV-99"), 2); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(item -> item.selectionMethod().equals("local-fallback")); + } + + @Test + @DisplayName("fallback이 비활성화되면 최소 유사도 미만 후보를 선택하지 않는다") + void excludesCandidatesBelowMinimumSimilarityWhenFallbackDisabled() { + properties.setDynamicSelectionEnabled(true); + properties.setFallbackEnabled(false); + properties.getSearch().setMinSimilarity(0.5); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "브랜드 운영", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{0, 1})); + + List result = service.searchRelevantFewShots(query("EV-99"), 1); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("서로 다른 검색 요청에서도 동일한 후보의 document embedding을 재사용한다") + void reusesDocumentEmbeddingAcrossDifferentQueries() { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0) + )); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1} + )); + + service.searchRelevantFewShots(query("EV-01", "Spring Boot API를 개발했습니다."), 1); + service.searchRelevantFewShots(query("EV-02", "Java 서버를 운영했습니다."), 1); + + verify(cohereEmbeddingClient, times(2)).embedQuery(any()); + verify(cohereEmbeddingClient, times(1)).embedDocuments(any()); + } + + @Test + @DisplayName("동일한 검색 질의를 다른 topK로 요청해도 query embedding을 재사용한다") + void reusesQueryEmbeddingAcrossDifferentTopK() { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0) + )); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1} + )); + FewShotSearchQuery query = query("EV-01", "Spring Boot API를 개발했습니다."); + + service.searchRelevantFewShots(query, 1); + service.searchRelevantFewShots(query, 2); + + verify(cohereEmbeddingClient, times(1)).embedQuery(any()); + } + + @Test + @DisplayName("동일 질의의 동시 요청은 query embedding 호출을 공유한다") + void deduplicatesConcurrentQueryEmbeddingForSameQuery() throws Exception { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "shared reference", 0))); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseEmbedding = new CountDownLatch(1); + when(cohereEmbeddingClient.embedQuery(any())).thenAnswer(invocation -> { + embeddingStarted.countDown(); + if (!releaseEmbedding.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("query embedding did not finish in time"); + } + return new float[]{1, 0}; + }); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + FewShotSearchQuery query = query("EV-01", "shared request"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> first = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> second = executor.submit( + () -> service.searchRelevantFewShots(query, 2) + ); + + releaseEmbedding.countDown(); + assertThat(first.get(2, TimeUnit.SECONDS)).hasSize(1); + assertThat(second.get(2, TimeUnit.SECONDS)).hasSize(1); + verify(cohereEmbeddingClient, times(1)).embedQuery(any()); + } finally { + releaseEmbedding.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("공유 query embedding이 실패하면 모든 대기 요청이 fallback하고 후속 요청은 재시도한다") + void recoversWaitingRequestsAfterSharedQueryEmbeddingFailure() throws Exception { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0), + caseItem("FS-3", "데이터 분석", 0) + )); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseFailure = new CountDownLatch(1); + when(cohereEmbeddingClient.embedQuery(any())) + .thenAnswer(invocation -> { + embeddingStarted.countDown(); + if (!releaseFailure.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("query embedding failure was not released in time"); + } + throw new RuntimeException("cohere down"); + }) + .thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1}, + new float[]{0.5f, 0.5f} + )); + FewShotSearchQuery query = query("EV-01", "shared failure request"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> owner = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> waiter = executor.submit( + () -> service.searchRelevantFewShots(query, 2) + ); + assertThatThrownBy(() -> waiter.get(100, TimeUnit.MILLISECONDS)) + .isInstanceOf(java.util.concurrent.TimeoutException.class); + + releaseFailure.countDown(); + assertThat(owner.get(2, TimeUnit.SECONDS)) + .allMatch(item -> item.selectionMethod().equals("local-fallback")); + assertThat(waiter.get(2, TimeUnit.SECONDS)) + .allMatch(item -> item.selectionMethod().equals("local-fallback")); + } finally { + releaseFailure.countDown(); + executor.shutdownNow(); + } + Map inFlightAfterFailure = (Map) ReflectionTestUtils.getField( + service, + "queryEmbeddingInFlight" + ); + assertThat(inFlightAfterFailure).isEmpty(); + + List retried = service.searchRelevantFewShots(query, 3); + + assertThat(retried).hasSize(3); + assertThat(retried).allMatch(item -> item.selectionMethod().equals("cohere-embedding")); + verify(cohereEmbeddingClient, times(2)).embedQuery(any()); + } + + @Test + @DisplayName("동일한 selection 키의 동시 요청은 하나의 외부 호출 결과를 공유한다") + void reusesInFlightSelectionForSameKey() throws Exception { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseEmbedding = new CountDownLatch(1); + when(cohereEmbeddingClient.embedQuery(any())).thenAnswer(invocation -> { + embeddingStarted.countDown(); + if (!releaseEmbedding.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("selection embedding did not finish in time"); + } + return new float[]{1, 0}; + }); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + FewShotSearchQuery query = query("EV-01", "same selection request"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> owner = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> waiter = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThatThrownBy(() -> waiter.get(100, TimeUnit.MILLISECONDS)) + .isInstanceOf(java.util.concurrent.TimeoutException.class); + releaseEmbedding.countDown(); + + assertThat(owner.get(2, TimeUnit.SECONDS)).hasSize(1); + assertThat(waiter.get(2, TimeUnit.SECONDS)).hasSize(1); + verify(cohereEmbeddingClient, times(1)).embedQuery(any()); + verify(cohereEmbeddingClient, times(1)).embedDocuments(any()); + Map selectionInFlight = (Map) ReflectionTestUtils.getField(service, "selectionInFlight"); + assertThat(selectionInFlight).isEmpty(); + } finally { + releaseEmbedding.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("동일 selection 대기는 설정된 제한 시간을 넘으면 종료된다") + void timesOutWaitingForInFlightSelection() throws Exception { + properties.setDynamicSelectionEnabled(true); + properties.setSelectionInFlightWaitTimeout(Duration.ofMillis(50)); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseEmbedding = new CountDownLatch(1); + when(cohereEmbeddingClient.embedQuery(any())).thenAnswer(invocation -> { + embeddingStarted.countDown(); + releaseEmbedding.await(3, TimeUnit.SECONDS); + return new float[]{1, 0}; + }); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + FewShotSearchQuery query = query("EV-01", "selection timeout request"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> owner = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> waiter = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + + ExecutionException exception = org.assertj.core.api.Assertions.catchThrowableOfType( + () -> waiter.get(1, TimeUnit.SECONDS), + ExecutionException.class + ); + assertThat(exception.getCause()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("공유된 Few-shot selection 대기 시간이 초과되었습니다."); + releaseEmbedding.countDown(); + assertThat(owner.get(2, TimeUnit.SECONDS)).hasSize(1); + } finally { + releaseEmbedding.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("공유 query embedding 대기가 제한 시간을 넘으면 로컬 fallback한다") + void fallsBackLocallyWhenSharedQueryEmbeddingWaitTimesOut() throws Exception { + properties.setDynamicSelectionEnabled(true); + properties.setQueryEmbeddingInFlightWaitTimeout(Duration.ofMillis(50)); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0) + )); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseEmbedding = new CountDownLatch(1); + when(cohereEmbeddingClient.embedQuery(any())).thenAnswer(invocation -> { + embeddingStarted.countDown(); + if (!releaseEmbedding.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("query embedding did not finish in time"); + } + return new float[]{1, 0}; + }); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1} + )); + FewShotSearchQuery query = query("EV-01", "timeout request"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> owner = executor.submit( + () -> service.searchRelevantFewShots(query, 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> waiter = executor.submit( + () -> service.searchRelevantFewShots(query, 2) + ); + + assertThat(waiter.get(1, TimeUnit.SECONDS)) + .allMatch(item -> item.selectionMethod().equals("local-fallback")); + releaseEmbedding.countDown(); + assertThat(owner.get(2, TimeUnit.SECONDS)).hasSize(1); + verify(cohereEmbeddingClient, times(1)).embedQuery(any()); + } finally { + releaseEmbedding.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("query embedding 캐시는 최근 접근 항목을 유지하고 오래 사용하지 않은 항목을 제거한다") + void evictsLeastRecentlyAccessedQueryEmbedding() throws Exception { + properties.setDynamicSelectionEnabled(true); + properties.setQueryEmbeddingCacheMaxSize(3); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + FewShotSearchQuery first = query("EV-A", "first request"); + FewShotSearchQuery second = query("EV-B", "second request"); + FewShotSearchQuery third = query("EV-C", "third request"); + + service.searchRelevantFewShots(first, 1); + TimeUnit.MILLISECONDS.sleep(2); + service.searchRelevantFewShots(second, 1); + TimeUnit.MILLISECONDS.sleep(2); + service.searchRelevantFewShots(first, 2); + TimeUnit.MILLISECONDS.sleep(2); + service.searchRelevantFewShots(third, 1); + service.searchRelevantFewShots(first, 3); + service.searchRelevantFewShots(second, 2); + + verify(cohereEmbeddingClient, times(4)).embedQuery(any()); + } + + @Test + @DisplayName("query embedding 실패 후 동일 질의를 재요청하면 Cohere 호출을 다시 시도한다") + void retriesSameQueryAfterQueryEmbeddingFailure() { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-1", "Spring Boot API 개발", 0), + caseItem("FS-2", "브랜드 운영", 0) + )); + when(cohereEmbeddingClient.embedQuery(any())) + .thenThrow(new RuntimeException("cohere down")) + .thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of( + new float[]{1, 0}, + new float[]{0, 1} + )); + FewShotSearchQuery query = query("EV-01", "retry request"); + + List failedAttempt = service.searchRelevantFewShots(query, 1); + Map inFlightAfterFailure = (Map) ReflectionTestUtils.getField( + service, + "queryEmbeddingInFlight" + ); + assertThat(failedAttempt).hasSize(1); + assertThat(failedAttempt.getFirst().selectionMethod()).isEqualTo("local-fallback"); + assertThat(inFlightAfterFailure).isEmpty(); + + List retried = service.searchRelevantFewShots(query, 2); + + assertThat(retried).hasSize(2); + assertThat(retried).allMatch(item -> item.selectionMethod().equals("cohere-embedding")); + verify(cohereEmbeddingClient, times(2)).embedQuery(any()); + } + + @Test + @DisplayName("query embedding 캐시는 설정된 최대 크기를 넘지 않도록 정리한다") + void boundsQueryEmbeddingCacheSize() { + properties.setDynamicSelectionEnabled(true); + properties.setQueryEmbeddingCacheMaxSize(3); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + for (int i = 0; i < 5; i++) { + service.searchRelevantFewShots(query("EV-" + i, "unique request " + i), 1); + } + + Map queryEmbeddingCache = (Map) ReflectionTestUtils.getField(service, "queryEmbeddingCache"); + assertThat(queryEmbeddingCache).hasSizeLessThanOrEqualTo(3); + } + + @Test + @DisplayName("selection과 document embedding 캐시는 각각 설정된 최대 크기를 넘지 않는다") + void boundsSelectionAndDocumentEmbeddingCaches() { + properties.setDynamicSelectionEnabled(true); + properties.setSelectionCacheMaxSize(2); + properties.setQueryEmbeddingCacheMaxSize(2); + properties.setDocumentEmbeddingCacheMaxSize(2); + AtomicInteger candidateSequence = new AtomicInteger(); + when(caseStore.loadActiveCases()).thenAnswer(invocation -> { + int sequence = candidateSequence.incrementAndGet(); + return List.of(caseItem("FS-" + sequence, "Spring Boot API 개발 " + sequence, 0)); + }); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + for (int i = 0; i < 5; i++) { + service.searchRelevantFewShots(query("EV-" + i, "bounded request " + i), 1); + } + + assertThat(service.selectionCacheSize()).isLessThanOrEqualTo(2); + assertThat(service.queryEmbeddingCacheSize()).isLessThanOrEqualTo(2); + assertThat(service.documentEmbeddingCacheSize()).isLessThanOrEqualTo(2); + } + + @Test + @DisplayName("동시 삽입이 끝난 뒤 selection과 document 캐시는 설정 상한 이내로 정리된다") + void boundsCachesAfterConcurrentInsertions() throws Exception { + int requestCount = 8; + properties.setDynamicSelectionEnabled(true); + properties.setSelectionCacheMaxSize(2); + properties.setDocumentEmbeddingCacheMaxSize(2); + properties.setQueryEmbeddingCacheMaxSize(20); + AtomicInteger candidateSequence = new AtomicInteger(); + when(caseStore.loadActiveCases()).thenAnswer(invocation -> { + int sequence = candidateSequence.incrementAndGet(); + return List.of(caseItem("FS-CONCURRENT-" + sequence, "API 개발 " + sequence, 0)); + }); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + CountDownLatch documentCallsStarted = new CountDownLatch(requestCount); + CountDownLatch releaseDocuments = new CountDownLatch(1); + when(cohereEmbeddingClient.embedDocuments(any())).thenAnswer(invocation -> { + documentCallsStarted.countDown(); + if (!releaseDocuments.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("concurrent document embeddings did not start in time"); + } + List documents = invocation.getArgument(0); + return documents.stream().map(ignored -> new float[]{1, 0}).toList(); + }); + + ExecutorService executor = Executors.newFixedThreadPool(requestCount); + List>> futures = new ArrayList<>(); + try { + for (int i = 0; i < requestCount; i++) { + int request = i; + futures.add(executor.submit(() -> service.searchRelevantFewShots( + query("EV-CONCURRENT-" + request, "concurrent request " + request), 1 + ))); + } + assertThat(documentCallsStarted.await(2, TimeUnit.SECONDS)).isTrue(); + releaseDocuments.countDown(); + for (Future> future : futures) { + assertThat(future.get(3, TimeUnit.SECONDS)).hasSize(1); + } + } finally { + releaseDocuments.countDown(); + executor.shutdownNow(); + } + + assertThat(service.selectionCacheSize()).isLessThanOrEqualTo(2); + assertThat(service.documentEmbeddingCacheSize()).isLessThanOrEqualTo(2); + } + + @Test + @DisplayName("조회가 진행 중인 캐시 정리와 겹쳐도 후속 정리를 예약하지 않는다") + void doesNotRequestFollowUpCleanupForReads() { + AtomicBoolean selectionInProgress = cleanupFlag("selectionCacheCleanupInProgress"); + AtomicBoolean selectionRequested = cleanupFlag("selectionCacheCleanupRequested"); + AtomicBoolean documentInProgress = cleanupFlag("documentEmbeddingCacheCleanupInProgress"); + AtomicBoolean documentRequested = cleanupFlag("documentEmbeddingCacheCleanupRequested"); + selectionInProgress.set(true); + documentInProgress.set(true); + + try { + ReflectionTestUtils.invokeMethod(service, "readSelectionCache", "missing-key"); + List embeddings = ReflectionTestUtils.invokeMethod( + service, + "resolveDocumentEmbeddings", + List.of(), + List.of() + ); + + assertThat(embeddings).isEmpty(); + assertThat(selectionRequested).isFalse(); + assertThat(documentRequested).isFalse(); + } finally { + selectionInProgress.set(false); + documentInProgress.set(false); + } + } + + @Test + @DisplayName("만료된 query embedding은 동일 질의 재요청에 사용하지 않는다") + void doesNotReuseExpiredQueryEmbedding() { + properties.setDynamicSelectionEnabled(true); + properties.setCacheTtl(Duration.ZERO); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + FewShotSearchQuery query = query("EV-01", "expired request"); + + service.searchRelevantFewShots(query, 1); + service.searchRelevantFewShots(query, 2); + + verify(cohereEmbeddingClient, times(2)).embedQuery(any()); + verify(cohereEmbeddingClient, times(2)).embedDocuments(any()); + } + + @Test + @DisplayName("후보 내용이 변경되면 document embedding을 다시 생성한다") + void refreshesDocumentEmbeddingWhenCandidateContentChanges() { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn( + List.of(caseItem("FS-1", "Spring Boot API 개발", 0)), + List.of(caseItem("FS-1", "브랜드 운영 경험", 0)) + ); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + service.searchRelevantFewShots(query("EV-01", "Spring Boot API를 개발했습니다."), 1); + service.searchRelevantFewShots(query("EV-01", "Spring Boot API를 개발했습니다."), 1); + + verify(cohereEmbeddingClient, times(1)).embedQuery(any()); + verify(cohereEmbeddingClient, times(2)).embedDocuments(any()); + } + + @Test + @DisplayName("캐시가 비활성화되면 매 요청마다 document embedding을 생성한다") + void embedsDocumentsOnEveryRequestWhenCacheDisabled() { + properties.setDynamicSelectionEnabled(true); + properties.setCacheEnabled(false); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + service.searchRelevantFewShots(query("EV-01", "Spring Boot API를 개발했습니다."), 1); + service.searchRelevantFewShots(query("EV-02", "Java 서버를 운영했습니다."), 1); + + verify(cohereEmbeddingClient, times(2)).embedQuery(any()); + verify(cohereEmbeddingClient, times(2)).embedDocuments(any()); + } + + @Test + @DisplayName("서로 다른 후보의 document embedding 호출은 동시에 실행할 수 있다") + void embedsDifferentDocumentKeysConcurrently() throws Exception { + properties.setDynamicSelectionEnabled(true); + properties.getSearch().setCandidateLimit(1); + when(caseStore.loadActiveCases()).thenReturn(List.of( + caseItem("FS-A", "alphaonly reference", 0), + caseItem("FS-B", "betaonly reference", 0) + )); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + CountDownLatch callsStarted = new CountDownLatch(2); + CountDownLatch releaseCalls = new CountDownLatch(1); + AtomicInteger activeCalls = new AtomicInteger(); + AtomicInteger maxActiveCalls = new AtomicInteger(); + when(cohereEmbeddingClient.embedDocuments(any())).thenAnswer(invocation -> { + int active = activeCalls.incrementAndGet(); + maxActiveCalls.accumulateAndGet(active, Math::max); + callsStarted.countDown(); + try { + if (!releaseCalls.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("concurrent embedding calls did not start in time"); + } + return List.of(new float[]{1, 0}); + } finally { + activeCalls.decrementAndGet(); + } + }); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> alpha = executor.submit( + () -> service.searchRelevantFewShots(query("EV-A", "alphaonly request"), 1) + ); + Future> beta = executor.submit( + () -> service.searchRelevantFewShots(query("EV-B", "betaonly request"), 1) + ); + + assertThat(callsStarted.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(maxActiveCalls.get()).isEqualTo(2); + releaseCalls.countDown(); + assertThat(alpha.get(2, TimeUnit.SECONDS)).hasSize(1); + assertThat(beta.get(2, TimeUnit.SECONDS)).hasSize(1); + } finally { + releaseCalls.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("동일 후보의 동시 요청은 document embedding 호출을 공유한다") + void deduplicatesConcurrentDocumentEmbeddingForSameKey() throws Exception { + properties.setDynamicSelectionEnabled(true); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "shared reference", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + CountDownLatch embeddingStarted = new CountDownLatch(1); + CountDownLatch releaseEmbedding = new CountDownLatch(1); + when(cohereEmbeddingClient.embedDocuments(any())).thenAnswer(invocation -> { + embeddingStarted.countDown(); + if (!releaseEmbedding.await(3, TimeUnit.SECONDS)) { + throw new IllegalStateException("document embedding did not finish in time"); + } + return List.of(new float[]{1, 0}); + }); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> first = executor.submit( + () -> service.searchRelevantFewShots(query("EV-A", "first request"), 1) + ); + assertThat(embeddingStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future> second = executor.submit( + () -> service.searchRelevantFewShots(query("EV-B", "second request"), 1) + ); + + releaseEmbedding.countDown(); + assertThat(first.get(2, TimeUnit.SECONDS)).hasSize(1); + assertThat(second.get(2, TimeUnit.SECONDS)).hasSize(1); + verify(cohereEmbeddingClient, times(1)).embedDocuments(any()); + } finally { + releaseEmbedding.countDown(); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("선택 캐시 접근 시 다른 키의 만료 항목도 함께 제거한다") + void evictsExpiredSelectionEntriesGlobally() { + properties.setDynamicSelectionEnabled(true); + properties.setCacheTtl(Duration.ZERO); + when(caseStore.loadActiveCases()).thenReturn(List.of(caseItem("FS-1", "Spring Boot API 개발", 0))); + when(cohereEmbeddingClient.embedQuery(any())).thenReturn(new float[]{1, 0}); + when(cohereEmbeddingClient.embedDocuments(any())).thenReturn(List.of(new float[]{1, 0})); + + service.searchRelevantFewShots(query("EV-01", "첫 번째 요청"), 1); + service.searchRelevantFewShots(query("EV-02", "두 번째 요청"), 1); + + Map selectionCache = (Map) ReflectionTestUtils.getField(service, "selectionCache"); + assertThat(selectionCache).isEmpty(); + } + + private AtomicBoolean cleanupFlag(String fieldName) { + return (AtomicBoolean) ReflectionTestUtils.getField(service, fieldName); } private static FewShotSearchQuery query(String caseId) { + return query(caseId, "Spring Boot API를 개발했습니다."); + } + + private static FewShotSearchQuery query(String caseId, String answer) { return new FewShotSearchQuery( caseId, "백엔드 개발", @@ -77,7 +755,7 @@ private static FewShotSearchQuery query(String caseId) { List.of("Spring Boot API 개발"), List.of("Java"), "지원 직무 경험", - "Spring Boot API를 개발했습니다." + answer ); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java index 362f215a..23c6095e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java @@ -15,16 +15,114 @@ class FewShotCaseStoreTest { @TempDir Path tempDir; + @Test + void rejectsAmbiguousCsvHeadersAndMalformedRows() throws Exception { + String header = "caseId,mainTasks,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus"; + var properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + Path csv = tempDir.resolve("malformed.csv"); + properties.setReviewedEvaluationCsvPath(csv.toString()); + var store = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()); + for (String content : java.util.List.of( + header + ",caseId\nEV-01,API,경험,답변,{},true,APPROVED,EV-02", + header + "\nEV-01,API,경험,답변,{},true,APPROVED,extra", + header + "\nEV-01,API,경험,\"unclosed")) { + Files.writeString(csv, content); + assertThat(store.loadActiveCases()).isEmpty(); + } + } + + @Test + @DisplayName("무효 행은 ID를 선점하지 않고 같은 ID의 첫 유효 행만 적재한다") + void retainsFirstValidRowAfterInvalidRowsWithSameCaseId() throws Exception { + Path csv = tempDir.resolve("duplicate-id.csv"); + Files.writeString(csv, """ + caseId,mainTasks,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus + EV-01,API 개발,경험,비활성 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",false,APPROVED + EV-01,API 개발,경험,미승인 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,IN_REVIEW + EV-01,API 개발,경험,,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + EV-01,API 개발,경험,잘못된 JSON 답변,{,true,APPROVED + EV-01,API 개발,경험,배열 JSON 답변,[],true,APPROVED + EV-01,API 개발,경험,첫 유효 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + EV-01,API 개발,경험,중복 유효 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + """); + FewShotProperties properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + properties.setReviewedEvaluationCsvPath(csv.toString()); + + var loaded = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()) + .loadActiveCases(); + + assertThat(loaded).extracting(FewShotCase::id).containsExactly("EV-01"); + assertThat(loaded.getFirst().sanitizedAnswer()).isEqualTo("첫 유효 답변"); + } + + @Test + void duplicateInputDoesNotReserveIdOfLaterDistinctInput() throws Exception { + Path csv = tempDir.resolve("duplicate-input.csv"); + String analysis = "\"{\"\"keyStrengths\"\":[],\"\"missingKeywords\"\":[],\"\"questionAnalyses\"\":[]}\""; + Files.writeString(csv, + "caseId,mainTasks,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus\n" + + "FIRST,API,경험,답변," + analysis + ",true,APPROVED\n" + + "SECOND,API,경험, 답변 ," + analysis + ",true,APPROVED\n" + + "SECOND,API,경험,다른 답변," + analysis + ",true,APPROVED\n"); + var properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + properties.setReviewedEvaluationCsvPath(csv.toString()); + var loaded = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()).loadActiveCases(); + assertThat(loaded).extracting(FewShotCase::id).containsExactly("FIRST", "SECOND"); + assertThat(loaded.getLast().sanitizedAnswer()).isEqualTo("다른 답변"); + } + + @Test + void preservesOptionalJdSectionsAndSkipsMalformedJsonPerRow() throws Exception { + Path csv = tempDir.resolve("optional-jd.csv"); + Files.writeString(csv, """ + caseId,jobCategorySmall,jobTitle,mainTasks,qualifications,preferences,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus + FS-05,PA,Project Assistant,,협업 능력,Adobe,경험,답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + FS-09,BX,Brand Designer,IP 관리,,,경험,답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + PREF,디자인,,, ,Adobe,경험,답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + EMPTY,디자인,,,,,경험,답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + BROKEN,디자인,,IP 관리,,,경험,답변,{,true,APPROVED + ARRAY,디자인,,IP 관리,,,경험,답변,[],true,APPROVED + TRAILING,디자인,,IP 관리,,,경험,답변,{} garbage,true,APPROVED + LAST,개발,,API 개발,,,경험,답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED + """); + FewShotProperties properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + properties.setReviewedEvaluationCsvPath(csv.toString()); + var loaded = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()) + .loadActiveCases(); + assertThat(loaded).extracting(FewShotCase::id).containsExactly("FS-05", "FS-09", "PREF", "LAST"); + assertThat(loaded.getFirst().jobTitle()).isEqualTo("Project Assistant"); + assertThat(loaded.getFirst().mainTasks()).isEmpty(); + assertThat(loaded.getFirst().promptBlock()).contains("- preference: Adobe"); + assertThat(loaded.get(1).qualifications()).isEmpty(); + assertThat(loaded.getLast().jobTitle()).isEqualTo("개발"); + } + @Test @DisplayName("reviewed evaluation CSV는 승인, 활성, 비식별 답변, 승인 분석이 있는 행만 후보로 적재한다") void loadsOnlyApprovedReviewedEvaluationRows() throws Exception { Path csv = tempDir.resolve("reviewed.csv"); Files.writeString(csv, """ caseId,jobCategorySmall,mainTasks,qualifications,question,answer,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus,fewShotPriority,fewShotTags - EV-01,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""questionAnalyses"":[]}",true,APPROVED,100,"spring,api" - EV-02,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,,"{""questionAnalyses"":[]}",true,APPROVED,100,spring - EV-03,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""questionAnalyses"":[]}",false,APPROVED,100,spring - EV-04,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""questionAnalyses"":[]}",true,IN_REVIEW,100,spring + EV-01,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED,100,"spring,api" + EV-02,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,APPROVED,100,spring + EV-03,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",false,APPROVED,100,spring + EV-04,백엔드,API 개발,Spring Boot,직무 경험,원본 답변,비식별 답변,"{""keyStrengths"":[],""missingKeywords"":[],""questionAnalyses"":[]}",true,IN_REVIEW,100,spring """); FewShotProperties properties = new FewShotProperties(); properties.getSource().setFixedEnabled(false); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorderTest.java new file mode 100644 index 00000000..21a9e546 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotMetricsRecorderTest.java @@ -0,0 +1,32 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FewShotMetricsRecorderTest { + + @Test + void recordsSelectionCallsAndBoundedFailureReason() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + FewShotMetricsRecorder recorder = new FewShotMetricsRecorder(registry); + + recorder.recordSelection(FewShotSelectionMode.EMBEDDING, false, 3, 42); + recorder.recordCohereLogicalCalls(2); + recorder.recordCohereFailure("UnexpectedVendorException"); + recorder.recordCacheEvent("selection", "evicted", 2); + + assertThat(registry.get("fewshot.selection.count") + .tags("mode", "EMBEDDING", "cache_hit", "false").counter().count()).isEqualTo(1.0); + assertThat(registry.get("fewshot.selection.duration") + .tags("mode", "EMBEDDING", "cache_hit", "false").timer().count()).isEqualTo(1L); + assertThat(registry.get("fewshot.selection.selected.candidates") + .tags("mode", "EMBEDDING", "cache_hit", "false").summary().totalAmount()).isEqualTo(3.0); + assertThat(registry.get("fewshot.cohere.logical.calls").counter().count()).isEqualTo(2.0); + assertThat(registry.get("fewshot.cohere.failure.count") + .tag("reason", "Other").counter().count()).isEqualTo(1.0); + assertThat(registry.get("fewshot.cache.events") + .tags("cache", "selection", "outcome", "evicted").counter().count()).isEqualTo(2.0); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java new file mode 100644 index 00000000..de816f9b --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java @@ -0,0 +1,39 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FewShotPrivacyMaskerTest { + private final FewShotPrivacyMasker masker = new FewShotPrivacyMasker(); + + @Test + @DisplayName("검색 입력의 대표 개인정보 유형을 정해진 토큰으로 마스킹한다") + void masksRepresentativePersonalInformation() { + String masked = masker.mask(""" + 이름: 홍길동 + 주소: 서울시 강남구 테헤란로 1 + 이메일 test.user@example.com, 전화 010-1234-5678 + 해외 표기 +82 10 9876 5432, 인터넷 전화 070-1111-2222 + 주민번호 900101-1234567 + 계정: hong_dev + 사번: EMP-1024 + 포트폴리오 https://example.com/users/hong?q=1 + """); + + assertThat(masked) + .contains("이름: [NAME]", "주소: [ADDRESS]", "[EMAIL]", "[PHONE]", "[RESIDENT_ID]", + "계정: [ACCOUNT]", "사번: [INTERNAL_ID]", "[URL]") + .doesNotContain("홍길동", "테헤란로", "test.user@example.com", "010-1234-5678", + "+82 10 9876 5432", "070-1111-2222", "900101-1234567", "hong_dev", + "EMP-1024", "example.com"); + } + + @Test + @DisplayName("직무 의미가 있는 회사명과 학교명은 라벨 없는 일반 문장에서 유지한다") + void keepsOrganizationsThatCarryExperienceMeaning() { + assertThat(masker.mask("네이버에서 검색 API를 개발했고 한국대학교에서 컴퓨터공학을 전공했습니다.")) + .isEqualTo("네이버에서 검색 API를 개발했고 한국대학교에서 컴퓨터공학을 전공했습니다."); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java index 189683b1..43d35c41 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java @@ -62,4 +62,72 @@ void buildsCandidateDocument() { .contains("Spring Boot 경험") .doesNotContain("embedding"); } + + @Test + @DisplayName("HTML을 제거하고 반복 공백을 정규화한 뒤 검색 텍스트를 구성한다") + void normalizesHtmlAndWhitespace() { + String text = builder.buildQueryText(new FewShotSearchQuery( + "EV-01", + " 백엔드 개발 ", + "Backend Engineer", + List.of("

Spring Boot

API 개발"), + List.of("Java\n\t개발 경험"), + "지원 직무 경험", + "JPA로 API를 개발했습니다." + )); + + assertThat(text) + .contains("[JOB_CATEGORY]\n백엔드 개발") + .contains("[JOB_TITLE]\nBackend Engineer") + .contains("[MAIN_TASKS]\n- Spring Boot API 개발") + .contains("[QUALIFICATIONS]\n- Java 개발 경험") + .contains("[QUESTION]\n지원 직무 경험") + .contains("[ANSWER]\nJPA로 API를 개발했습니다.") + .doesNotContain("", "