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"}