vignette/apps/api/app/session_read_model.py
2026-06-28 21:50:21 +09:00

1379 lines
48 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 Literal, Optional
from pydantic import BaseModel, Field
from . import turn_runtime
from .config import settings
from .services import session_metrics
from .store import InProcSession, TurnRecord
StageLabel = Literal["라포", "탐색", "개입", "정리"]
WorksheetSpeaker = Literal["learner", "client"]
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
_PHASE_KEY_BY_LABEL = {
"라포": "rapport",
"탐색": "explore",
"개입": "intervene",
"정리": "closing",
}
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: str
label: str
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
class SessionReviewResponse(BaseModel):
session_id: str
client: ReviewClient
date: str
durationLabel: str
durationSeconds: int
reachedPhase: str
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) -> str:
return turn_runtime.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: str, 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[str]) -> list[ReviewPhaseSegment]:
counts = Counter(stage_labels)
return [
ReviewPhaseSegment(
key=_PHASE_KEY_BY_LABEL.get(label, 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":
error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or ""))
return (
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. "
+ (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.")
)
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 _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_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")
return payload if isinstance(payload, dict) else {}
_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)
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}".rstrip(" ·") or "의도와 다른 부분",
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] = []
if turn.silence_ms is not None and turn.silence_ms >= 1000:
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
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))
evaluation_record = read_input.evaluation_record
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,
)
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,
)