개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -11,7 +11,7 @@ import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, Optional, cast
from typing import Any, Callable, Literal, Optional, cast
from pydantic import BaseModel, Field
@ -36,6 +36,10 @@ 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):
@ -51,6 +55,7 @@ class LearnerSessionSummary(BaseModel):
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
@ -111,6 +116,23 @@ class LearnerDashboardPersonaProgress(BaseModel):
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
@ -137,6 +159,9 @@ class LearnerDashboardResponse(BaseModel):
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
@ -230,6 +255,8 @@ class SessionDetailTurn(BaseModel):
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
@ -240,6 +267,7 @@ class SessionDetailResponse(BaseModel):
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
@ -306,6 +334,31 @@ class ReviewRubricRow(BaseModel):
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
@ -421,6 +474,7 @@ class SessionTeacherReviewStatus(BaseModel):
class SessionReviewResponse(BaseModel):
session_id: str
sessionNo: int = Field(ge=1)
client: ReviewClient
date: str
durationLabel: str
@ -437,6 +491,7 @@ class SessionReviewResponse(BaseModel):
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)
@ -446,6 +501,7 @@ class SessionReviewResponse(BaseModel):
pdfExportUrl: Optional[str] = None
degraded: bool = True
reviewReady: bool = False
learnerFeedbackEnabled: bool = True
teacherReview: SessionTeacherReviewStatus | None = None
@ -469,6 +525,8 @@ class SessionReviewReadInput:
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
@ -494,6 +552,7 @@ def learner_summary(
sess: InProcSession,
*,
review_ready: bool = False,
learner_feedback_enabled: bool | None = None,
archived: bool = False,
archived_at: str | None = None,
) -> LearnerSessionSummary:
@ -513,6 +572,11 @@ def learner_summary(
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,
)
@ -591,6 +655,7 @@ def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
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:
@ -600,8 +665,17 @@ def dashboard_persona_progress(
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(
ordered,
metric_sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
@ -632,6 +706,45 @@ def dashboard_persona_progress(
)
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"
@ -705,11 +818,14 @@ 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,
@ -729,6 +845,11 @@ def session_detail(
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,
@ -820,6 +941,87 @@ def _clip_text(text: str, limit: int = 180) -> str:
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"):
@ -1176,29 +1378,78 @@ def session_share_payload(review: SessionReviewResponse) -> dict[str, object]:
}
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
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)
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) -> str:
return guardrail.mask_pii(str(value or "")).text_masked
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) -> Any:
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)
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) for key, child in value.items()}
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) for child in value]
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) for child in value]
return [
_mask_payload_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
for child in value
]
return value
@ -1614,6 +1865,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
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)))
@ -1628,17 +1880,29 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
now_ts = read_input.now_ts or datetime.now().timestamp()
evaluation_record = read_input.evaluation_record
if evaluation_record is None and not hidden_turns:
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 hidden_turns else _evaluation_payload(evaluation_record)
evaluation_status = (
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""
evaluation_payload = (
{}
if feedback_hidden
else _evaluation_payload(
evaluation_record,
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
)
evaluation_ready = not hidden_turns and evaluation_status == "ready"
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] = []
@ -1646,25 +1910,35 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
speaker: Literal["learner", "client"] = (
"learner" if turn.speaker == "counselor" else "client"
)
turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None
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 client_name,
text=turn.text_masked,
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" else [],
note=_review_note_from_turn_eval(turn_eval, turn.text_masked),
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 hidden_turns:
if not feedback_hidden:
counselor_baseline = counselor_baseline_points(
visible_turns,
first_turn_ts=first_turn_ts,
@ -1690,7 +1964,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
session_signal = "진행 중"
transcript_summary = _review_summary(
client_name=client_name,
client_name="내담자",
reached_phase=reached_phase,
turns=turns,
)
@ -1713,9 +1987,11 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations"))
next_line = _next_line_from_evaluation(evaluation_payload)
client_feedback = _latest_client_feedback(turns)
review_degraded = bool(turns) and not evaluation_ready
if evaluation_ready:
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 = "평가 실패"
@ -1724,18 +2000,37 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
else:
supervisor_state = "기록 대기"
summary = _review_summary_from_evaluation(
fallback=transcript_summary,
evaluation_record=None if hidden_turns else evaluation_record,
payload=evaluation_payload,
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 hidden_turns and not read_input.evaluation_durable:
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)
case_worksheet = (
saved_case_worksheet_from_payload(read_input.saved_worksheet_payload)
or generated_worksheet
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
@ -1757,6 +2052,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
return SessionReviewResponse(
session_id=sess.session_id,
sessionNo=sess.session_no,
client=ReviewClient(
name=client_name,
initial=client_initial,
@ -1777,6 +2073,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
counselorBaseline=counselor_baseline,
turns=turns,
rubric=rubric,
firstSessionChecklist=first_session_review,
goodMoments=good_moments,
growthPoints=growth_points,
caseWorksheet=case_worksheet,
@ -1786,5 +2083,6 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
pdfExportUrl=None,
degraded=review_degraded,
reviewReady=evaluation_ready,
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
teacherReview=teacher_review,
)