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