회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
This commit is contained in:
parent
a479db7a5a
commit
a0311c5957
100 changed files with 4884 additions and 11210 deletions
358
apps/api/app/session_dashboard_projection.py
Normal file
358
apps/api/app/session_dashboard_projection.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""학습자 대시보드 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue