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