상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
2224 lines
79 KiB
Python
2224 lines
79 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 .contracts.client_affect import ClientInnerReactionV1
|
||
from .services import guardrail, session_metrics, state_machine
|
||
from .stage_contract import (
|
||
ReviewPhaseKey,
|
||
StageLabel,
|
||
review_phase_key,
|
||
stage_label as _normalize_stage_label,
|
||
stage_label_or_none as stage_label_or_none,
|
||
)
|
||
from .store import InProcSession, TurnRecord
|
||
from .session_evaluation_timeout import session_evaluation_stale_after_seconds
|
||
|
||
WorksheetSpeaker = Literal["learner", "client"]
|
||
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
|
||
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
|
||
|
||
LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
|
||
MISSING_SESSION_EVALUATION_ERROR = (
|
||
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
|
||
)
|
||
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
|
||
TRAINING_EXPOSURE_MIN_COMPLETED = 4
|
||
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
|
||
FIRST_SESSION_CHECKLIST_VERSION = "first-session-rapport-open-question.v1"
|
||
|
||
|
||
class LearnerSessionSummary(BaseModel):
|
||
session_id: str
|
||
# 같은 내담자도 새 사례에서 다시 S1이 될 수 있으므로, 이력 행의 연속체 경계를 함께 준다.
|
||
case_id: str = ""
|
||
persona_code: str
|
||
persona_name: str
|
||
session_no: int
|
||
status: Literal["active", "ended"]
|
||
stage: StageLabel
|
||
turn_count: int
|
||
learner_turn_count: int
|
||
client_turn_count: int
|
||
started_at: str
|
||
ended_at: str | None = None
|
||
review_ready: bool = False
|
||
learner_feedback_enabled: bool = True
|
||
archived: bool = False
|
||
archived_at: str | None = None
|
||
|
||
|
||
class LearnerSessionsResponse(BaseModel):
|
||
source: str = "runtime"
|
||
sessions: list[LearnerSessionSummary] = Field(default_factory=list)
|
||
|
||
|
||
class CaseProgressStats(BaseModel):
|
||
"""한 사례 안에서만 누적한 learner-visible 진행 수치."""
|
||
|
||
total_sessions: int = 0
|
||
completed_sessions: int = 0
|
||
total_turns: int = 0
|
||
total_duration_seconds: int = 0
|
||
active_session_id: str | None = None
|
||
active_session_no: int | None = None
|
||
active_started_at: str | None = None
|
||
last_activity_at: str | None = None
|
||
|
||
|
||
class LearnerCaseSummary(BaseModel):
|
||
case_id: str
|
||
persona_code: str
|
||
persona_name: str
|
||
last_session_no: int = 0
|
||
progress: CaseProgressStats = Field(default_factory=CaseProgressStats)
|
||
|
||
|
||
class LearnerCaseListResponse(BaseModel):
|
||
# case별 누적 수치는 DB 전체 집계여야 한다. runtime cache 추정값은 반환하지 않는다.
|
||
source: Literal["database"] = "database"
|
||
cases: list[LearnerCaseSummary] = Field(default_factory=list)
|
||
|
||
|
||
class CaseMemoryPreview(BaseModel):
|
||
"""접힌 learner UI에만 쓰는 최소·마스킹된 사례 기억 투영."""
|
||
|
||
case_id: str
|
||
memory_available: bool = False
|
||
case_digest: str | None = None
|
||
latest_session_digest: str | None = None
|
||
open_threads: list[str] = Field(default_factory=list)
|
||
pinned_facts: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class LearnerDashboardOverview(BaseModel):
|
||
total_sessions: int = 0
|
||
completed_sessions: int = 0
|
||
active_sessions: int = 0
|
||
review_ready_sessions: int = 0
|
||
archived_sessions: int = 0
|
||
learner_turns: int = 0
|
||
client_turns: int = 0
|
||
last_practiced_at: str | None = None
|
||
|
||
|
||
class LearnerDashboardGrowthPoint(BaseModel):
|
||
session_id: str
|
||
session_no: int
|
||
persona_code: str
|
||
stage: StageLabel
|
||
started_at: str
|
||
ended_at: str | None = None
|
||
score: float | None = None
|
||
rapport: float | None = None
|
||
technique_count: int = 0
|
||
watch_count: int = 0
|
||
|
||
|
||
class LearnerDashboardGrowth(BaseModel):
|
||
first_score: float | None = None
|
||
latest_score: float | None = None
|
||
score_delta: float | None = None
|
||
avg_score: float | None = None
|
||
avg_rapport: float | None = None
|
||
trend: str = "insufficient"
|
||
evaluated_sessions: int = 0
|
||
top_techniques: list[str] = Field(default_factory=list)
|
||
points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list)
|
||
|
||
|
||
class LearnerDashboardPersonaProgress(BaseModel):
|
||
persona_code: str
|
||
persona_name: str
|
||
sessions: int = 0
|
||
completed_sessions: int = 0
|
||
active_sessions: int = 0
|
||
review_ready_sessions: int = 0
|
||
latest_at: str | None = None
|
||
latest_stage: StageLabel | None = None
|
||
latest_score: float | None = None
|
||
trend: str = "insufficient"
|
||
# P2 누적 게이지: 이 페르소나와 쌓아온 라포 누적(전 주기 임계 기준 %).
|
||
rapport_percent: int = 0
|
||
|
||
|
||
class LearnerDashboardTrainingExposure(BaseModel):
|
||
version: str = TRAINING_EXPOSURE_VERSION
|
||
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
|
||
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
|
||
completed_sessions: int = 0
|
||
minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED
|
||
attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD
|
||
dominant_persona_code: str | None = None
|
||
dominant_persona_name: str | None = None
|
||
dominant_sessions: int = 0
|
||
dominant_share: float | None = None
|
||
definition: str = (
|
||
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, "
|
||
"공정성 평가나 임상진단이 아닙니다."
|
||
)
|
||
|
||
|
||
class LearnerDashboardAchievement(BaseModel):
|
||
id: str
|
||
label: str
|
||
state: Literal["done", "available", "locked"] = "locked"
|
||
detail: str
|
||
|
||
|
||
class LearnerDashboardFeedbackItem(BaseModel):
|
||
session_id: str
|
||
persona_code: str
|
||
persona_name: str
|
||
session_no: int
|
||
stage: StageLabel
|
||
turn_seq: int
|
||
created_at: str
|
||
score: float | None = None
|
||
rapport: float | None = None
|
||
note: str
|
||
techniques: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class LearnerDashboardResponse(BaseModel):
|
||
source: str = "runtime"
|
||
overview: LearnerDashboardOverview
|
||
growth: LearnerDashboardGrowth
|
||
persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list)
|
||
training_exposure: LearnerDashboardTrainingExposure = Field(
|
||
default_factory=LearnerDashboardTrainingExposure
|
||
)
|
||
achievements: list[LearnerDashboardAchievement] = Field(default_factory=list)
|
||
recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list)
|
||
message: str
|
||
|
||
|
||
class SessionArchiveResponse(BaseModel):
|
||
session_id: str
|
||
archived: bool
|
||
archived_at: str | None = None
|
||
source: str = "runtime"
|
||
session: LearnerSessionSummary
|
||
|
||
|
||
class SessionStageProgress(BaseModel):
|
||
"""단계별 누적 게이지(P2). 상태머신 결정론 수치의 파생값만 담는다."""
|
||
|
||
stage: StageLabel
|
||
percent: int = 0
|
||
achieved: bool = False
|
||
is_goal: bool = False
|
||
|
||
|
||
class SessionProgress(BaseModel):
|
||
"""회기 진행 상세(P2). 내부 원값 명칭 대신 학습자-안전 파생 %만 노출한다."""
|
||
|
||
stages: list[SessionStageProgress] = Field(default_factory=list)
|
||
rapport_percent: int = 0
|
||
rapport_delta_percent: int = 0
|
||
resistance_percent: int = 0
|
||
openness_percent: int = 0
|
||
|
||
|
||
# 전 주기(라포→정리) 기준 라포 만점: 마지막 전이 임계(개입→정리)를 100으로 본다.
|
||
_FULL_CYCLE_RAPPORT = max(state_machine.STAGE_ADVANCE_RAPPORT.values())
|
||
|
||
|
||
def _rapport_percent(credit: float) -> int:
|
||
if _FULL_CYCLE_RAPPORT <= 0:
|
||
return 0
|
||
return max(0, min(100, int(round(float(credit or 0.0) / _FULL_CYCLE_RAPPORT * 100))))
|
||
|
||
|
||
def build_session_progress(
|
||
state: state_machine.SessionState,
|
||
*,
|
||
prev_rapport_credit: float = 0.0,
|
||
goal_stages: list[str] | None = None,
|
||
) -> SessionProgress:
|
||
"""상태머신 수치 → 단계 누적 게이지/상세 수치 파생(순수함수, LLM 미경유).
|
||
|
||
누적성: rapport_credit 은 회기 간 ×0.7 이월되므로 다음 회기 게이지는 이월분에서 시작한다.
|
||
"""
|
||
goals = {stage_label(goal) for goal in (goal_stages or [])}
|
||
order = state_machine.STAGE_ORDER
|
||
cur_idx = order.index(state.stage)
|
||
stages: list[SessionStageProgress] = []
|
||
for i, st in enumerate(order):
|
||
label = stage_label(st)
|
||
if i < cur_idx:
|
||
pct, achieved = 100, True
|
||
elif i > cur_idx:
|
||
pct, achieved = 0, False
|
||
elif st is state_machine.Stage.CLOSE:
|
||
pct, achieved = 100, True
|
||
else:
|
||
need = state_machine.STAGE_ADVANCE_RAPPORT.get(st, 1.0)
|
||
ratio = min(1.0, float(state.rapport_credit or 0.0) / need) if need > 0 else 0.0
|
||
pct, achieved = min(99, int(round(ratio * 99))), False
|
||
stages.append(
|
||
SessionStageProgress(stage=label, percent=pct, achieved=achieved, is_goal=label in goals)
|
||
)
|
||
rapport_pct = _rapport_percent(state.rapport_credit)
|
||
prev_pct = _rapport_percent(prev_rapport_credit)
|
||
return SessionProgress(
|
||
stages=stages,
|
||
rapport_percent=rapport_pct,
|
||
rapport_delta_percent=max(0, rapport_pct - prev_pct),
|
||
resistance_percent=max(0, min(100, int(round(float(state.resistance or 0.0) * 100)))),
|
||
openness_percent=max(0, min(100, int(round(float(state.effective_openness or 0.0) * 100)))),
|
||
)
|
||
|
||
|
||
class SessionDetailTurn(BaseModel):
|
||
turn_seq: int
|
||
speaker: Literal["learner", "client"]
|
||
stage: StageLabel
|
||
text: str
|
||
created_at: str
|
||
|
||
|
||
class SessionDetailResponse(BaseModel):
|
||
session_id: str
|
||
case_id: str
|
||
persona_id: str | None = None
|
||
persona_version: int | None = None
|
||
persona_code: str
|
||
persona_name: str
|
||
theory_mode: str
|
||
status: Literal["active", "ended"]
|
||
stage: StageLabel
|
||
effective_openness: float
|
||
started_at: str
|
||
ended_at: str | None = None
|
||
turns: list[SessionDetailTurn] = Field(default_factory=list)
|
||
review_ready: bool = False
|
||
learner_feedback_enabled: bool = True
|
||
# 시간 기반 회기(2026-07-13 회의 P1): 새로고침 복원 시 타이머·목표 표시의 기준.
|
||
goal_stages: list[StageLabel] = Field(default_factory=list)
|
||
duration_limit_seconds: int = 0
|
||
warning_before_end_seconds: int = 0
|
||
# P2 단계 누적 게이지·상세 수치.
|
||
progress: SessionProgress | None = None
|
||
|
||
|
||
class ReviewClient(BaseModel):
|
||
name: str
|
||
initial: str
|
||
persona: str
|
||
|
||
|
||
class ReviewTechnique(BaseModel):
|
||
kind: str
|
||
label: str
|
||
|
||
|
||
class ReviewNonverbalEvent(BaseModel):
|
||
kind: Literal["audio", "silence", "pace", "barge_in", "paralinguistic", "prosody", "audio_quality"]
|
||
label: str
|
||
detail: str
|
||
|
||
|
||
class ReviewNote(BaseModel):
|
||
author: str
|
||
tone: str
|
||
title: str
|
||
body: str
|
||
quote: Optional[str] = None
|
||
|
||
|
||
class ReviewTurn(BaseModel):
|
||
id: str
|
||
# `id`는 화면 순서용 t1/t2 anchor다. 측정·관계 원장의 evidence FK에는
|
||
# 실제 app.turns UUID를 사용해야 하므로 두 식별자를 섞지 않고 함께 노출한다.
|
||
turn_id: str | None = None
|
||
ts: str
|
||
speaker: Literal["learner", "client"]
|
||
who: str
|
||
text: str
|
||
techniques: list[ReviewTechnique] = Field(default_factory=list)
|
||
nonverbal: list[ReviewNonverbalEvent] = Field(default_factory=list)
|
||
note: Optional[ReviewNote] = None
|
||
# 내담자 턴에만 채워지는 속마음 요약(§8.2·§9). feedback_hidden이면 넣지 않는다.
|
||
innerReaction: Optional[ClientInnerReactionV1] = None
|
||
|
||
|
||
class ReviewPhaseSegment(BaseModel):
|
||
key: ReviewPhaseKey
|
||
label: StageLabel
|
||
weight: float
|
||
|
||
|
||
class ReviewValencePoint(BaseModel):
|
||
t: float
|
||
v: float
|
||
|
||
|
||
class ReviewRubricRow(BaseModel):
|
||
name: str
|
||
cluster: str
|
||
ratio: float
|
||
quality: Literal["good", "watch"]
|
||
freq: str
|
||
|
||
|
||
class ReviewFirstSessionChecklistEvidence(BaseModel):
|
||
turnId: str
|
||
turnSeq: int
|
||
quote: str
|
||
|
||
|
||
class ReviewFirstSessionChecklistCriterion(BaseModel):
|
||
criterionId: str
|
||
label: str
|
||
description: str
|
||
status: Literal["met", "not_observed"]
|
||
evidenceTurns: list[ReviewFirstSessionChecklistEvidence] = Field(default_factory=list)
|
||
|
||
|
||
class ReviewFirstSessionChecklist(BaseModel):
|
||
version: str = FIRST_SESSION_CHECKLIST_VERSION
|
||
applicable: bool = True
|
||
status: Literal["ready", "not_applicable"] = "ready"
|
||
title: str = "첫 회기 라포·개방질문 체크리스트"
|
||
note: str = (
|
||
"축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다."
|
||
)
|
||
criteria: list[ReviewFirstSessionChecklistCriterion] = Field(default_factory=list)
|
||
|
||
|
||
class ReviewPoint(BaseModel):
|
||
title: str
|
||
body: str
|
||
jumpTo: Optional[str] = None
|
||
|
||
|
||
class ReviewWorksheetEvidence(BaseModel):
|
||
turnId: str
|
||
speaker: Literal["learner", "client"]
|
||
quote: str
|
||
|
||
|
||
class ReviewWorksheetItem(BaseModel):
|
||
key: str
|
||
label: str
|
||
value: Optional[str] = None
|
||
evidence: list[ReviewWorksheetEvidence] = Field(default_factory=list)
|
||
confidence: Literal["none", "low", "medium"] = "none"
|
||
emptyReason: Optional[str] = None
|
||
|
||
|
||
class ReviewWorksheetSection(BaseModel):
|
||
key: str
|
||
title: str
|
||
items: list[ReviewWorksheetItem] = Field(default_factory=list)
|
||
|
||
|
||
class ReviewCaseWorksheet(BaseModel):
|
||
status: Literal["empty", "draft_from_transcript", "saved_by_learner"] = "empty"
|
||
generatedBy: str = "rule-based transcript extractor"
|
||
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
||
limitations: list[str] = Field(default_factory=list)
|
||
savedAt: Optional[str] = None
|
||
|
||
|
||
class ReviewCaseWorksheetSaveRequest(BaseModel):
|
||
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
||
limitations: list[str] = Field(default_factory=list)
|
||
|
||
|
||
CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [
|
||
(
|
||
"exploration_11",
|
||
"탐색 11항목",
|
||
[
|
||
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
|
||
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"),
|
||
("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
|
||
("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"),
|
||
("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"),
|
||
("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"),
|
||
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
|
||
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
|
||
("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
|
||
("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None),
|
||
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
|
||
],
|
||
),
|
||
(
|
||
"five_domains",
|
||
"호소 5영역",
|
||
[
|
||
("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"),
|
||
("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"),
|
||
("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"),
|
||
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
|
||
("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"),
|
||
],
|
||
),
|
||
(
|
||
"cognitive_triad_emotions",
|
||
"인지삼제·1/2차 감정",
|
||
[
|
||
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
|
||
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
|
||
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
|
||
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
|
||
("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"),
|
||
],
|
||
),
|
||
(
|
||
"protective_barrier_quadrants",
|
||
"보호·방해 4사분면",
|
||
[
|
||
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None),
|
||
("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"),
|
||
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
|
||
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
|
||
],
|
||
),
|
||
(
|
||
"biopsychosocial_goals",
|
||
"생물·심리·사회 목표",
|
||
[
|
||
("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"),
|
||
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
|
||
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
class SessionTeacherReviewStatus(BaseModel):
|
||
status: Literal["pending", "viewed", "closed"] = "pending"
|
||
note: str = ""
|
||
reviewerId: str | None = None
|
||
reviewedAt: str | None = None
|
||
updatedAt: str | None = None
|
||
worksheetStatus: Literal["pending", "approved", "changes_requested", "rejected"] = "pending"
|
||
worksheetNote: str = ""
|
||
worksheetReviewedAt: str | None = None
|
||
|
||
|
||
class ReviewEvaluationFailure(BaseModel):
|
||
"""교수자용 deep-loop 실패 분류. 원문 예외·축어록은 절대 응답에 넣지 않는다."""
|
||
|
||
code: Literal[
|
||
"timeout",
|
||
"engine_unavailable",
|
||
"legacy_argv_limit",
|
||
"prompt_too_large",
|
||
"invalid_structured_output",
|
||
"missing_evaluation",
|
||
"unknown",
|
||
]
|
||
retryable: bool
|
||
|
||
|
||
class SessionReviewResponse(BaseModel):
|
||
session_id: str
|
||
sessionNo: int = Field(ge=1)
|
||
client: ReviewClient
|
||
date: str
|
||
durationLabel: str
|
||
durationSeconds: int
|
||
reachedPhase: StageLabel
|
||
sessionSignal: str
|
||
supervisorState: str
|
||
supervisorName: str
|
||
summary: str
|
||
phases: list[ReviewPhaseSegment] = Field(default_factory=list)
|
||
phaseAxis: list[str] = Field(default_factory=list)
|
||
valenceAxis: list[str] = Field(default_factory=list)
|
||
clientValence: list[ReviewValencePoint] = Field(default_factory=list)
|
||
counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list)
|
||
turns: list[ReviewTurn] = Field(default_factory=list)
|
||
rubric: list[ReviewRubricRow] = Field(default_factory=list)
|
||
firstSessionChecklist: ReviewFirstSessionChecklist | None = None
|
||
goodMoments: list[ReviewPoint] = Field(default_factory=list)
|
||
growthPoints: list[ReviewPoint] = Field(default_factory=list)
|
||
caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet)
|
||
nextLine: Optional[str] = None
|
||
clientFeedback: Optional[str] = None
|
||
audioUrl: Optional[str] = None
|
||
pdfExportUrl: Optional[str] = None
|
||
degraded: bool = True
|
||
reviewReady: bool = False
|
||
evaluationFailure: ReviewEvaluationFailure | None = None
|
||
learnerFeedbackEnabled: bool = True
|
||
teacherReview: SessionTeacherReviewStatus | None = None
|
||
|
||
|
||
class SessionShareResponse(BaseModel):
|
||
shareUrl: str
|
||
title: str
|
||
description: str
|
||
imageUrl: str
|
||
createdAt: str
|
||
|
||
|
||
class SessionShareDeleteResponse(BaseModel):
|
||
revoked: bool
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SessionReviewReadInput:
|
||
session: InProcSession
|
||
evaluation_record: dict[str, object] | None = None
|
||
evaluation_durable: bool = False
|
||
saved_worksheet_payload: dict[str, object] | None = None
|
||
include_teacher_review: bool = False
|
||
teacher_review_record: dict[str, object] | None = None
|
||
learner_feedback_enabled: bool = True
|
||
expose_learner_feedback: bool = True
|
||
inner_reactions: dict[str, ClientInnerReactionV1] | 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, tz=timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
def session_calendar_date(ts: float) -> str:
|
||
return datetime.fromtimestamp(ts, tz=KOREA_STANDARD_TIME).strftime("%Y-%m-%d")
|
||
|
||
|
||
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
|
||
return sess.turns_visible_to(LEARNER_VISIBLE_AI_ROLE)
|
||
|
||
|
||
def learner_summary(
|
||
sess: InProcSession,
|
||
*,
|
||
review_ready: bool = False,
|
||
learner_feedback_enabled: bool | None = None,
|
||
archived: bool = False,
|
||
archived_at: str | None = None,
|
||
) -> LearnerSessionSummary:
|
||
turns = learner_visible_turns(sess)
|
||
learner_turns = sum(1 for turn in turns if turn.speaker == "counselor")
|
||
client_turns = sum(1 for turn in turns if turn.speaker == "client")
|
||
return LearnerSessionSummary(
|
||
session_id=sess.session_id,
|
||
case_id=sess.case_id,
|
||
persona_code=sess.persona_code,
|
||
persona_name=sess.persona.display_name,
|
||
session_no=sess.session_no,
|
||
status="ended" if sess.ended else "active",
|
||
stage=stage_label(sess.state.stage),
|
||
turn_count=len(turns),
|
||
learner_turn_count=learner_turns,
|
||
client_turn_count=client_turns,
|
||
started_at=iso(sess.created_at) or "",
|
||
ended_at=iso(sess.ended_at),
|
||
review_ready=review_ready if learner_turns > 0 else False,
|
||
learner_feedback_enabled=(
|
||
sess.learner_feedback_enabled
|
||
if learner_feedback_enabled is None
|
||
else learner_feedback_enabled
|
||
),
|
||
archived=archived,
|
||
archived_at=archived_at,
|
||
)
|
||
|
||
|
||
def dashboard_overview(
|
||
sessions: list[InProcSession],
|
||
*,
|
||
visible_review_ready: dict[str, bool],
|
||
archived_sessions: int,
|
||
) -> LearnerDashboardOverview:
|
||
return LearnerDashboardOverview(
|
||
total_sessions=len(sessions),
|
||
completed_sessions=sum(
|
||
1 for sess in sessions if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
|
||
),
|
||
active_sessions=sum(1 for sess in sessions if not sess.ended),
|
||
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
|
||
archived_sessions=archived_sessions,
|
||
learner_turns=sum(
|
||
1
|
||
for sess in sessions
|
||
for turn in learner_visible_turns(sess)
|
||
if turn.speaker == "counselor"
|
||
),
|
||
client_turns=sum(
|
||
1
|
||
for sess in sessions
|
||
for turn in learner_visible_turns(sess)
|
||
if turn.speaker == "client"
|
||
),
|
||
last_practiced_at=session_metrics.iso_datetime(
|
||
max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0)
|
||
)
|
||
if sessions
|
||
else None,
|
||
)
|
||
|
||
|
||
def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint:
|
||
return LearnerDashboardGrowthPoint(
|
||
session_id=point.session_id,
|
||
session_no=point.session_no,
|
||
persona_code=point.persona_code,
|
||
stage=stage_label(point.stage),
|
||
started_at=point.started_at,
|
||
ended_at=point.ended_at,
|
||
score=point.score,
|
||
rapport=point.rapport,
|
||
technique_count=point.technique_count,
|
||
watch_count=point.watch_count,
|
||
)
|
||
|
||
|
||
def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
|
||
metrics = session_metrics.build_learner_growth(
|
||
sessions,
|
||
learner_label=lambda _learner_id: "나",
|
||
limit=1,
|
||
)
|
||
if not metrics:
|
||
return LearnerDashboardGrowth()
|
||
item = metrics[0]
|
||
points = [_dashboard_growth_point(point) for point in item.points]
|
||
return LearnerDashboardGrowth(
|
||
first_score=item.first_score,
|
||
latest_score=item.latest_score,
|
||
score_delta=item.score_delta,
|
||
avg_score=item.avg_score,
|
||
avg_rapport=item.avg_rapport,
|
||
trend=item.trend,
|
||
evaluated_sessions=sum(1 for point in points if point.score is not None),
|
||
top_techniques=item.top_techniques,
|
||
points=points,
|
||
)
|
||
|
||
|
||
def dashboard_persona_progress(
|
||
sessions: list[InProcSession],
|
||
review_ready: dict[str, bool],
|
||
learner_feedback_enabled: dict[str, bool] | None = None,
|
||
) -> list[LearnerDashboardPersonaProgress]:
|
||
grouped: dict[str, list[InProcSession]] = {}
|
||
for sess in sessions:
|
||
grouped.setdefault(sess.persona_code, []).append(sess)
|
||
|
||
rows: list[LearnerDashboardPersonaProgress] = []
|
||
for persona_code, items in grouped.items():
|
||
ordered = sorted(items, key=session_metrics.session_activity_time)
|
||
latest = ordered[-1]
|
||
metric_sessions = (
|
||
ordered
|
||
if learner_feedback_enabled is None
|
||
else [
|
||
sess
|
||
for sess in ordered
|
||
if learner_feedback_enabled.get(sess.session_id, True)
|
||
]
|
||
)
|
||
metrics = session_metrics.build_learner_growth(
|
||
metric_sessions,
|
||
learner_label=lambda _learner_id: "나",
|
||
limit=1,
|
||
)
|
||
growth = metrics[0] if metrics else None
|
||
rows.append(
|
||
LearnerDashboardPersonaProgress(
|
||
persona_code=persona_code,
|
||
persona_name=latest.persona.display_name,
|
||
sessions=len(ordered),
|
||
completed_sessions=sum(
|
||
1
|
||
for sess in ordered
|
||
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
|
||
),
|
||
active_sessions=sum(1 for sess in ordered if not sess.ended),
|
||
review_ready_sessions=sum(
|
||
1 for sess in ordered if review_ready.get(sess.session_id, False)
|
||
),
|
||
latest_at=session_metrics.iso_datetime(
|
||
session_metrics.session_activity_time(latest)
|
||
),
|
||
latest_stage=stage_label(latest.state.stage),
|
||
latest_score=growth.latest_score if growth else None,
|
||
trend=growth.trend if growth else "insufficient",
|
||
rapport_percent=_rapport_percent(latest.state.rapport_credit),
|
||
)
|
||
)
|
||
return sorted(
|
||
rows,
|
||
key=lambda row: row.latest_at or "",
|
||
reverse=True,
|
||
)
|
||
|
||
|
||
def dashboard_training_exposure(
|
||
sessions: list[InProcSession],
|
||
) -> LearnerDashboardTrainingExposure:
|
||
"""Compute a transparent practice-exposure signal from ended sessions only."""
|
||
|
||
completed = [
|
||
sess
|
||
for sess in sessions
|
||
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
|
||
]
|
||
counts = Counter(sess.persona_code for sess in completed)
|
||
if not counts:
|
||
return LearnerDashboardTrainingExposure()
|
||
|
||
dominant_code, dominant_count = sorted(
|
||
counts.items(),
|
||
key=lambda item: (-item[1], item[0]),
|
||
)[0]
|
||
dominant_session = next(
|
||
sess for sess in completed if sess.persona_code == dominant_code
|
||
)
|
||
total = len(completed)
|
||
share = dominant_count / total
|
||
if total < TRAINING_EXPOSURE_MIN_COMPLETED:
|
||
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
|
||
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
|
||
elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD:
|
||
status = "attention"
|
||
label = "훈련 집중 주의"
|
||
else:
|
||
status = "balanced"
|
||
label = "균형"
|
||
return LearnerDashboardTrainingExposure(
|
||
status=status,
|
||
label=label,
|
||
completed_sessions=total,
|
||
dominant_persona_code=dominant_code,
|
||
dominant_persona_name=dominant_session.persona.display_name,
|
||
dominant_sessions=dominant_count,
|
||
dominant_share=round(share, 4),
|
||
)
|
||
|
||
|
||
def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]:
|
||
if done:
|
||
return "done"
|
||
if available:
|
||
return "available"
|
||
return "locked"
|
||
|
||
|
||
def dashboard_achievements(
|
||
sessions: list[InProcSession],
|
||
review_ready: dict[str, bool],
|
||
) -> list[LearnerDashboardAchievement]:
|
||
completed = sum(1 for sess in sessions if sess.ended)
|
||
active = sum(1 for sess in sessions if not sess.ended)
|
||
review_count = sum(1 for ready in review_ready.values() if ready)
|
||
by_persona = Counter(sess.persona_code for sess in sessions)
|
||
max_persona_sessions = max(by_persona.values(), default=0)
|
||
persona_coverage = len(by_persona)
|
||
return [
|
||
LearnerDashboardAchievement(
|
||
id="first_session_complete",
|
||
label="첫 회기 완료",
|
||
state=_achievement_state(completed >= 1, active >= 1),
|
||
detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.",
|
||
),
|
||
LearnerDashboardAchievement(
|
||
id="review_ready",
|
||
label="리뷰 확인 가능",
|
||
state=_achievement_state(review_count >= 1, completed >= 1),
|
||
detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.",
|
||
),
|
||
LearnerDashboardAchievement(
|
||
id="persona_repeat",
|
||
label="같은 내담자 반복 연습",
|
||
state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1),
|
||
detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.",
|
||
),
|
||
LearnerDashboardAchievement(
|
||
id="persona_coverage",
|
||
label="여러 페르소나 경험",
|
||
state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2),
|
||
detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.",
|
||
),
|
||
]
|
||
|
||
|
||
def dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]:
|
||
items: list[LearnerDashboardFeedbackItem] = []
|
||
for item in session_metrics.recent_feedback_notes(sessions, limit=5):
|
||
score = item.get("score")
|
||
rapport = item.get("rapport")
|
||
items.append(
|
||
LearnerDashboardFeedbackItem(
|
||
session_id=str(item["session_id"]),
|
||
persona_code=str(item["persona_code"]),
|
||
persona_name=str(item["persona_name"]),
|
||
session_no=int(item["session_no"]),
|
||
stage=stage_label(item["stage"]),
|
||
turn_seq=int(item["turn_seq"]),
|
||
created_at=str(item["created_at"]),
|
||
score=float(score) if isinstance(score, (int, float)) else None,
|
||
rapport=float(rapport) if isinstance(rapport, (int, float)) else None,
|
||
note=str(item["note"]),
|
||
techniques=[str(label) for label in item.get("techniques", [])],
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
def session_detail(
|
||
sess: InProcSession,
|
||
*,
|
||
review_ready: bool = False,
|
||
learner_feedback_enabled: bool | None = None,
|
||
) -> SessionDetailResponse:
|
||
turns = learner_visible_turns(sess)
|
||
return SessionDetailResponse(
|
||
session_id=sess.session_id,
|
||
case_id=sess.case_id,
|
||
persona_id=sess.persona_id,
|
||
persona_version=sess.persona_version,
|
||
persona_code=sess.persona_code,
|
||
persona_name=sess.persona.display_name,
|
||
theory_mode=sess.theory_mode,
|
||
status="ended" if sess.ended else "active",
|
||
stage=stage_label(sess.state.stage),
|
||
effective_openness=round(sess.state.effective_openness, 4),
|
||
started_at=iso(sess.created_at) or "",
|
||
ended_at=iso(sess.ended_at),
|
||
turns=[
|
||
SessionDetailTurn(
|
||
turn_seq=turn.turn_seq,
|
||
speaker="learner" if turn.speaker == "counselor" else "client",
|
||
stage=stage_label(turn.stage),
|
||
text=turn.text_masked,
|
||
created_at=iso(turn.created_at) or "",
|
||
)
|
||
for turn in turns
|
||
],
|
||
review_ready=review_ready,
|
||
learner_feedback_enabled=(
|
||
sess.learner_feedback_enabled
|
||
if learner_feedback_enabled is None
|
||
else learner_feedback_enabled
|
||
),
|
||
goal_stages=[stage_label(goal) for goal in (sess.goal_stages or [])],
|
||
duration_limit_seconds=max(0, settings.session_duration_minutes) * 60,
|
||
warning_before_end_seconds=max(0, settings.session_warning_minutes) * 60,
|
||
progress=build_session_progress(
|
||
sess.state,
|
||
prev_rapport_credit=sess.prev_rapport_credit,
|
||
goal_stages=list(sess.goal_stages or []),
|
||
),
|
||
)
|
||
|
||
|
||
def _offset_label(seconds: float) -> str:
|
||
whole = max(0, int(round(seconds)))
|
||
minutes, sec = divmod(whole, 60)
|
||
return f"{minutes}:{sec:02d}"
|
||
|
||
|
||
def _duration_label(seconds: int) -> str:
|
||
if seconds < 60:
|
||
return f"{seconds}초"
|
||
minutes, sec = divmod(seconds, 60)
|
||
return f"{minutes}분 {sec}초"
|
||
|
||
|
||
def _client_name(raw: str) -> str:
|
||
name = raw.split("(", 1)[0].strip()
|
||
return name or raw.strip() or "내담자"
|
||
|
||
|
||
_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"}
|
||
|
||
|
||
def _persona_label(code: str, difficulty: str) -> str:
|
||
return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}"
|
||
|
||
|
||
def _josa_wa_gwa(name: str) -> str:
|
||
"""이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다."""
|
||
tail = name[-1] if name else ""
|
||
if "가" <= tail <= "힣":
|
||
has_final = (ord(tail) - 0xAC00) % 28 != 0
|
||
if has_final:
|
||
return "과"
|
||
return "와"
|
||
return "와(과)"
|
||
|
||
|
||
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
|
||
if not turns:
|
||
return (
|
||
"아직 실제 발화가 없어 리뷰를 만들 수 없습니다. 회기를 진행한 뒤 종료하면 "
|
||
"저장된 축어록을 기준으로 리뷰가 표시됩니다."
|
||
)
|
||
learner_count = sum(1 for turn in turns if turn.speaker == "learner")
|
||
client_count = sum(1 for turn in turns if turn.speaker == "client")
|
||
return (
|
||
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
|
||
f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, "
|
||
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
|
||
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
|
||
)
|
||
|
||
|
||
def _phase_segments(stage_labels: list[StageLabel]) -> list[ReviewPhaseSegment]:
|
||
counts = Counter(stage_labels)
|
||
return [
|
||
ReviewPhaseSegment(
|
||
key=review_phase_key(label),
|
||
label=label,
|
||
weight=float(count),
|
||
)
|
||
for label, count in counts.items()
|
||
if count > 0
|
||
]
|
||
|
||
|
||
def _clamp_ratio(value: float) -> float:
|
||
return round(max(0.0, min(1.0, value)), 3)
|
||
|
||
|
||
def _compact_text(text: str) -> str:
|
||
return " ".join(text.split())
|
||
|
||
|
||
def _clip_text(text: str, limit: int = 180) -> str:
|
||
compact = _compact_text(text)
|
||
if len(compact) <= limit:
|
||
return compact
|
||
return f"{compact[: max(0, limit - 1)].rstrip()}..."
|
||
|
||
|
||
_FIRST_SESSION_RAPPORT_CUES = (
|
||
"마음",
|
||
"느껴",
|
||
"느낌",
|
||
"힘들",
|
||
"이해",
|
||
"그랬군",
|
||
"그랬구나",
|
||
"천천히",
|
||
"버티",
|
||
)
|
||
_FIRST_SESSION_OPEN_QUESTION_WORDS = ("어떤", "어떻게", "무엇", "언제", "어디", "어느")
|
||
_FIRST_SESSION_CLOSED_QUESTION_CUES = ("맞죠", "그렇죠", "아니에요?", "했나요?", "인가요?")
|
||
|
||
|
||
def _first_session_evidence(
|
||
turns: list[ReviewTurn],
|
||
predicate: Callable[[str], bool],
|
||
) -> list[ReviewFirstSessionChecklistEvidence]:
|
||
evidence: list[ReviewFirstSessionChecklistEvidence] = []
|
||
for index, turn in enumerate(turns, start=1):
|
||
if turn.speaker != "learner" or not predicate(turn.text):
|
||
continue
|
||
evidence.append(
|
||
ReviewFirstSessionChecklistEvidence(
|
||
turnId=turn.id,
|
||
turnSeq=index,
|
||
quote=_clip_text(turn.text, 120),
|
||
)
|
||
)
|
||
if len(evidence) >= 3:
|
||
break
|
||
return evidence
|
||
|
||
|
||
def first_session_checklist(
|
||
*,
|
||
session_no: int,
|
||
turns: list[ReviewTurn],
|
||
) -> ReviewFirstSessionChecklist:
|
||
"""Build a non-clinical, evidence-linked first-session behavior checklist."""
|
||
|
||
if session_no != 1:
|
||
return ReviewFirstSessionChecklist(
|
||
applicable=False,
|
||
status="not_applicable",
|
||
criteria=[],
|
||
)
|
||
|
||
rapport_evidence = _first_session_evidence(
|
||
turns,
|
||
lambda text: any(cue in text for cue in _FIRST_SESSION_RAPPORT_CUES),
|
||
)
|
||
open_question_evidence = _first_session_evidence(
|
||
turns,
|
||
lambda text: (
|
||
text.count("?") == 1
|
||
and any(word in text for word in _FIRST_SESSION_OPEN_QUESTION_WORDS)
|
||
and not any(cue in text for cue in _FIRST_SESSION_CLOSED_QUESTION_CUES)
|
||
),
|
||
)
|
||
return ReviewFirstSessionChecklist(
|
||
criteria=[
|
||
ReviewFirstSessionChecklistCriterion(
|
||
criterionId="first-session.rapport-reflection",
|
||
label="라포를 위한 정서·경험 반영",
|
||
description="내담자의 정서나 경험을 평가·조언보다 먼저 반영한 발화를 찾습니다.",
|
||
status="met" if rapport_evidence else "not_observed",
|
||
evidenceTurns=rapport_evidence,
|
||
),
|
||
ReviewFirstSessionChecklistCriterion(
|
||
criterionId="first-session.open-question-one-focus",
|
||
label="한 초점의 개방형 질문",
|
||
description="한 번에 한 초점으로 탐색을 넓히는 개방형 질문을 찾습니다.",
|
||
status="met" if open_question_evidence else "not_observed",
|
||
evidenceTurns=open_question_evidence,
|
||
),
|
||
]
|
||
)
|
||
|
||
|
||
def _point_title(text: str, fallback: str) -> str:
|
||
compact = _clip_text(text, 72)
|
||
for sep in (".", "。", "!", "?", "\n"):
|
||
if sep in compact:
|
||
first = compact.split(sep, 1)[0].strip()
|
||
if first:
|
||
return _clip_text(first, 44)
|
||
return _clip_text(compact, 44) or fallback
|
||
|
||
|
||
def _ai_review_points(values: object, *, fallback_prefix: str) -> list[ReviewPoint]:
|
||
if not isinstance(values, list):
|
||
return []
|
||
points: list[ReviewPoint] = []
|
||
for index, value in enumerate(values, start=1):
|
||
body = _compact_text(str(value or ""))
|
||
if not body:
|
||
continue
|
||
points.append(
|
||
ReviewPoint(
|
||
title=_point_title(body, f"{fallback_prefix} {index}"),
|
||
body=body,
|
||
jumpTo=None,
|
||
)
|
||
)
|
||
return points[:3]
|
||
|
||
|
||
def _intent_deviation_points(values: object) -> list[ReviewPoint]:
|
||
if not isinstance(values, list):
|
||
return []
|
||
points: list[ReviewPoint] = []
|
||
for index, value in enumerate(values, start=1):
|
||
if not isinstance(value, dict):
|
||
continue
|
||
dimension = _compact_text(str(value.get("dimension") or f"의도 이탈 {index}"))
|
||
expected = _compact_text(str(value.get("expected") or ""))
|
||
actual = _compact_text(str(value.get("actual") or ""))
|
||
severity = _compact_text(str(value.get("severity") or "minor"))
|
||
body_parts = []
|
||
if expected:
|
||
body_parts.append(f"기대: {expected}")
|
||
if actual:
|
||
body_parts.append(f"실제: {actual}")
|
||
if severity:
|
||
body_parts.append(f"심각도: {severity}")
|
||
if body_parts:
|
||
points.append(
|
||
ReviewPoint(
|
||
title=dimension,
|
||
body=" · ".join(body_parts),
|
||
jumpTo=None,
|
||
)
|
||
)
|
||
return points[:3]
|
||
|
||
|
||
def _rubric_from_evaluation(payload: dict[str, object]) -> list[ReviewRubricRow]:
|
||
distribution = payload.get("distribution")
|
||
if not isinstance(distribution, dict):
|
||
return []
|
||
by_category = distribution.get("by_category")
|
||
if not isinstance(by_category, dict):
|
||
return []
|
||
total = int(distribution.get("total") or 0)
|
||
if total <= 0:
|
||
return []
|
||
overused = {str(item) for item in distribution.get("overused") or []}
|
||
underused = {str(item) for item in distribution.get("underused") or []}
|
||
rows: list[ReviewRubricRow] = []
|
||
for category, raw_count in sorted(by_category.items(), key=lambda item: str(item[0])):
|
||
try:
|
||
count = int(raw_count)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
code = str(category)
|
||
watch = code in overused or code in underused
|
||
rows.append(
|
||
ReviewRubricRow(
|
||
name=code.replace("_", " ").title(),
|
||
cluster="평가 AI 기법 분포",
|
||
ratio=_clamp_ratio(count / max(1, total)),
|
||
quality="watch" if watch else "good",
|
||
freq=f"{count}/{total} labels",
|
||
)
|
||
)
|
||
return rows
|
||
|
||
|
||
def _review_summary_from_evaluation(
|
||
*,
|
||
fallback: str,
|
||
evaluation_record: dict[str, object] | None,
|
||
payload: dict[str, object],
|
||
) -> str:
|
||
if not evaluation_record:
|
||
return fallback
|
||
status = str(evaluation_record.get("status") or "")
|
||
if status != "ready":
|
||
failure = teacher_evaluation_failure(evaluation_record)
|
||
if failure is not None and not failure.retryable:
|
||
return (
|
||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
|
||
"평가 입력 경로를 조정한 뒤 다시 생성해야 합니다."
|
||
)
|
||
return (
|
||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
|
||
"AI 평가 재시도가 필요합니다."
|
||
)
|
||
rationale = _compact_text(str(payload.get("supervisor_rationale") or ""))
|
||
critique = _compact_text(str(payload.get("supervisor_critique") or ""))
|
||
evaluated = payload.get("turns_evaluated")
|
||
prefix = f"평가 AI가 학습자 발화 {evaluated}개를 deep-loop로 분석했습니다. "
|
||
details = " ".join(part for part in [rationale, critique] if part)
|
||
return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.")
|
||
|
||
|
||
def teacher_evaluation_failure(
|
||
evaluation_record: dict[str, object] | None,
|
||
) -> ReviewEvaluationFailure | None:
|
||
"""오류 원문을 노출하지 않고 교수자가 취할 다음 행동만 분류한다."""
|
||
if not evaluation_record or str(evaluation_record.get("status") or "") != "error":
|
||
return None
|
||
raw_error = str(evaluation_record.get("error") or "").strip().lower()
|
||
if raw_error == MISSING_SESSION_EVALUATION_ERROR.lower() or "저장되지 않았습니다" in raw_error:
|
||
return ReviewEvaluationFailure(code="missing_evaluation", retryable=True)
|
||
if (
|
||
("24,000" in raw_error or "명령줄 안전 한도" in raw_error)
|
||
and "agy" in raw_error
|
||
):
|
||
# 2026-08 이전 Windows Agy argv 경로의 durable 오류다. 현재 stdin 경로로는
|
||
# 재평가할 수 있으므로, 같은 오류처럼 보이더라도 복구 재시도를 열어 둔다.
|
||
return ReviewEvaluationFailure(code="legacy_argv_limit", retryable=True)
|
||
if "argv" in raw_error or "24000" in raw_error or "prompt too large" in raw_error:
|
||
return ReviewEvaluationFailure(code="prompt_too_large", retryable=False)
|
||
if "timeout" in raw_error or "timed out" in raw_error:
|
||
return ReviewEvaluationFailure(code="timeout", retryable=True)
|
||
if "no_structured_output" in raw_error or "parse_error" in raw_error:
|
||
return ReviewEvaluationFailure(code="invalid_structured_output", retryable=True)
|
||
if (
|
||
"engine_error" in raw_error
|
||
or "engine unavailable" in raw_error
|
||
or "transport error" in raw_error
|
||
):
|
||
return ReviewEvaluationFailure(code="engine_unavailable", retryable=True)
|
||
return ReviewEvaluationFailure(code="unknown", retryable=True)
|
||
|
||
|
||
def teacher_evaluation_failure_message(
|
||
failure: ReviewEvaluationFailure | None,
|
||
) -> str | None:
|
||
"""교수자 목록 API에서 provider 원문 대신 사용할 최소 행동 안내."""
|
||
if failure is None:
|
||
return None
|
||
if failure.code == "timeout":
|
||
return "AI 평가가 제한 시간 안에 끝나지 않았습니다. 다시 시도할 수 있습니다."
|
||
if failure.code == "engine_unavailable":
|
||
return "평가 엔진에 일시적으로 연결하지 못했습니다. 다시 시도할 수 있습니다."
|
||
if failure.code == "legacy_argv_limit":
|
||
return "이전 Windows 입력 한도에 걸린 평가입니다. 현재 입력 경로로 다시 시도할 수 있습니다."
|
||
if failure.code == "prompt_too_large":
|
||
return "평가 입력이 허용 크기를 넘어섰습니다. 입력 경로 조정이 필요합니다."
|
||
if failure.code == "invalid_structured_output":
|
||
return "평가 결과 형식이 검증되지 않았습니다. 다시 시도할 수 있습니다."
|
||
if failure.code == "missing_evaluation":
|
||
return "회기말 평가 기록이 아직 저장되지 않았습니다. 다시 시도할 수 있습니다."
|
||
return "AI 평가를 완료하지 못했습니다. 최신 상태를 확인해 주세요."
|
||
|
||
|
||
def _missing_session_evaluation_record(
|
||
sess: InProcSession,
|
||
*,
|
||
has_visible_turns: bool,
|
||
now_ts: float,
|
||
) -> dict[str, object] | None:
|
||
if not sess.ended or not has_visible_turns or sess.ended_at is None:
|
||
return None
|
||
stale_after = session_evaluation_stale_after_seconds()
|
||
if now_ts - sess.ended_at < stale_after:
|
||
return None
|
||
return {
|
||
"status": "error",
|
||
"source": "read_model",
|
||
"scope": "session_end",
|
||
"stage": stage_label(sess.state.stage),
|
||
"payload": {"error": MISSING_SESSION_EVALUATION_ERROR},
|
||
"error": MISSING_SESSION_EVALUATION_ERROR,
|
||
"updated_at": iso(now_ts),
|
||
}
|
||
|
||
|
||
def missing_session_evaluation_record(
|
||
sess: InProcSession,
|
||
*,
|
||
has_visible_turns: bool,
|
||
now_ts: float,
|
||
) -> dict[str, object] | None:
|
||
return _missing_session_evaluation_record(
|
||
sess,
|
||
has_visible_turns=has_visible_turns,
|
||
now_ts=now_ts,
|
||
)
|
||
|
||
|
||
def _next_line_from_evaluation(payload: dict[str, object]) -> str | None:
|
||
alternatives = payload.get("alternative_utterances")
|
||
if not isinstance(alternatives, list):
|
||
return None
|
||
for value in alternatives:
|
||
line = _compact_text(str(value or ""))
|
||
if line:
|
||
return line
|
||
return None
|
||
|
||
|
||
def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None:
|
||
for turn in reversed(turns):
|
||
if turn.speaker == "client":
|
||
return _clip_text(turn.text)
|
||
return None
|
||
|
||
|
||
def _worksheet_evidence(turn: ReviewTurn) -> ReviewWorksheetEvidence:
|
||
return ReviewWorksheetEvidence(
|
||
turnId=turn.id,
|
||
speaker=turn.speaker,
|
||
quote=_clip_text(turn.text, 120),
|
||
)
|
||
|
||
|
||
def _worksheet_item(
|
||
*,
|
||
key: str,
|
||
label: str,
|
||
turns: list[ReviewTurn],
|
||
keywords: list[str],
|
||
preferred_speaker: Literal["learner", "client"] | None = None,
|
||
fallback_turn: ReviewTurn | None = None,
|
||
) -> ReviewWorksheetItem:
|
||
lowered_keywords = [keyword.lower() for keyword in keywords if keyword]
|
||
candidates = turns
|
||
if preferred_speaker:
|
||
preferred = [turn for turn in turns if turn.speaker == preferred_speaker]
|
||
candidates = preferred + [turn for turn in turns if turn.speaker != preferred_speaker]
|
||
|
||
for turn in candidates:
|
||
text = _compact_text(turn.text)
|
||
lower_text = text.lower()
|
||
if lowered_keywords and any(keyword in lower_text for keyword in lowered_keywords):
|
||
return ReviewWorksheetItem(
|
||
key=key,
|
||
label=label,
|
||
value=_clip_text(text, 140),
|
||
evidence=[_worksheet_evidence(turn)],
|
||
confidence="medium",
|
||
)
|
||
|
||
if fallback_turn is not None:
|
||
return ReviewWorksheetItem(
|
||
key=key,
|
||
label=label,
|
||
value=_clip_text(fallback_turn.text, 140),
|
||
evidence=[_worksheet_evidence(fallback_turn)],
|
||
confidence="low",
|
||
)
|
||
|
||
return ReviewWorksheetItem(
|
||
key=key,
|
||
label=label,
|
||
value=None,
|
||
evidence=[],
|
||
confidence="none",
|
||
emptyReason="저장된 축어록에서 명시 근거를 찾지 못했습니다.",
|
||
)
|
||
|
||
|
||
def _worksheet_section(
|
||
key: str,
|
||
title: str,
|
||
specs: list[WorksheetItemSpec],
|
||
turns: list[ReviewTurn],
|
||
fallback_client: ReviewTurn | None,
|
||
fallback_learner: ReviewTurn | None,
|
||
) -> ReviewWorksheetSection:
|
||
items: list[ReviewWorksheetItem] = []
|
||
for item_key, label, keywords, speaker in specs:
|
||
fallback = fallback_client if speaker == "client" else fallback_learner if speaker == "learner" else None
|
||
items.append(
|
||
_worksheet_item(
|
||
key=item_key,
|
||
label=label,
|
||
turns=turns,
|
||
keywords=keywords,
|
||
preferred_speaker=speaker,
|
||
fallback_turn=fallback if item_key in {"presenting_complaint", "first_goal"} else None,
|
||
)
|
||
)
|
||
return ReviewWorksheetSection(key=key, title=title, items=items)
|
||
|
||
|
||
def case_worksheet_template_item_keys() -> dict[str, set[str]]:
|
||
return {
|
||
section_key: {item_key for item_key, _, _, _ in item_specs}
|
||
for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS
|
||
}
|
||
|
||
|
||
def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
|
||
if not turns:
|
||
return ReviewCaseWorksheet(
|
||
status="empty",
|
||
sections=[],
|
||
limitations=["저장된 축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
|
||
)
|
||
|
||
fallback_client = next((turn for turn in turns if turn.speaker == "client"), None)
|
||
fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None)
|
||
|
||
sections = [
|
||
_worksheet_section(
|
||
key,
|
||
title,
|
||
specs,
|
||
turns,
|
||
fallback_client,
|
||
fallback_learner,
|
||
)
|
||
for key, title, specs in CASE_WORKSHEET_SECTION_SPECS
|
||
]
|
||
return ReviewCaseWorksheet(
|
||
status="draft_from_transcript",
|
||
sections=sections,
|
||
limitations=[
|
||
"저장된 축어록에서 키워드 근거를 추출한 1차 초안입니다.",
|
||
"임상팀 루브릭, 교수자 검수, 학습자 수정 입력 전에는 확정 사례개념화로 보지 않습니다.",
|
||
],
|
||
)
|
||
|
||
|
||
def saved_case_worksheet_from_payload(payload: dict[str, object] | None) -> ReviewCaseWorksheet | None:
|
||
if not payload:
|
||
return None
|
||
try:
|
||
worksheet = ReviewCaseWorksheet.model_validate(payload)
|
||
except Exception:
|
||
return None
|
||
return worksheet.model_copy(update={"status": "saved_by_learner"})
|
||
|
||
|
||
def _worksheet_review_status_value(
|
||
value: object,
|
||
) -> Literal["pending", "approved", "changes_requested", "rejected"]:
|
||
raw = str(value or "pending")
|
||
if raw in {"approved", "changes_requested", "rejected"}:
|
||
return raw # type: ignore[return-value]
|
||
return "pending"
|
||
|
||
|
||
def _worksheet_share_highlights(worksheet: ReviewCaseWorksheet, *, limit: int = 4) -> list[dict[str, str]]:
|
||
highlights: list[dict[str, str]] = []
|
||
for section in worksheet.sections:
|
||
for item in section.items:
|
||
value = _compact_text(item.value or "")
|
||
if not value:
|
||
continue
|
||
highlights.append(
|
||
{
|
||
"section": section.title,
|
||
"label": item.label,
|
||
"value": "비공개 요약 항목",
|
||
}
|
||
)
|
||
if len(highlights) >= limit:
|
||
return highlights
|
||
return highlights
|
||
|
||
|
||
def _review_point_titles(points: list[ReviewPoint], *, limit: int = 3) -> list[str]:
|
||
return [_clip_text(point.title or point.body, 72) for point in points[:limit] if (point.title or point.body)]
|
||
|
||
|
||
def _share_image_url() -> str:
|
||
return f"{settings.frontend_base_url.rstrip('/')}/design-elements/clinical-paper-ambient.png"
|
||
|
||
|
||
def session_share_payload(review: SessionReviewResponse) -> dict[str, object]:
|
||
title = f"Vignette 회기 리뷰 · {review.client.name} {review.date}"
|
||
description = _clip_text(review.summary, 156)
|
||
return {
|
||
"version": 1,
|
||
"title": title,
|
||
"description": description,
|
||
"summary": _clip_text(review.summary, 420),
|
||
"clientName": review.client.name,
|
||
"persona": review.client.persona,
|
||
"date": review.date,
|
||
"durationLabel": review.durationLabel,
|
||
"reachedPhase": review.reachedPhase,
|
||
"sessionSignal": review.sessionSignal,
|
||
"reviewReady": review.reviewReady,
|
||
"goodMoments": _review_point_titles(review.goodMoments),
|
||
"growthPoints": _review_point_titles(review.growthPoints),
|
||
"worksheetHighlights": _worksheet_share_highlights(review.caseWorksheet),
|
||
"imageUrl": _share_image_url(),
|
||
"appUrl": settings.frontend_base_url.rstrip("/"),
|
||
"privacy": "공유 카드에는 회기 원문 축어록과 학습자 식별 정보를 포함하지 않습니다.",
|
||
}
|
||
|
||
|
||
def _evaluation_payload(
|
||
record: dict[str, object] | None,
|
||
*,
|
||
counselor_identity: str | None = None,
|
||
client_identity: str | None = None,
|
||
) -> dict[str, object]:
|
||
if not record:
|
||
return {}
|
||
payload = record.get("payload")
|
||
if not isinstance(payload, dict):
|
||
return {}
|
||
masked = _mask_payload_text_values(
|
||
payload,
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
)
|
||
return masked if isinstance(masked, dict) else {}
|
||
|
||
|
||
def _mask_payload_text(
|
||
value: object,
|
||
*,
|
||
counselor_identity: str | None = None,
|
||
client_identity: str | None = None,
|
||
) -> str:
|
||
return guardrail.mask_role_identities(
|
||
str(value or ""),
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
synthetic_generated=True,
|
||
).text_masked
|
||
|
||
|
||
def _mask_payload_text_values(
|
||
value: Any,
|
||
*,
|
||
counselor_identity: str | None = None,
|
||
client_identity: str | None = None,
|
||
) -> Any:
|
||
if isinstance(value, str):
|
||
return _mask_payload_text(
|
||
value,
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
)
|
||
if isinstance(value, dict):
|
||
return {
|
||
str(key): _mask_payload_text_values(
|
||
child,
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
)
|
||
for key, child in value.items()
|
||
}
|
||
if isinstance(value, list):
|
||
return [
|
||
_mask_payload_text_values(
|
||
child,
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
)
|
||
for child in value
|
||
]
|
||
if isinstance(value, tuple):
|
||
return [
|
||
_mask_payload_text_values(
|
||
child,
|
||
counselor_identity=counselor_identity,
|
||
client_identity=client_identity,
|
||
)
|
||
for child in value
|
||
]
|
||
return value
|
||
|
||
|
||
_TECHNIQUE_KIND_BY_CATEGORY = {
|
||
"relational": "empathy",
|
||
"exploratory": "explore",
|
||
"intervention": "confront",
|
||
"stabilizing": "reflect",
|
||
"structuring": "closed",
|
||
}
|
||
|
||
|
||
def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]:
|
||
if not isinstance(ev, dict):
|
||
return []
|
||
out: list[ReviewTechnique] = []
|
||
for tag in ev.get("techniques") or []:
|
||
if not isinstance(tag, dict):
|
||
continue
|
||
label = str(tag.get("label_ko") or tag.get("code") or "").strip()
|
||
if not label:
|
||
continue
|
||
kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore")
|
||
out.append(ReviewTechnique(kind=kind, label=label))
|
||
return out
|
||
|
||
|
||
def _review_note_body_markdown(text: str) -> str:
|
||
body = text.strip()
|
||
body = re.sub(
|
||
r"`?\beffective[_\s-]?openness\b`?(?!\(유효 개방도\))",
|
||
"`effective openness(유효 개방도)`",
|
||
body,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
body = re.sub(
|
||
r"(?<![A-Za-z0-9_])'([^'\n]{1,40})'(?![A-Za-z0-9_])",
|
||
lambda m: f"**“{m.group(1).strip()}”**",
|
||
body,
|
||
)
|
||
if "\n" not in body:
|
||
body = re.sub(r"\s+(다만|하지만|참고로)\s+", r"\n\n\1 ", body, count=1)
|
||
return body
|
||
|
||
|
||
def _review_quote_excerpt(text: str | None, *, limit: int = 96) -> str | None:
|
||
clean = " ".join(str(text or "").split())
|
||
if not clean:
|
||
return None
|
||
sentences = [part.strip() for part in re.split(r"(?<=[.!?。!?])\s+", clean) if part.strip()]
|
||
for sentence in sentences:
|
||
if "가장 큰 마음" in sentence:
|
||
return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..."
|
||
for sentence in sentences:
|
||
if "?" in sentence:
|
||
return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..."
|
||
return clean if len(clean) <= limit else f"{clean[: limit - 3].rstrip()}..."
|
||
|
||
|
||
def _review_note_from_turn_eval(
|
||
ev: dict[str, object] | None,
|
||
learner_text: str | None = None,
|
||
) -> Optional[ReviewNote]:
|
||
if not isinstance(ev, dict):
|
||
return None
|
||
quote = _review_quote_excerpt(learner_text)
|
||
error_text = str(ev.get("error") or "").strip()
|
||
if error_text:
|
||
return ReviewNote(
|
||
author="평가 AI",
|
||
tone="warn",
|
||
title="턴 직후 평가 실패",
|
||
body=_review_note_body_markdown(
|
||
"이 발화의 fast-loop(턴 직후) 평가를 완료하지 못했습니다.\n\n"
|
||
"AI 평가 재시도가 필요합니다."
|
||
),
|
||
quote=quote,
|
||
)
|
||
dev = ev.get("intent_deviation")
|
||
if isinstance(dev, dict):
|
||
dimension = str(dev.get("dimension") or "").strip()
|
||
expected = str(dev.get("expected") or "").strip()
|
||
actual = str(dev.get("actual") or "").strip()
|
||
body_parts = (
|
||
f"- 권장: {expected}" if expected else "",
|
||
f"- 실제: {actual}" if actual else "",
|
||
)
|
||
body = "\n".join(p for p in body_parts if p)
|
||
return ReviewNote(
|
||
author="평가 AI",
|
||
tone="warn",
|
||
title=f"의도와 다른 부분 · {dimension} · 턴 직후".replace(" · · ", " · ").rstrip(" ·"),
|
||
body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."),
|
||
quote=quote,
|
||
)
|
||
appropriateness = str(ev.get("appropriateness") or "neutral")
|
||
note_text = str(ev.get("appropriateness_note") or "").strip()
|
||
if appropriateness == "pos":
|
||
return ReviewNote(
|
||
author="평가 AI",
|
||
tone="good",
|
||
title="적절한 개입 · 턴 직후",
|
||
body=_review_note_body_markdown(
|
||
note_text
|
||
or (
|
||
"타당화·공감·탐색이 회기 흐름에 맞았습니다.\n\n"
|
||
"`effective openness(유효 개방도)`가 낮은 내담자라면 다음 질문은 "
|
||
"더 작고 구체적인 선택지로 낮춰도 좋습니다."
|
||
)
|
||
),
|
||
quote=quote,
|
||
)
|
||
if appropriateness == "warn" and note_text:
|
||
return ReviewNote(
|
||
author="평가 AI",
|
||
tone="warn",
|
||
title="점검해볼 지점 · 턴 직후",
|
||
body=_review_note_body_markdown(note_text),
|
||
quote=quote,
|
||
)
|
||
return None
|
||
|
||
|
||
def _seconds_label(milliseconds: int) -> str:
|
||
seconds = max(0, milliseconds) / 1000.0
|
||
if seconds >= 10:
|
||
return f"{seconds:.0f}초"
|
||
return f"{seconds:.1f}초"
|
||
|
||
|
||
_PROVIDER_REVIEW_EVENT_LABELS: dict[str, tuple[str, str, str]] = {
|
||
"sigh": ("paralinguistic", "음성 단서", "한숨 감지"),
|
||
"cry": ("paralinguistic", "음성 단서", "울음 감지"),
|
||
"laugh": ("paralinguistic", "음성 단서", "웃음 감지"),
|
||
"breath": ("paralinguistic", "음성 단서", "호흡 변화"),
|
||
"pitch": ("prosody", "운율", "피치 변화"),
|
||
"intonation": ("prosody", "운율", "억양 변화"),
|
||
"prosody": ("prosody", "운율", "운율 변화"),
|
||
"background_noise": ("audio_quality", "오디오 품질", "배경 소음"),
|
||
}
|
||
|
||
|
||
def _provider_confidence_label(event: dict[str, object]) -> str:
|
||
raw = event.get("confidence", event.get("score"))
|
||
if not isinstance(raw, (int, float)):
|
||
return ""
|
||
value = float(raw)
|
||
if 0 <= value <= 1:
|
||
return f"신뢰도 {value * 100:.0f}%"
|
||
if 1 < value <= 100:
|
||
return f"신뢰도 {value:.0f}%"
|
||
return ""
|
||
|
||
|
||
def _provider_duration_label(event: dict[str, object]) -> str:
|
||
raw = event.get("duration_ms")
|
||
if not isinstance(raw, (int, float)):
|
||
return ""
|
||
milliseconds = int(raw)
|
||
if milliseconds <= 0:
|
||
return ""
|
||
return _seconds_label(milliseconds)
|
||
|
||
|
||
def _review_provider_event(event: dict[str, object]) -> ReviewNonverbalEvent | None:
|
||
event_type = str(event.get("event_type") or "").strip()
|
||
if not event_type:
|
||
return None
|
||
if event_type == "barge_in":
|
||
return ReviewNonverbalEvent(kind="barge_in", label="끼어듦", detail="provider 감지")
|
||
if event_type == "silence":
|
||
detail = _provider_duration_label(event) or "provider 감지"
|
||
return ReviewNonverbalEvent(kind="silence", label="침묵", detail=detail)
|
||
if event_type == "speech_rate":
|
||
return ReviewNonverbalEvent(kind="pace", label="발화 속도", detail="provider 감지")
|
||
|
||
mapped = _PROVIDER_REVIEW_EVENT_LABELS.get(event_type)
|
||
if mapped is None:
|
||
return None
|
||
kind, label, detail = mapped
|
||
extras = [item for item in (_provider_confidence_label(event), _provider_duration_label(event)) if item]
|
||
if extras:
|
||
detail = f"{detail} · {' · '.join(extras)}"
|
||
return ReviewNonverbalEvent(kind=kind, label=label, detail=detail)
|
||
|
||
|
||
def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
|
||
events: list[ReviewNonverbalEvent] = []
|
||
has_turn_level_silence = turn.silence_ms is not None and turn.silence_ms >= 1000
|
||
if has_turn_level_silence:
|
||
events.append(
|
||
ReviewNonverbalEvent(
|
||
kind="silence",
|
||
label="침묵",
|
||
detail=_seconds_label(turn.silence_ms),
|
||
)
|
||
)
|
||
if turn.speech_rate is not None:
|
||
events.append(
|
||
ReviewNonverbalEvent(
|
||
kind="pace",
|
||
label="발화 속도",
|
||
detail=f"분당 {turn.speech_rate:.0f}자",
|
||
)
|
||
)
|
||
if turn.barge_in is True:
|
||
events.append(
|
||
ReviewNonverbalEvent(
|
||
kind="barge_in",
|
||
label="끼어듦",
|
||
detail="내담자 발화 중 시작",
|
||
)
|
||
)
|
||
if turn.audio_ref:
|
||
events.append(
|
||
ReviewNonverbalEvent(
|
||
kind="audio",
|
||
label="음성 발화",
|
||
detail="오디오 메타 저장됨",
|
||
)
|
||
)
|
||
for event in turn.provider_events:
|
||
if not isinstance(event, dict):
|
||
continue
|
||
if has_turn_level_silence and str(event.get("event_type") or "").strip() == "silence":
|
||
continue
|
||
review_event = _review_provider_event(event)
|
||
if review_event is not None:
|
||
events.append(review_event)
|
||
return events
|
||
|
||
|
||
# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ──────────────────────────────
|
||
_VALENCE_MAX_POINTS = 10
|
||
|
||
# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵).
|
||
_CLIENT_STATE_VALENCE: dict[str, float] = {
|
||
# 음의 극성 — 방어·위축·위기 신호
|
||
"involuntary": -0.5,
|
||
"defensive": -0.7,
|
||
"suicidal_ideation_admit": -0.9,
|
||
"negative_self_perception": -0.7,
|
||
"conflicted": -0.4,
|
||
"lack_of_confidence": -0.4,
|
||
"compliant_surface": -0.2,
|
||
"externalizing": -0.3,
|
||
"apparent_competence": -0.2,
|
||
"active_passivity": -0.3,
|
||
"self_harm_disclosure": -0.9,
|
||
"somatic_complaint": -0.4,
|
||
"affect_masking": -0.3,
|
||
"focus_drift_fusion": -0.3,
|
||
# 양의 극성 — 개방·접촉·진전 신호
|
||
"affect_contact": 0.6,
|
||
"thought_organizing": 0.5,
|
||
"responds_to_exploration": 0.5,
|
||
"expresses_plan": 0.7,
|
||
"defense_loosening": 0.6,
|
||
"seeks_guidance": 0.2,
|
||
}
|
||
|
||
|
||
def _clamp_valence(value: float) -> float:
|
||
return max(-1.0, min(1.0, value))
|
||
|
||
|
||
def _valence_t(
|
||
created_at: float, first_turn_ts: float, duration_seconds: float
|
||
) -> float:
|
||
"""턴 시각 → 회기 진행률(0~1 클램프)."""
|
||
if duration_seconds <= 0:
|
||
return 0.0
|
||
return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds))
|
||
|
||
|
||
def _finalize_valence_points(
|
||
points: list[ReviewValencePoint],
|
||
) -> list[ReviewValencePoint]:
|
||
"""2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지)."""
|
||
if len(points) < 2:
|
||
return []
|
||
if len(points) <= _VALENCE_MAX_POINTS:
|
||
return points
|
||
last = len(points) - 1
|
||
indices: list[int] = []
|
||
for i in range(_VALENCE_MAX_POINTS):
|
||
idx = round(i * last / (_VALENCE_MAX_POINTS - 1))
|
||
if not indices or idx != indices[-1]:
|
||
indices.append(idx)
|
||
return [points[i] for i in indices]
|
||
|
||
|
||
def counselor_baseline_points(
|
||
turns: list[TurnRecord],
|
||
*,
|
||
first_turn_ts: float,
|
||
duration_seconds: float,
|
||
) -> list[ReviewValencePoint]:
|
||
"""학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적."""
|
||
points: list[ReviewValencePoint] = []
|
||
total = 0.0
|
||
count = 0
|
||
for turn in turns:
|
||
if turn.speaker != "counselor":
|
||
continue
|
||
ev = session_metrics.turn_eval(turn)
|
||
if ev is None or str(ev.get("error") or "").strip():
|
||
continue
|
||
rapport = session_metrics.turn_rapport(ev)
|
||
if rapport is None:
|
||
continue
|
||
count += 1
|
||
total += rapport
|
||
points.append(
|
||
ReviewValencePoint(
|
||
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
|
||
v=_clamp_valence(total / count),
|
||
)
|
||
)
|
||
return _finalize_valence_points(points)
|
||
|
||
|
||
def _fallback_client_valence_points(
|
||
turns: list[TurnRecord],
|
||
*,
|
||
first_turn_ts: float,
|
||
duration_seconds: float,
|
||
) -> list[ReviewValencePoint]:
|
||
"""폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합."""
|
||
points: list[ReviewValencePoint] = []
|
||
for turn in turns:
|
||
if turn.speaker != "counselor":
|
||
continue
|
||
ev = session_metrics.turn_eval(turn)
|
||
if ev is None or str(ev.get("error") or "").strip():
|
||
continue
|
||
polarities: list[float] = []
|
||
for state in ev.get("client_state_read") or []:
|
||
code = state.get("code") if isinstance(state, dict) else state
|
||
mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip())
|
||
if mapped is not None:
|
||
polarities.append(mapped)
|
||
score01 = session_metrics.turn_score(ev)
|
||
parts: list[float] = []
|
||
if polarities:
|
||
parts.append(0.7 * (sum(polarities) / len(polarities)))
|
||
if score01 is not None:
|
||
parts.append(0.3 * (score01 * 2.0 - 1.0))
|
||
if not parts:
|
||
continue
|
||
points.append(
|
||
ReviewValencePoint(
|
||
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
|
||
v=_clamp_valence(sum(parts)),
|
||
)
|
||
)
|
||
return points
|
||
|
||
|
||
def client_valence_points(
|
||
turns: list[TurnRecord],
|
||
evaluation_payload: dict[str, object],
|
||
*,
|
||
first_turn_ts: float,
|
||
duration_seconds: float,
|
||
) -> list[ReviewValencePoint]:
|
||
"""내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백."""
|
||
raw = (
|
||
evaluation_payload.get("turn_valence")
|
||
if isinstance(evaluation_payload, dict)
|
||
else None
|
||
)
|
||
points: list[ReviewValencePoint] = []
|
||
if isinstance(raw, list):
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
seq = item.get("seq")
|
||
v = item.get("v")
|
||
if isinstance(seq, bool) or not isinstance(seq, int):
|
||
continue
|
||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||
continue
|
||
index = seq - 1
|
||
if index < 0 or index >= len(turns):
|
||
continue
|
||
points.append(
|
||
ReviewValencePoint(
|
||
t=_valence_t(
|
||
turns[index].created_at, first_turn_ts, duration_seconds
|
||
),
|
||
v=_clamp_valence(float(v)),
|
||
)
|
||
)
|
||
points.sort(key=lambda point: point.t)
|
||
if not points:
|
||
points = _fallback_client_valence_points(
|
||
turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds
|
||
)
|
||
return _finalize_valence_points(points)
|
||
|
||
|
||
def _valence_axis(
|
||
duration_seconds: int, *, has_points: bool, fallback: list[str]
|
||
) -> list[str]:
|
||
"""포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지."""
|
||
if not has_points or duration_seconds <= 0:
|
||
return fallback
|
||
return [_offset_label(duration_seconds * i / 3) for i in range(4)]
|
||
|
||
|
||
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
|
||
sess = read_input.session
|
||
visible_turns = learner_visible_turns(sess)
|
||
hidden_turns = len(visible_turns) != len(sess.turns)
|
||
feedback_hidden = hidden_turns or not read_input.expose_learner_feedback
|
||
|
||
end_ts = sess.ended_at or read_input.now_ts or datetime.now().timestamp()
|
||
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
|
||
client_name = _client_name(sess.persona.display_name)
|
||
client_initial = client_name[:1] or "내"
|
||
|
||
reached_phase = stage_label(sess.state.stage)
|
||
stage_labels = [stage_label(turn.stage) for turn in visible_turns] or [reached_phase]
|
||
axis = ["0:00"]
|
||
if duration_seconds > 0:
|
||
axis.append(_offset_label(duration_seconds))
|
||
|
||
now_ts = read_input.now_ts or datetime.now().timestamp()
|
||
evaluation_record = read_input.evaluation_record
|
||
if evaluation_record is None and not feedback_hidden:
|
||
evaluation_record = _missing_session_evaluation_record(
|
||
sess,
|
||
has_visible_turns=bool(visible_turns),
|
||
now_ts=now_ts,
|
||
)
|
||
evaluation_payload = (
|
||
{}
|
||
if feedback_hidden
|
||
else _evaluation_payload(
|
||
evaluation_record,
|
||
counselor_identity=sess.learner_label,
|
||
client_identity=sess.persona.display_name,
|
||
)
|
||
)
|
||
evaluation_status = (
|
||
""
|
||
if feedback_hidden
|
||
else str(evaluation_record.get("status") or "")
|
||
if evaluation_record
|
||
else ""
|
||
)
|
||
evaluation_ready = not feedback_hidden and evaluation_status == "ready"
|
||
evaluation_failure = (
|
||
teacher_evaluation_failure(evaluation_record)
|
||
if read_input.include_teacher_review and not feedback_hidden
|
||
else None
|
||
)
|
||
|
||
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
|
||
turns: list[ReviewTurn] = []
|
||
for index, turn in enumerate(visible_turns):
|
||
speaker: Literal["learner", "client"] = (
|
||
"learner" if turn.speaker == "counselor" else "client"
|
||
)
|
||
safe_turn_text = guardrail.mask_role_identities(
|
||
turn.text_masked,
|
||
counselor_identity=sess.learner_label,
|
||
client_identity=sess.persona.display_name,
|
||
synthetic_generated=speaker == "client",
|
||
).text_masked
|
||
turn_eval = turn.evaluation if (speaker == "learner" and not feedback_hidden) else None
|
||
inner_reaction = (
|
||
(read_input.inner_reactions or {}).get(turn.turn_id)
|
||
if speaker == "client" and turn.turn_id 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),
|
||
innerReaction=inner_reaction,
|
||
)
|
||
)
|
||
|
||
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
|
||
counselor_baseline: list[ReviewValencePoint] = []
|
||
client_valence: list[ReviewValencePoint] = []
|
||
if not feedback_hidden:
|
||
counselor_baseline = counselor_baseline_points(
|
||
visible_turns,
|
||
first_turn_ts=first_turn_ts,
|
||
duration_seconds=duration_seconds,
|
||
)
|
||
client_valence = client_valence_points(
|
||
visible_turns,
|
||
evaluation_payload,
|
||
first_turn_ts=first_turn_ts,
|
||
duration_seconds=duration_seconds,
|
||
)
|
||
valence_axis = _valence_axis(
|
||
duration_seconds,
|
||
has_points=bool(counselor_baseline or client_valence),
|
||
fallback=axis,
|
||
)
|
||
|
||
if not turns:
|
||
session_signal = "기록 없음"
|
||
elif sess.ended:
|
||
session_signal = "종료됨"
|
||
else:
|
||
session_signal = "진행 중"
|
||
|
||
transcript_summary = _review_summary(
|
||
client_name="내담자",
|
||
reached_phase=reached_phase,
|
||
turns=turns,
|
||
)
|
||
|
||
rubric: list[ReviewRubricRow] = []
|
||
good_moments: list[ReviewPoint] = []
|
||
growth_points: list[ReviewPoint] = []
|
||
next_line: str | None = None
|
||
if evaluation_ready:
|
||
rubric = _rubric_from_evaluation(evaluation_payload)
|
||
good_moments = _ai_review_points(
|
||
evaluation_payload.get("strengths"),
|
||
fallback_prefix="강점",
|
||
)
|
||
growth_points = _ai_review_points(
|
||
evaluation_payload.get("improvements"),
|
||
fallback_prefix="개선점",
|
||
)
|
||
if not growth_points:
|
||
growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations"))
|
||
next_line = _next_line_from_evaluation(evaluation_payload)
|
||
|
||
client_feedback = None if feedback_hidden else _latest_client_feedback(turns)
|
||
review_degraded = bool(turns) and not evaluation_ready and not feedback_hidden
|
||
if not read_input.expose_learner_feedback:
|
||
supervisor_state = "피드백 비활성"
|
||
elif evaluation_ready:
|
||
supervisor_state = "평가 완료"
|
||
elif evaluation_status == "error":
|
||
supervisor_state = "평가 실패"
|
||
elif turns:
|
||
supervisor_state = "평가 대기"
|
||
else:
|
||
supervisor_state = "기록 대기"
|
||
|
||
summary = (
|
||
"관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. "
|
||
"회기 축어록과 가상 내담자 응답은 그대로 보존됩니다."
|
||
if not read_input.expose_learner_feedback
|
||
else _review_summary_from_evaluation(
|
||
fallback=transcript_summary,
|
||
evaluation_record=None if hidden_turns else evaluation_record,
|
||
payload=evaluation_payload,
|
||
)
|
||
)
|
||
if evaluation_record and not feedback_hidden and not read_input.evaluation_durable:
|
||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
||
|
||
saved_worksheet = saved_case_worksheet_from_payload(
|
||
read_input.saved_worksheet_payload
|
||
)
|
||
generated_worksheet = case_worksheet_from_turns(turns)
|
||
if not read_input.expose_learner_feedback:
|
||
case_worksheet = saved_worksheet or ReviewCaseWorksheet(
|
||
status="empty",
|
||
generatedBy="learner input only",
|
||
limitations=[
|
||
"AI 자동 초안은 표시하지 않습니다. 저장한 학습자 워크시트는 그대로 보존됩니다."
|
||
],
|
||
)
|
||
else:
|
||
case_worksheet = saved_worksheet or generated_worksheet
|
||
first_session_review = (
|
||
None
|
||
if feedback_hidden
|
||
else first_session_checklist(session_no=sess.session_no, turns=turns)
|
||
)
|
||
|
||
teacher_review: SessionTeacherReviewStatus | None = None
|
||
if read_input.include_teacher_review:
|
||
review_status = read_input.teacher_review_record or {}
|
||
review_status_value = str(review_status.get("status") or "pending")
|
||
if review_status_value not in {"viewed", "closed"}:
|
||
review_status_value = "pending"
|
||
teacher_review = SessionTeacherReviewStatus(
|
||
status=review_status_value, # type: ignore[arg-type]
|
||
note=str(review_status.get("note") or ""),
|
||
reviewerId=str(review_status.get("reviewer_id") or "") or None,
|
||
reviewedAt=str(review_status.get("reviewed_at") or "") or None,
|
||
updatedAt=str(review_status.get("updated_at") or "") or None,
|
||
worksheetStatus=_worksheet_review_status_value(review_status.get("worksheet_status")),
|
||
worksheetNote=str(review_status.get("worksheet_note") or ""),
|
||
worksheetReviewedAt=str(review_status.get("worksheet_reviewed_at") or "") or None,
|
||
)
|
||
|
||
return SessionReviewResponse(
|
||
session_id=sess.session_id,
|
||
sessionNo=sess.session_no,
|
||
client=ReviewClient(
|
||
name=client_name,
|
||
initial=client_initial,
|
||
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
|
||
),
|
||
date=session_calendar_date(sess.created_at),
|
||
durationLabel=_duration_label(duration_seconds),
|
||
durationSeconds=duration_seconds,
|
||
reachedPhase=reached_phase,
|
||
sessionSignal=session_signal,
|
||
supervisorState=supervisor_state,
|
||
supervisorName="AI",
|
||
summary=summary,
|
||
phases=_phase_segments(stage_labels),
|
||
phaseAxis=axis,
|
||
valenceAxis=valence_axis,
|
||
clientValence=client_valence,
|
||
counselorBaseline=counselor_baseline,
|
||
turns=turns,
|
||
rubric=rubric,
|
||
firstSessionChecklist=first_session_review,
|
||
goodMoments=good_moments,
|
||
growthPoints=growth_points,
|
||
caseWorksheet=case_worksheet,
|
||
nextLine=next_line,
|
||
clientFeedback=client_feedback,
|
||
audioUrl=None,
|
||
pdfExportUrl=None,
|
||
degraded=review_degraded,
|
||
reviewReady=evaluation_ready,
|
||
evaluationFailure=evaluation_failure,
|
||
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
|
||
teacherReview=teacher_review,
|
||
)
|