vignette/apps/api/app/session_read_model.py
Yun Chan a0311c5957
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
2026-09-08 23:28:06 +09:00

2214 lines
78 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.

"""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, timedelta, timezone
from typing import Any, Callable, Literal, Optional, cast
from pydantic import BaseModel, Field
from .config import settings
from .services import guardrail, session_metrics, state_machine
from .stage_contract import (
ReviewPhaseKey,
StageLabel,
review_phase_key,
stage_label as _normalize_stage_label,
stage_label_or_none as stage_label_or_none,
)
from .store import InProcSession, TurnRecord
from .session_evaluation_timeout import session_evaluation_stale_after_seconds
WorksheetSpeaker = Literal["learner", "client"]
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
MISSING_SESSION_EVALUATION_ERROR = (
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
)
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
TRAINING_EXPOSURE_MIN_COMPLETED = 4
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
FIRST_SESSION_CHECKLIST_VERSION = "first-session-rapport-open-question.v1"
class LearnerSessionSummary(BaseModel):
session_id: str
# 같은 내담자도 새 사례에서 다시 S1이 될 수 있으므로, 이력 행의 연속체 경계를 함께 준다.
case_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
learner_feedback_enabled: bool = True
archived: bool = False
archived_at: str | None = None
class LearnerSessionsResponse(BaseModel):
source: str = "runtime"
sessions: list[LearnerSessionSummary] = Field(default_factory=list)
class CaseProgressStats(BaseModel):
"""한 사례 안에서만 누적한 learner-visible 진행 수치."""
total_sessions: int = 0
completed_sessions: int = 0
total_turns: int = 0
total_duration_seconds: int = 0
active_session_id: str | None = None
active_session_no: int | None = None
active_started_at: str | None = None
last_activity_at: str | None = None
class LearnerCaseSummary(BaseModel):
case_id: str
persona_code: str
persona_name: str
last_session_no: int = 0
progress: CaseProgressStats = Field(default_factory=CaseProgressStats)
class LearnerCaseListResponse(BaseModel):
# case별 누적 수치는 DB 전체 집계여야 한다. runtime cache 추정값은 반환하지 않는다.
source: Literal["database"] = "database"
cases: list[LearnerCaseSummary] = Field(default_factory=list)
class CaseMemoryPreview(BaseModel):
"""접힌 learner UI에만 쓰는 최소·마스킹된 사례 기억 투영."""
case_id: str
memory_available: bool = False
case_digest: str | None = None
latest_session_digest: str | None = None
open_threads: list[str] = Field(default_factory=list)
pinned_facts: list[str] = 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"
# P2 누적 게이지: 이 페르소나와 쌓아온 라포 누적(전 주기 임계 기준 %).
rapport_percent: int = 0
class LearnerDashboardTrainingExposure(BaseModel):
version: str = TRAINING_EXPOSURE_VERSION
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
completed_sessions: int = 0
minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED
attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD
dominant_persona_code: str | None = None
dominant_persona_name: str | None = None
dominant_sessions: int = 0
dominant_share: float | None = None
definition: str = (
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, "
"공정성 평가나 임상진단이 아닙니다."
)
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)
training_exposure: LearnerDashboardTrainingExposure = Field(
default_factory=LearnerDashboardTrainingExposure
)
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 SessionStageProgress(BaseModel):
"""단계별 누적 게이지(P2). 상태머신 결정론 수치의 파생값만 담는다."""
stage: StageLabel
percent: int = 0
achieved: bool = False
is_goal: bool = False
class SessionProgress(BaseModel):
"""회기 진행 상세(P2). 내부 원값 명칭 대신 학습자-안전 파생 %만 노출한다."""
stages: list[SessionStageProgress] = Field(default_factory=list)
rapport_percent: int = 0
rapport_delta_percent: int = 0
resistance_percent: int = 0
openness_percent: int = 0
# 전 주기(라포→정리) 기준 라포 만점: 마지막 전이 임계(개입→정리)를 100으로 본다.
_FULL_CYCLE_RAPPORT = max(state_machine.STAGE_ADVANCE_RAPPORT.values())
def _rapport_percent(credit: float) -> int:
if _FULL_CYCLE_RAPPORT <= 0:
return 0
return max(0, min(100, int(round(float(credit or 0.0) / _FULL_CYCLE_RAPPORT * 100))))
def build_session_progress(
state: state_machine.SessionState,
*,
prev_rapport_credit: float = 0.0,
goal_stages: list[str] | None = None,
) -> SessionProgress:
"""상태머신 수치 → 단계 누적 게이지/상세 수치 파생(순수함수, LLM 미경유).
누적성: rapport_credit 은 회기 간 ×0.7 이월되므로 다음 회기 게이지는 이월분에서 시작한다.
"""
goals = {stage_label(goal) for goal in (goal_stages or [])}
order = state_machine.STAGE_ORDER
cur_idx = order.index(state.stage)
stages: list[SessionStageProgress] = []
for i, st in enumerate(order):
label = stage_label(st)
if i < cur_idx:
pct, achieved = 100, True
elif i > cur_idx:
pct, achieved = 0, False
elif st is state_machine.Stage.CLOSE:
pct, achieved = 100, True
else:
need = state_machine.STAGE_ADVANCE_RAPPORT.get(st, 1.0)
ratio = min(1.0, float(state.rapport_credit or 0.0) / need) if need > 0 else 0.0
pct, achieved = min(99, int(round(ratio * 99))), False
stages.append(
SessionStageProgress(stage=label, percent=pct, achieved=achieved, is_goal=label in goals)
)
rapport_pct = _rapport_percent(state.rapport_credit)
prev_pct = _rapport_percent(prev_rapport_credit)
return SessionProgress(
stages=stages,
rapport_percent=rapport_pct,
rapport_delta_percent=max(0, rapport_pct - prev_pct),
resistance_percent=max(0, min(100, int(round(float(state.resistance or 0.0) * 100)))),
openness_percent=max(0, min(100, int(round(float(state.effective_openness or 0.0) * 100)))),
)
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_id: str | None = None
persona_version: int | None = None
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
learner_feedback_enabled: bool = True
# 시간 기반 회기(2026-07-13 회의 P1): 새로고침 복원 시 타이머·목표 표시의 기준.
goal_stages: list[StageLabel] = Field(default_factory=list)
duration_limit_seconds: int = 0
warning_before_end_seconds: int = 0
# P2 단계 누적 게이지·상세 수치.
progress: SessionProgress | None = None
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
# `id`는 화면 순서용 t1/t2 anchor다. 측정·관계 원장의 evidence FK에는
# 실제 app.turns UUID를 사용해야 하므로 두 식별자를 섞지 않고 함께 노출한다.
turn_id: str | None = None
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 ReviewFirstSessionChecklistEvidence(BaseModel):
turnId: str
turnSeq: int
quote: str
class ReviewFirstSessionChecklistCriterion(BaseModel):
criterionId: str
label: str
description: str
status: Literal["met", "not_observed"]
evidenceTurns: list[ReviewFirstSessionChecklistEvidence] = Field(default_factory=list)
class ReviewFirstSessionChecklist(BaseModel):
version: str = FIRST_SESSION_CHECKLIST_VERSION
applicable: bool = True
status: Literal["ready", "not_applicable"] = "ready"
title: str = "첫 회기 라포·개방질문 체크리스트"
note: str = (
"축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다."
)
criteria: list[ReviewFirstSessionChecklistCriterion] = Field(default_factory=list)
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 ReviewEvaluationFailure(BaseModel):
"""교수자용 deep-loop 실패 분류. 원문 예외·축어록은 절대 응답에 넣지 않는다."""
code: Literal[
"timeout",
"engine_unavailable",
"legacy_argv_limit",
"prompt_too_large",
"invalid_structured_output",
"missing_evaluation",
"unknown",
]
retryable: bool
class SessionReviewResponse(BaseModel):
session_id: str
sessionNo: int = Field(ge=1)
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)
firstSessionChecklist: ReviewFirstSessionChecklist | None = None
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
evaluationFailure: ReviewEvaluationFailure | None = None
learnerFeedbackEnabled: bool = True
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
learner_feedback_enabled: bool = True
expose_learner_feedback: bool = True
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, tz=timezone.utc).isoformat(timespec="seconds")
def session_calendar_date(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=KOREA_STANDARD_TIME).strftime("%Y-%m-%d")
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,
learner_feedback_enabled: bool | None = None,
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,
case_id=sess.case_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 if learner_turns > 0 else False,
learner_feedback_enabled=(
sess.learner_feedback_enabled
if learner_feedback_enabled is None
else learner_feedback_enabled
),
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 and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
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],
learner_feedback_enabled: dict[str, bool] | None = None,
) -> 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]
metric_sessions = (
ordered
if learner_feedback_enabled is None
else [
sess
for sess in ordered
if learner_feedback_enabled.get(sess.session_id, True)
]
)
metrics = session_metrics.build_learner_growth(
metric_sessions,
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 and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
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",
rapport_percent=_rapport_percent(latest.state.rapport_credit),
)
)
return sorted(
rows,
key=lambda row: row.latest_at or "",
reverse=True,
)
def dashboard_training_exposure(
sessions: list[InProcSession],
) -> LearnerDashboardTrainingExposure:
"""Compute a transparent practice-exposure signal from ended sessions only."""
completed = [
sess
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
]
counts = Counter(sess.persona_code for sess in completed)
if not counts:
return LearnerDashboardTrainingExposure()
dominant_code, dominant_count = sorted(
counts.items(),
key=lambda item: (-item[1], item[0]),
)[0]
dominant_session = next(
sess for sess in completed if sess.persona_code == dominant_code
)
total = len(completed)
share = dominant_count / total
if total < TRAINING_EXPOSURE_MIN_COMPLETED:
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD:
status = "attention"
label = "훈련 집중 주의"
else:
status = "balanced"
label = "균형"
return LearnerDashboardTrainingExposure(
status=status,
label=label,
completed_sessions=total,
dominant_persona_code=dominant_code,
dominant_persona_name=dominant_session.persona.display_name,
dominant_sessions=dominant_count,
dominant_share=round(share, 4),
)
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,
learner_feedback_enabled: bool | None = None,
) -> SessionDetailResponse:
turns = learner_visible_turns(sess)
return SessionDetailResponse(
session_id=sess.session_id,
case_id=sess.case_id,
persona_id=sess.persona_id,
persona_version=sess.persona_version,
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,
learner_feedback_enabled=(
sess.learner_feedback_enabled
if learner_feedback_enabled is None
else learner_feedback_enabled
),
goal_stages=[stage_label(goal) for goal in (sess.goal_stages or [])],
duration_limit_seconds=max(0, settings.session_duration_minutes) * 60,
warning_before_end_seconds=max(0, settings.session_warning_minutes) * 60,
progress=build_session_progress(
sess.state,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
),
)
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 "내담자"
_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"}
def _persona_label(code: str, difficulty: str) -> str:
return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}"
def _josa_wa_gwa(name: str) -> str:
"""이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다."""
tail = name[-1] if name else ""
if "" <= tail <= "":
has_final = (ord(tail) - 0xAC00) % 28 != 0
if has_final:
return ""
return ""
return "와(과)"
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}{_josa_wa_gwa(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()}..."
_FIRST_SESSION_RAPPORT_CUES = (
"마음",
"느껴",
"느낌",
"힘들",
"이해",
"그랬군",
"그랬구나",
"천천히",
"버티",
)
_FIRST_SESSION_OPEN_QUESTION_WORDS = ("어떤", "어떻게", "무엇", "언제", "어디", "어느")
_FIRST_SESSION_CLOSED_QUESTION_CUES = ("맞죠", "그렇죠", "아니에요?", "했나요?", "인가요?")
def _first_session_evidence(
turns: list[ReviewTurn],
predicate: Callable[[str], bool],
) -> list[ReviewFirstSessionChecklistEvidence]:
evidence: list[ReviewFirstSessionChecklistEvidence] = []
for index, turn in enumerate(turns, start=1):
if turn.speaker != "learner" or not predicate(turn.text):
continue
evidence.append(
ReviewFirstSessionChecklistEvidence(
turnId=turn.id,
turnSeq=index,
quote=_clip_text(turn.text, 120),
)
)
if len(evidence) >= 3:
break
return evidence
def first_session_checklist(
*,
session_no: int,
turns: list[ReviewTurn],
) -> ReviewFirstSessionChecklist:
"""Build a non-clinical, evidence-linked first-session behavior checklist."""
if session_no != 1:
return ReviewFirstSessionChecklist(
applicable=False,
status="not_applicable",
criteria=[],
)
rapport_evidence = _first_session_evidence(
turns,
lambda text: any(cue in text for cue in _FIRST_SESSION_RAPPORT_CUES),
)
open_question_evidence = _first_session_evidence(
turns,
lambda text: (
text.count("?") == 1
and any(word in text for word in _FIRST_SESSION_OPEN_QUESTION_WORDS)
and not any(cue in text for cue in _FIRST_SESSION_CLOSED_QUESTION_CUES)
),
)
return ReviewFirstSessionChecklist(
criteria=[
ReviewFirstSessionChecklistCriterion(
criterionId="first-session.rapport-reflection",
label="라포를 위한 정서·경험 반영",
description="내담자의 정서나 경험을 평가·조언보다 먼저 반영한 발화를 찾습니다.",
status="met" if rapport_evidence else "not_observed",
evidenceTurns=rapport_evidence,
),
ReviewFirstSessionChecklistCriterion(
criterionId="first-session.open-question-one-focus",
label="한 초점의 개방형 질문",
description="한 번에 한 초점으로 탐색을 넓히는 개방형 질문을 찾습니다.",
status="met" if open_question_evidence else "not_observed",
evidenceTurns=open_question_evidence,
),
]
)
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":
failure = teacher_evaluation_failure(evaluation_record)
if failure is not None and not failure.retryable:
return (
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
"평가 입력 경로를 조정한 뒤 다시 생성해야 합니다."
)
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 teacher_evaluation_failure(
evaluation_record: dict[str, object] | None,
) -> ReviewEvaluationFailure | None:
"""오류 원문을 노출하지 않고 교수자가 취할 다음 행동만 분류한다."""
if not evaluation_record or str(evaluation_record.get("status") or "") != "error":
return None
raw_error = str(evaluation_record.get("error") or "").strip().lower()
if raw_error == MISSING_SESSION_EVALUATION_ERROR.lower() or "저장되지 않았습니다" in raw_error:
return ReviewEvaluationFailure(code="missing_evaluation", retryable=True)
if (
("24,000" in raw_error or "명령줄 안전 한도" in raw_error)
and "agy" in raw_error
):
# 2026-08 이전 Windows Agy argv 경로의 durable 오류다. 현재 stdin 경로로는
# 재평가할 수 있으므로, 같은 오류처럼 보이더라도 복구 재시도를 열어 둔다.
return ReviewEvaluationFailure(code="legacy_argv_limit", retryable=True)
if "argv" in raw_error or "24000" in raw_error or "prompt too large" in raw_error:
return ReviewEvaluationFailure(code="prompt_too_large", retryable=False)
if "timeout" in raw_error or "timed out" in raw_error:
return ReviewEvaluationFailure(code="timeout", retryable=True)
if "no_structured_output" in raw_error or "parse_error" in raw_error:
return ReviewEvaluationFailure(code="invalid_structured_output", retryable=True)
if (
"engine_error" in raw_error
or "engine unavailable" in raw_error
or "transport error" in raw_error
):
return ReviewEvaluationFailure(code="engine_unavailable", retryable=True)
return ReviewEvaluationFailure(code="unknown", retryable=True)
def teacher_evaluation_failure_message(
failure: ReviewEvaluationFailure | None,
) -> str | None:
"""교수자 목록 API에서 provider 원문 대신 사용할 최소 행동 안내."""
if failure is None:
return None
if failure.code == "timeout":
return "AI 평가가 제한 시간 안에 끝나지 않았습니다. 다시 시도할 수 있습니다."
if failure.code == "engine_unavailable":
return "평가 엔진에 일시적으로 연결하지 못했습니다. 다시 시도할 수 있습니다."
if failure.code == "legacy_argv_limit":
return "이전 Windows 입력 한도에 걸린 평가입니다. 현재 입력 경로로 다시 시도할 수 있습니다."
if failure.code == "prompt_too_large":
return "평가 입력이 허용 크기를 넘어섰습니다. 입력 경로 조정이 필요합니다."
if failure.code == "invalid_structured_output":
return "평가 결과 형식이 검증되지 않았습니다. 다시 시도할 수 있습니다."
if failure.code == "missing_evaluation":
return "회기말 평가 기록이 아직 저장되지 않았습니다. 다시 시도할 수 있습니다."
return "AI 평가를 완료하지 못했습니다. 최신 상태를 확인해 주세요."
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_stale_after_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,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> dict[str, object]:
if not record:
return {}
payload = record.get("payload")
if not isinstance(payload, dict):
return {}
masked = _mask_payload_text_values(
payload,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
return masked if isinstance(masked, dict) else {}
def _mask_payload_text(
value: object,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> str:
return guardrail.mask_role_identities(
str(value or ""),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
).text_masked
def _mask_payload_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> Any:
if isinstance(value, str):
return _mask_payload_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
if isinstance(value, dict):
return {
str(key): _mask_payload_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_payload_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_payload_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
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
# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ──────────────────────────────
_VALENCE_MAX_POINTS = 10
# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵).
_CLIENT_STATE_VALENCE: dict[str, float] = {
# 음의 극성 — 방어·위축·위기 신호
"involuntary": -0.5,
"defensive": -0.7,
"suicidal_ideation_admit": -0.9,
"negative_self_perception": -0.7,
"conflicted": -0.4,
"lack_of_confidence": -0.4,
"compliant_surface": -0.2,
"externalizing": -0.3,
"apparent_competence": -0.2,
"active_passivity": -0.3,
"self_harm_disclosure": -0.9,
"somatic_complaint": -0.4,
"affect_masking": -0.3,
"focus_drift_fusion": -0.3,
# 양의 극성 — 개방·접촉·진전 신호
"affect_contact": 0.6,
"thought_organizing": 0.5,
"responds_to_exploration": 0.5,
"expresses_plan": 0.7,
"defense_loosening": 0.6,
"seeks_guidance": 0.2,
}
def _clamp_valence(value: float) -> float:
return max(-1.0, min(1.0, value))
def _valence_t(
created_at: float, first_turn_ts: float, duration_seconds: float
) -> float:
"""턴 시각 → 회기 진행률(0~1 클램프)."""
if duration_seconds <= 0:
return 0.0
return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds))
def _finalize_valence_points(
points: list[ReviewValencePoint],
) -> list[ReviewValencePoint]:
"""2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지)."""
if len(points) < 2:
return []
if len(points) <= _VALENCE_MAX_POINTS:
return points
last = len(points) - 1
indices: list[int] = []
for i in range(_VALENCE_MAX_POINTS):
idx = round(i * last / (_VALENCE_MAX_POINTS - 1))
if not indices or idx != indices[-1]:
indices.append(idx)
return [points[i] for i in indices]
def counselor_baseline_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적."""
points: list[ReviewValencePoint] = []
total = 0.0
count = 0
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
rapport = session_metrics.turn_rapport(ev)
if rapport is None:
continue
count += 1
total += rapport
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(total / count),
)
)
return _finalize_valence_points(points)
def _fallback_client_valence_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합."""
points: list[ReviewValencePoint] = []
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
polarities: list[float] = []
for state in ev.get("client_state_read") or []:
code = state.get("code") if isinstance(state, dict) else state
mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip())
if mapped is not None:
polarities.append(mapped)
score01 = session_metrics.turn_score(ev)
parts: list[float] = []
if polarities:
parts.append(0.7 * (sum(polarities) / len(polarities)))
if score01 is not None:
parts.append(0.3 * (score01 * 2.0 - 1.0))
if not parts:
continue
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(sum(parts)),
)
)
return points
def client_valence_points(
turns: list[TurnRecord],
evaluation_payload: dict[str, object],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백."""
raw = (
evaluation_payload.get("turn_valence")
if isinstance(evaluation_payload, dict)
else None
)
points: list[ReviewValencePoint] = []
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
seq = item.get("seq")
v = item.get("v")
if isinstance(seq, bool) or not isinstance(seq, int):
continue
if isinstance(v, bool) or not isinstance(v, (int, float)):
continue
index = seq - 1
if index < 0 or index >= len(turns):
continue
points.append(
ReviewValencePoint(
t=_valence_t(
turns[index].created_at, first_turn_ts, duration_seconds
),
v=_clamp_valence(float(v)),
)
)
points.sort(key=lambda point: point.t)
if not points:
points = _fallback_client_valence_points(
turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds
)
return _finalize_valence_points(points)
def _valence_axis(
duration_seconds: int, *, has_points: bool, fallback: list[str]
) -> list[str]:
"""포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지."""
if not has_points or duration_seconds <= 0:
return fallback
return [_offset_label(duration_seconds * i / 3) for i in range(4)]
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)
feedback_hidden = hidden_turns or not read_input.expose_learner_feedback
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 feedback_hidden:
evaluation_record = _missing_session_evaluation_record(
sess,
has_visible_turns=bool(visible_turns),
now_ts=now_ts,
)
evaluation_payload = (
{}
if feedback_hidden
else _evaluation_payload(
evaluation_record,
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
)
evaluation_status = (
""
if feedback_hidden
else str(evaluation_record.get("status") or "")
if evaluation_record
else ""
)
evaluation_ready = not feedback_hidden and evaluation_status == "ready"
evaluation_failure = (
teacher_evaluation_failure(evaluation_record)
if read_input.include_teacher_review and not feedback_hidden
else None
)
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"
)
safe_turn_text = guardrail.mask_role_identities(
turn.text_masked,
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
synthetic_generated=speaker == "client",
).text_masked
turn_eval = turn.evaluation if (speaker == "learner" and not feedback_hidden) else None
turns.append(
ReviewTurn(
id=f"t{index + 1}",
turn_id=turn.turn_id,
ts=_offset_label(turn.created_at - first_turn_ts),
speaker=speaker,
who="상담자" if speaker == "learner" else "내담자",
text=safe_turn_text,
techniques=_review_techniques_from_turn_eval(turn_eval),
nonverbal=(
_review_nonverbal_events(turn)
if speaker == "learner" and not feedback_hidden
else []
),
note=_review_note_from_turn_eval(turn_eval, safe_turn_text),
)
)
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
counselor_baseline: list[ReviewValencePoint] = []
client_valence: list[ReviewValencePoint] = []
if not feedback_hidden:
counselor_baseline = counselor_baseline_points(
visible_turns,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
client_valence = client_valence_points(
visible_turns,
evaluation_payload,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
valence_axis = _valence_axis(
duration_seconds,
has_points=bool(counselor_baseline or client_valence),
fallback=axis,
)
if not turns:
session_signal = "기록 없음"
elif sess.ended:
session_signal = "종료됨"
else:
session_signal = "진행 중"
transcript_summary = _review_summary(
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 = None if feedback_hidden else _latest_client_feedback(turns)
review_degraded = bool(turns) and not evaluation_ready and not feedback_hidden
if not read_input.expose_learner_feedback:
supervisor_state = "피드백 비활성"
elif evaluation_ready:
supervisor_state = "평가 완료"
elif evaluation_status == "error":
supervisor_state = "평가 실패"
elif turns:
supervisor_state = "평가 대기"
else:
supervisor_state = "기록 대기"
summary = (
"관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. "
"회기 축어록과 가상 내담자 응답은 그대로 보존됩니다."
if not read_input.expose_learner_feedback
else _review_summary_from_evaluation(
fallback=transcript_summary,
evaluation_record=None if hidden_turns else evaluation_record,
payload=evaluation_payload,
)
)
if evaluation_record and not feedback_hidden and not read_input.evaluation_durable:
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
saved_worksheet = saved_case_worksheet_from_payload(
read_input.saved_worksheet_payload
)
generated_worksheet = case_worksheet_from_turns(turns)
if not read_input.expose_learner_feedback:
case_worksheet = saved_worksheet or ReviewCaseWorksheet(
status="empty",
generatedBy="learner input only",
limitations=[
"AI 자동 초안은 표시하지 않습니다. 저장한 학습자 워크시트는 그대로 보존됩니다."
],
)
else:
case_worksheet = saved_worksheet or generated_worksheet
first_session_review = (
None
if feedback_hidden
else first_session_checklist(session_no=sess.session_no, turns=turns)
)
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,
sessionNo=sess.session_no,
client=ReviewClient(
name=client_name,
initial=client_initial,
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
),
date=session_calendar_date(sess.created_at),
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=valence_axis,
clientValence=client_valence,
counselorBaseline=counselor_baseline,
turns=turns,
rubric=rubric,
firstSessionChecklist=first_session_review,
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,
evaluationFailure=evaluation_failure,
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
teacherReview=teacher_review,
)