세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -111,6 +111,23 @@ def normalize_json_value(value: Any) -> Any:
return value
def export_safe_supervisor_comments(value: Any) -> list[dict[str, Any]]:
comments = normalize_json_value(value or [])
if not isinstance(comments, list):
return []
safe_comments: list[dict[str, Any]] = []
for item in comments:
if not isinstance(item, Mapping):
continue
safe_item: dict[str, Any] = {}
for key in ("kind", "intent_deviation"):
if key in item:
safe_item[key] = json_safe(normalize_json_value(item[key]))
if safe_item:
safe_comments.append(safe_item)
return safe_comments
def _redacted_sample(kind: str, value: str) -> str:
if kind == "email" and "@" in value:
return f"<email:{value.rsplit('@', 1)[1].lower()}>"
@ -180,7 +197,7 @@ def build_dataset_record(
"techniques": json_safe(normalize_json_value(row.get("techniques") or [])),
"client_states": json_safe(normalize_json_value(row.get("client_states") or [])),
"feedback_scores": json_safe(normalize_json_value(row.get("feedback_scores") or [])),
"supervisor_comments": json_safe(normalize_json_value(row.get("supervisor_comments") or [])),
"supervisor_comments": export_safe_supervisor_comments(row.get("supervisor_comments") or []),
"source_refs": {
"session_started_at": json_safe(row.get("session_started_at") or row.get("started_at")),
"export_manifest_id": export_manifest_id,
@ -393,6 +410,13 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
errors.append("kappa must be >= 0.70")
if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75")
selection_criteria = manifest.get("selection_criteria") or {}
if not isinstance(selection_criteria, Mapping) or selection_criteria.get("include_withdrawn") is not False:
errors.append("include_withdrawn must be false")
consent_scope = manifest.get("consent_scope") or {}
allowed_uses = consent_scope.get("allowed_uses") if isinstance(consent_scope, Mapping) else None
if not isinstance(allowed_uses, list) or "recursive_learning_seed" not in allowed_uses:
errors.append("recursive_learning_seed consent scope is required")
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
if not str(approvals.get(key) or "").strip():
errors.append(f"approval missing: {key}")

View file

@ -52,6 +52,7 @@ from ..taxonomy import (
CommentKind,
Technique,
TechniqueCategory,
speaker_ko_label,
)
from . import guardrail
@ -482,7 +483,7 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_pii(client_reply).text_masked
recent = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in (ctx.memory.recent_turns or [])[-4:]
) or "(직전 맥락 없음)"
@ -537,7 +538,7 @@ def build_deep_messages(
) -> list[EngineMessage]:
"""deep-loop 평가 프롬프트(전체 회기 + 코드 집계 분포 + 골든라벨 후보)."""
transcript = "\n".join(
f"{t.get('seq', '')}{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
f"{t.get('seq', '')}{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in masked_turns
) or "(축어록 없음)"
dist_lines = ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
@ -736,11 +737,11 @@ async def evaluate_turn(
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
except EngineError as e:
base.error = f"engine_error: {e}"
except EngineError:
base.error = "engine_error"
return base
except Exception as e: # 방어 — 어떤 예외도 상담 루프를 막지 않게
base.error = f"eval_error: {e}"
except Exception: # 방어 — 어떤 예외도 상담 루프를 막지 않게
base.error = "eval_error"
return base
payload = structured_payload_from_response(resp)
@ -751,8 +752,8 @@ async def evaluate_turn(
result = _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
_evaluator_cache_put(cache_key, result.model_dump())
return result
except Exception as e: # 파싱 방어
base.error = f"parse_error: {e}"
except Exception: # 파싱 방어
base.error = "parse_error"
return base

View file

@ -25,6 +25,7 @@ from ..contracts.engine_gateway import structured_payload_from_response
from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest
from ..paths import repo_root, repo_path
from ..session_read_model import StageLabel, stage_label_or_none
from ..taxonomy import speaker_ko_label
from . import guardrail
if TYPE_CHECKING:
@ -33,6 +34,7 @@ if TYPE_CHECKING:
Tone = Literal["pos", "warn", "neutral"]
CoachStatus = Literal["ready", "degraded"]
CoachCreditEventType = Literal["use", "recharge"]
CoachFocus = Literal[
"rapport",
"exploration",
@ -71,6 +73,35 @@ class LiveCoachSuggestion(BaseModel):
sources: list[LiveCoachSource] = Field(default_factory=list)
safety_note: Optional[str] = None
latency_ms: int = 0
persistence_source: Literal["database", "runtime"] = "database"
quota: Optional["LiveCoachQuota"] = None
credit_events: list["LiveCoachCreditEvent"] = Field(default_factory=list)
class LiveCoachQuota(BaseModel):
"""회기 중 즉시 코칭 사용 가능 횟수."""
remaining: int = Field(ge=0)
max: int = Field(ge=1)
class LiveCoachCreditEvent(BaseModel):
"""코칭 기회 사용/충전 학습 기록."""
event_id: str
session_id: str
turn_seq: int
stage: StageLabel | None = None
event_type: CoachCreditEventType
delta: int
balance: int = Field(ge=0)
reason: str
created_at: str
@field_validator("stage", mode="before")
@classmethod
def _normalize_stage(cls, value: object) -> StageLabel | None:
return stage_label_or_none(value)
class LiveCoachEvent(BaseModel):
@ -313,6 +344,7 @@ def build_rag_index_payloads() -> list[dict[str, Any]]:
"sensitivity": _rag_sensitivity(source, chunk, kb_kind),
"meta": {
"live_coaching_source": True,
"source_title": title,
"source_type": str(chunk.get("source_type") or source.get("source_type") or ""),
"source_version": version_label,
"citation": chunk_citation,
@ -557,14 +589,33 @@ def _grounding_block(grounding: list[LiveCoachGrounding]) -> str:
return "\n".join(lines)
def _mask_prompt_text(value: object) -> str:
return guardrail.mask_pii(str(value or "")).text_masked
def _mask_prompt_value(value: Any) -> Any:
if isinstance(value, str):
return _mask_prompt_text(value)
if isinstance(value, dict):
return {
_mask_prompt_text(key): _mask_prompt_value(child)
for key, child in value.items()
}
if isinstance(value, list):
return [_mask_prompt_value(child) for child in value]
if isinstance(value, tuple):
return [_mask_prompt_value(child) for child in value]
return value
def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list[EngineMessage]:
learner_masked = guardrail.mask_pii(item.learner_text).text_masked
client_masked = guardrail.mask_pii(item.client_reply or "").text_masked
learner_masked = _mask_prompt_text(item.learner_text)
client_masked = _mask_prompt_text(item.client_reply or "")
recent = "\n".join(
f"{'상담자' if t.get('speaker') == 'counselor' else '내담자'}: {t.get('text', '')}"
f"{speaker_ko_label(t.get('speaker'))}: {_mask_prompt_text(t.get('text', ''))}"
for t in item.recent_turns[-6:]
) or "(최근 맥락 없음)"
evaluation = json.dumps(item.evaluation or {}, ensure_ascii=False)[:1200]
evaluation = json.dumps(_mask_prompt_value(item.evaluation or {}), ensure_ascii=False)[:1200]
system = (
"당신은 심리상담 수련생에게 회기 중 즉시 피드백을 주는 라이브 코치다.\n"
"목표는 지금 흐름을 끊지 않고 다음 상담자 발화 하나를 더 낫게 만드는 것이다.\n\n"
@ -669,6 +720,8 @@ __all__ = [
"LiveCoachEvent",
"LiveCoachGrounding",
"LiveCoachInput",
"LiveCoachCreditEvent",
"LiveCoachQuota",
"LiveCoachSource",
"LiveCoachSuggestion",
"clear_local_source_pack_cache",

View file

@ -23,6 +23,7 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Optional
from .state_machine import SessionState
from ..taxonomy import speaker_ko_label
_SESSION_DIGEST_EXCERPT_CHARS = 90
@ -331,7 +332,7 @@ def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
pinned 사실 보존·정답 미포함 지시 포함.
"""
transcript = "\n".join(
f"{('상담자' if t.speaker == 'counselor' else '내담자')}: {t.text}"
f"{speaker_ko_label(t.speaker)}: {t.text}"
for t in job.digest_input.masked_turns
)
threads = "\n".join(f"- {t}" for t in job.digest_input.open_threads) or "(없음)"

View file

@ -50,6 +50,25 @@ EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
def turn_evaluation_error_payload(ctx: "TurnContext", error: BaseException | str) -> dict[str, Any]:
"""Represent a non-fatal fast-loop evaluator failure without hiding it."""
st = ctx.state_after or ctx.state_before
if isinstance(error, BaseException):
# Exception messages can contain raw learner/provider text. Keep review-facing
# evidence to the exception type; detailed trace stays in server logs.
detail = type(error).__name__
else:
detail = str(error).strip() or "unknown turn evaluation error"
masked = guardrail.mask_pii(detail).text_masked.strip() or "unknown turn evaluation error"
return {
"loop": "fast",
"turn_seq": st.turn_seq,
"stage": st.stage.value,
"appropriateness": "neutral",
"error": masked,
}
@dataclass(slots=True)
class TurnContext:
"""한 턴 파이프라인을 관통하는 컨텍스트(가드레일/상태/페르소나 산출 집약)."""
@ -248,8 +267,9 @@ async def run_turn_generate(
if eval_hook is not None:
try:
evaluation = await eval_hook(ctx, reply)
except Exception:
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
except Exception as exc:
# 평가 실패는 상담 루프를 막지 않되, 리뷰/대시보드에서 조용히 사라지지 않게 남긴다.
evaluation = turn_evaluation_error_payload(ctx, exc)
return TurnResult(
turn_seq=st.turn_seq,

View file

@ -77,6 +77,8 @@ def turn_eval(turn: Any) -> dict[str, Any] | None:
def turn_score(ev: dict[str, Any]) -> float | None:
if str(ev.get("error") or "").strip():
return None
raw = str(ev.get("appropriateness") or "").strip().lower()
return _APPROPRIATENESS_SCORE.get(raw)
@ -118,6 +120,8 @@ def turn_techniques(ev: dict[str, Any]) -> list[str]:
def turn_feedback_note(ev: dict[str, Any]) -> str | None:
if str(ev.get("error") or "").strip():
return None
raw = ev.get("appropriateness_note")
if raw is None:
return None

View file

@ -15,16 +15,9 @@ MASTERPLAN §0/§2.2 + MEMORY_KNOWLEDGE_PERSONA_DESIGN §1.1·P2:
from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Optional
# ── 단계 (taxonomy.Stage 와 한글 값 동일, 서비스 내부 결정론 전이용) ────────
class Stage(str, Enum):
RAPPORT = "라포"
EXPLORE = "탐색"
INTERVENE = "개입"
CLOSE = "정리"
from ..taxonomy import Stage # 단계 라벨 단일 정의 = taxonomy.Stage; 이 모듈은 전이 로직만 소유.
# 단계별 기본 개방도(stage_base). 라포는 낮게 시작, 개입에서 가장 깊게 다룸.