2088 lines
73 KiB
Python
2088 lines
73 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, 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
|
||
|
||
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_GRACE_SECONDS = 30.0
|
||
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
|
||
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 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 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
|
||
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,
|
||
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,
|
||
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),
|
||
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),
|
||
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]
|
||
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":
|
||
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,
|
||
*,
|
||
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"
|
||
|
||
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,
|
||
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
|
||
teacherReview=teacher_review,
|
||
)
|