From fe2796f05ae65daac534502958e349ec86477b6d Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 1 Jul 2026 12:10:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=ED=8F=89=EA=B0=80?= =?UTF-8?q?=EC=99=80=20=EA=B5=90=EC=88=98=EC=9E=90=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1-Photo-1.jpg | Bin 0 -> 25018 bytes apps/api/app/auth_sessions.py | 18 +- apps/api/app/config.py | 4 + apps/api/app/db.py | 14 +- apps/api/app/routes/eval.py | 67 +- apps/api/app/routes/sessions.py | 188 +++- apps/api/app/routes/teacher.py | 111 ++- apps/api/app/services/guardrail.py | 72 +- apps/api/app/services/orchestrator.py | 20 +- apps/api/app/services/session_metrics.py | 3 +- apps/api/app/session_persistence.py | 14 +- apps/api/app/session_read_model.py | 5 +- apps/api/app/test_eval_routes.py | 213 ++++ apps/api/app/test_evaluation_persistence.py | 11 + apps/api/app/test_notifications.py | 47 + apps/api/app/test_orchestrator_masking.py | 75 +- apps/api/app/test_pii_masking_eval.py | 13 +- apps/api/app/test_rbac_idor.py | 146 +++ apps/api/app/test_runtime_policy.py | 46 +- apps/api/app/test_session_turn_persistence.py | 109 +++ apps/api/app/test_teacher_dashboard.py | 84 ++ apps/web/e2e/dev-dashboard.spec.ts | 336 +++++++ apps/web/e2e/layout-visual-gate.spec.ts | 196 ++++ apps/web/e2e/learner.spec.ts | 38 + apps/web/e2e/session-mvp.spec.ts | 173 +++- apps/web/e2e/session-persistence.spec.ts | 907 ++++++++++++++++++ apps/web/e2e/session-review.spec.ts | 269 +++++- apps/web/e2e/teacher.spec.ts | 135 +++ apps/web/e2e/voice-success.spec.ts | 49 +- .../public/worklets/voice-capture-worklet.js | 35 + apps/web/src/App.tsx | 9 + apps/web/src/components/shell/Sidebar.tsx | 2 + apps/web/src/lib/api.gen.ts | 112 +++ apps/web/src/lib/api.ts | 19 + apps/web/src/pages/Professor.tsx | 410 +++++++- apps/web/src/pages/Session.tsx | 165 +++- apps/web/src/pages/SessionReview.tsx | 91 +- apps/web/src/pages/learner-home.css | 31 +- apps/web/src/pages/professor.css | 500 +++++++++- .../pages/session-review/session-review.css | 48 + apps/web/src/pages/session/session.css | 49 + data/privacy/pii-masking-ko-fixtures.json | 12 + docs/HANDOFF.md | 12 +- docs/dev_dashboard.html | 111 ++- docs/guides/architecture.md | 12 +- docs/guides/source-docs-and-gaps.md | 4 +- docs/guides/testing.md | 25 +- docs/ops/backlog-2026-06-26.md | 29 +- scripts/check-deploy-preflight.py | 14 +- scripts/check-dev-dashboard-ssot.py | 27 +- scripts/test_dev_dashboard_ssot.py | 88 ++ 51 files changed, 4928 insertions(+), 240 deletions(-) create mode 100644 .codex-remote-attachments/019f16b1-aa14-72b3-b828-a202b055dd1a/f3bdca82-8850-4471-b95d-9e222db9b377/1-Photo-1.jpg create mode 100644 apps/api/app/test_eval_routes.py create mode 100644 apps/web/e2e/dev-dashboard.spec.ts create mode 100644 scripts/test_dev_dashboard_ssot.py diff --git a/.codex-remote-attachments/019f16b1-aa14-72b3-b828-a202b055dd1a/f3bdca82-8850-4471-b95d-9e222db9b377/1-Photo-1.jpg b/.codex-remote-attachments/019f16b1-aa14-72b3-b828-a202b055dd1a/f3bdca82-8850-4471-b95d-9e222db9b377/1-Photo-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..679fc64d2f8a1c916ce428c0109eb2515a8c1b24 GIT binary patch literal 25018 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 literal 0 HcmV?d00001 diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index a4f792a..8fd31b5 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -359,8 +359,16 @@ async def _runtime_tables_ready(conn) -> bool: SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' AND table_name = 'turns' - AND column_name = 'provider_events' - ) AS has_turn_provider_events, + AND column_name IN ( + 'audio_ref', + 'silence_ms', + 'speech_rate', + 'barge_in', + 'provider_events' + ) + GROUP BY table_schema, table_name + HAVING count(*) = 5 + ) AS has_turn_voice_metadata_columns, EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' @@ -485,7 +493,7 @@ async def _runtime_tables_ready(conn) -> bool: and row["has_engine_config"] and row["has_session_columns"] and row["has_state_columns"] - and row["has_turn_provider_events"] + and row["has_turn_voice_metadata_columns"] and row["has_session_review_worksheet_columns"] and row["has_stage_defs"] and row["has_admin_health_event"] @@ -908,6 +916,10 @@ async def ensure_runtime_tables() -> None: await conn.execute( """ ALTER TABLE app.turns + ADD COLUMN IF NOT EXISTS audio_ref TEXT, + ADD COLUMN IF NOT EXISTS silence_ms INT, + ADD COLUMN IF NOT EXISTS speech_rate REAL, + ADD COLUMN IF NOT EXISTS barge_in BOOLEAN, ADD COLUMN IF NOT EXISTS provider_events JSONB NOT NULL DEFAULT '[]'::jsonb """ ) diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 4c6e69c..5fc65c7 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -104,6 +104,10 @@ class Settings(BaseSettings): default=256, validation_alias="EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES", ) + session_evaluation_timeout: float = Field( + default=0.0, + validation_alias="SESSION_EVALUATION_TIMEOUT", + ) session_digest_worker_enabled: bool = Field( default=False, validation_alias="SESSION_DIGEST_WORKER_ENABLED", diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 2bb8f00..5ae5aed 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -161,8 +161,16 @@ async def healthcheck() -> bool: SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' AND table_name = 'turns' - AND column_name = 'provider_events' - ) AS has_turn_provider_events, + AND column_name IN ( + 'audio_ref', + 'silence_ms', + 'speech_rate', + 'barge_in', + 'provider_events' + ) + GROUP BY table_schema, table_name + HAVING count(*) = 5 + ) AS has_turn_voice_metadata_columns, EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' @@ -191,7 +199,7 @@ async def healthcheck() -> bool: and row["has_sessions"] and row["has_turns"] and row["has_session_review_status"] - and row["has_turn_provider_events"] + and row["has_turn_voice_metadata_columns"] and row["has_session_review_worksheet_columns"] ) except Exception: diff --git a/apps/api/app/routes/eval.py b/apps/api/app/routes/eval.py index 0439aae..532fcf4 100644 --- a/apps/api/app/routes/eval.py +++ b/apps/api/app/routes/eval.py @@ -17,6 +17,7 @@ in-proc cache/session fallback 은 local dev 에서만 허용한다. from __future__ import annotations +import logging from typing import Annotated, Any, Optional from fastapi import APIRouter, Depends, HTTPException, status @@ -33,6 +34,7 @@ from ..store import InProcSession from ..store import store router = APIRouter(prefix="/eval", tags=["eval"]) +logger = logging.getLogger(__name__) # 교수자/관리자만 평가 조회·트리거 (학습자 비노출) TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] @@ -52,6 +54,9 @@ class EvaluationSummary(BaseModel): session_id: str stage: StageLabel | None = None + status: str | None = None + error: str | None = None + durable: bool = False deep: Optional[dict[str, Any]] = None distribution: dict[str, Any] = Field(default_factory=dict) @@ -76,11 +81,14 @@ async def _load_session_or_404(session_id: str, principal: Principal) -> InProcS def _theory_mode_of(sess) -> Optional[str]: + # 회기에서 학습자가 명시 선택한 이론 모드가 최우선이다. + sess_theory = str(getattr(sess, "theory_mode", "") or "").strip() + if sess_theory: + return sess_theory tt = getattr(sess.persona, "theory_target", None) if isinstance(tt, (list, tuple)) and tt: return ", ".join(str(x) for x in tt) - # store 가 theory_mode 문자열도 보유(InProcSession.theory_mode) - return getattr(sess, "theory_mode", None) + return None def _summary_stage(value: object) -> StageLabel | None: @@ -93,6 +101,12 @@ async def eval_health() -> dict[str, str]: return {"status": "ok", "owner": "features:evaluator", "loops": "fast,deep"} +def _session_evaluation_error_status(error: str) -> int: + if error.startswith("engine_error"): + return status.HTTP_503_SERVICE_UNAVAILABLE + return status.HTTP_502_BAD_GATEWAY + + # ════════════════════════════════════════════════════════════════════════════ # 회기 deep-loop 재평가 트리거 (교수자/관리자) # ════════════════════════════════════════════════════════════════════════════ @@ -131,18 +145,35 @@ async def reevaluate_session( audit_hook=session_persistence.record_llm_call_audit, ) except EngineError as e: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}") - - if result.error and result.error.startswith("engine_error"): - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error) - - await session_persistence.save_session_evaluation( - session_persistence.SessionEvaluationWrite.from_result( + detail = f"engine unavailable: {e}" + write = session_persistence.SessionEvaluationWrite.from_error( session_id=session_id, learner_id=sess.learner_id, - result=result, + scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end", + stage=sess.state.stage.value, + error=detail, ) + saved = await session_persistence.save_session_evaluation(write) + if not saved: + logger.error( + "session evaluation retry error record did not reach durable store: session_id=%s error=%s", + session_id, + write.error, + ) + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail) + + write = session_persistence.SessionEvaluationWrite.from_result( + session_id=session_id, + learner_id=sess.learner_id, + result=result, ) + saved = await session_persistence.save_session_evaluation(write) + if not saved: + detail = "session evaluation retry result was generated but could not be saved" + logger.error("%s: session_id=%s status=%s", detail, session_id, write.status) + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail) + if result.error: + raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error) return result @@ -193,6 +224,7 @@ async def reevaluate_turn( learner_text_masked=learner.text_masked, state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시) memory=TurnMemory(recent_turns=recent), + theory_mode=_theory_mode_of(sess), ) result = await evaluator.evaluate_turn( @@ -219,15 +251,26 @@ async def get_session_evaluation( 아직 평가 트리거가 없었다면 deep=None + 빈 분포. """ await _load_session_or_404(session_id, principal) - record, _durable = await session_persistence.load_session_evaluation(session_id, principal) + record, durable = await session_persistence.load_session_evaluation(session_id, principal) if record is None: - return EvaluationSummary(session_id=session_id, stage=None, deep=None, distribution={}) + return EvaluationSummary( + session_id=session_id, + stage=None, + status=None, + error=None, + durable=durable, + deep=None, + distribution={}, + ) payload = record.get("payload") deep = payload if isinstance(payload, dict) else {} distribution = deep.get("distribution") return EvaluationSummary( session_id=session_id, stage=_summary_stage(record.get("stage") or deep.get("stage")), + status=str(record.get("status") or "") or None, + error=str(record.get("error") or "") or None, + durable=durable, deep=deep, distribution=distribution if isinstance(distribution, dict) else {}, ) diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 13f5e54..5c1d13b 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -482,22 +482,20 @@ async def _load_session_or_404( return sess -async def _load_review_session_or_404( +def _review_supervisor_principal(principal: Principal) -> Principal | None: + if principal.role in {Role.TEACHER, Role.ADMIN}: + return principal + if principal.super_admin: + return principal.with_role(Role.ADMIN) + return None + + +async def _load_supervisor_review_session_or_404( session_id: str, principal: Principal, *, include_turn_evaluation: bool = False, ) -> InProcSession: - if principal.role == Role.LEARNER: - return await _load_session_or_404( - session_id, - principal, - allow_ended=True, - include_turn_evaluation=include_turn_evaluation, - ) - if principal.role not in {Role.TEACHER, Role.ADMIN}: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied") - sess = await session_persistence.load_session( session_id, principal, @@ -511,6 +509,50 @@ async def _load_review_session_or_404( return sess +async def _load_review_session_or_404( + session_id: str, + principal: Principal, + *, + include_turn_evaluation: bool = False, +) -> tuple[InProcSession, Principal]: + if principal.role == Role.LEARNER: + try: + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=include_turn_evaluation, + ) + return sess, principal + except HTTPException as exc: + supervisor = _review_supervisor_principal(principal) + if supervisor is None or exc.status_code not in { + status.HTTP_403_FORBIDDEN, + status.HTTP_404_NOT_FOUND, + }: + raise + return ( + await _load_supervisor_review_session_or_404( + session_id, + supervisor, + include_turn_evaluation=include_turn_evaluation, + ), + supervisor, + ) + + supervisor = _review_supervisor_principal(principal) + if supervisor is None: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied") + return ( + await _load_supervisor_review_session_or_404( + session_id, + supervisor, + include_turn_evaluation=include_turn_evaluation, + ), + supervisor, + ) + + async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None: if await session_persistence.end_session(sess, carry): sess.ended = True @@ -635,6 +677,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: if not sess.turns: return + timeout_seconds = _session_evaluation_timeout_seconds() enriched: list[dict[str, object]] = [] for index, turn in enumerate(sess.masked_turns(), start=1): item: dict[str, object] = dict(turn) @@ -653,31 +696,89 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: scope="session_end", audit_hook=session_persistence.record_llm_call_audit, ), - timeout=min(float(settings.engine_timeout), 45.0), + timeout=timeout_seconds, ) - saved = await session_persistence.save_session_evaluation( - session_persistence.SessionEvaluationWrite.from_result( - session_id=sess.session_id, - learner_id=sess.learner_id, - result=result, + write = session_persistence.SessionEvaluationWrite.from_result( + session_id=sess.session_id, + learner_id=sess.learner_id, + result=result, + ) + saved = await session_persistence.save_session_evaluation(write) + if not saved: + logger.error( + "session evaluation save did not reach durable store: session_id=%s status=%s scope=%s", + sess.session_id, + write.status, + write.scope, ) + if saved: + await _enqueue_session_review_ready_notification(sess.session_id) + except asyncio.TimeoutError: + message = f"session evaluation timeout after {timeout_seconds:g}s" + logger.exception("%s: session_id=%s", message, sess.session_id) + write = session_persistence.SessionEvaluationWrite.from_error( + session_id=sess.session_id, + learner_id=sess.learner_id, + scope="session_end", + stage=_stage_label(sess.state.stage), + error=message, ) + saved = await session_persistence.save_session_evaluation(write) + if not saved: + logger.error( + "session evaluation error save did not reach durable store: session_id=%s error=%s", + sess.session_id, + write.error, + ) if saved: await _enqueue_session_review_ready_notification(sess.session_id) except Exception as exc: - saved = await session_persistence.save_session_evaluation( - session_persistence.SessionEvaluationWrite.from_error( - session_id=sess.session_id, - learner_id=sess.learner_id, - scope="session_end", - stage=_stage_label(sess.state.stage), - error=str(exc), - ) + logger.exception("session evaluation failed: session_id=%s", sess.session_id) + write = session_persistence.SessionEvaluationWrite.from_error( + session_id=sess.session_id, + learner_id=sess.learner_id, + scope="session_end", + stage=_stage_label(sess.state.stage), + error=exc, ) + saved = await session_persistence.save_session_evaluation(write) + if not saved: + logger.error( + "session evaluation failure record did not reach durable store: session_id=%s error=%s", + sess.session_id, + write.error, + ) if saved: await _enqueue_session_review_ready_notification(sess.session_id) +def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str) -> None: + try: + task.result() + except asyncio.CancelledError: + logger.warning("session evaluation background task cancelled: session_id=%s", session_id) + except Exception: + logger.exception("session evaluation background task crashed: session_id=%s", session_id) + + +def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None: + if not sess.turns: + return None + task = asyncio.create_task( + _generate_and_save_session_evaluation(sess), + name=f"session-evaluation:{sess.session_id}", + ) + task.add_done_callback( + lambda done, session_id=sess.session_id: _observe_session_evaluation_task(done, session_id) + ) + return task + + +def _session_evaluation_timeout_seconds() -> float: + configured = float(settings.session_evaluation_timeout or settings.engine_timeout) + return max(configured, 1.0) + + async def _enqueue_session_review_ready_notification(session_id: str) -> None: try: await notifications.enqueue_session_review_ready(session_id=session_id) @@ -685,12 +786,6 @@ async def _enqueue_session_review_ready_notification(session_id: str) -> None: logger.warning("session review notification enqueue failed: %s", exc) -def _schedule_session_evaluation(sess: InProcSession) -> None: - if not sess.turns: - return - asyncio.create_task(_generate_and_save_session_evaluation(sess)) - - async def _load_learner_sessions( principal: Principal, *, @@ -978,25 +1073,25 @@ async def get_session_review( principal: CurrentPrincipal, ) -> SessionReviewResponse: """Return a role-safe review built only from the stored session transcript.""" - sess = await _load_review_session_or_404( + sess, review_principal = await _load_review_session_or_404( session_id, principal, include_turn_evaluation=True, ) evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation( session_id, - principal, + review_principal, ) saved_worksheet_payload, _ = await session_persistence.load_case_worksheet( session_id, - principal, + review_principal, ) - include_teacher_review = principal.role in {Role.TEACHER, Role.ADMIN} + include_teacher_review = review_principal.role in {Role.TEACHER, Role.ADMIN} teacher_review_record = None if include_teacher_review: teacher_review_record, _ = await session_persistence.load_session_review_status( session_id, - principal, + review_principal, ) return build_session_review( @@ -1201,18 +1296,15 @@ async def live_coach_turn( grounding=grounding, audit_hook=session_persistence.record_llm_call_audit, ) - try: - await session_persistence.save_live_coach_event( - session_id=sess.session_id, - learner_id=sess.learner_id, - turn_seq=turn_seq, - stage=stage, - learner_text=body.learner_text, - client_reply=body.client_reply, - suggestion=suggestion, - ) - except Exception: - pass + await session_persistence.save_live_coach_event( + session_id=sess.session_id, + learner_id=sess.learner_id, + turn_seq=turn_seq, + stage=stage, + learner_text=body.learner_text, + client_reply=body.client_reply, + suggestion=suggestion, + ) return suggestion diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py index 4d7547c..24f2118 100644 --- a/apps/api/app/routes/teacher.py +++ b/apps/api/app/routes/teacher.py @@ -12,6 +12,7 @@ from ..deps import Principal, Role, require_role from ..runtime_policy import require_runtime_fallback_allowed from ..session_read_model import StageLabel, stage_label from ..services import session_metrics +from ..stage_contract import STAGE_LABEL_VALUES from ..store import InProcSession, store router = APIRouter(prefix="/teacher", tags=["teacher"]) @@ -86,6 +87,28 @@ class TeacherLearnerGrowth(BaseModel): points: list[TeacherGrowthPoint] = Field(default_factory=list) +class TeacherStageBreakdown(BaseModel): + stage: StageLabel + sessions: int = 0 + turns: int = 0 + + +class TeacherLearnerAnalysisResponse(BaseModel): + source: str = "in_memory" + learner_id: str + learner_label: str + total_sessions: int + active_sessions: int + ended_sessions: int + pending_reviews: int + closed_reviews: int + summary: TeacherLearnerGrowth + points: list[TeacherGrowthPoint] = Field(default_factory=list) + stage_breakdown: list[TeacherStageBreakdown] = Field(default_factory=list) + sessions: list[TeacherSessionSummary] = Field(default_factory=list) + message: str + + class TeacherSafetyAlert(BaseModel): id: str session_id: str @@ -134,11 +157,17 @@ def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoi ) -def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerGrowth]: +def _build_learner_growth( + sessions: list[InProcSession], + *, + limit: int | None = 12, + point_limit: int | None = 6, +) -> list[TeacherLearnerGrowth]: metrics = session_metrics.build_learner_growth( sessions, learner_label=_learner_label, - limit=12, + limit=limit, + point_limit=point_limit, ) return [ TeacherLearnerGrowth( @@ -160,6 +189,26 @@ def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerG ] +def _stage_breakdown(sessions: list[InProcSession]) -> list[TeacherStageBreakdown]: + buckets = { + stage: {"sessions": 0, "turns": 0} + for stage in STAGE_LABEL_VALUES + } + for sess in sessions: + label = stage_label(sess.state.stage) + bucket = buckets[label] + bucket["sessions"] += 1 + bucket["turns"] += len(sess.turns) + return [ + TeacherStageBreakdown( + stage=stage, + sessions=buckets[stage]["sessions"], + turns=buckets[stage]["turns"], + ) + for stage in STAGE_LABEL_VALUES + ] + + def _review_status_value(record: dict[str, object] | None) -> Literal["pending", "viewed", "closed"]: value = str((record or {}).get("status") or "pending") if value in {"viewed", "closed"}: @@ -274,6 +323,64 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp ) +@router.get( + "/learners/{learner_id}/analysis", + response_model=TeacherLearnerAnalysisResponse, +) +async def learner_analysis( + learner_id: str, + principal: TeacherPrincipal, +) -> TeacherLearnerAnalysisResponse: + """Return one learner's full teacher-visible session timeline and trend.""" + sessions, durable = await session_persistence.list_sessions( + principal, + include_turn_evaluation=True, + ) + if not durable: + require_runtime_fallback_allowed("teacher learner analysis") + sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True) + + learner_sessions = [sess for sess in sessions if sess.learner_id == learner_id] + if not learner_sessions: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="learner not found") + + ordered = sorted( + learner_sessions, + key=lambda sess: (sess.created_at, sess.session_no, sess.session_id), + ) + review_statuses, _ = await session_persistence.list_session_review_statuses( + [sess.session_id for sess in ordered if sess.ended], + principal, + ) + summaries = [ + _summary(sess, review_statuses.get(sess.session_id)) + for sess in ordered + ] + growth = _build_learner_growth(ordered, limit=None, point_limit=None)[0] + pending_reviews = sum( + 1 + for item in summaries + if item.status == "ended" and item.review_status != "closed" + ) + closed_reviews = sum(1 for item in summaries if item.review_status == "closed") + + return TeacherLearnerAnalysisResponse( + source="database" if durable else "runtime", + learner_id=learner_id, + learner_label=growth.learner_label, + total_sessions=len(ordered), + active_sessions=sum(1 for sess in ordered if not sess.ended), + ended_sessions=sum(1 for sess in ordered if sess.ended), + pending_reviews=pending_reviews, + closed_reviews=closed_reviews, + summary=growth, + points=growth.points, + stage_breakdown=_stage_breakdown(ordered), + sessions=summaries, + message=f"{growth.learner_label}의 실제 회기 {len(ordered)}건을 오래된 순서로 표시합니다.", + ) + + @router.put( "/sessions/{session_id}/review-status", response_model=TeacherSessionReviewStatusResponse, diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index e565f32..e29bbc2 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -44,6 +44,7 @@ _KOREAN_SURNAME_CHARS = ( ) _KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}" _KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?" +_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}" _KOREAN_NAME_STOPWORDS = { "연락", "연락처", @@ -126,7 +127,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ( "NAME", re.compile( - rf"(?{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" + rf"(?{_KOREAN_CONTEXTLESS_NAME})" r"(?P(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))" ), ), @@ -372,6 +373,71 @@ _MEANS_TERMS = [ "약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약", "손목을", "칼로", "뛰어내리", "수면제 ", "독", "방법은", "어떻게 죽", ] +_DISPLAY_PLACEHOLDER_LABELS = { + "NAME": "그 이름", + "ORG": "그 기관", + "PHONE": "연락처", + "EMAIL": "이메일", + "RRN": "식별번호", + "NUMID": "식별번호", + "DATE": "그 날짜", + "MONEY": "그 금액", + "ADDR": "그 주소", +} +_DISPLAY_PLACEHOLDER_RE = re.compile( + r"\[(?P