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
18 changes: 18 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,24 @@ 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)**: 패널 평가위원에게 **자기 축 기준값만** 준다
(`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 의 차이가 곧 점수 차이**인데, 세 곳이 어긋나 있었다.
Expand Down
13 changes: 13 additions & 0 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 호환."""

Expand All @@ -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 {}
Expand All @@ -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
Expand Down
127 changes: 112 additions & 15 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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 기준값으로 결정론적 집계(하이브리드).

Expand All @@ -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 "없음"
Expand Down
125 changes: 125 additions & 0 deletions ai/tests/test_axis_score_anchor.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading