358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""학습자 대시보드 DTO와 순수 projection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .services import session_metrics
|
|
from .session_projection import _rapport_percent, learner_visible_turns, stage_label
|
|
from .stage_contract import StageLabel
|
|
from .store import InProcSession
|
|
|
|
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
|
|
TRAINING_EXPOSURE_MIN_COMPLETED = 4
|
|
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
|
|
|
|
|
|
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"
|
|
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
|
|
|
|
|
|
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
|