diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 6581b5be..de880593 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -400,6 +400,24 @@ 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)**: 패널 평가위원에게 **자기 축 기준값만** 준다 + (`build_axis_score_basis` → `PanelFeedbackGenerator(axis_score_basis=…)`). + 전에는 네 축(technical/logic/communication/**overall**)이 담긴 같은 블록을 모든 위원에게 + 똑같이 넘겼다. 문제가 셋이었다: + - 프롬프트는 "해당 축 기준값이 있으면 ±15점 이내"라고 하는데, 기준값 라벨 + (`logic_score`)과 위원의 축 이름(`논리·인과관계 명확성`)이 **달라서** 위원이 스스로 + 짝을 찾아야 했다. 직군 위원은 축 이름이 "직무 역량·깊이"인데 라벨은 + `technical_accuracy` 라 더 멀다. + - `overall_score` 가 보였다 — **어느 위원도 써서는 안 될 앵커**다. + - 남의 축 숫자는 앵커링으로 새어 들어간다(논리 위원이 technical 80 을 보면 자기 점수를 + 그쪽으로 당긴다). + - 직군 위원은 여러 명이라 key 가 `tech:{직군}` 이다 — `axis_of` 가 이걸 `technical` 로 + 접는다. 하나라도 빠지면 그 위원만 조용히 옛 동작(전체 기준값)으로 떨어진다. + - **축별 기준값이 없으면(per-answer 평가 0건) 기존 공용 문자열로 폴백한다.** 앵커가 + 통째로 사라지면 점수가 캘리브레이션 없이 흔들린다. + - 단일 생성기(`LlmFeedbackGenerator`)는 네 축을 한 번에 내므로 전체 기준값이 필요하다 — + 그래서 선택 인자이고, 소비자가 `inspect.signature` 로 지원 여부를 보고 넘긴다. + - 숫자 계산은 `_score_baselines` 한 곳으로 모았다(전체 문자열과 축별이 달라지면 안 된다). - **정직한 "모르겠습니다" 채점 정정 (2026-09-29)**: 답변 평가 점수를 전부 nullable 로 바꾸고 "null = 채점 대상 아님"을 집계 전 구간에 관철했다. `_mean` 이 None 을 제외하므로 **0 과 null 의 차이가 곧 점수 차이**인데, 세 곳이 어긋나 있었다. diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index f7bf4388..b730fd00 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -839,6 +839,13 @@ def _filter_highlights( return out +def axis_of(spec_key: str) -> str: + """평가위원 key → 기준값 축. 직군 위원은 여러 명이라 key 가 `tech:{직군}` 이다.""" + if spec_key.startswith("tech:"): + return "technical" + return spec_key + + class PanelFeedbackGenerator: """직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환.""" @@ -865,6 +872,9 @@ async def generate( voice_analysis_summary: str = "", score_basis: str = "(없음)", domain_question_counts: dict[str, int] | None = None, + # 평가위원 축별 기준값. 주어지면 **자기 축 것만** 보여준다 — 남의 축 숫자는 + # 앵커링으로 새어 들어가고, overall 은 어느 위원도 써서는 안 될 앵커다. + axis_score_basis: dict[str, str] | None = None, ) -> FeedbackResult: domain_specs = _domain_specs_weighted( job_category, mode, domain_question_counts or {} @@ -890,6 +900,9 @@ async def generate( "persona": s.persona, "dimension_name": s.dimension_name, "dimension_guide": s.dimension_guide, + "score_basis": (axis_score_basis or {}).get( + axis_of(s.key), shared["score_basis"] + ), } ) for s in specs diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index a6e4bc7d..2f73ac6c 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -149,6 +149,7 @@ async def _process( ) transcript = _build_transcript(req.messages) score_basis = _build_score_basis(req.messages) + axis_score_basis = build_axis_score_basis(req.messages) user_id = envelope.context.user_id rag_context = await self._build_rag_context(req, user_id) voice_analysis_summary = _build_voice_analysis_summary( @@ -200,6 +201,7 @@ async def _tracked(coro: Awaitable[T]) -> T: end_reason=req.end_reason, transcript=transcript, score_basis=score_basis, + axis_score_basis=axis_score_basis, rag_context=rag_context, voice_analysis_summary=voice_analysis_summary, domain_question_counts=req.domain_question_counts, @@ -336,6 +338,7 @@ async def _generate_panel( end_reason: str | None, transcript: str, score_basis: str, + axis_score_basis: dict[str, str], rag_context: str, voice_analysis_summary: str, domain_question_counts: dict[str, int] | None, @@ -355,6 +358,13 @@ async def _generate_panel( rag_context=rag_context, voice_analysis_summary=voice_analysis_summary, domain_question_counts=domain_question_counts, + # 패널만 축별 기준값을 쓴다. 단일 생성기는 네 축을 한 번에 내므로 + # 전체 기준값이 필요하다 — 그래서 시그니처에 선택 인자로 둔다. + **( + {"axis_score_basis": axis_score_basis} + if _supports_axis_basis(self._generator) + else {} + ), ) except Exception as exc: # noqa: BLE001 log.warning( @@ -780,6 +790,104 @@ def _to_100(x: float | None) -> int | None: return None if x is None else round(x * 20) +def _supports_axis_basis(generator: object) -> bool: + """생성기가 축별 기준값을 받는가. 단일 생성기(LlmFeedbackGenerator)는 안 받는다 — + 네 축을 한 번에 내므로 전체 기준값이 필요하다.""" + import inspect + + try: + return "axis_score_basis" in inspect.signature(generator.generate).parameters + except (TypeError, ValueError): # 목/부분 적용 등 + return False + + +def _score_baselines(evals: list) -> dict[str, int | None]: + """per-answer 평가(0~5)를 차원별 0~100 기준값으로. 숫자 계산은 여기 한 곳에서만.""" + 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 은 자동 제외 + # 채점 불가 답변의 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) + comm_100 = _to_100(_mean([v for v in (spec, struct) if v is not None])) + parts = [v for v in (tech_100, logic_100, comm_100) if v is not None] + return { + "spec": spec, + "logic_raw": logic, + "struct": struct, + "corr": corr, + "technical": tech_100, + "logic": logic_100, + "communication": comm_100, + "overall": round(sum(parts) / len(parts)) if parts else None, + } + + +# 평가위원 축 → 그 위원에게만 보여줄 기준값 설명. +_AXIS_BASIS_LABEL = { + "technical": ("직무 역량", "per-answer correctness(자료 근거 사실성) 평균"), + "logic": ("논리", "per-answer logic 평균"), + "communication": ( + "전달력", + "per-answer specificity(명료성) + structure(구조화) 평균", + ), +} + + +def build_axis_score_basis(messages: list[FeedbackMessageItem]) -> dict[str, str]: + """평가위원 축별 기준값. **자기 축 것만** 준다. + + 전에는 네 축(technical/logic/communication/overall)의 기준값이 담긴 같은 블록을 + 모든 평가위원에게 똑같이 넘겼다. 문제가 셋이었다: + + - 프롬프트는 "해당 축 기준값이 있으면 ±15점 이내"라고 하는데, 기준값 라벨 + (`logic_score`)과 위원의 축 이름(`논리·인과관계 명확성`)이 **달라서** 위원이 + 스스로 짝을 찾아야 했다. 직군 위원은 축 이름이 "직무 역량·깊이"인데 라벨은 + `technical_accuracy` 라 더 멀다. + - `overall_score` 까지 보였다. **어느 위원도 써서는 안 될 앵커**다. + - 남의 축 숫자는 앵커링으로 새어 들어간다 — 논리 위원이 technical 80 을 보면 + 자기 점수를 그쪽으로 당긴다. + """ + evals = [ + m.evaluation + for m in messages + if m.role == "INTERVIEWEE" and m.evaluation is not None + ] + if not evals: + return {} + + b = _score_baselines(evals) + scoreable = sum(1 for e in evals if _is_scoreable(e)) + unscoreable = len(evals) - scoreable + note = ( + f" · 채점 대상 아님 {unscoreable}건" + " (모름·질문 재설명 요청·확인형 단답 — 감점 사유가 아님)" + if unscoreable + else "" + ) + + out: dict[str, str] = {} + for axis, (ko, source) in _AXIS_BASIS_LABEL.items(): + value = b[axis] + if value is None: + out[axis] = ( + f"- 채점된 답변 수: {scoreable}{note}\n" + f"- {ko} 기준값: 근거 없음({source} 미산정). 전사로 보수적으로 판단." + ) + else: + out[axis] = ( + f"- 채점된 답변 수: {scoreable}{note}\n" + f"- {ko} 기준값 ≈ {value} ({source})\n" + "- 이 값에서 ±15점 이내로 산정하세요. 다른 축의 기준값은 주어지지 않습니다." + ) + return out + + def _build_score_basis(messages: list[FeedbackMessageItem]) -> str: """per-answer 평가(0~5)를 차원별 0~100 기준값으로 결정론적 집계(하이브리드). @@ -794,21 +902,10 @@ def _build_score_basis(messages: list[FeedbackMessageItem]) -> str: if not evals: return "(per-answer 평가 없음 — 전사 내용으로만 산정. 과대평가 금지.)" - 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 은 자동 제외 - # 채점 불가 답변의 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) - comm_src = _mean([v for v in (spec, struct) if v is not None]) - comm_100 = _to_100(comm_src) - overall_src = [v for v in (tech_100, logic_100, comm_100) if v is not None] - overall_100 = round(sum(overall_src) / len(overall_src)) if overall_src else None + b = _score_baselines(evals) + spec, logic, struct, corr = b["spec"], b["logic_raw"], b["struct"], b["corr"] + tech_100, logic_100 = b["technical"], b["logic"] + comm_100, overall_100 = b["communication"], b["overall"] def fmt5(x: float | None) -> str: return f"{x:.1f}/5" if x is not None else "없음" diff --git a/ai/tests/test_axis_score_anchor.py b/ai/tests/test_axis_score_anchor.py new file mode 100644 index 00000000..37c0b6ec --- /dev/null +++ b/ai/tests/test_axis_score_anchor.py @@ -0,0 +1,125 @@ +"""평가위원은 **자기 축 기준값만** 봐야 한다. + +전에는 네 축(technical/logic/communication/overall)이 담긴 같은 블록을 모든 위원에게 +똑같이 넘겼다. 프롬프트는 "해당 축 기준값이 있으면 ±15점 이내"라고 하지만, 기준값 +라벨(`logic_score`)과 위원의 축 이름(`논리·인과관계 명확성`)이 달라 위원이 스스로 짝을 +찾아야 했고, 무엇보다 `overall_score` 라는 **어느 위원도 써서는 안 될 앵커**가 보였다. +""" + +from __future__ import annotations + +import pytest + +from ai_server.chain.feedback_generation_chain import ( + EvaluatorResult, + PanelFeedbackGenerator, + axis_of, +) +from ai_server.messaging.consumers.feedback_consumer import build_axis_score_basis +from ai_server.model.messages.feedback import FeedbackMessageItem, MessageEvaluation + + +def _answers(*evals: MessageEvaluation) -> list[FeedbackMessageItem]: + return [ + FeedbackMessageItem( + id=i, sequence_number=i, role="INTERVIEWEE", content="답변", evaluation=e + ) + for i, e in enumerate(evals, start=1) + ] + + +def test_axis_of_maps_every_domain_evaluator_to_technical(): + # 직군 위원은 여러 명이라 key 가 tech:{직군} 이다. 하나라도 빠지면 그 위원만 + # 전체 기준값으로 떨어져 조용히 옛 동작이 된다. + assert axis_of("tech:BACKEND") == "technical" + assert axis_of("tech:SALES") == "technical" + assert axis_of("technical") == "technical" + assert axis_of("logic") == "logic" + assert axis_of("communication") == "communication" + + +def test_each_axis_sees_only_its_own_number(): + basis = build_axis_score_basis( + _answers( + MessageEvaluation( + specificity=5, logic=1, structure="FULL_STAR", correctness=4 + ) + ) + ) + + # 각 축의 기준값은 서로 다른 값이라 섞이면 바로 드러난다. + assert "직무 역량 기준값 ≈ 80" in basis["technical"] + assert "논리 기준값 ≈ 20" in basis["logic"] + assert "전달력 기준값 ≈ 100" in basis["communication"] + + for axis, text in basis.items(): + # overall 은 어느 위원에게도 보이면 안 된다. + assert "overall" not in text.lower(), f"{axis}: {text}" + # 남의 축 라벨도 보이면 안 된다. + others = {"직무 역량", "논리", "전달력"} - { + {"technical": "직무 역량", "logic": "논리", "communication": "전달력"}[axis] + } + for other in others: + assert f"{other} 기준값" not in text, f"{axis} 가 {other} 를 본다: {text}" + + +def test_axis_basis_is_empty_without_per_answer_evaluations(): + # 평가가 없으면 축별 기준값도 없다 — 패널은 전체 문자열로 폴백한다. + assert build_axis_score_basis([]) == {} + + +class _CapturingChain: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def ainvoke(self, payload): + self.calls.append(payload) + return EvaluatorResult(score=70) + + +@pytest.mark.asyncio +async def test_panel_gives_each_evaluator_its_own_basis(): + chain = _CapturingChain() + gen = PanelFeedbackGenerator(chain) + + await gen.generate( + job_category="BACKEND", + mode="TECHNICAL", + total_question_count=3, + end_reason="x", + transcript="t", + rag_context="(none)", + voice_analysis_summary="", + score_basis="(전체 기준값 — 쓰이면 안 된다)", + axis_score_basis={ + "technical": "TECH-ONLY", + "logic": "LOGIC-ONLY", + "communication": "COMM-ONLY", + }, + ) + + seen = {c["dimension_name"]: c["score_basis"] for c in chain.calls} + assert seen["직무 역량·깊이"] == "TECH-ONLY" + assert seen["논리·인과관계 명확성"] == "LOGIC-ONLY" + assert seen["명료성·구조화·전달력"] == "COMM-ONLY" + + +@pytest.mark.asyncio +async def test_panel_falls_back_to_shared_basis_when_axis_basis_absent(): + """축별 기준값이 없으면(평가 0건) 기존 동작을 유지해야 한다 — 앵커가 통째로 + 사라지면 점수가 캘리브레이션 없이 흔들린다.""" + chain = _CapturingChain() + gen = PanelFeedbackGenerator(chain) + + await gen.generate( + job_category="BACKEND", + mode="TECHNICAL", + total_question_count=3, + end_reason="x", + transcript="t", + rag_context="(none)", + voice_analysis_summary="", + score_basis="SHARED", + ) + + assert {c["score_basis"] for c in chain.calls} == {"SHARED"}