vignette/apps/api/app/services/evaluator.py
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
2026-06-27 02:30:46 +09:00

767 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""평가 AI(슈퍼바이저/교수 AI) — 학습자 발화 2-loop 평가. 한신대 AI 3종 중 ③.
MASTERPLAN §2.3 (평가 AI 2-tier 루프):
- fast-loop : 턴 직후 가벼운 4차원 태깅(기법/내담자상태 읽기/적절성 신호/의도이탈 여부).
콜드스타트 지연·비용 통제. tier='feedback' Opus 라우팅(게이트웨이가 최종 결정).
- deep-loop : 단계전환/회기말 정밀(기법 분포·잘한 순간·개선점 최대3·슈퍼바이저 rationale/critique).
전체 회기 + 골든라벨 후보 enum 으로 0-5 채점 근거 + 대안 발화.
설계 원칙 (소유권 분리):
- 평가 AI 는 *전부 봐도 된다*(CCD/정답/상태수치 포함). 비노출은 client AI 쪽 책임이고,
학습자에겐 RBAC×AIView 로 차단(deps.AIView.EVALUATOR). 이 모듈은 평가 신호만 산출한다.
- 정답 라벨 enum 은 taxonomy.py 가 단일 원천(SoT). 프롬프트는 TECHNIQUE_KO/CLIENT_STATE_KO 를
후보로 제시하고 *근거(rationale)* 를 요구한다. LLM 출력은 enum 으로 안전 파싱(미지값은 버림).
- intent_deviation('의도와 다른 부분')은 1급 시민 → SupervisorComment(critique) 형식과 정합:
{dimension, expected, actual, severity}.
- engine_client.generate(tier='feedback', structured_schema=...) 로 LLM 평가. 엔진 장애·파싱
실패는 *비치명적* — orchestrator 의 eval_hook 가 None 으로 흡수(상담 루프를 막지 않음).
- 결과는 pydantic 모델로 반환. orchestrator 가 주입형으로 부르는 async 함수
evaluate_turn(...) / evaluate_session(...) 을 export.
⚠️ taxonomy / engine_client / orchestrator / state_machine / persona 는 *읽기 전용* 의존이다.
이 모듈만 평가 로직을 소유한다(라우트는 routes/eval.py).
"""
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, Field
from ..engine_client import (
EngineClient,
EngineError,
EngineMessage,
GenerateRequest,
GenerateResponse,
)
from ..taxonomy import (
CLIENT_STATE_KO,
TECHNIQUE_CATEGORY,
TECHNIQUE_KO,
ClientState,
CommentKind,
Technique,
TechniqueCategory,
)
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
from .orchestrator import TurnContext
# ════════════════════════════════════════════════════════════════════════════
# 0. enum 역인덱스 (LLM 한글/코드 출력 → taxonomy enum 안전 파싱)
# ════════════════════════════════════════════════════════════════════════════
# LLM 은 후보로 한글 라벨을 받지만, 코드값(value)을 돌려줄 수도 있어 둘 다 받는다.
_TECHNIQUE_BY_KO: dict[str, Technique] = {ko: t for t, ko in TECHNIQUE_KO.items()}
_TECHNIQUE_BY_CODE: dict[str, Technique] = {t.value: t for t in Technique}
_CLIENT_STATE_BY_KO: dict[str, ClientState] = {ko: s for s, ko in CLIENT_STATE_KO.items()}
_CLIENT_STATE_BY_CODE: dict[str, ClientState] = {s.value: s for s in ClientState}
# 적절성 신호 — fast-loop 의 경량 판단(상태머신 라포 추정과 별개 차원).
_APPROPRIATENESS = ("pos", "warn", "neutral")
# 의도이탈 심각도 (taxonomy.SupervisorComment.severity 와 동일 어휘).
_SEVERITY = ("minor", "moderate", "major")
def _parse_technique(raw: str) -> Optional[Technique]:
s = (raw or "").strip()
return _TECHNIQUE_BY_KO.get(s) or _TECHNIQUE_BY_CODE.get(s)
def _parse_client_state(raw: str) -> Optional[ClientState]:
s = (raw or "").strip()
return _CLIENT_STATE_BY_KO.get(s) or _CLIENT_STATE_BY_CODE.get(s)
def _coerce_str_list(val: Any) -> list[str]:
if val is None:
return []
if isinstance(val, str):
return [val]
if isinstance(val, (list, tuple)):
return [str(x) for x in val if x is not None]
return []
# ════════════════════════════════════════════════════════════════════════════
# 1. 결과 모델 (pydantic) — orchestrator/route 반환 + intent_deviation 1급 시민
# ════════════════════════════════════════════════════════════════════════════
class IntentDeviation(BaseModel):
"""'의도와 다른 부분'(윤찬 1급 시민). taxonomy.SupervisorComment(critique) intent_deviation 정합.
{dimension, expected, actual, severity} 구조화. dimension 은 평가 차원
(예: 'reflection', 'self_disclosure', 'pacing', 'risk_assessment').
"""
dimension: str = Field(..., description="관련 평가 차원(기법/페이싱/위험사정 등)")
expected: str = Field(..., description="권장된 반응/의도")
actual: str = Field(..., description="실제 나타난 반응")
severity: str = Field("minor", description="minor | moderate | major")
class TechniqueTag(BaseModel):
"""fast-loop 기법 태그 1건 — taxonomy.Technique 코드 + 한글 + 군집 + 근거."""
code: str # taxonomy.Technique.value
label_ko: str # TECHNIQUE_KO
category: str # TechniqueCategory.value (분포 집계축)
rationale: Optional[str] = None # 왜 이 기법으로 봤는지(근거 요구)
class ClientStateRead(BaseModel):
"""내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거)."""
code: str # taxonomy.ClientState.value
label_ko: str # CLIENT_STATE_KO
rationale: Optional[str] = None
class TurnEvaluation(BaseModel):
"""fast-loop 턴 평가 결과(턴 직후 경량 4차원).
4차원:
① technique[] : 학습자(상담자) 발화에 부착된 기법 라벨(복수)
② client_state_read[] : 내담자 응답에서 읽은 상태(복수)
③ appropriateness : 적절성 신호 pos|warn|neutral (경량)
④ intent_deviation : '의도와 다른 부분' 있으면 구조화(없으면 None)
"""
loop: str = "fast"
turn_seq: int
stage: str
techniques: list[TechniqueTag] = Field(default_factory=list)
client_state_read: list[ClientStateRead] = Field(default_factory=list)
appropriateness: str = "neutral" # pos | warn | neutral
appropriateness_note: Optional[str] = None
intent_deviation: Optional[IntentDeviation] = None # 있을 때만(1급 시민)
rapport_signal: Optional[float] = None # 평가 AI 가 본 라포 신호(1~+1, 상태머신 주입 가능)
theory_mode: Optional[str] = None
error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
def to_hook_dict(self) -> dict[str, Any]:
"""orchestrator.EvalHook 가 기대하는 평가 dict(turns.evaluation 적재용)."""
return self.model_dump(exclude_none=True)
class TechniqueDistribution(BaseModel):
"""deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단."""
by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
by_technique: dict[str, int] = Field(default_factory=dict) # technique.value -> count
total: int = 0
overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
class SessionEvaluation(BaseModel):
"""deep-loop 회기말/단계전환 정밀 평가 결과.
기법분포 + 잘한 순간 + 개선점(최대3) + 슈퍼바이저 rationale/critique + 의도이탈 집계.
"""
loop: str = "deep"
session_id: str
stage: str # 평가 시점 단계(전환 트리거면 from-stage)
scope: str = "session_end" # 'session_end' | 'stage_transition'
turns_evaluated: int = 0
distribution: TechniqueDistribution = Field(default_factory=TechniqueDistribution)
strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
intent_deviations: list[IntentDeviation] = Field(default_factory=list)
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
theory_mode: Optional[str] = None
error: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return self.model_dump(exclude_none=True)
# ════════════════════════════════════════════════════════════════════════════
# 2. structured_schema (게이트웨이 Structured Outputs 강제 — CCD/형식 안전)
# ════════════════════════════════════════════════════════════════════════════
def _technique_enum_values() -> list[str]:
return [t.value for t in Technique]
def _client_state_enum_values() -> list[str]:
return [s.value for s in ClientState]
def _fast_schema() -> dict[str, Any]:
"""fast-loop 구조화 출력 스키마. enum 후보를 코드값으로 강제(파싱 안정)."""
return {
"type": "object",
"additionalProperties": False,
"properties": {
"techniques": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string", "enum": _technique_enum_values()},
"rationale": {"type": "string"},
},
"required": ["code"],
},
},
"client_state_read": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string", "enum": _client_state_enum_values()},
"rationale": {"type": "string"},
},
"required": ["code"],
},
},
"appropriateness": {"type": "string", "enum": list(_APPROPRIATENESS)},
"appropriateness_note": {"type": "string"},
"rapport_signal": {"type": "number", "minimum": -1, "maximum": 1},
"intent_deviation": {
"type": ["object", "null"],
"additionalProperties": False,
"properties": {
"dimension": {"type": "string"},
"expected": {"type": "string"},
"actual": {"type": "string"},
"severity": {"type": "string", "enum": list(_SEVERITY)},
},
"required": ["dimension", "expected", "actual", "severity"],
},
},
"required": ["techniques", "client_state_read", "appropriateness"],
}
def _deep_schema() -> dict[str, Any]:
"""deep-loop 구조화 출력 스키마. 분포는 코드로 재집계하므로 LLM 엔 정성 평가만 요구."""
return {
"type": "object",
"additionalProperties": False,
"properties": {
"strengths": {"type": "array", "items": {"type": "string"}},
"improvements": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
"intent_deviations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"dimension": {"type": "string"},
"expected": {"type": "string"},
"actual": {"type": "string"},
"severity": {"type": "string", "enum": list(_SEVERITY)},
},
"required": ["dimension", "expected", "actual", "severity"],
},
},
"supervisor_rationale": {"type": "string"},
"supervisor_critique": {"type": "string"},
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
},
"required": ["strengths", "improvements"],
}
# ════════════════════════════════════════════════════════════════════════════
# 3. 프롬프트 빌더 — 정답 라벨 enum 후보 제시 + 근거 요구
# ════════════════════════════════════════════════════════════════════════════
_EVAL_ROLE = (
"당신은 상담 수련생을 지도하는 슈퍼바이저(평가 AI)다. 학습자(상담자) 발화를 임상적으로 평가한다.\n"
"당신은 내담자의 내부 설정(핵심신념·진단 차원·정답 라벨·상태 수치)을 *전부 볼 수 있다*. "
"이 정보는 평가 근거로만 쓰고, 평가 결과 자체는 학습자에게 직접 노출되지 않는다(시스템이 차단).\n"
"반드시 제시된 후보 라벨(code) 중에서만 고르고, 각 판단에 한국어 근거(rationale)를 붙인다. "
"추측·과잉 라벨링을 피하고, 근거가 약하면 부착하지 않는다."
)
def _technique_candidates_block() -> str:
"""기법 후보(군집별 그룹핑) — code: 한글 형식으로 제시."""
lines: list[str] = ["[기법 후보 — code: 라벨(군집)]"]
# 군집 순서대로 보기 좋게 그룹핑(평가자가 분포를 의식하도록).
by_cat: dict[TechniqueCategory, list[Technique]] = {}
for t, cat in TECHNIQUE_CATEGORY.items():
by_cat.setdefault(cat, []).append(t)
for cat, techs in by_cat.items():
items = ", ".join(f"{t.value}:{TECHNIQUE_KO[t]}" for t in techs)
lines.append(f"- {cat.value}: {items}")
return "\n".join(lines)
def _client_state_candidates_block() -> str:
items = ", ".join(f"{s.value}:{ko}" for s, ko in CLIENT_STATE_KO.items())
return f"[내담자 상태 후보 — code:라벨]\n- {items}"
# ─ few-shot 골든셋 예시(data/golden) — 명시적으로 켠 환경에서만 로딩 ─────────
# 골든셋은 학습/평가 보정 자료이지 운영 런타임의 기본 데이터가 아니다.
_GOLDEN_FEWSHOT_ENABLED = os.environ.get("EVALUATOR_GOLDEN_FEWSHOT_ENABLED", "").lower() in {
"1",
"true",
"yes",
"on",
}
_GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..", "data", "golden"
)
def _load_fewshot_examples(max_n: int = 6) -> list[dict[str, Any]]:
"""골든셋에서 기법 다양성을 커버하는 상담자 발화 few-shot 예시(없으면 빈 리스트)."""
out: list[dict[str, Any]] = []
if not _GOLDEN_FEWSHOT_ENABLED:
return out
seen: set[str] = set()
try:
files = sorted(f for f in os.listdir(_GOLDEN_DIR) if f.endswith(".jsonl"))
except OSError:
return out
for fn in files:
try:
with open(os.path.join(_GOLDEN_DIR, fn), encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("speaker") != "counselor" or not d.get("techniques"):
continue
if set(d["techniques"]) - seen: # 새 기법 커버 예시 우선
out.append(d)
seen.update(d["techniques"])
if len(out) >= max_n:
return out
except (OSError, json.JSONDecodeError):
continue
return out
def _fewshot_block() -> str:
"""발화→기법 few-shot 예시 블록(평가 라벨 일관성 보정). 골든셋 없으면 빈 문자열."""
ex = _load_fewshot_examples()
if not ex:
return ""
lines = ["[few-shot 예시 — 발화 → 기법 code (골든셋 참고, 라벨 일관성 보정용)]"]
for e in ex:
techs = ",".join(e.get("techniques", []))
text = (e.get("text") or "").replace("\n", " ")[:70]
rat = next(
(c.get("text", "") for c in e.get("comments", []) if c.get("kind") == "rationale"),
"",
)
line = f'- "{text}"{techs}'
if rat:
line += f" (근거: {rat[:48]}…)"
lines.append(line)
return "\n".join(lines)
def _theory_mode(ctx: "TurnContext") -> Optional[str]:
"""이론 모드(이론부합 평가용): 학습자 선택(회기 theory_mode) 우선, 없으면 페르소나 theory_target."""
sess_theory = getattr(ctx, "theory_mode", None)
if sess_theory:
return str(sess_theory)
tt = getattr(ctx.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
return None
def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMessage]:
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
recent = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in (ctx.recent_turns or [])[-4:]
) or "(직전 맥락 없음)"
crisis_note = ""
if ctx.crisis is not None and getattr(ctx.crisis, "escalate", False):
crisis_note = (
"\n[안전] 학습자 발화에서 실제 위기 신호가 감지됨 — 위험사정(risk_assessment) "
"적절성과 안전 페이싱을 특히 살펴라."
)
system = "\n\n".join(
p for p in [
_EVAL_ROLE,
_technique_candidates_block(),
_client_state_candidates_block(),
_fewshot_block(), # 골든셋 few-shot(없으면 빈 문자열 → 필터됨)
(
"[평가 4차원]\n"
"① technique: 이번 *상담자(학습자)* 발화에 부착되는 기법(복수 가능, 후보 code 만).\n"
"② client_state_read: 이어진 *내담자* 응답에서 읽히는 상태(복수, 후보 code 만).\n"
"③ appropriateness: 이번 상담자 반응의 적절성 — pos(적절)/warn(주의)/neutral(중립).\n"
"④ intent_deviation: '의도와 다른 부분'이 있으면 {dimension, expected, actual, severity}로. "
"없으면 null. 이 항목은 가장 중요하다 — 무리한 생성 금지, 진짜 이탈만.\n"
"추가로 rapport_signal(1~+1): 이 발화가 라포에 끼친 방향(공감·반영=+, 조언점프·평가=)."
),
] if p
)
user = (
f"[단계] {st.stage.value} [effective_openness] {st.effective_openness:.2f} "
f"[ideation_stage] {st.ideation_stage}"
+ (f" [이론모드] {theory}" if theory else "")
+ crisis_note
+ f"\n\n[직전 맥락]\n{recent}\n\n"
f"[평가 대상 — 상담자(학습자) 발화]\n{ctx.learner_text_masked}\n\n"
f"[이어진 내담자 응답]\n{client_reply}\n\n"
"위 4차원으로 구조화 평가하라. 후보 code 외 라벨 금지, 각 판단에 rationale 첨부."
)
return [
EngineMessage(role="system", content=system, cache=True),
EngineMessage(role="user", content=user, cache=False),
]
def build_deep_messages(
*,
stage: str,
scope: str,
theory_mode: Optional[str],
masked_turns: list[dict[str, str]],
distribution: "TechniqueDistribution",
) -> list[EngineMessage]:
"""deep-loop 평가 프롬프트(전체 회기 + 코드 집계 분포 + 골든라벨 후보)."""
transcript = "\n".join(
f"{t.get('seq', '')}{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in masked_turns
) or "(축어록 없음)"
dist_lines = ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
over = ", ".join(distribution.overused) or "(없음)"
under = ", ".join(distribution.underused) or "(없음)"
system = "\n\n".join(
[
_EVAL_ROLE,
_technique_candidates_block(),
(
"[deep-loop 지시]\n"
"전체 회기를 보고 정밀 평가한다. 다음을 산출하라:\n"
"- strengths: 학습자가 잘한 구체적 순간(근거 포함, 발화 인용 가능).\n"
"- improvements: 개선점 최대 3개(우선순위 순, 실행가능한 코칭).\n"
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개."
),
]
)
user = (
f"[평가 시점] {scope} [단계] {stage}"
+ (f" [이론모드] {theory_mode}" if theory_mode else "")
+ f"\n[기법 군집 분포(코드 집계)] {dist_lines}\n"
f"[과다 군집] {over} [과소/미사용 군집] {under}\n\n"
f"[마스킹 축어록]\n{transcript}\n\n"
"위를 근거로 deep-loop 평가를 구조화 산출하라. improvements 는 최대 3개."
)
return [
EngineMessage(role="system", content=system, cache=True),
EngineMessage(role="user", content=user, cache=False),
]
# ════════════════════════════════════════════════════════════════════════════
# 4. 응답 파싱 — structured 우선, 없으면 text(JSON) 폴백, 실패는 빈 결과
# ════════════════════════════════════════════════════════════════════════════
def _structured_payload(resp: GenerateResponse) -> Optional[dict[str, Any]]:
"""게이트웨이 structured 우선, 없으면 text 에서 JSON 추출(코드펜스/잡텍스트 관용)."""
if resp.structured is not None and isinstance(resp.structured, dict):
return resp.structured
raw = (resp.text or "").strip()
if not raw:
return None
# ```json ... ``` 펜스 제거
if raw.startswith("```"):
raw = raw.split("```", 2)[1] if raw.count("```") >= 2 else raw.strip("`")
if raw.lstrip().lower().startswith("json"):
raw = raw.lstrip()[4:]
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
# 본문 안에 묻힌 첫 객체만 시도
start, end = raw.find("{"), raw.rfind("}")
if 0 <= start < end:
try:
obj = json.loads(raw[start : end + 1])
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
return None
return None
def _parse_intent_deviation(d: Any) -> Optional[IntentDeviation]:
if not isinstance(d, dict):
return None
dim = str(d.get("dimension") or "").strip()
exp = str(d.get("expected") or "").strip()
act = str(d.get("actual") or "").strip()
if not (dim and exp and act):
return None
sev = str(d.get("severity") or "minor").strip()
if sev not in _SEVERITY:
sev = "minor"
return IntentDeviation(dimension=dim, expected=exp, actual=act, severity=sev)
def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
theory: Optional[str]) -> TurnEvaluation:
techniques: list[TechniqueTag] = []
for item in payload.get("techniques") or []:
if not isinstance(item, dict):
continue
t = _parse_technique(str(item.get("code", "")))
if t is None:
continue
techniques.append(
TechniqueTag(
code=t.value,
label_ko=TECHNIQUE_KO[t],
category=TECHNIQUE_CATEGORY[t].value,
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
)
)
states: list[ClientStateRead] = []
for item in payload.get("client_state_read") or []:
if not isinstance(item, dict):
continue
s = _parse_client_state(str(item.get("code", "")))
if s is None:
continue
states.append(
ClientStateRead(
code=s.value,
label_ko=CLIENT_STATE_KO[s],
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
)
)
appro = str(payload.get("appropriateness") or "neutral").strip()
if appro not in _APPROPRIATENESS:
appro = "neutral"
rapport = payload.get("rapport_signal")
rapport_val: Optional[float] = None
if isinstance(rapport, (int, float)):
rapport_val = max(-1.0, min(1.0, float(rapport)))
return TurnEvaluation(
loop="fast",
turn_seq=turn_seq,
stage=stage,
techniques=techniques,
client_state_read=states,
appropriateness=appro,
appropriateness_note=(str(payload.get("appropriateness_note")).strip() or None)
if payload.get("appropriateness_note")
else None,
intent_deviation=_parse_intent_deviation(payload.get("intent_deviation")),
rapport_signal=rapport_val,
theory_mode=theory,
)
# ════════════════════════════════════════════════════════════════════════════
# 5. 분포 집계 (코드 결정론 — LLM 미경유, 무손실)
# ════════════════════════════════════════════════════════════════════════════
def aggregate_distribution(technique_codes: list[str]) -> TechniqueDistribution:
"""부착된 기법 코드 리스트 → 군집/기법 분포 + 과다·과소 진단(결정론).
과다/과소는 회기 전체 5개 군집 균형 기준의 *경량 휴리스틱*이다(정밀 채점은 deep LLM).
"""
by_tech: dict[str, int] = {}
by_cat: dict[str, int] = {}
for code in technique_codes:
t = _TECHNIQUE_BY_CODE.get(code)
if t is None:
continue
by_tech[t.value] = by_tech.get(t.value, 0) + 1
cat = TECHNIQUE_CATEGORY[t].value
by_cat[cat] = by_cat.get(cat, 0) + 1
total = sum(by_cat.values())
all_cats = [c.value for c in TechniqueCategory]
overused: list[str] = []
underused: list[str] = []
if total > 0:
# 균등 기대치 = total / 군집수. 1.6배↑=과다, 미사용=과소.
expected = total / len(all_cats)
for c in all_cats:
cnt = by_cat.get(c, 0)
if cnt == 0:
underused.append(c)
elif cnt >= max(2, expected * 1.6):
overused.append(c)
return TechniqueDistribution(
by_category=by_cat,
by_technique=by_tech,
total=total,
overused=overused,
underused=underused,
)
# ════════════════════════════════════════════════════════════════════════════
# 6. 공개 평가 함수 (orchestrator 주입형) — async
# ════════════════════════════════════════════════════════════════════════════
async def evaluate_turn(
ctx: "TurnContext",
client_reply: str,
*,
engine: EngineClient,
) -> TurnEvaluation:
"""fast-loop 턴 평가 — 턴 직후 경량 4차원 태깅(비치명적).
engine 장애·파싱 실패 시 빈 평가(error 사유 기록)를 반환한다. 절대 raise 하지 않는다
(orchestrator.run_turn_generate 의 eval_hook 가 None 으로 흡수하지만, 여기서 1차 흡수).
"""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
base = TurnEvaluation(loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory)
try:
req = GenerateRequest(
ai_role="evaluator",
messages=build_fast_messages(ctx, client_reply),
structured_schema=_fast_schema(),
max_tokens=900,
temperature=0.2, # 평가는 보수적·재현적으로
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
resp = await engine.generate(req)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
except Exception as e: # 방어 — 어떤 예외도 상담 루프를 막지 않게
base.error = f"eval_error: {e}"
return base
payload = _structured_payload(resp)
if payload is None:
base.error = "no_structured_output"
return base
try:
return _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
except Exception as e: # 파싱 방어
base.error = f"parse_error: {e}"
return base
async def evaluate_session(
*,
session_id: str,
stage: str,
masked_turns: list[dict[str, Any]],
engine: EngineClient,
technique_codes: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
scope: str = "session_end",
) -> SessionEvaluation:
"""deep-loop 정밀 평가 — 단계전환/회기말. 전체 축어록 + 코드 집계 분포 + LLM 정성 평가.
technique_codes: fast-loop 들에서 누적된 부착 기법 코드(없으면 빈 분포).
엔진/파싱 실패는 비치명적(error 기록 + 분포는 코드로 채움).
"""
distribution = aggregate_distribution(technique_codes or [])
counselor_turns = sum(1 for t in masked_turns if t.get("speaker") == "counselor")
base = SessionEvaluation(
loop="deep",
session_id=session_id,
stage=stage,
scope=scope,
turns_evaluated=counselor_turns,
distribution=distribution,
theory_mode=theory_mode,
)
try:
req = GenerateRequest(
ai_role="evaluator",
messages=build_deep_messages(
stage=stage,
scope=scope,
theory_mode=theory_mode,
masked_turns=[{k: v for k, v in t.items()} for t in masked_turns],
distribution=distribution,
),
structured_schema=_deep_schema(),
max_tokens=2048,
temperature=0.3,
session_id=session_id,
metadata={"loop": "deep", "scope": scope, "stage": stage},
)
resp = await engine.generate(req)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
except Exception as e:
base.error = f"eval_error: {e}"
return base
payload = _structured_payload(resp)
if payload is None:
base.error = "no_structured_output"
return base
base.strengths = _coerce_str_list(payload.get("strengths"))
base.improvements = _coerce_str_list(payload.get("improvements"))[:3] # 최대 3
base.alternative_utterances = _coerce_str_list(payload.get("alternative_utterances"))
rationale = payload.get("supervisor_rationale")
critique = payload.get("supervisor_critique")
base.supervisor_rationale = str(rationale).strip() if rationale else None
base.supervisor_critique = str(critique).strip() if critique else None
for d in payload.get("intent_deviations") or []:
dev = _parse_intent_deviation(d)
if dev is not None:
base.intent_deviations.append(dev)
return base
# ════════════════════════════════════════════════════════════════════════════
# 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩)
# ════════════════════════════════════════════════════════════════════════════
def make_eval_hook(engine: EngineClient):
"""orchestrator.EvalHook(Callable[[TurnContext, str], Awaitable[Optional[dict]]]) 호환 클로저.
sessions 라우트가 run_turn_generate(ctx, engine, eval_hook=make_eval_hook(engine_client)) 로
주입한다. 평가 실패는 None 으로(상담 루프 비차단).
"""
async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]:
ev = await evaluate_turn(ctx, client_reply, engine=engine)
d = ev.to_hook_dict()
return d if d else None
return _hook
__all__ = [
"IntentDeviation",
"TechniqueTag",
"ClientStateRead",
"TurnEvaluation",
"TechniqueDistribution",
"SessionEvaluation",
"aggregate_distribution",
"evaluate_turn",
"evaluate_session",
"make_eval_hook",
"build_fast_messages",
"build_deep_messages",
]