vignette/apps/api/app/session_read_model.py
2026-07-03 19:53:14 +09:00

1477 lines
51 KiB
Python

"""Browser-facing session read models and deterministic builders.
Routes keep ownership of auth, RLS-backed DB reads, and persistence. This module
owns the response DTOs plus pure projection logic so a future Node.js read API
has one contract surface to mirror.
"""
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal, Optional, cast
from pydantic import BaseModel, Field
from .config import settings
from .services import guardrail, session_metrics
from .stage_contract import (
ReviewPhaseKey,
StageLabel,
review_phase_key,
stage_label as _normalize_stage_label,
stage_label_or_none,
)
from .store import InProcSession, TurnRecord
WorksheetSpeaker = Literal["learner", "client"]
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
MISSING_SESSION_EVALUATION_ERROR = (
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
)
class LearnerSessionSummary(BaseModel):
session_id: str
persona_code: str
persona_name: str
session_no: int
status: Literal["active", "ended"]
stage: StageLabel
turn_count: int
learner_turn_count: int
client_turn_count: int
started_at: str
ended_at: str | None = None
review_ready: bool = False
archived: bool = False
archived_at: str | None = None
class LearnerSessionsResponse(BaseModel):
source: str = "runtime"
sessions: list[LearnerSessionSummary] = Field(default_factory=list)
class LearnerDashboardOverview(BaseModel):
total_sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
archived_sessions: int = 0
learner_turns: int = 0
client_turns: int = 0
last_practiced_at: str | None = None
class LearnerDashboardGrowthPoint(BaseModel):
session_id: str
session_no: int
persona_code: str
stage: StageLabel
started_at: str
ended_at: str | None = None
score: float | None = None
rapport: float | None = None
technique_count: int = 0
watch_count: int = 0
class LearnerDashboardGrowth(BaseModel):
first_score: float | None = None
latest_score: float | None = None
score_delta: float | None = None
avg_score: float | None = None
avg_rapport: float | None = None
trend: str = "insufficient"
evaluated_sessions: int = 0
top_techniques: list[str] = Field(default_factory=list)
points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list)
class LearnerDashboardPersonaProgress(BaseModel):
persona_code: str
persona_name: str
sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
latest_at: str | None = None
latest_stage: StageLabel | None = None
latest_score: float | None = None
trend: str = "insufficient"
class LearnerDashboardAchievement(BaseModel):
id: str
label: str
state: Literal["done", "available", "locked"] = "locked"
detail: str
class LearnerDashboardFeedbackItem(BaseModel):
session_id: str
persona_code: str
persona_name: str
session_no: int
stage: StageLabel
turn_seq: int
created_at: str
score: float | None = None
rapport: float | None = None
note: str
techniques: list[str] = Field(default_factory=list)
class LearnerDashboardResponse(BaseModel):
source: str = "runtime"
overview: LearnerDashboardOverview
growth: LearnerDashboardGrowth
persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list)
achievements: list[LearnerDashboardAchievement] = Field(default_factory=list)
recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list)
message: str
class SessionArchiveResponse(BaseModel):
session_id: str
archived: bool
archived_at: str | None = None
source: str = "runtime"
session: LearnerSessionSummary
class SessionDetailTurn(BaseModel):
turn_seq: int
speaker: Literal["learner", "client"]
stage: StageLabel
text: str
created_at: str
class SessionDetailResponse(BaseModel):
session_id: str
case_id: str
persona_code: str
persona_name: str
theory_mode: str
status: Literal["active", "ended"]
stage: StageLabel
effective_openness: float
started_at: str
ended_at: str | None = None
turns: list[SessionDetailTurn] = Field(default_factory=list)
review_ready: bool = False
class ReviewClient(BaseModel):
name: str
initial: str
persona: str
class ReviewTechnique(BaseModel):
kind: str
label: str
class ReviewNonverbalEvent(BaseModel):
kind: Literal["audio", "silence", "pace", "barge_in", "paralinguistic", "prosody", "audio_quality"]
label: str
detail: str
class ReviewNote(BaseModel):
author: str
tone: str
title: str
body: str
quote: Optional[str] = None
class ReviewTurn(BaseModel):
id: str
ts: str
speaker: Literal["learner", "client"]
who: str
text: str
techniques: list[ReviewTechnique] = Field(default_factory=list)
nonverbal: list[ReviewNonverbalEvent] = Field(default_factory=list)
note: Optional[ReviewNote] = None
class ReviewPhaseSegment(BaseModel):
key: ReviewPhaseKey
label: StageLabel
weight: float
class ReviewValencePoint(BaseModel):
t: float
v: float
class ReviewRubricRow(BaseModel):
name: str
cluster: str
ratio: float
quality: Literal["good", "watch"]
freq: str
class ReviewPoint(BaseModel):
title: str
body: str
jumpTo: Optional[str] = None
class ReviewWorksheetEvidence(BaseModel):
turnId: str
speaker: Literal["learner", "client"]
quote: str
class ReviewWorksheetItem(BaseModel):
key: str
label: str
value: Optional[str] = None
evidence: list[ReviewWorksheetEvidence] = Field(default_factory=list)
confidence: Literal["none", "low", "medium"] = "none"
emptyReason: Optional[str] = None
class ReviewWorksheetSection(BaseModel):
key: str
title: str
items: list[ReviewWorksheetItem] = Field(default_factory=list)
class ReviewCaseWorksheet(BaseModel):
status: Literal["empty", "draft_from_transcript", "saved_by_learner"] = "empty"
generatedBy: str = "rule-based transcript extractor"
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
savedAt: Optional[str] = None
class ReviewCaseWorksheetSaveRequest(BaseModel):
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [
(
"exploration_11",
"탐색 11항목",
[
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", ""], "client"),
("emotion", "정서", ["불안", "우울", "", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
("cognition", "생각", ["생각", "느낌", "해야", "", "실패", "의미"], "client"),
("behavior", "행동", ["피하", "", "", "", "", "연락", "공부", ""], "client"),
("body", "신체·수면", ["", "식욕", "", "두통", "심장", "", "피곤"], "client"),
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
("risk", "위험 신호", ["", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
("motivation", "변화동기", ["", "바라", "변화", "해보고", ""], None),
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
],
),
(
"five_domains",
"호소 5영역",
[
("domain_emotion", "정서", ["불안", "우울", "", "슬프", "답답", "외롭"], "client"),
("domain_cognition", "인지", ["생각", "걱정", "실패", "", "의미"], "client"),
("domain_behavior", "행동", ["피하", "연락", "공부", "", ""], "client"),
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
("domain_body", "신체", ["", "식욕", "", "두통", "피곤", ""], "client"),
],
),
(
"cognitive_triad_emotions",
"인지삼제·1/2차 감정",
[
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
("secondary_emotion", "2차 감정", ["", "짜증", "수치", "죄책", "부끄"], "client"),
],
),
(
"protective_barrier_quadrants",
"보호·방해 4사분면",
[
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "", "견뎠"], None),
("internal_barrier", "내적 방해요인", ["", "두려", "불안", "회피", "걱정"], "client"),
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
],
),
(
"biopsychosocial_goals",
"생물·심리·사회 목표",
[
("bio_goal", "생물", ["", "식사", "운동", "", "피곤"], "client"),
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
],
),
]
class SessionTeacherReviewStatus(BaseModel):
status: Literal["pending", "viewed", "closed"] = "pending"
note: str = ""
reviewerId: str | None = None
reviewedAt: str | None = None
updatedAt: str | None = None
worksheetStatus: Literal["pending", "approved", "changes_requested", "rejected"] = "pending"
worksheetNote: str = ""
worksheetReviewedAt: str | None = None
class SessionReviewResponse(BaseModel):
session_id: str
client: ReviewClient
date: str
durationLabel: str
durationSeconds: int
reachedPhase: StageLabel
sessionSignal: str
supervisorState: str
supervisorName: str
summary: str
phases: list[ReviewPhaseSegment] = Field(default_factory=list)
phaseAxis: list[str] = Field(default_factory=list)
valenceAxis: list[str] = Field(default_factory=list)
clientValence: list[ReviewValencePoint] = Field(default_factory=list)
counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list)
turns: list[ReviewTurn] = Field(default_factory=list)
rubric: list[ReviewRubricRow] = Field(default_factory=list)
goodMoments: list[ReviewPoint] = Field(default_factory=list)
growthPoints: list[ReviewPoint] = Field(default_factory=list)
caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet)
nextLine: Optional[str] = None
clientFeedback: Optional[str] = None
audioUrl: Optional[str] = None
pdfExportUrl: Optional[str] = None
degraded: bool = True
reviewReady: bool = False
teacherReview: SessionTeacherReviewStatus | None = None
class SessionShareResponse(BaseModel):
shareUrl: str
title: str
description: str
imageUrl: str
createdAt: str
class SessionShareDeleteResponse(BaseModel):
revoked: bool
@dataclass(frozen=True)
class SessionReviewReadInput:
session: InProcSession
evaluation_record: dict[str, object] | None = None
evaluation_durable: bool = False
saved_worksheet_payload: dict[str, object] | None = None
include_teacher_review: bool = False
teacher_review_record: dict[str, object] | None = None
now_ts: float | None = None
def stage_label(stage: object) -> StageLabel:
return cast(StageLabel, _normalize_stage_label(stage))
def iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
return sess.turns_visible_to(LEARNER_VISIBLE_AI_ROLE)
def learner_summary(
sess: InProcSession,
*,
review_ready: bool = False,
archived: bool = False,
archived_at: str | None = None,
) -> LearnerSessionSummary:
turns = learner_visible_turns(sess)
learner_turns = sum(1 for turn in turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in turns if turn.speaker == "client")
return LearnerSessionSummary(
session_id=sess.session_id,
persona_code=sess.persona_code,
persona_name=sess.persona.display_name,
session_no=sess.session_no,
status="ended" if sess.ended else "active",
stage=stage_label(sess.state.stage),
turn_count=len(turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=iso(sess.created_at) or "",
ended_at=iso(sess.ended_at),
review_ready=review_ready,
archived=archived,
archived_at=archived_at,
)
def dashboard_overview(
sessions: list[InProcSession],
*,
visible_review_ready: dict[str, bool],
archived_sessions: int,
) -> LearnerDashboardOverview:
return LearnerDashboardOverview(
total_sessions=len(sessions),
completed_sessions=sum(1 for sess in sessions if sess.ended),
active_sessions=sum(1 for sess in sessions if not sess.ended),
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
archived_sessions=archived_sessions,
learner_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "counselor"
),
client_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "client"
),
last_practiced_at=session_metrics.iso_datetime(
max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0)
)
if sessions
else None,
)
def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint:
return LearnerDashboardGrowthPoint(
session_id=point.session_id,
session_no=point.session_no,
persona_code=point.persona_code,
stage=stage_label(point.stage),
started_at=point.started_at,
ended_at=point.ended_at,
score=point.score,
rapport=point.rapport,
technique_count=point.technique_count,
watch_count=point.watch_count,
)
def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
if not metrics:
return LearnerDashboardGrowth()
item = metrics[0]
points = [_dashboard_growth_point(point) for point in item.points]
return LearnerDashboardGrowth(
first_score=item.first_score,
latest_score=item.latest_score,
score_delta=item.score_delta,
avg_score=item.avg_score,
avg_rapport=item.avg_rapport,
trend=item.trend,
evaluated_sessions=sum(1 for point in points if point.score is not None),
top_techniques=item.top_techniques,
points=points,
)
def dashboard_persona_progress(
sessions: list[InProcSession],
review_ready: dict[str, bool],
) -> list[LearnerDashboardPersonaProgress]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.persona_code, []).append(sess)
rows: list[LearnerDashboardPersonaProgress] = []
for persona_code, items in grouped.items():
ordered = sorted(items, key=session_metrics.session_activity_time)
latest = ordered[-1]
metrics = session_metrics.build_learner_growth(
ordered,
learner_label=lambda _learner_id: "",
limit=1,
)
growth = metrics[0] if metrics else None
rows.append(
LearnerDashboardPersonaProgress(
persona_code=persona_code,
persona_name=latest.persona.display_name,
sessions=len(ordered),
completed_sessions=sum(1 for sess in ordered if sess.ended),
active_sessions=sum(1 for sess in ordered if not sess.ended),
review_ready_sessions=sum(
1 for sess in ordered if review_ready.get(sess.session_id, False)
),
latest_at=session_metrics.iso_datetime(
session_metrics.session_activity_time(latest)
),
latest_stage=stage_label(latest.state.stage),
latest_score=growth.latest_score if growth else None,
trend=growth.trend if growth else "insufficient",
)
)
return sorted(
rows,
key=lambda row: row.latest_at or "",
reverse=True,
)
def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]:
if done:
return "done"
if available:
return "available"
return "locked"
def dashboard_achievements(
sessions: list[InProcSession],
review_ready: dict[str, bool],
) -> list[LearnerDashboardAchievement]:
completed = sum(1 for sess in sessions if sess.ended)
active = sum(1 for sess in sessions if not sess.ended)
review_count = sum(1 for ready in review_ready.values() if ready)
by_persona = Counter(sess.persona_code for sess in sessions)
max_persona_sessions = max(by_persona.values(), default=0)
persona_coverage = len(by_persona)
return [
LearnerDashboardAchievement(
id="first_session_complete",
label="첫 회기 완료",
state=_achievement_state(completed >= 1, active >= 1),
detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.",
),
LearnerDashboardAchievement(
id="review_ready",
label="리뷰 확인 가능",
state=_achievement_state(review_count >= 1, completed >= 1),
detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.",
),
LearnerDashboardAchievement(
id="persona_repeat",
label="같은 내담자 반복 연습",
state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1),
detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.",
),
LearnerDashboardAchievement(
id="persona_coverage",
label="여러 페르소나 경험",
state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2),
detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.",
),
]
def dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]:
items: list[LearnerDashboardFeedbackItem] = []
for item in session_metrics.recent_feedback_notes(sessions, limit=5):
score = item.get("score")
rapport = item.get("rapport")
items.append(
LearnerDashboardFeedbackItem(
session_id=str(item["session_id"]),
persona_code=str(item["persona_code"]),
persona_name=str(item["persona_name"]),
session_no=int(item["session_no"]),
stage=stage_label(item["stage"]),
turn_seq=int(item["turn_seq"]),
created_at=str(item["created_at"]),
score=float(score) if isinstance(score, (int, float)) else None,
rapport=float(rapport) if isinstance(rapport, (int, float)) else None,
note=str(item["note"]),
techniques=[str(label) for label in item.get("techniques", [])],
)
)
return items
def session_detail(
sess: InProcSession,
*,
review_ready: bool = False,
) -> SessionDetailResponse:
turns = learner_visible_turns(sess)
return SessionDetailResponse(
session_id=sess.session_id,
case_id=sess.case_id,
persona_code=sess.persona_code,
persona_name=sess.persona.display_name,
theory_mode=sess.theory_mode,
status="ended" if sess.ended else "active",
stage=stage_label(sess.state.stage),
effective_openness=round(sess.state.effective_openness, 4),
started_at=iso(sess.created_at) or "",
ended_at=iso(sess.ended_at),
turns=[
SessionDetailTurn(
turn_seq=turn.turn_seq,
speaker="learner" if turn.speaker == "counselor" else "client",
stage=stage_label(turn.stage),
text=turn.text_masked,
created_at=iso(turn.created_at) or "",
)
for turn in turns
],
review_ready=review_ready,
)
def _offset_label(seconds: float) -> str:
whole = max(0, int(round(seconds)))
minutes, sec = divmod(whole, 60)
return f"{minutes}:{sec:02d}"
def _duration_label(seconds: int) -> str:
if seconds < 60:
return f"{seconds}"
minutes, sec = divmod(seconds, 60)
return f"{minutes}{sec}"
def _client_name(raw: str) -> str:
name = raw.split("(", 1)[0].strip()
return name or raw.strip() or "내담자"
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
if not turns:
return (
"아직 실제 발화가 없어 리뷰를 만들 수 없습니다. 회기를 진행한 뒤 종료하면 "
"저장된 축어록을 기준으로 리뷰가 표시됩니다."
)
learner_count = sum(1 for turn in turns if turn.speaker == "learner")
client_count = sum(1 for turn in turns if turn.speaker == "client")
return (
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, "
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
)
def _phase_segments(stage_labels: list[StageLabel]) -> list[ReviewPhaseSegment]:
counts = Counter(stage_labels)
return [
ReviewPhaseSegment(
key=review_phase_key(label),
label=label,
weight=float(count),
)
for label, count in counts.items()
if count > 0
]
def _clamp_ratio(value: float) -> float:
return round(max(0.0, min(1.0, value)), 3)
def _compact_text(text: str) -> str:
return " ".join(text.split())
def _clip_text(text: str, limit: int = 180) -> str:
compact = _compact_text(text)
if len(compact) <= limit:
return compact
return f"{compact[: max(0, limit - 1)].rstrip()}..."
def _point_title(text: str, fallback: str) -> str:
compact = _clip_text(text, 72)
for sep in (".", "", "!", "?", "\n"):
if sep in compact:
first = compact.split(sep, 1)[0].strip()
if first:
return _clip_text(first, 44)
return _clip_text(compact, 44) or fallback
def _ai_review_points(values: object, *, fallback_prefix: str) -> list[ReviewPoint]:
if not isinstance(values, list):
return []
points: list[ReviewPoint] = []
for index, value in enumerate(values, start=1):
body = _compact_text(str(value or ""))
if not body:
continue
points.append(
ReviewPoint(
title=_point_title(body, f"{fallback_prefix} {index}"),
body=body,
jumpTo=None,
)
)
return points[:3]
def _intent_deviation_points(values: object) -> list[ReviewPoint]:
if not isinstance(values, list):
return []
points: list[ReviewPoint] = []
for index, value in enumerate(values, start=1):
if not isinstance(value, dict):
continue
dimension = _compact_text(str(value.get("dimension") or f"의도 이탈 {index}"))
expected = _compact_text(str(value.get("expected") or ""))
actual = _compact_text(str(value.get("actual") or ""))
severity = _compact_text(str(value.get("severity") or "minor"))
body_parts = []
if expected:
body_parts.append(f"기대: {expected}")
if actual:
body_parts.append(f"실제: {actual}")
if severity:
body_parts.append(f"심각도: {severity}")
if body_parts:
points.append(
ReviewPoint(
title=dimension,
body=" · ".join(body_parts),
jumpTo=None,
)
)
return points[:3]
def _rubric_from_evaluation(payload: dict[str, object]) -> list[ReviewRubricRow]:
distribution = payload.get("distribution")
if not isinstance(distribution, dict):
return []
by_category = distribution.get("by_category")
if not isinstance(by_category, dict):
return []
total = int(distribution.get("total") or 0)
if total <= 0:
return []
overused = {str(item) for item in distribution.get("overused") or []}
underused = {str(item) for item in distribution.get("underused") or []}
rows: list[ReviewRubricRow] = []
for category, raw_count in sorted(by_category.items(), key=lambda item: str(item[0])):
try:
count = int(raw_count)
except (TypeError, ValueError):
continue
code = str(category)
watch = code in overused or code in underused
rows.append(
ReviewRubricRow(
name=code.replace("_", " ").title(),
cluster="평가 AI 기법 분포",
ratio=_clamp_ratio(count / max(1, total)),
quality="watch" if watch else "good",
freq=f"{count}/{total} labels",
)
)
return rows
def _review_summary_from_evaluation(
*,
fallback: str,
evaluation_record: dict[str, object] | None,
payload: dict[str, object],
) -> str:
if not evaluation_record:
return fallback
status = str(evaluation_record.get("status") or "")
if status != "ready":
return (
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
"AI 평가 재시도가 필요합니다."
)
rationale = _compact_text(str(payload.get("supervisor_rationale") or ""))
critique = _compact_text(str(payload.get("supervisor_critique") or ""))
evaluated = payload.get("turns_evaluated")
prefix = f"평가 AI가 학습자 발화 {evaluated}개를 deep-loop로 분석했습니다. "
details = " ".join(part for part in [rationale, critique] if part)
return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.")
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(1.0, configured)
def _missing_session_evaluation_record(
sess: InProcSession,
*,
has_visible_turns: bool,
now_ts: float,
) -> dict[str, object] | None:
if not sess.ended or not has_visible_turns or sess.ended_at is None:
return None
stale_after = _session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
if now_ts - sess.ended_at < stale_after:
return None
return {
"status": "error",
"source": "read_model",
"scope": "session_end",
"stage": stage_label(sess.state.stage),
"payload": {"error": MISSING_SESSION_EVALUATION_ERROR},
"error": MISSING_SESSION_EVALUATION_ERROR,
"updated_at": iso(now_ts),
}
def missing_session_evaluation_record(
sess: InProcSession,
*,
has_visible_turns: bool,
now_ts: float,
) -> dict[str, object] | None:
return _missing_session_evaluation_record(
sess,
has_visible_turns=has_visible_turns,
now_ts=now_ts,
)
def _next_line_from_evaluation(payload: dict[str, object]) -> str | None:
alternatives = payload.get("alternative_utterances")
if not isinstance(alternatives, list):
return None
for value in alternatives:
line = _compact_text(str(value or ""))
if line:
return line
return None
def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None:
for turn in reversed(turns):
if turn.speaker == "client":
return _clip_text(turn.text)
return None
def _worksheet_evidence(turn: ReviewTurn) -> ReviewWorksheetEvidence:
return ReviewWorksheetEvidence(
turnId=turn.id,
speaker=turn.speaker,
quote=_clip_text(turn.text, 120),
)
def _worksheet_item(
*,
key: str,
label: str,
turns: list[ReviewTurn],
keywords: list[str],
preferred_speaker: Literal["learner", "client"] | None = None,
fallback_turn: ReviewTurn | None = None,
) -> ReviewWorksheetItem:
lowered_keywords = [keyword.lower() for keyword in keywords if keyword]
candidates = turns
if preferred_speaker:
preferred = [turn for turn in turns if turn.speaker == preferred_speaker]
candidates = preferred + [turn for turn in turns if turn.speaker != preferred_speaker]
for turn in candidates:
text = _compact_text(turn.text)
lower_text = text.lower()
if lowered_keywords and any(keyword in lower_text for keyword in lowered_keywords):
return ReviewWorksheetItem(
key=key,
label=label,
value=_clip_text(text, 140),
evidence=[_worksheet_evidence(turn)],
confidence="medium",
)
if fallback_turn is not None:
return ReviewWorksheetItem(
key=key,
label=label,
value=_clip_text(fallback_turn.text, 140),
evidence=[_worksheet_evidence(fallback_turn)],
confidence="low",
)
return ReviewWorksheetItem(
key=key,
label=label,
value=None,
evidence=[],
confidence="none",
emptyReason="저장된 축어록에서 명시 근거를 찾지 못했습니다.",
)
def _worksheet_section(
key: str,
title: str,
specs: list[WorksheetItemSpec],
turns: list[ReviewTurn],
fallback_client: ReviewTurn | None,
fallback_learner: ReviewTurn | None,
) -> ReviewWorksheetSection:
items: list[ReviewWorksheetItem] = []
for item_key, label, keywords, speaker in specs:
fallback = fallback_client if speaker == "client" else fallback_learner if speaker == "learner" else None
items.append(
_worksheet_item(
key=item_key,
label=label,
turns=turns,
keywords=keywords,
preferred_speaker=speaker,
fallback_turn=fallback if item_key in {"presenting_complaint", "first_goal"} else None,
)
)
return ReviewWorksheetSection(key=key, title=title, items=items)
def case_worksheet_template_item_keys() -> dict[str, set[str]]:
return {
section_key: {item_key for item_key, _, _, _ in item_specs}
for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS
}
def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
if not turns:
return ReviewCaseWorksheet(
status="empty",
sections=[],
limitations=["저장된 축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
)
fallback_client = next((turn for turn in turns if turn.speaker == "client"), None)
fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None)
sections = [
_worksheet_section(
key,
title,
specs,
turns,
fallback_client,
fallback_learner,
)
for key, title, specs in CASE_WORKSHEET_SECTION_SPECS
]
return ReviewCaseWorksheet(
status="draft_from_transcript",
sections=sections,
limitations=[
"저장된 축어록에서 키워드 근거를 추출한 1차 초안입니다.",
"임상팀 루브릭, 교수자 검수, 학습자 수정 입력 전에는 확정 사례개념화로 보지 않습니다.",
],
)
def saved_case_worksheet_from_payload(payload: dict[str, object] | None) -> ReviewCaseWorksheet | None:
if not payload:
return None
try:
worksheet = ReviewCaseWorksheet.model_validate(payload)
except Exception:
return None
return worksheet.model_copy(update={"status": "saved_by_learner"})
def _worksheet_review_status_value(
value: object,
) -> Literal["pending", "approved", "changes_requested", "rejected"]:
raw = str(value or "pending")
if raw in {"approved", "changes_requested", "rejected"}:
return raw # type: ignore[return-value]
return "pending"
def _worksheet_share_highlights(worksheet: ReviewCaseWorksheet, *, limit: int = 4) -> list[dict[str, str]]:
highlights: list[dict[str, str]] = []
for section in worksheet.sections:
for item in section.items:
value = _compact_text(item.value or "")
if not value:
continue
highlights.append(
{
"section": section.title,
"label": item.label,
"value": "비공개 요약 항목",
}
)
if len(highlights) >= limit:
return highlights
return highlights
def _review_point_titles(points: list[ReviewPoint], *, limit: int = 3) -> list[str]:
return [_clip_text(point.title or point.body, 72) for point in points[:limit] if (point.title or point.body)]
def _share_image_url() -> str:
return f"{settings.frontend_base_url.rstrip('/')}/design-elements/clinical-paper-ambient.png"
def session_share_payload(review: SessionReviewResponse) -> dict[str, object]:
title = f"Vignette 회기 리뷰 · {review.client.name} {review.date}"
description = _clip_text(review.summary, 156)
return {
"version": 1,
"title": title,
"description": description,
"summary": _clip_text(review.summary, 420),
"clientName": review.client.name,
"persona": review.client.persona,
"date": review.date,
"durationLabel": review.durationLabel,
"reachedPhase": review.reachedPhase,
"sessionSignal": review.sessionSignal,
"reviewReady": review.reviewReady,
"goodMoments": _review_point_titles(review.goodMoments),
"growthPoints": _review_point_titles(review.growthPoints),
"worksheetHighlights": _worksheet_share_highlights(review.caseWorksheet),
"imageUrl": _share_image_url(),
"appUrl": settings.frontend_base_url.rstrip("/"),
"privacy": "공유 카드에는 회기 원문 축어록과 학습자 식별 정보를 포함하지 않습니다.",
}
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
if not record:
return {}
payload = record.get("payload")
if not isinstance(payload, dict):
return {}
masked = _mask_payload_text_values(payload)
return masked if isinstance(masked, dict) else {}
def _mask_payload_text(value: object) -> str:
return guardrail.mask_pii(str(value or "")).text_masked
def _mask_payload_text_values(value: Any) -> Any:
if isinstance(value, str):
return _mask_payload_text(value)
if isinstance(value, dict):
return {str(key): _mask_payload_text_values(child) for key, child in value.items()}
if isinstance(value, list):
return [_mask_payload_text_values(child) for child in value]
if isinstance(value, tuple):
return [_mask_payload_text_values(child) for child in value]
return value
_TECHNIQUE_KIND_BY_CATEGORY = {
"relational": "empathy",
"exploratory": "explore",
"intervention": "confront",
"stabilizing": "reflect",
"structuring": "closed",
}
def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]:
if not isinstance(ev, dict):
return []
out: list[ReviewTechnique] = []
for tag in ev.get("techniques") or []:
if not isinstance(tag, dict):
continue
label = str(tag.get("label_ko") or tag.get("code") or "").strip()
if not label:
continue
kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore")
out.append(ReviewTechnique(kind=kind, label=label))
return out
def _review_note_body_markdown(text: str) -> str:
body = text.strip()
body = re.sub(
r"`?\beffective[_\s-]?openness\b`?(?!\(유효 개방도\))",
"`effective openness(유효 개방도)`",
body,
flags=re.IGNORECASE,
)
body = re.sub(
r"(?<![A-Za-z0-9_])'([^'\n]{1,40})'(?![A-Za-z0-9_])",
lambda m: f"**“{m.group(1).strip()}”**",
body,
)
if "\n" not in body:
body = re.sub(r"\s+(다만|하지만|참고로)\s+", r"\n\n\1 ", body, count=1)
return body
def _review_quote_excerpt(text: str | None, *, limit: int = 96) -> str | None:
clean = " ".join(str(text or "").split())
if not clean:
return None
sentences = [part.strip() for part in re.split(r"(?<=[.!?。!?])\s+", clean) if part.strip()]
for sentence in sentences:
if "가장 큰 마음" in sentence:
return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..."
for sentence in sentences:
if "?" in sentence:
return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..."
return clean if len(clean) <= limit else f"{clean[: limit - 3].rstrip()}..."
def _review_note_from_turn_eval(
ev: dict[str, object] | None,
learner_text: str | None = None,
) -> Optional[ReviewNote]:
if not isinstance(ev, dict):
return None
quote = _review_quote_excerpt(learner_text)
error_text = str(ev.get("error") or "").strip()
if error_text:
return ReviewNote(
author="평가 AI",
tone="warn",
title="턴 직후 평가 실패",
body=_review_note_body_markdown(
"이 발화의 fast-loop(턴 직후) 평가를 완료하지 못했습니다.\n\n"
"AI 평가 재시도가 필요합니다."
),
quote=quote,
)
dev = ev.get("intent_deviation")
if isinstance(dev, dict):
dimension = str(dev.get("dimension") or "").strip()
expected = str(dev.get("expected") or "").strip()
actual = str(dev.get("actual") or "").strip()
body_parts = (
f"- 권장: {expected}" if expected else "",
f"- 실제: {actual}" if actual else "",
)
body = "\n".join(p for p in body_parts if p)
return ReviewNote(
author="평가 AI",
tone="warn",
title=f"의도와 다른 부분 · {dimension} · 턴 직후".replace(" · · ", " · ").rstrip(" ·"),
body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."),
quote=quote,
)
appropriateness = str(ev.get("appropriateness") or "neutral")
note_text = str(ev.get("appropriateness_note") or "").strip()
if appropriateness == "pos":
return ReviewNote(
author="평가 AI",
tone="good",
title="적절한 개입 · 턴 직후",
body=_review_note_body_markdown(
note_text
or (
"타당화·공감·탐색이 회기 흐름에 맞았습니다.\n\n"
"`effective openness(유효 개방도)`가 낮은 내담자라면 다음 질문은 "
"더 작고 구체적인 선택지로 낮춰도 좋습니다."
)
),
quote=quote,
)
if appropriateness == "warn" and note_text:
return ReviewNote(
author="평가 AI",
tone="warn",
title="점검해볼 지점 · 턴 직후",
body=_review_note_body_markdown(note_text),
quote=quote,
)
return None
def _seconds_label(milliseconds: int) -> str:
seconds = max(0, milliseconds) / 1000.0
if seconds >= 10:
return f"{seconds:.0f}"
return f"{seconds:.1f}"
_PROVIDER_REVIEW_EVENT_LABELS: dict[str, tuple[str, str, str]] = {
"sigh": ("paralinguistic", "음성 단서", "한숨 감지"),
"cry": ("paralinguistic", "음성 단서", "울음 감지"),
"laugh": ("paralinguistic", "음성 단서", "웃음 감지"),
"breath": ("paralinguistic", "음성 단서", "호흡 변화"),
"pitch": ("prosody", "운율", "피치 변화"),
"intonation": ("prosody", "운율", "억양 변화"),
"prosody": ("prosody", "운율", "운율 변화"),
"background_noise": ("audio_quality", "오디오 품질", "배경 소음"),
}
def _provider_confidence_label(event: dict[str, object]) -> str:
raw = event.get("confidence", event.get("score"))
if not isinstance(raw, (int, float)):
return ""
value = float(raw)
if 0 <= value <= 1:
return f"신뢰도 {value * 100:.0f}%"
if 1 < value <= 100:
return f"신뢰도 {value:.0f}%"
return ""
def _provider_duration_label(event: dict[str, object]) -> str:
raw = event.get("duration_ms")
if not isinstance(raw, (int, float)):
return ""
milliseconds = int(raw)
if milliseconds <= 0:
return ""
return _seconds_label(milliseconds)
def _review_provider_event(event: dict[str, object]) -> ReviewNonverbalEvent | None:
event_type = str(event.get("event_type") or "").strip()
if not event_type:
return None
if event_type == "barge_in":
return ReviewNonverbalEvent(kind="barge_in", label="끼어듦", detail="provider 감지")
if event_type == "silence":
detail = _provider_duration_label(event) or "provider 감지"
return ReviewNonverbalEvent(kind="silence", label="침묵", detail=detail)
if event_type == "speech_rate":
return ReviewNonverbalEvent(kind="pace", label="발화 속도", detail="provider 감지")
mapped = _PROVIDER_REVIEW_EVENT_LABELS.get(event_type)
if mapped is None:
return None
kind, label, detail = mapped
extras = [item for item in (_provider_confidence_label(event), _provider_duration_label(event)) if item]
if extras:
detail = f"{detail} · {' · '.join(extras)}"
return ReviewNonverbalEvent(kind=kind, label=label, detail=detail)
def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
events: list[ReviewNonverbalEvent] = []
has_turn_level_silence = turn.silence_ms is not None and turn.silence_ms >= 1000
if has_turn_level_silence:
events.append(
ReviewNonverbalEvent(
kind="silence",
label="침묵",
detail=_seconds_label(turn.silence_ms),
)
)
if turn.speech_rate is not None:
events.append(
ReviewNonverbalEvent(
kind="pace",
label="발화 속도",
detail=f"분당 {turn.speech_rate:.0f}",
)
)
if turn.barge_in is True:
events.append(
ReviewNonverbalEvent(
kind="barge_in",
label="끼어듦",
detail="내담자 발화 중 시작",
)
)
if turn.audio_ref:
events.append(
ReviewNonverbalEvent(
kind="audio",
label="음성 발화",
detail="오디오 메타 저장됨",
)
)
for event in turn.provider_events:
if not isinstance(event, dict):
continue
if has_turn_level_silence and str(event.get("event_type") or "").strip() == "silence":
continue
review_event = _review_provider_event(event)
if review_event is not None:
events.append(review_event)
return events
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
sess = read_input.session
visible_turns = learner_visible_turns(sess)
hidden_turns = len(visible_turns) != len(sess.turns)
end_ts = sess.ended_at or read_input.now_ts or datetime.now().timestamp()
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
client_name = _client_name(sess.persona.display_name)
client_initial = client_name[:1] or ""
reached_phase = stage_label(sess.state.stage)
stage_labels = [stage_label(turn.stage) for turn in visible_turns] or [reached_phase]
axis = ["0:00"]
if duration_seconds > 0:
axis.append(_offset_label(duration_seconds))
now_ts = read_input.now_ts or datetime.now().timestamp()
evaluation_record = read_input.evaluation_record
if evaluation_record is None and not hidden_turns:
evaluation_record = _missing_session_evaluation_record(
sess,
has_visible_turns=bool(visible_turns),
now_ts=now_ts,
)
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
evaluation_status = (
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""
)
evaluation_ready = not hidden_turns and evaluation_status == "ready"
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
turns: list[ReviewTurn] = []
for index, turn in enumerate(visible_turns):
speaker: Literal["learner", "client"] = (
"learner" if turn.speaker == "counselor" else "client"
)
turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None
turns.append(
ReviewTurn(
id=f"t{index + 1}",
ts=_offset_label(turn.created_at - first_turn_ts),
speaker=speaker,
who="학습자" if speaker == "learner" else client_name,
text=turn.text_masked,
techniques=_review_techniques_from_turn_eval(turn_eval),
nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [],
note=_review_note_from_turn_eval(turn_eval, turn.text_masked),
)
)
if not turns:
session_signal = "기록 없음"
elif sess.ended:
session_signal = "종료됨"
else:
session_signal = "진행 중"
transcript_summary = _review_summary(
client_name=client_name,
reached_phase=reached_phase,
turns=turns,
)
rubric: list[ReviewRubricRow] = []
good_moments: list[ReviewPoint] = []
growth_points: list[ReviewPoint] = []
next_line: str | None = None
if evaluation_ready:
rubric = _rubric_from_evaluation(evaluation_payload)
good_moments = _ai_review_points(
evaluation_payload.get("strengths"),
fallback_prefix="강점",
)
growth_points = _ai_review_points(
evaluation_payload.get("improvements"),
fallback_prefix="개선점",
)
if not growth_points:
growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations"))
next_line = _next_line_from_evaluation(evaluation_payload)
client_feedback = _latest_client_feedback(turns)
review_degraded = bool(turns) and not evaluation_ready
if evaluation_ready:
supervisor_state = "평가 완료"
elif evaluation_status == "error":
supervisor_state = "평가 실패"
elif turns:
supervisor_state = "평가 대기"
else:
supervisor_state = "기록 대기"
summary = _review_summary_from_evaluation(
fallback=transcript_summary,
evaluation_record=None if hidden_turns else evaluation_record,
payload=evaluation_payload,
)
if evaluation_record and not hidden_turns and not read_input.evaluation_durable:
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
generated_worksheet = case_worksheet_from_turns(turns)
case_worksheet = (
saved_case_worksheet_from_payload(read_input.saved_worksheet_payload)
or generated_worksheet
)
teacher_review: SessionTeacherReviewStatus | None = None
if read_input.include_teacher_review:
review_status = read_input.teacher_review_record or {}
review_status_value = str(review_status.get("status") or "pending")
if review_status_value not in {"viewed", "closed"}:
review_status_value = "pending"
teacher_review = SessionTeacherReviewStatus(
status=review_status_value, # type: ignore[arg-type]
note=str(review_status.get("note") or ""),
reviewerId=str(review_status.get("reviewer_id") or "") or None,
reviewedAt=str(review_status.get("reviewed_at") or "") or None,
updatedAt=str(review_status.get("updated_at") or "") or None,
worksheetStatus=_worksheet_review_status_value(review_status.get("worksheet_status")),
worksheetNote=str(review_status.get("worksheet_note") or ""),
worksheetReviewedAt=str(review_status.get("worksheet_reviewed_at") or "") or None,
)
return SessionReviewResponse(
session_id=sess.session_id,
client=ReviewClient(
name=client_name,
initial=client_initial,
persona=f"{sess.persona_code} · {sess.persona.difficulty}",
),
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
durationLabel=_duration_label(duration_seconds),
durationSeconds=duration_seconds,
reachedPhase=reached_phase,
sessionSignal=session_signal,
supervisorState=supervisor_state,
supervisorName="AI",
summary=summary,
phases=_phase_segments(stage_labels),
phaseAxis=axis,
valenceAxis=axis,
clientValence=[],
counselorBaseline=[],
turns=turns,
rubric=rubric,
goodMoments=good_moments,
growthPoints=growth_points,
caseWorksheet=case_worksheet,
nextLine=next_line,
clientFeedback=client_feedback,
audioUrl=None,
pdfExportUrl=None,
degraded=review_degraded,
reviewReady=evaluation_ready,
teacherReview=teacher_review,
)