Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,23 @@ docker run --env-file .env -p 8000:8000 stackup-ai
(직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 `panelBreakdown` 의 `evaluator="직무 적합도"`·
`evaluator="직무 이해도"` 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀.
- **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `<intent>…</intent><question>…</question><meta>{json}</meta>` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `<question>` 토큰만 `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`).
Expand Down
6 changes: 4 additions & 2 deletions ai/src/ai_server/chain/prompts/followup_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
" <intent>NORMAL 또는 DONT_KNOW 또는 CLARIFICATION</intent>\n"
" <question>지원자에게 보여줄 한국어 질문 1개(또는 CLARIFICATION 시 재설명 문장)</question>\n"
' <meta>{{"specificity": <0~5>, "logic": <0~5>, '
' <meta>{{"specificity": <0~5 또는 null>, "logic": <0~5 또는 null>, '
'"structure": "FULL_STAR|PARTIAL_STAR|NONE", "correctness": <0~5 또는 null>}}</meta>\n'
"- <intent> 를 가장 먼저 확정해 출력합니다. DONT_KNOW 여도 <question> 블록은 비우지 말고 한 문장 넣되, "
"표시는 시스템이 결정합니다."
Expand Down
34 changes: 32 additions & 2 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)]",
Expand Down
20 changes: 17 additions & 3 deletions ai/src/ai_server/model/messages/followup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions ai/tests/test_honest_dontknow_scoring.py
Original file line number Diff line number Diff line change
@@ -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"<intent>DONT_KNOW</intent><question>q</question>"
f'<meta>{{"specificity": {j(spec)}, "logic": {j(logic)}, '
f'"structure": "{structure}", "correctness": {j(correctness)}}}</meta>'
)


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
Loading