diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index bb8f61a4..6581b5be 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -400,6 +400,23 @@ docker run --env-file .env -p 8000:8000 stackup-ai (직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 `panelBreakdown` 의 `evaluator="직무 적합도"`· `evaluator="직무 이해도"` 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀. - **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `……{json}` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `` 토큰만 `SessionRealtimeNotifier`(`messaging/session_notify.py`)로 `SESSION_MESSAGE_DELTA` 발행(`stackup.realtime`/`realtime.session.notify`, Core 우회). `DONT_KNOW` 면 델타 미발행. 종료 후 `parse_followup_result` 로 검증해 기존 `callback.questions(FOLLOWUP, followupMessageId)` 발행. 와이어링은 `messaging/runner.py`(분석 진행 publisher 재사용). +- **정직한 "모르겠습니다" 채점 정정 (2026-09-29)**: 답변 평가 점수를 전부 nullable 로 + 바꾸고 "null = 채점 대상 아님"을 집계 전 구간에 관철했다. `_mean` 이 None 을 제외하므로 + **0 과 null 의 차이가 곧 점수 차이**인데, 세 곳이 어긋나 있었다. + - `AnswerEvaluation.specificity/logic` 이 **required** 였다. 프롬프트는 DONT_KNOW· + 확인형 단답에 null 을 지시하고 있었는데, LLM 이 그대로 따르면 + `model_validate_json` 이 터지고 `parse_followup_result` 가 그 예외를 삼켜 + **평가가 통째로 사라졌다**(structure 까지). 진짜 파싱 실패와 구분도 안 됐다. + → `float | None` 로. 받는 쪽(`MessageEvaluation`)은 처음부터 전부 nullable 이었다. + - 프롬프트가 `"보수적으로(낮게/null)"` 로 **모호**했다. 모호하면 LLM 이 매번 다르게 + 고른다. → `"반드시 null"` + meta 스키마에도 `<0~5 또는 null>` 명시. + - `_STRUCTURE_SCORE["NONE"] = 0.0` 이라 채점 불가 답변의 structure 가 0 으로 집계됐다. + 전부 모름인 세션이 **communication ≈ 0, overall ≈ 0** 으로 나왔다(테스트가 잡았다). + → `_is_scoreable`(specificity·logic 이 둘 다 null 이면 채점 불가)로 structure 도 제외. + **structure="NONE" 자체는 정상 감점 사유다** — 길게 답했는데 구조가 없는 경우. + 구분 기준은 structure 값이 아니라 채점 가능 여부다. + - 기준값 문자열에 `"채점 대상 아님 N건(… 감점 사유가 아님)"` 을 넣는다. 숫자만 주면 + LLM 이 "답변 수가 적네" 로 읽고 깎을 수 있다. - **짧은 확인 질문/답변 커버 본 구현**: followup 프롬프트(`chain/prompts/followup_generation.py`)가 매 턴 깊이 파기 대신 "그럼 OO 하신 건가요?" 같은 **짧은 확인형 질문**도 던지도록 안내하고, 직전 답변이 확인형 단답('네 맞습니다'·'아뇨 그건 아닙니다')이면 specificity/logic/correctness=null·structure=NONE 으로 두어 짧다는 이유로 감점하지 않도록 지시. 피드백 단계에선 `feedback_consumer._is_short_confirmation`(확인/부정 표현으로 시작 + 짧은 길이) 으로 그런 단답을 **질문별 복기(코칭) 대상에서 제외** — '네 맞습니다'에 모범답안/리라이트가 붙지 않게 한다(결정론적). 출력 포맷·콜백 스키마 무변경. - **문장 단위 TTS 본 구현 (Part B)**: followup consumer 스트림 루프가 `chain/sentence_split.next_sentences` 로 문장 경계를 잡아, 문장마다 `TtsProvider` 인라인 합성(`asyncio.create_task` 백그라운드, 텍스트 델타 비차단)→S3 `interview/tts/{sid}/{mid}/seg-{seq}.{ext}` PUT→`SessionRealtimeNotifier.emit_audio`(`SESSION_MESSAGE_AUDIO`). 콜백 전 `gather` 로 수거. 라이브 세그먼트는 휘발성(DB 미기록). - **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py index 0bb32e9c..1e6ec077 100644 --- a/ai/src/ai_server/chain/prompts/followup_generation.py +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -35,14 +35,16 @@ " - CLARIFICATION: 답변 대신 '질문을 다시/쉽게 설명해 달라'고 요청한 경우. " "이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n" " - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n" - " - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n" + " - DONT_KNOW/CLARIFICATION 이면 specificity/logic/correctness 를 **반드시 null** 로 두고 " + "structure 는 NONE 으로 둡니다. 낮은 점수를 주지 마세요 — null 은 '채점 대상 아님'이고 " + "0 은 '형편없는 답변'입니다. 모른다고 정직하게 말한 것을 틀린 답과 같이 취급하면 안 됩니다.\n" "- 직전 답변이 확인형 질문에 대한 **짧은 단답·정정**(예: '네 맞습니다', '아뇨 그건 아닙니다')이라 " "평가할 내용이 거의 없으면 specificity/logic/correctness 를 null, structure 를 NONE 으로 두고 " "**짧다는 이유만으로 감점하지 마세요**(확인형 질문에 대한 단답은 정상입니다).\n" "- 출력은 정확히 다음 3개 블록을 이 순서로만 작성합니다(설명·코드펜스 금지):\n" " NORMAL 또는 DONT_KNOW 또는 CLARIFICATION\n" " 지원자에게 보여줄 한국어 질문 1개(또는 CLARIFICATION 시 재설명 문장)\n" - ' {{"specificity": <0~5>, "logic": <0~5>, ' + ' {{"specificity": <0~5 또는 null>, "logic": <0~5 또는 null>, ' '"structure": "FULL_STAR|PARTIAL_STAR|NONE", "correctness": <0~5 또는 null>}}\n' "- 를 가장 먼저 확정해 출력합니다. DONT_KNOW 여도 블록은 비우지 말고 한 문장 넣되, " "표시는 시스템이 결정합니다." diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 7174b225..a6e4bc7d 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -753,6 +753,24 @@ def _format_evaluation(e) -> str: _STRUCTURE_SCORE = {"FULL_STAR": 5.0, "PARTIAL_STAR": 2.5, "NONE": 0.0} +def _is_scoreable(evaluation) -> bool: + """채점 대상인 답변인가. + + specificity·logic 이 **둘 다** null 이면 채점할 내용이 없다는 뜻이다 — 정직한 + "모르겠습니다"(DONT_KNOW), 질문 재설명 요청(CLARIFICATION), 확인형 질문에 대한 + "네 맞습니다" 가 여기 해당한다. + + 이때 structure 까지 같이 빼야 한다. `_STRUCTURE_SCORE["NONE"] = 0.0` 이라서, 그냥 + 두면 "평가할 내용이 없음"이 **구조가 형편없는 답변(0점)** 으로 집계돼 전달력과 종합 + 점수를 끌어내린다. 실제로 전부 모름인 세션이 communication_score ≈ 0, overall ≈ 0 + 으로 나왔다. + + structure 가 "NONE" 인 것 자체는 정상 감점 사유다 — 길게 답했는데 구조가 없는 경우. + 구분 기준은 structure 값이 아니라 **채점 가능 여부**다. + """ + return evaluation.specificity is not None or evaluation.logic is not None + + def _mean(values: list[float]) -> float | None: vals = [v for v in values if v is not None] return sum(vals) / len(vals) if vals else None @@ -779,7 +797,11 @@ def _build_score_basis(messages: list[FeedbackMessageItem]) -> str: spec = _mean([e.specificity for e in evals]) logic = _mean([e.logic for e in evals]) corr = _mean([e.correctness for e in evals]) # null 은 자동 제외 - struct = _mean([_STRUCTURE_SCORE.get(e.structure) for e in evals]) + # 채점 불가 답변의 structure 는 빼야 한다 — "NONE" 이 0.0 이라 그냥 두면 + # "평가할 내용 없음"이 "구조 형편없음"으로 둔갑한다(_is_scoreable 참고). + struct = _mean( + [_STRUCTURE_SCORE.get(e.structure) for e in evals if _is_scoreable(e)] + ) tech_100 = _to_100(corr) logic_100 = _to_100(logic) @@ -792,8 +814,16 @@ def fmt5(x: float | None) -> str: return f"{x:.1f}/5" if x is not None else "없음" corr_count = sum(1 for e in evals if e.correctness is not None) + scoreable = sum(1 for e in evals if _is_scoreable(e)) + unscoreable = len(evals) - scoreable lines = [ - f"- 채점된 답변 수: {len(evals)} (correctness 산정 {corr_count}건)", + f"- 채점된 답변 수: {scoreable} (correctness 산정 {corr_count}건)" + + ( + f" · 채점 대상 아님 {unscoreable}건" + " (모름·질문 재설명 요청·확인형 단답 — 감점 사유가 아님)" + if unscoreable + else "" + ), f"- specificity 평균: {fmt5(spec)}, logic 평균: {fmt5(logic)}, " f"structure 평균: {fmt5(struct)}, correctness 평균: {fmt5(corr)}", "[차원별 기준값(0~100)]", diff --git a/ai/src/ai_server/model/messages/followup.py b/ai/src/ai_server/model/messages/followup.py index 58f31deb..8e50f166 100644 --- a/ai/src/ai_server/model/messages/followup.py +++ b/ai/src/ai_server/model/messages/followup.py @@ -39,12 +39,26 @@ class GenerateFollowupRequest(BaseModel): class AnswerEvaluation(BaseModel): - """답변 평가 (US-19). LLM 이 specificity/logic/structure/correctness 채움.""" + """답변 평가 (US-19). LLM 이 specificity/logic/structure/correctness 채움. + + **모든 점수가 nullable 이다.** null 은 "0점"이 아니라 "채점 대상 아님"이고, 집계에서 + 제외된다(`_build_score_basis` 의 `_mean`). 이 구분이 필요한 경우가 둘 있다: + + - 지원자가 정직하게 "모르겠습니다" 라고 한 경우(DONT_KNOW). 0점을 주면 **틀린 답을 + 길게 늘어놓은 것과 같은 취급**이 된다. 면접에서 모름을 인정하는 것은 오답이 아니다. + - 확인형 질문에 "네 맞습니다" 로 답한 경우. 평가할 내용이 없다. + + 전에는 specificity/logic 이 required 였다. 프롬프트는 이미 이 두 경우에 null 을 + 지시하고 있었는데, LLM 이 그대로 따르면 `model_validate_json` 이 터지고 그 예외를 + 파서가 삼켜 **평가 전체가 조용히 사라졌다**(structure 까지). 진짜 파싱 실패와 + 구분되지도 않았다. + """ model_config = camel_config() - specificity: float # 0~5 - logic: float # 0~5 + # null = 채점 대상 아님(모름/확인형 단답). 0 과 다르다. + specificity: float | None = None # 0~5 + logic: float | None = None # 0~5 structure: Literal["FULL_STAR", "PARTIAL_STAR", "NONE"] # 답변이 자료(RAG)와 사실적으로 맞는지. 검색 컨텍스트 없으면 null. correctness: float | None = None # 0~5 diff --git a/ai/tests/test_honest_dontknow_scoring.py b/ai/tests/test_honest_dontknow_scoring.py new file mode 100644 index 00000000..b473e9c7 --- /dev/null +++ b/ai/tests/test_honest_dontknow_scoring.py @@ -0,0 +1,143 @@ +"""정직한 "모르겠습니다" 가 오답처럼 감점되지 않아야 한다. + +`_build_score_basis` 의 `_mean` 은 None 을 제외한다. 그래서 **0 과 null 의 차이가 곧 +점수 차이**다. 그런데 스키마가 specificity/logic 을 required 로 두고 있어서: + +- LLM 이 스키마대로 0 을 쓰면 → 모른다고 말한 것이 틀린 답과 같은 취급 +- 프롬프트 지시대로 null 을 쓰면 → 검증 실패를 파서가 삼켜 **평가 전체가 사라짐** + (structure 까지, 그리고 진짜 파싱 실패와 구분 불가) + +어느 쪽도 맞지 않았다. +""" + +from __future__ import annotations + +from ai_server.chain.followup_generation_chain import parse_followup_result +from ai_server.messaging.consumers.feedback_consumer import _build_score_basis +from ai_server.model.messages.feedback import FeedbackMessageItem, MessageEvaluation + +# AnswerEvaluation 은 AI→Core(꼬리질문 채점), MessageEvaluation 은 Core→AI(피드백 롤업). +# 받는 쪽은 처음부터 전부 nullable 이었고 보내는 쪽만 막고 있었다. + + +def _meta(spec, logic, structure="NONE", correctness=None) -> str: + def j(v): + return "null" if v is None else str(v) + + return ( + f"DONT_KNOWq" + f'{{"specificity": {j(spec)}, "logic": {j(logic)}, ' + f'"structure": "{structure}", "correctness": {j(correctness)}}}' + ) + + +def _answer(evaluation: MessageEvaluation | None) -> FeedbackMessageItem: + return FeedbackMessageItem( + id=1, + sequence_number=2, + role="INTERVIEWEE", + content="답변", + evaluation=evaluation, + ) + + +def test_null_scores_survive_parsing(): + """예전에는 여기서 ValidationError 가 나고 파서가 삼켜 평가가 통째로 사라졌다.""" + result = parse_followup_result(_meta(None, None)) + + assert result.answer_evaluation is not None, "null 평가가 통째로 버려졌다" + assert result.answer_evaluation.specificity is None + assert result.answer_evaluation.logic is None + # structure 는 살아남아야 한다 — 예전엔 이것까지 함께 사라졌다. + assert result.answer_evaluation.structure == "NONE" + + +def test_null_is_excluded_from_aggregate_but_zero_is_not(): + """이 차이가 이 수정의 전부다.""" + good = MessageEvaluation( + specificity=4, logic=4, structure="FULL_STAR", correctness=4 + ) + + with_null = _build_score_basis( + [ + _answer(good), + _answer(MessageEvaluation(specificity=None, logic=None, structure="NONE")), + ] + ) + with_zero = _build_score_basis( + [ + _answer(good), + _answer(MessageEvaluation(specificity=0, logic=0, structure="NONE")), + ] + ) + + # null 이면 좋은 답변 하나만 반영 → logic 4.0/5 → 80 + assert "logic_score ≈ 80" in with_null, with_null + # 0 이면 평균이 반토막 난다 — 모른다고 말한 대가로 20점을 잃는다 + assert "logic_score ≈ 40" in with_zero, with_zero + + +def test_all_null_evaluations_report_no_basis_not_zero(): + """전부 모름이어도 0점이 아니라 '근거 없음' 이어야 한다.""" + basis = _build_score_basis( + [ + _answer(MessageEvaluation(specificity=None, logic=None, structure="NONE")), + ] + ) + + assert "logic_score ≈ 근거 없음" in basis, basis + assert "≈ 0" not in basis, basis + + +def test_prompt_demands_null_not_low_score(): + """'낮게/null' 처럼 모호하면 LLM 이 매번 다르게 고른다.""" + from ai_server.chain.prompts.followup_generation import SYSTEM_PROMPT + + assert "반드시 null" in SYSTEM_PROMPT + assert "낮게/null" not in SYSTEM_PROMPT + # meta 스키마도 null 을 허용한다고 말해야 한다 — 안 그러면 LLM 이 숫자를 낸다. + assert '"specificity": <0~5 또는 null>' in SYSTEM_PROMPT + + +def test_unscoreable_answer_does_not_drag_communication_via_structure(): + """`_STRUCTURE_SCORE["NONE"] = 0.0` 이라 채점 불가 답변의 구조를 빼지 않으면 + "평가할 내용 없음"이 "구조가 형편없음(0점)"으로 둔갑한다.""" + good = MessageEvaluation( + specificity=4, logic=4, structure="FULL_STAR", correctness=4 + ) + unscoreable = MessageEvaluation( + specificity=None, logic=None, structure="NONE", correctness=None + ) + + basis = _build_score_basis([_answer(good), _answer(unscoreable)]) + + # 좋은 답변 하나만 반영: specificity 4/5, structure 5/5 → 평균 4.5/5 → 90 + assert "communication_score ≈ 90" in basis, basis + + +def test_structure_none_still_penalises_a_real_but_unstructured_answer(): + """구분 기준은 structure 값이 아니라 채점 가능 여부다. + + 길게 답했는데 구조가 없는 것은 **정상 감점 사유**다 — 이것까지 빼면 안 된다. + """ + rambling = MessageEvaluation( + specificity=3, logic=3, structure="NONE", correctness=None + ) + + basis = _build_score_basis([_answer(rambling)]) + + # specificity 3/5 + structure 0/5 → 평균 1.5/5 → 30 + assert "communication_score ≈ 30" in basis, basis + + +def test_basis_tells_the_llm_that_unscoreable_is_not_a_penalty(): + """기준값만 주고 이유를 안 주면 LLM 이 '답변 수가 적네' 로 읽고 깎을 수 있다.""" + basis = _build_score_basis( + [ + _answer(MessageEvaluation(specificity=4, logic=4, structure="FULL_STAR")), + _answer(MessageEvaluation(specificity=None, logic=None, structure="NONE")), + ] + ) + + assert "채점 대상 아님 1건" in basis, basis + assert "감점 사유가 아님" in basis, basis