From f472883c312f218207ab47bdb7e6b977ae8d7356 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 27 Jun 2026 17:51:54 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=A0=95=EB=A6=AC=201=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/routes/sessions.py | 6 +- apps/api/app/routes/voice.py | 3 +- apps/api/app/test_phase3_artifact_checker.py | 226 +++++++++++++ apps/api/app/turn_runtime.py | 20 ++ apps/api/engine_gateway/test_gateway_model.py | 70 ++++ apps/web/src/lib/api.ts | 318 +++--------------- apps/web/src/pages/Admin.tsx | 3 +- apps/web/src/pages/Professor.tsx | 2 +- apps/web/src/pages/Settings.tsx | 28 +- docs/HANDOFF.md | 7 +- docs/dev_dashboard.html | 26 +- docs/ops/backlog-2026-06-26.md | 4 +- scripts/check-phase3-artifacts.py | 190 ++++++++++- 13 files changed, 592 insertions(+), 311 deletions(-) create mode 100644 apps/api/app/test_phase3_artifact_checker.py diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index ea87e95..7ee451d 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -1410,13 +1410,12 @@ async def submit_turn( detail=f"engine unavailable: {exc}", ) from exc - await turn_runtime.record_completed_turn( + await turn_runtime.finalize_completed_turn( sess, ctx, result, context_prefix="session", ) - await turn_runtime.record_safety_event(sess, ctx, result) return TurnResponse( turn_seq=result.turn_seq, @@ -1473,13 +1472,12 @@ async def stream_turn( data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)} evaluation = await _evaluate_stream_turn(ctx, final_reply) result = _stream_result_from_done(ctx, final_reply, data, evaluation) - await turn_runtime.record_completed_turn( + await turn_runtime.finalize_completed_turn( sess, ctx, result, context_prefix="session", ) - await turn_runtime.record_safety_event(sess, ctx, result) yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} else: yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 730d1a3..85676b9 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -329,7 +329,7 @@ async def _run_turn_and_speak( reply = result.client_reply or "" # Persist only after the client reply has been generated. A failed AI turn # must not leave a learner-only transcript in review or history. - await turn_runtime.record_completed_turn( + await turn_runtime.finalize_completed_turn( sess, ctx, result, @@ -347,7 +347,6 @@ async def _run_turn_and_speak( evaluation=result.evaluation, ), ) - await turn_runtime.record_safety_event(sess, ctx, result) # Send the final client text before audio playback. await _safe_send_json( diff --git a/apps/api/app/test_phase3_artifact_checker.py b/apps/api/app/test_phase3_artifact_checker.py new file mode 100644 index 0000000..681d73c --- /dev/null +++ b/apps/api/app/test_phase3_artifact_checker.py @@ -0,0 +1,226 @@ +import hashlib +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] +CHECKER_PATH = REPO_ROOT / "scripts" / "check-phase3-artifacts.py" + +spec = importlib.util.spec_from_file_location("phase3_artifact_checker", CHECKER_PATH) +assert spec is not None and spec.loader is not None +checker = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = checker +spec.loader.exec_module(checker) + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class Phase3ArtifactCheckerTests(unittest.TestCase): + def make_root(self) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + + write_text( + root / "00-intake" / "pilot_roster.csv", + "participant_id,cohort_id,consent_version,consent_signed_at,withdrawal_state,enrolled_at\n" + "P3-001,phase3,v1,2026-06-27T00:00:00Z,active,2026-06-27T00:00:00Z\n", + ) + write_text( + root / "00-intake" / "consent_receipts.csv", + "participant_id,consent_version,signed_at,signer_role,receipt_id\n" + "P3-001,v1,2026-06-27T00:00:00Z,self,R-001\n", + ) + write_text( + root / "01-sessions" / "session_completion.csv", + "participant_id,session_id,persona_id,started_at,ended_at,completion_state,turns_count,supervisor_reviewed_at\n" + "P3-001,S-001,P1,2026-06-27T00:00:00Z,2026-06-27T00:50:00Z,completed,12,2026-06-27T01:00:00Z\n", + ) + write_text( + root / "02-measures" / "prepost_measures.csv", + "participant_id,measure_name,timepoint,score,collected_at\n" + "P3-001,self_efficacy,pre,3,2026-06-27T00:00:00Z\n" + "P3-001,self_efficacy,post,4,2026-06-27T01:00:00Z\n", + ) + write_text( + root / "02-measures" / "sus_responses.csv", + "participant_id,item,response,collected_at\n" + "P3-001,1,5,2026-06-27T01:00:00Z\n", + ) + write_text( + root / "04-privacy" / "withdrawal_log.csv", + "participant_id,requested_at,effective_at,scope,status,attestation_path\n", + ) + write_text( + root / "04-privacy" / "privacy_audit.md", + "# Privacy audit\n\nLegal/privacy reviewer: reviewer@example.invalid\n", + ) + write_text(root / "03-export" / "anonymized_dataset.jsonl", "{}\n") + + metrics = { + name: { + "value": 1, + "threshold": 1, + "pass": True, + "numerator": 1, + "denominator": 1, + "method": "fixture", + "source_files": ["fixture"], + } + for name in checker.KPI_METRICS + } + write_text( + root / "02-measures" / "kpi_report.json", + json.dumps( + { + "pilot_id": "phase3-fixture", + "generated_at": "2026-06-27T00:00:00Z", + "source_window": { + "started_at": "2026-06-27T00:00:00Z", + "ended_at": "2026-06-27T01:00:00Z", + }, + "cohort_size": 1, + "metrics": metrics, + "exclusions": [], + "open_schema_gaps": [], + "review": { + "operator": "test", + "reviewed_at": "2026-06-27T01:00:00Z", + "decision": "fixture", + }, + }, + ensure_ascii=False, + ) + + "\n", + ) + dataset_path = root / "03-export" / "anonymized_dataset.jsonl" + write_text( + root / "03-export" / "export_manifest.json", + json.dumps( + { + "export_id": "phase3-fixture", + "dataset_name": "vignette_phase3_recursive_learning_seed", + "export_status": checker.APPROVED_EXPORT_STATUS, + "created_at": "2026-06-27T00:00:00Z", + "purpose": "test", + "source_window": { + "started_at": "2026-06-27T00:00:00Z", + "ended_at": "2026-06-27T01:00:00Z", + }, + "source_tables": ["app.sessions"], + "selection_criteria": { + "include_withdrawn": False, + "min_completed_sessions": 2, + }, + "consent_scope": { + "allowed_uses": ["education_quality_review", "recursive_learning_seed"], + "participants_included": 1, + "participants_excluded": 0, + }, + "anonymization": { + "text_transform": "masked_text_only", + "direct_identifier_policy": "blocked", + }, + "pii_scan": {"status": "pass"}, + "agreement": {"kappa": 0.60, "icc": 0.75}, + "files": [ + { + "path": "03-export/anonymized_dataset.jsonl", + "rows": 1, + "sha256": sha256(dataset_path), + "schema": "phase3_dataset_item_v1", + } + ], + "approvals": { + "data_steward": "steward", + "legal_or_privacy_reviewer": "privacy", + "technical_operator": "operator", + "approved_at": "2026-06-27T01:00:00Z", + }, + "known_limitations": [], + }, + ensure_ascii=False, + ) + + "\n", + ) + return root + + def test_valid_approved_fixture_passes(self) -> None: + root = self.make_root() + report = checker.validate(root, max_scan_rows=100) + self.assertEqual([], report.errors) + + def test_approved_manifest_requires_privacy_and_agreement_gates(self) -> None: + root = self.make_root() + manifest_path = root / "03-export" / "export_manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["pii_scan"]["status"] = "pending" + manifest["agreement"]["kappa"] = 0.59 + manifest["agreement"]["icc"] = 0.74 + manifest["selection_criteria"]["include_withdrawn"] = True + manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + report = checker.validate(root, max_scan_rows=100) + + errors = "\n".join(report.errors) + self.assertIn("pii_scan.status='pass'", errors) + self.assertIn("agreement.kappa >= 0.60", errors) + self.assertIn("agreement.icc >= 0.75", errors) + self.assertIn("include_withdrawn=false", errors) + self.assertIn("recursive_learning_seed consent scope", errors) + + def test_kpi_metric_required_fields_are_errors(self) -> None: + root = self.make_root() + report_path = root / "02-measures" / "kpi_report.json" + data = json.loads(report_path.read_text(encoding="utf-8")) + del data["metrics"]["sus"]["source_files"] + report_path.write_text(json.dumps(data), encoding="utf-8") + + report = checker.validate(root, max_scan_rows=100) + + self.assertTrue( + any("metric 'sus' missing 'source_files'" in error for error in report.errors), + report.errors, + ) + + def test_csv_enum_values_are_validated(self) -> None: + root = self.make_root() + write_text( + root / "01-sessions" / "session_completion.csv", + "participant_id,session_id,persona_id,started_at,ended_at,completion_state,turns_count,supervisor_reviewed_at\n" + "P3-001,S-001,P1,2026-06-27T00:00:00Z,2026-06-27T00:50:00Z,done,12,2026-06-27T01:00:00Z\n", + ) + + report = checker.validate(root, max_scan_rows=100) + + self.assertTrue( + any("invalid completion_state 'done'" in error for error in report.errors), + report.errors, + ) + + def test_approved_manifest_hash_must_match_existing_file(self) -> None: + root = self.make_root() + manifest_path = root / "03-export" / "export_manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files"][0]["sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + report = checker.validate(root, max_scan_rows=100) + + self.assertTrue(any("sha256 mismatch" in error for error in report.errors), report.errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py index 57fe73a..af479e0 100644 --- a/apps/api/app/turn_runtime.py +++ b/apps/api/app/turn_runtime.py @@ -182,9 +182,29 @@ async def record_safety_event( pass +async def finalize_completed_turn( + sess: InProcSession, + ctx: orchestrator.TurnContext, + result: orchestrator.TurnResult, + *, + context_prefix: str, + counselor_turn: TurnRecord | None = None, +) -> None: + """Persist a completed turn and emit any derived safety alert in route-safe order.""" + await record_completed_turn( + sess, + ctx, + result, + context_prefix=context_prefix, + counselor_turn=counselor_turn, + ) + await record_safety_event(sess, ctx, result) + + __all__ = [ "SessionAccessError", "append_completed_turn", + "finalize_completed_turn", "load_owned_session", "record_safety_event", "record_completed_turn", diff --git a/apps/api/engine_gateway/test_gateway_model.py b/apps/api/engine_gateway/test_gateway_model.py index de3d13b..c1b35df 100644 --- a/apps/api/engine_gateway/test_gateway_model.py +++ b/apps/api/engine_gateway/test_gateway_model.py @@ -53,6 +53,32 @@ def _model_arg(args): return args[args.index("--model") + 1] +async def _read_streaming_response(response): + chunks = [] + async for chunk in response.body_iterator: + if isinstance(chunk, bytes): + chunks.append(chunk.decode("utf-8")) + else: + chunks.append(str(chunk)) + return "".join(chunks) + + +class _FakeStreamSession: + def __init__(self, events, model="test-model"): + self.events = events + self.model = model + self.closed = False + + async def turn_stream(self, content, timeout=600.0): + self.content = content + self.timeout = timeout + for event in self.events: + yield event + + async def close(self): + self.closed = True + + class GatewayModelTest(unittest.TestCase): def setUp(self): gateway.SESSIONS.clear() @@ -212,6 +238,50 @@ class GatewayModelTest(unittest.TestCase): self.assertEqual(closed, [started[0]]) self.assertNotIn(started[0].id, gateway.SESSIONS) + def test_v1_stream_frames_token_and_done_events(self): + session = _FakeStreamSession( + [ + {"type": "delta", "text": "안녕"}, + {"type": "done", "cost_usd": 0.03, "turns": 2}, + ], + model="stream-model", + ) + + async def fake_resolve(req, system_prompt): + return session, True + + with patch.object(gateway, "_resolve_session", fake_resolve): + response = asyncio.run(gateway.v1_stream(_request())) + body = asyncio.run(_read_streaming_response(response)) + + self.assertIn("event: token", body) + self.assertIn('data: {"text": "안녕"}', body) + self.assertIn("event: done", body) + self.assertIn('"provider": "claude_cli"', body) + self.assertIn('"model": "stream-model"', body) + self.assertIn('"cost_usd": 0.03', body) + self.assertEqual(session.content, "hello") + self.assertEqual(session.timeout, 600.0) + self.assertTrue(session.closed) + + def test_v1_stream_frames_engine_error_event(self): + session = _FakeStreamSession( + [ + {"type": "done", "is_error": True, "error": "engine failed"}, + ] + ) + + async def fake_resolve(req, system_prompt): + return session, False + + with patch.object(gateway, "_resolve_session", fake_resolve): + response = asyncio.run(gateway.v1_stream(_request())) + body = asyncio.run(_read_streaming_response(response)) + + self.assertIn("event: error", body) + self.assertIn('data: {"detail": "engine failed"}', body) + self.assertFalse(session.closed) + if __name__ == "__main__": unittest.main() diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index bcb28a5..57116b7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -155,32 +155,9 @@ export const api = { ===================================================================== */ /** GET /auth/me — auth.py MeResponse */ -export interface MeResponse { - user_id: string; - email: string; - display_name: string; - role: string; // "learner" | "teacher" | "admin" - cohort_ids: string[]; - consent_at: number | null; -} - -export interface ConsentResponse { - consent_at: number | null; -} - -export interface AuthConfigResponse { - google_oauth_configured: boolean; - saml_configured: boolean; - providers: Array<{ - provider: "google" | "saml"; - configured: boolean; - enabled: boolean; - login_path: string; - }>; - allowed_email_domains: string[]; - redirect_uri: string; - dev_login_enabled: boolean; -} +export type MeResponse = ApiSchema<"MeResponse">; +export type ConsentResponse = ApiSchema<"ConsentResponse">; +export type AuthConfigResponse = ApiSchema<"AuthConfigResponse">; export const authApi = { config: () => api.get("/auth/config"), @@ -191,34 +168,11 @@ export const authApi = { export type SessionStage = "라포" | "탐색" | "개입" | "정리"; /** GET /personas — personas.py PersonaSummary */ -export interface PersonaSummary { - code: string; - display_name: string; - difficulty: "easy" | "moderate" | "hard" | string; - theory_target: string[]; - demographics: Record; - presenting_summary: string; - voice_preset: string | null; - source: string; - degraded: boolean; -} +export type PersonaSummary = ApiSchema<"PersonaSummary">; export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived"; -export type PersonaReviewAction = "approve" | "reject"; - -export interface PersonaReviewSummary { - persona_id: string; - code: string; - version: number; - status: PersonaReviewStatus; - display_name: string; - difficulty: "easy" | "moderate" | "hard" | string; - theory_target: string[]; - source_provenance: string; - is_synthetic: boolean; - created_at: string | null; - approved_at: string | null; -} +export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"]; +export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">; export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">; export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">; @@ -240,39 +194,15 @@ export type TurnResponse = ApiSchema<"TurnResponse">; /** POST /sessions/{id}/end — sessions.py SessionEndResponse */ export type SessionEndResponse = ApiSchema<"SessionEndResponse">; -export interface CrisisResource { - title: string; - number: string; - message: string; -} - -export interface LearnerSessionSummary { - session_id: string; - persona_code: string; - persona_name: string; - session_no: number; - status: "active" | "ended"; - stage: string; - turn_count: number; - learner_turn_count: number; - client_turn_count: number; - started_at: string; - ended_at: string | null; - review_ready: boolean; -} +export type CrisisResource = ApiSchema<"CrisisResourceResponse">; +export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">; export interface LearnerSessionsResponse { source: string; sessions: LearnerSessionSummary[]; } -export interface SessionDetailTurn { - turn_seq: number; - speaker: "learner" | "client"; - stage: string; - text: string; - created_at: string; -} +export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">; export interface SessionDetailResponse { session_id: string; @@ -289,30 +219,10 @@ export interface SessionDetailResponse { review_ready: boolean; } -export interface ReviewClient { - name: string; - initial: string; - persona: string; -} - -export interface ReviewTechnique { - kind: string; - label: string; -} - -export interface ReviewNonverbalEvent { - kind: "audio" | "silence" | "pace" | "barge_in"; - label: string; - detail: string; -} - -export interface ReviewNote { - author: "ai" | "instructor" | string; - tone: "good" | "watch"; - title: string; - body: string; - quote?: string | null; -} +export type ReviewClient = ApiSchema<"ReviewClient">; +export type ReviewTechnique = ApiSchema<"ReviewTechnique">; +export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">; +export type ReviewNote = ApiSchema<"ReviewNote">; export interface ReviewTurn { id: string; @@ -325,36 +235,11 @@ export interface ReviewTurn { note?: ReviewNote | null; } -export interface ReviewPhaseSegment { - key: string; - label: string; - weight: number; -} - -export interface ReviewValencePoint { - t: number; - v: number; -} - -export interface ReviewRubricRow { - name: string; - cluster: string; - ratio: number; - quality: "good" | "watch"; - freq: string; -} - -export interface ReviewPoint { - title: string; - body: string; - jumpTo?: string | null; -} - -export interface ReviewWorksheetEvidence { - turnId: string; - speaker: "learner" | "client"; - quote: string; -} +export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">; +export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">; +export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">; +export type ReviewPoint = ApiSchema<"ReviewPoint">; +export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">; export interface ReviewWorksheetItem { key: string; @@ -608,82 +493,23 @@ export const sessionApi = { stream: openSessionStream, }; -export type AdminHealthStatus = "ok" | "degraded" | "down"; - -export interface AdminServiceHealth { - key: string; - name: string; - status: AdminHealthStatus; - detail: string; - metric: string; - load: number; -} - -export interface AdminHealthResponse { - status: AdminHealthStatus; - environment: string; - engine_mode: string; - services: AdminServiceHealth[]; -} - -export interface AdminUsageBreakdown { - provider: string; - model: string; - turns: number; - tokens_in: number; - tokens_out: number; - cost_usd: number; -} - -export interface AdminUsageBudget { - limit_usd: number; - used_ratio: number; - remaining_usd: number | null; - status: "disabled" | "ok" | "warn" | "exceeded"; -} - -export interface AdminUsageResponse { - source: "database" | "server_session_registry"; - durable: boolean; - window_days: number; - generated_at: number; - total_turns: number; - metered_turns: number; - tokens_in: number; - tokens_out: number; - cost_usd: number; - budget: AdminUsageBudget; - by_provider: AdminUsageBreakdown[]; -} +export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"]; +export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">; +export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">; +export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">; +export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">; +export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">; export const adminApi = { health: () => api.get("/admin/health"), usage: (windowDays = 7) => api.get(`/admin/usage?window_days=${windowDays}`), }; -export interface AdminManagedUser { - user_id: string; - email: string; - display_name: string; - role: "learner" | "teacher" | "admin"; - cohort_ids: string[]; - affiliation: string; - active_sessions: number; - created_at: number; - last_seen_at: number; - source: "database" | "server_session_registry"; -} - -export interface AdminUsersResponse { - source: "database" | "server_session_registry"; - durable: boolean; - users: AdminManagedUser[]; -} - -export type AdminUserCreateRequest = Pick< - AdminManagedUser, - "email" | "display_name" | "role" | "affiliation" | "cohort_ids" ->; +export type AdminManagedUser = ApiSchema<"AdminUserResponse">; +export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">; +export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">; +export type AdminUserPatchRequest = ApiSchema<"AdminUserPatch">; +export type AdminUserDeleteResponse = ApiSchema<"AdminUserDeleteResponse">; export const adminUsersApi = { list: () => api.get("/admin/users"), @@ -691,13 +517,13 @@ export const adminUsersApi = { apiFetch("/admin/users", { method: "POST", body }), update: ( userId: string, - body: Partial>, + body: AdminUserPatchRequest, ) => apiFetch(`/admin/users/${encodeURIComponent(userId)}`, { method: "PATCH", body, }), deactivate: (userId: string) => - apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, { + apiFetch(`/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE", }), }; @@ -718,33 +544,8 @@ export interface TeacherSessionSummary { ended_at: string | null; } -export interface TeacherSafetyAlert { - id: string; - session_id: string; - learner_id: string; - learner_label: string; - persona_code: string; - session_no: number; - trigger_type: string; - ko_risk_level: number; - escalated: boolean; - created_at: string; - resource_title: string; - resource_number: string; -} - -export interface TeacherGrowthPoint { - session_id: string; - session_no: number; - persona_code: string; - stage: string; - started_at: string; - ended_at: string | null; - score: number | null; - rapport: number | null; - technique_count: number; - watch_count: number; -} +export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">; +export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">; export interface TeacherLearnerGrowth { learner_id: string; @@ -779,61 +580,30 @@ export const teacherApi = { dashboard: () => api.get("/teacher/dashboard"), }; -export interface UserProfileResponse { - user_id: string; - email: string; - display_name: string; - role: RoleString; - cohort_ids: string[]; - affiliation: string; -} - -export interface NotificationPreferences { - session_done: boolean; - safety_signal: boolean; - learner_progress: boolean; - product_news: boolean; -} - -export interface UserPreferencesResponse { - theme: "system" | "light" | "dark" | string; - voice_preset_id: string; - voice_rate: number; - notifications: NotificationPreferences; -} - -export interface VoicePresetResponse { - id: string; - voice_id: string; - name: string; - desc: string; - persona_hint: string; -} +export type UserProfileResponse = ApiSchema<"UserProfileResponse">; +export type NotificationPreferences = ApiSchema<"NotificationPreferences">; +export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">; +export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">; +export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">; +export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">; export type RoleString = "learner" | "teacher" | "admin" | string; export const userApi = { me: () => api.get("/users/me"), - updateMe: (body: { display_name?: string; affiliation?: string }) => + updateMe: (body: UserProfilePatchRequest) => apiFetch("/users/me", { method: "PATCH", body }), preferences: () => api.get("/users/me/preferences"), - updatePreferences: (body: Partial) => + updatePreferences: (body: UserPreferencesPatchRequest) => apiFetch("/users/me/preferences", { method: "PATCH", body }), voicePresets: () => api.get("/users/me/voice-presets"), }; -export interface AdminEngineConfigResponse { - engine_mode: string; - engine_url: string; - model: string; - updated_by: string | null; - updated_at: number | null; - durable: boolean; - source: "database" | "runtime_cache" | "runtime_default" | string; -} +export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">; +export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">; export const adminEngineApi = { get: () => api.get("/admin/engine-config"), - update: (body: Partial>) => + update: (body: AdminEngineConfigPatchRequest) => apiFetch("/admin/engine-config", { method: "PATCH", body }), }; diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index 296ade0..8dc4381 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -13,7 +13,8 @@ import { } from "../lib/api"; type UserDraft = Pick; -type NewUserDraft = AdminUserCreateRequest; +type NewUserDraft = Required> & + Pick; const MAX_RENDERED_USERS = 40; const EMPTY_NEW_USER: NewUserDraft = { diff --git a/apps/web/src/pages/Professor.tsx b/apps/web/src/pages/Professor.tsx index eda2107..84262b5 100644 --- a/apps/web/src/pages/Professor.tsx +++ b/apps/web/src/pages/Professor.tsx @@ -554,7 +554,7 @@ export default function Professor() { {personaReviewStatusLabel(persona.status)} - {formatDateTime(persona.created_at)} + {formatDateTime(persona.created_at ?? null)}
updateNotification(item.id as keyof NotificationPreferences, next) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 63a8d59..7e15ffc 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # Vignette Handoff -> Updated: 2026-06-27 17:25 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. +> Updated: 2026-06-27 18:05 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. ## 현재 상태 @@ -13,7 +13,7 @@ - 최신 앱 배포 소스 commit: `0ec266a` (`동의 게이트와 런타임 안정화`) - 최신 Cloudflare Pages production deploy: `12da49f3`, source `0ec266a`, branch `main`. - Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr` -- 최신 백엔드 회귀: `python -m pytest app/ -q` → `125 passed` +- 최신 백엔드 회귀: `python -m pytest app/ -q` → `130 passed` - X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부. - C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속. - C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속. @@ -21,6 +21,9 @@ - X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다. - H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속. - RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed. +- 계약 SSOT 2차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. 검증: `npm run check:api-types`, `npm run typecheck`. +- 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed. +- Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다. - M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index fd6c99d..f8da445 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -286,7 +286,7 @@
Full Playwright 72 통과 - Backend pytest 122 통과 + Backend pytest 130 통과 Layout 시각게이트 7/7 accept Pages prod 12da49f3 Public API prod OK @@ -596,7 +596,7 @@
-
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 이번 패스에서 app_user.consent_at 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 125 passed, npm run check:api-types, npm run typecheck, npm run build.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

+
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 이번 패스에서 app_user.consent_at 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 최신 130 passed, npm run check:api-types, npm run typecheck, npm run build.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

@@ -631,12 +631,12 @@
증상

라이브 회기에서 내담자(민재)가 "저는 Claude Code인데… Vignette 코드베이스… git status…"로 캐릭터를 깨고 개발 맥락 노출. 원인: ENGINE_MODE=claude_cli가 로컬 claude(=Claude Code)를 호출하는데 게이트웨이가 페르소나를 --append-system-prompt로 코딩 어시스턴트 정체성에 덧붙이고, cwd=repo라 CLAUDE.md·git status·env가 시스템프롬프트에 포함됨.

수정·검증

engine_gateway/gateway.py: --append-system-prompt--system-prompt(페르소나만 교체 주입) + --exclude-dynamic-system-prompt-sections(cwd/env/git/메모리 섹션 제거). 라이브: 깨던 입력 그대로 → 민재 완벽 인캐릭터, 누출 0. 게이트웨이 7/7 + 전체 API 119 passed.

- -
현재

app/turn_runtime.py를 추가해 load_owned_session, record_completed_turn, append_completed_turn, update_session_state, record_safety_event를 공통화했다. routes/sessions.py의 submit/stream과 routes/voice.py가 같은 저장·위기 이벤트 경로를 사용하고, stream fast-loop 평가 누락도 닫았다. voice 경로도 세션 recall cache를 사용한다. 검증: 관련 회귀 45 passed, 전체 API 119 passed, gateway 7 passed.

잔여

더 큰 finalize_turn/SessionRepository 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.

+ +
현재

app/turn_runtime.py를 추가해 load_owned_session, record_completed_turn, append_completed_turn, update_session_state, record_safety_event를 공통화했다. 이번 패스에서 finalize_completed_turn을 추가해 record_completed_turn + record_safety_event 호출쌍을 REST submit/stream과 voice WS 라우트에서 제거했다. stream fast-loop 평가 누락, voice 세션 recall cache, visible_to/evaluator turn 분리는 보존.

검증

pytest app/test_session_turn_persistence.py app/test_voice_ws.py engine_gateway/test_gateway_model.py app/test_phase3_artifact_checker.py app/test_dataset_export.py -q 40 passed.

잔여

더 큰 finalize_turn/SessionRepository 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.

- -
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡고, TurnResponse/SessionEndResponse는 생성 타입 alias를 사용한다. 기존 누락 필드 crisis_kind, end_state, crisis_resource, safety_alerts, learner_growth, PersonaDraftPayload는 생성 타입에 포함된다. 검증: npm run check:api-types, npm run typecheck, pytest app/ 119 passed, pytest engine_gateway/ 7 passed.

잔여

나머지 수기 미러 타입(PersonaSummary, review/admin/teacher/user DTO 등)을 생성 타입 alias로 점진 전환하고, 실제 CI 워크플로에 npm run check:api-types를 연결해야 한다.

+ +
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡고, TurnResponse/SessionEndResponse는 생성 타입 alias를 사용한다. 이번 패스에서 MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO를 ApiSchema<...> alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다.

검증

npm run check:api-types, npm run typecheck passed. Leaf DTO residual check에서 전환 대상은 ApiSchema alias로 확인.

잔여

aggregate 응답(SessionReviewResponse, TeacherDashboardResponse, worksheet/review 배열 등)은 generated 배열 optional 차이를 UI fallback으로 먼저 흡수한 뒤 전환한다. 실제 CI 워크플로에 npm run check:api-types를 연결하는 작업도 남아 있다.

@@ -721,9 +721,9 @@