diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 5ae5aed..5a6c1af 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -156,6 +156,7 @@ async def healthcheck() -> bool: to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery, to_regclass('app.sessions') IS NOT NULL AS has_sessions, to_regclass('app.turns') IS NOT NULL AS has_turns, + to_regclass('app.safety_events') IS NOT NULL AS has_safety_events, to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status, EXISTS ( SELECT 1 FROM information_schema.columns @@ -171,6 +172,21 @@ async def healthcheck() -> bool: 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' + AND table_name = 'safety_events' + AND column_name IN ( + 'session_id', + 'trigger_type', + 'ko_risk_level', + 'escalated', + 'detail', + 'created_at' + ) + GROUP BY table_schema, table_name + HAVING count(*) = 6 + ) AS has_safety_event_columns, EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' @@ -198,8 +214,10 @@ async def healthcheck() -> bool: and row["has_notification_delivery"] and row["has_sessions"] and row["has_turns"] + and row["has_safety_events"] and row["has_session_review_status"] and row["has_turn_voice_metadata_columns"] + and row["has_safety_event_columns"] and row["has_session_review_worksheet_columns"] ) except Exception: diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 5c1d13b..f20920f 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -1324,6 +1324,7 @@ async def stream_turn( ) async def event_generator(): + finalized_turn = False last_beat = asyncio.get_running_loop().time() final_reply = "" try: @@ -1338,15 +1339,43 @@ async def stream_turn( yield {"event": "token", "data": text} elif ev.event == "done": 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) + if not finalized_turn: + evaluation = await _evaluate_stream_turn(ctx, final_reply) + result = _stream_result_from_done(ctx, final_reply, data, evaluation) + await turn_runtime.finalize_completed_turn( + sess, + ctx, + result, + context_prefix="session", + ) + finalized_turn = True + yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} + elif ( + ev.event == "safety" + and bool(ev.data.get("conversation_stopped")) + and ctx.crisis is not None + and ctx.crisis.escalate + and not finalized_turn + ): + safety_data = { + "session_id": ctx.session_id, + "stage": _stage_label(ctx.state_after.stage), + "effective_openness": round(ctx.state_after.effective_openness, 4), + "turn_seq": ctx.state_after.turn_seq, + "safety_flagged": True, + "crisis_kind": ctx.crisis.kind.value, + "crisis_resource": ev.data.get("crisis_resource"), + "conversation_stopped": True, + } + result = _stream_result_from_done(ctx, final_reply, safety_data, None) await turn_runtime.finalize_completed_turn( sess, ctx, result, context_prefix="session", ) - yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} + finalized_turn = True + yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} else: yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py index 24f2118..5e5863c 100644 --- a/apps/api/app/routes/teacher.py +++ b/apps/api/app/routes/teacher.py @@ -304,6 +304,8 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp ) for item in raw_alerts ] + else: + require_runtime_fallback_allowed("safety alert list") if sessions: message = "현재 기록된 실제 학습 세션만 표시합니다." diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py index 0c2a193..7b57030 100644 --- a/apps/api/app/session_persistence.py +++ b/apps/api/app/session_persistence.py @@ -5,6 +5,7 @@ from __future__ import annotations import time import uuid import hashlib +import json from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Iterable, Protocol @@ -197,6 +198,27 @@ def _model_payload(value: Any) -> dict[str, Any]: return {} +def _json_object_payload(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + if not isinstance(value, str): + return {} + text = value.strip() + for _ in range(2): + if not text: + return {} + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return {} + if isinstance(parsed, dict): + return parsed + if not isinstance(parsed, str): + return {} + text = parsed.strip() + return {} + + def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None: text = (value or "").strip() if not text: @@ -1023,6 +1045,49 @@ async def ensure_review_tables() -> None: ) """ ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.safety_events ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE, + turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL, + trigger_type TEXT NOT NULL, + ko_risk_level SMALLINT, + escalated BOOLEAN NOT NULL DEFAULT FALSE, + latency_ms INT, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_safety_session + ON app.safety_events(session_id); + + ALTER TABLE app.safety_events ENABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS p_safety_events_select ON app.safety_events; + DROP POLICY IF EXISTS p_safety_events_insert ON app.safety_events; + + CREATE POLICY p_safety_events_select + ON app.safety_events FOR SELECT USING ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.safety_events.session_id + AND s.learner_id = app.current_uid() + ) + ); + CREATE POLICY p_safety_events_insert + ON app.safety_events FOR INSERT WITH CHECK ( + app.is_ai_context() + OR app.current_role_name() IN ('admin','instructor') + OR EXISTS ( + SELECT 1 FROM app.sessions s + WHERE s.id = app.safety_events.session_id + AND s.learner_id = app.current_uid() + ) + ) + """ + ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.session_review_status ( @@ -2557,7 +2622,7 @@ async def list_safety_alerts( "trigger_type": str(row["trigger_type"] or "crisis"), "ko_risk_level": int(row["ko_risk_level"] or 0), "escalated": bool(row["escalated"]), - "detail": dict(row["detail"] or {}), + "detail": _json_object_payload(row["detail"]), "created_at": _iso_dt(row["created_at"]), } for row in rows diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index 7918bfc..e8ec056 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch from fastapi import HTTPException -from . import auth_sessions, session_persistence +from . import auth_sessions, db, session_persistence from .config import Settings, settings from .deps import Principal, Role from .routes import admin as admin_routes @@ -229,6 +229,57 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): self.assertIn(column, conn.query) self.assertIn("HAVING count(*) = 5", conn.query) + async def test_healthcheck_requires_safety_events_schema(self) -> None: + class MissingSafetyEventsConn: + def __init__(self) -> None: + self.query = "" + + async def fetchrow(self, query: str, *args, **kwargs): + self.query = query + return { + "has_user": True, + "has_auth_session": True, + "has_preferences": True, + "has_engine_config": True, + "has_admin_health_event": True, + "has_admin_health_daily_rollup": True, + "has_support_ticket": True, + "has_notification_event": True, + "has_notification_delivery": True, + "has_sessions": True, + "has_turns": True, + "has_safety_events": False, + "has_session_review_status": True, + "has_turn_voice_metadata_columns": True, + "has_safety_event_columns": False, + "has_session_review_worksheet_columns": True, + } + + class MissingSafetyEventsAcquire: + def __init__(self, conn: MissingSafetyEventsConn) -> None: + self.conn = conn + + async def __aenter__(self) -> MissingSafetyEventsConn: + return self.conn + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + class MissingSafetyEventsPool: + def __init__(self, conn: MissingSafetyEventsConn) -> None: + self.conn = conn + + def acquire(self): + return MissingSafetyEventsAcquire(self.conn) + + conn = MissingSafetyEventsConn() + with patch.object(db, "get_pool", return_value=MissingSafetyEventsPool(conn)): + healthy = await db.healthcheck() + + self.assertFalse(healthy) + self.assertIn("to_regclass('app.safety_events')", conn.query) + self.assertIn("has_safety_event_columns", conn.query) + async def test_staging_uses_env_engine_config_when_row_missing(self) -> None: class EmptyConfigConn: async def fetchrow(self, *args, **kwargs): diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index fd2174f..e176451 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -7,6 +7,9 @@ import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from fastapi import HTTPException + +from .config import settings from . import session_persistence, turn_runtime from .contracts.engine_gateway import EngineGatewaySseLineDecoder from .deps import Principal, Role @@ -267,20 +270,75 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: return None - with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()): + with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire: await turn_runtime.record_safety_event(sess, ctx, result) + acquire.assert_called_once_with(ai_context=True) self.assertEqual(len(calls), 1) query, args = calls[0] self.assertIn("INSERT INTO app.safety_events", query) self.assertEqual(args[0], sess.session_id) self.assertEqual(args[1], "learner_real") self.assertGreaterEqual(args[2], 4) - detail = json.loads(args[3]) + detail = args[3] self.assertTrue(detail["conversation_stopped"]) self.assertEqual(detail["crisis_resource"]["number"], "109") self.assertEqual(detail["alert_status"], "teacher_dashboard") + def test_safety_alert_detail_parser_handles_legacy_json_string(self) -> None: + detail = session_persistence._json_object_payload( + '"{\\"crisis_resource\\":{\\"number\\":\\"109\\"},\\"alert_status\\":\\"teacher_dashboard\\"}"' + ) + + self.assertEqual(detail["crisis_resource"]["number"], "109") + self.assertEqual(detail["alert_status"], "teacher_dashboard") + + async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None: + principal = _principal() + sess = _session(principal) + ctx = orchestrator.prepare_turn( + session_id=sess.session_id, + case_id=sess.case_id, + card=sess.persona, + state=sess.state, + learner_text="저 지금 자살하고 싶어요. 도와주세요.", + theory_mode=sess.theory_mode, + ) + result = orchestrator.TurnResult( + turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1, + stage=ctx.state_after.stage.value if ctx.state_after else "라포", + effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0, + client_reply=None, + safety_flagged=True, + state_after=ctx.state_after or sess.state, + crisis_kind="learner_real", + crisis_resource={"title": "자살예방상담전화 109", "number": "109"}, + conversation_stopped=True, + ) + + class FakeConn: + async def execute(self, query: str, *args: object) -> str: + raise RuntimeError("safety_events unavailable") + + class FakeAcquire: + async def __aenter__(self) -> FakeConn: + return FakeConn() + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + previous_environment = settings.environment + settings.environment = "staging" + try: + with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()): + with self.assertRaises(HTTPException) as raised: + await turn_runtime.record_safety_event(sess, ctx, result) + finally: + settings.environment = previous_environment + + self.assertEqual(raised.exception.status_code, 503) + self.assertIn("safety event persistence unavailable", raised.exception.detail) + async def test_stream_turn_persists_client_engine_telemetry(self) -> None: principal = _principal() sess = _session(principal) @@ -328,7 +386,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): def should_not_stream(*args, **kwargs): raise AssertionError("stream engine must not be called for learner_real crisis") - with patch.object(sessions.engine_client, "stream", should_not_stream): + with ( + patch.object(sessions.engine_client, "stream", should_not_stream), + patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn, + patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event, + ): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."), @@ -341,8 +403,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): self.assertIn("'event': 'done'", rendered) self.assertIn("109", rendered) self.assertIn("conversation_stopped", rendered) - self.assertEqual(len(sess.turns), 1) - self.assertEqual(sess.turns[0].speaker, "counselor") + completed_turn.assert_awaited_once() + safety_event.assert_awaited_once() + saved_result = completed_turn.await_args.args[2] + self.assertTrue(saved_result.safety_flagged) + self.assertTrue(saved_result.conversation_stopped) + self.assertEqual(saved_result.crisis_resource["number"], "109") async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None: principal = _principal() diff --git a/apps/api/app/test_teacher_dashboard.py b/apps/api/app/test_teacher_dashboard.py index 1209183..14babe7 100644 --- a/apps/api/app/test_teacher_dashboard.py +++ b/apps/api/app/test_teacher_dashboard.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from fastapi import HTTPException +from .config import settings from .deps import Principal, Role from .routes import teacher from .services import state_machine @@ -186,6 +187,45 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(closed_summary.review_status, "closed") + async def test_dashboard_fails_closed_when_safety_alerts_are_not_durable_outside_dev(self) -> None: + principal = _principal() + sess = _session( + session_id="00000000-0000-0000-0000-00000000b211", + session_no=1, + learner_id="00000000-0000-0000-0000-000000000333", + score="neutral", + rapport=0.1, + technique="reflection", + created_at=1_000.0, + ) + previous_environment = settings.environment + settings.environment = "staging" + try: + with ( + patch.object( + teacher.session_persistence, + "list_sessions", + AsyncMock(return_value=([sess], True)), + ), + patch.object( + teacher.session_persistence, + "list_safety_alerts", + AsyncMock(return_value=([], False)), + ), + patch.object( + teacher.session_persistence, + "list_session_review_statuses", + AsyncMock(return_value=({}, True)), + ), + ): + with self.assertRaises(HTTPException) as raised: + await teacher.teacher_dashboard(principal) + finally: + settings.environment = previous_environment + + self.assertEqual(raised.exception.status_code, 503) + self.assertIn("safety alert list persistence unavailable", raised.exception.detail) + async def test_teacher_can_mark_session_review_closed_with_note(self) -> None: principal = _principal() sess = _session( diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py index 70a83ec..4a57e11 100644 --- a/apps/api/app/turn_runtime.py +++ b/apps/api/app/turn_runtime.py @@ -7,7 +7,7 @@ from __future__ import annotations from enum import Enum -import json +import logging from typing import Optional from . import db, session_persistence @@ -17,6 +17,8 @@ from .services import guardrail, orchestrator, state_machine from .stage_contract import stage_label from .store import InProcSession, TurnRecord, store +logger = logging.getLogger(__name__) + class SessionAccessError(str, Enum): NOT_FOUND = "not_found" @@ -147,8 +149,16 @@ async def record_safety_event( if crisis is None or not getattr(crisis, "escalate", False): return kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) + detail = { + "matched": list(getattr(crisis, "matched", []) or []), + "stage": getattr(result, "stage", None), + "turn_seq": getattr(result, "turn_seq", None), + "conversation_stopped": getattr(result, "conversation_stopped", False), + "crisis_resource": getattr(result, "crisis_resource", None), + "alert_status": "teacher_dashboard", + } try: - async with db.acquire() as conn: + async with db.acquire(ai_context=True) as conn: await conn.execute( """ INSERT INTO app.safety_events @@ -158,17 +168,15 @@ async def record_safety_event( sess.session_id, kind, int(getattr(crisis, "risk_level", 0) or 0), - json.dumps({ - "matched": list(getattr(crisis, "matched", []) or []), - "stage": getattr(result, "stage", None), - "turn_seq": getattr(result, "turn_seq", None), - "conversation_stopped": getattr(result, "conversation_stopped", False), - "crisis_resource": getattr(result, "crisis_resource", None), - "alert_status": "teacher_dashboard", - }), + detail, ) except Exception: - pass + logger.exception( + "safety event persistence failed: session_id=%s trigger_type=%s", + sess.session_id, + kind, + ) + require_runtime_fallback_allowed("safety event") async def finalize_completed_turn( diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 8b3da6c..3792f54 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -559,7 +559,28 @@ test.describe("layout visual gate @single-run", () => { }); }); - test("professor student analysis stays contained across all widths", async ({ page }) => { + test("professor student analysis overview stays contained across all widths", async ({ page }) => { + const fixture = teacherAnalysisFixture(); + await page.route("**/api/teacher/dashboard", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(fixture.dashboard), + }), + ); + await signInAsTeacher(page); + await page.goto("/teach/analysis"); + await gateScreen(page, "professor-analysis", async () => { + await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible({ + timeout: 15_000, + }); + await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1); + await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0); + await expect(page.getByLabel("학습자 검색")).toBeVisible(); + }); + }); + + test("professor learner detail analysis stays contained across all widths", async ({ page }) => { const fixture = teacherAnalysisFixture(); await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ @@ -576,12 +597,12 @@ test.describe("layout visual gate @single-run", () => { }), ); await signInAsTeacher(page); - await page.goto("/teach/analysis"); - await gateScreen(page, "professor-analysis", async () => { - await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 }); + await page.goto(`/teach/analysis?learner=${fixture.analysis.learner_id}`); + await gateScreen(page, "professor-analysis-detail", async () => { await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({ timeout: 15_000, }); + await page.getByRole("tab", { name: /^전체 회기/ }).click(); await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5); }); }); diff --git a/apps/web/e2e/session-persistence.spec.ts b/apps/web/e2e/session-persistence.spec.ts index 69f49a5..e6fe9d6 100644 --- a/apps/web/e2e/session-persistence.spec.ts +++ b/apps/web/e2e/session-persistence.spec.ts @@ -11,6 +11,17 @@ interface HealthResponse { engine?: boolean; } +interface TeacherSafetyAlert { + session_id: string; + resource_title: string; + resource_number: string; +} + +interface TeacherDashboardResponse { + source: string; + safety_alerts: TeacherSafetyAlert[]; +} + interface ReviewTurn { speaker: string; text: string; @@ -492,6 +503,72 @@ test.describe("session persistence", () => { ).toBe(true); }); + test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({ + page, + }) => { + test.setTimeout(120_000); + + const healthResponse = await page.request.get("/api/health"); + await expectResponseOk(healthResponse); + const health = (await healthResponse.json()) as HealthResponse; + test.skip(!health.db, "crisis safety queue persistence requires DB"); + + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page, 1); + const crisisText = "저 지금 자살하고 싶어요. 도와주세요."; + + await page.goto(`/learn/session/${persona.code}`); + await page.getByRole("button", { name: "회기 시작" }).click(); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 }); + await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i); + const sessionId = new URL(page.url()).pathname.split("/").at(-1); + expect(sessionId).toMatch(/^[0-9a-f-]+$/i); + + const streamResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "POST" && + url.pathname.endsWith(`/api/sessions/${sessionId}/stream`) + ); + }); + + const input = page.getByLabel("학습자 발화 입력"); + await input.fill(crisisText); + await page.getByRole("button", { name: "보내기" }).click(); + + const streamResponse = await streamResponsePromise; + await expectResponseOk(streamResponse); + await expect(page.locator(".sx-crisis-resource")).toContainText("자살예방상담전화 109", { + timeout: 15_000, + }); + await expect(page.getByRole("link", { name: "109" })).toBeVisible(); + await expect(input).toBeDisabled(); + + await signInAsTeacher(page); + await expect + .poll( + async () => { + const dashboardResponse = await page.request.get("/api/teacher/dashboard"); + await expectResponseOk(dashboardResponse); + const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse; + expect(dashboard.source).toBe("database"); + return dashboard.safety_alerts.some( + (alert) => alert.session_id === sessionId && alert.resource_number === "109", + ); + }, + { + timeout: 30_000, + intervals: [500, 1_000, 2_000], + message: "crisis safety event should reach the DB-backed teacher dashboard", + }, + ) + .toBe(true); + + await page.goto("/teach"); + await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109"); + }); + test("persists AI tutor coaching history through reload @single-run", async ({ page }) => { test.setTimeout(150_000); diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts index 02f2ab8..53bb5b3 100644 --- a/apps/web/e2e/teacher.spec.ts +++ b/apps/web/e2e/teacher.spec.ts @@ -640,16 +640,33 @@ test.describe("teacher console", () => { await page.getByRole("link", { name: "학생 분석" }).click(); await expect(page).toHaveURL(/\/teach\/analysis$/); + const overviewTable = page.locator('[data-learner-overview-table="true"]'); + await expect(overviewTable).toBeVisible(); + await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(2); + await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0); + + await page.getByLabel("학습자 검색").fill("사용자 B"); + const learnerBRow = page + .locator('[data-learner-overview-row="true"]') + .filter({ hasText: "사용자 B" }); + await expect(learnerBRow).toHaveCount(1); + await expect(learnerBRow.getByText(learnerB)).toBeVisible(); + await learnerBRow.getByRole("button", { name: "사용자 B 상세 분석 열기" }).click(); + await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerB}$`)); + const analysisPanel = page.locator('[data-learner-analysis-panel="true"]'); await expect(analysisPanel).toBeVisible(); - await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7); - await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible(); - await expect(page.getByText("전체 회기 7건")).toBeVisible(); - - await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click(); + await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0); + await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click(); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2); await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible(); await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0); + await expect(page.getByText("전체 회기 2건")).toBeVisible(); + + await page.getByRole("button", { name: "전체 목록" }).click(); + await expect(page).toHaveURL(/\/teach\/analysis$/); + await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible(); + await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1); await expectNoHorizontalOverflow(page); }); diff --git a/apps/web/src/pages/Professor.tsx b/apps/web/src/pages/Professor.tsx index 3d38c79..ad34a71 100644 --- a/apps/web/src/pages/Professor.tsx +++ b/apps/web/src/pages/Professor.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui"; import { @@ -20,6 +20,8 @@ import "./professor.css"; type LoadState = "loading" | "ready" | "error"; type ProfessorView = "console" | "analysis"; +type LearnerSort = "latest" | "sessions" | "score" | "delta"; +type LearnerAnalysisTab = "trend" | "sessions" | "stages"; function formatDateTime(value: string | null): string { if (!value) return "-"; @@ -95,7 +97,9 @@ function EmptyState({ title, desc }: { title: string; desc: string }) { } export default function Professor({ view = "console" }: { view?: ProfessorView }) { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const isAnalysisView = view === "analysis"; + const selectedLearnerId = isAnalysisView ? searchParams.get("learner") : null; const [dashboard, setDashboard] = useState(null); const [personaReviews, setPersonaReviews] = useState([]); const [loadState, setLoadState] = useState("loading"); @@ -104,7 +108,8 @@ export default function Professor({ view = "console" }: { view?: ProfessorView } const [personaReviewError, setPersonaReviewError] = useState(null); const [personaDecisionBusy, setPersonaDecisionBusy] = useState(null); const [updatedAt, setUpdatedAt] = useState(null); - const [selectedLearnerId, setSelectedLearnerId] = useState(null); + const [learnerSearch, setLearnerSearch] = useState(""); + const [learnerSort, setLearnerSort] = useState("latest"); const [learnerAnalysis, setLearnerAnalysis] = useState(null); const [learnerAnalysisState, setLearnerAnalysisState] = useState("ready"); @@ -235,17 +240,49 @@ export default function Professor({ view = "console" }: { view?: ProfessorView } loadState === "loading" || (!isAnalysisView && personaReviewLoading) || (isAnalysisView && learnerAnalysisState === "loading"); + const openLearnerDetail = useCallback( + (learnerId: string) => { + setSearchParams({ learner: learnerId }); + }, + [setSearchParams], + ); + const closeLearnerDetail = useCallback(() => { + setSearchParams({}); + }, [setSearchParams]); + const visibleLearners = useMemo(() => { + const query = learnerSearch.trim().toLocaleLowerCase("ko-KR"); + const filtered = query + ? learnerGrowth.filter((learner) => { + const haystack = [ + learner.learner_label, + learner.learner_id, + trendLabel(learner.trend), + ...(learner.top_techniques ?? []), + ] + .join(" ") + .toLocaleLowerCase("ko-KR"); + return haystack.includes(query); + }) + : [...learnerGrowth]; + + return filtered.sort((a, b) => { + if (learnerSort === "sessions") return b.sessions - a.sessions; + if (learnerSort === "score") return (b.latest_score ?? -1) - (a.latest_score ?? -1); + if (learnerSort === "delta") return (b.score_delta ?? -999) - (a.score_delta ?? -999); + return Date.parse(b.latest_at ?? "") - Date.parse(a.latest_at ?? ""); + }); + }, [learnerGrowth, learnerSearch, learnerSort]); useEffect(() => { if (!isAnalysisView) return; - if (learnerGrowth.length === 0) { - if (selectedLearnerId !== null) setSelectedLearnerId(null); - return; + if ( + selectedLearnerId && + learnerGrowth.length > 0 && + !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId) + ) { + setSearchParams({}); } - if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) { - setSelectedLearnerId(learnerGrowth[0].learner_id); - } - }, [isAnalysisView, learnerGrowth, selectedLearnerId]); + }, [isAnalysisView, learnerGrowth, selectedLearnerId, setSearchParams]); useEffect(() => { if (!isAnalysisView) { @@ -293,10 +330,10 @@ export default function Professor({ view = "console" }: { view?: ProfessorView } {isAnalysisView ? "학생 분석" : "교수 콘솔"} {isAnalysisView ? ( <> -

학습자별 추이와 전체 회기를 분리해서 봅니다.

+

전체 학습자를 먼저 보고, 필요한 학습자만 상세로 들어갑니다.

- 왼쪽에서 학습자를 고르면 점수 변화, 단계 분포, 전체 회기 타임라인을 한 흐름으로 - 확인하고 각 회기의 상세 검토로 들어갑니다. + 검색 테이블에서 학습자별 회기 수, 최근 점수, 변화폭, 라포와 미니 추이를 비교한 뒤 + 상세 화면에서 추이·전체 회기·단계 분석 탭으로 나눠 확인합니다.

) : ( @@ -408,103 +445,82 @@ export default function Professor({ view = "console" }: { view?: ProfessorView } ) : null} {isAnalysisView ? ( -
- - -
- -
-
-
- 사용자별 분석 -

{selectedLearner?.learner_label ?? "학습자 이력"}

-
- - {learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"} - -
- - - {learnerGrowth.length === 0 ? ( - - ) : learnerAnalysisState === "loading" ? ( - - ) : learnerAnalysisError ? ( -
- - 학습자 분석을 불러오지 못했습니다 - {learnerAnalysisError} -
- ) : learnerAnalysis ? ( - navigate(`/teach/session/${sessionId}/review`)} +
+ {!selectedLearnerId ? ( + ) : ( - +
+
+ + +
+ +
+
+ 사용자별 분석 +

{selectedLearner?.learner_label ?? "학습자 이력"}

+
+ + {learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"} + +
+ + + {learnerAnalysisState === "loading" ? ( + + ) : learnerAnalysisError ? ( +
+ + 학습자 분석을 불러오지 못했습니다 + {learnerAnalysisError} +
+ ) : learnerAnalysis ? ( + navigate(`/teach/session/${sessionId}/review`)} + /> + ) : ( + + )} +
+
)} - -
-
) : null} {!isAnalysisView ? ( @@ -798,6 +814,150 @@ export default function Professor({ view = "console" }: { view?: ProfessorView } ); } +function LearnerOverviewTable({ + learners, + totalLearners, + query, + sort, + loadState, + onSearch, + onSort, + onOpen, +}: { + learners: TeacherLearnerGrowth[]; + totalLearners: number; + query: string; + sort: LearnerSort; + loadState: LoadState; + onSearch: (value: string) => void; + onSort: (value: LearnerSort) => void; + onOpen: (learnerId: string) => void; +}) { + const sortOptions: Array<{ key: LearnerSort; label: string }> = [ + { key: "latest", label: "최근순" }, + { key: "sessions", label: "회기순" }, + { key: "score", label: "점수순" }, + { key: "delta", label: "변화순" }, + ]; + + return ( +
+
+
+ 전체 학습자 +

검색 가능한 학습자 추이 테이블

+
+ 0 ? "accent" : "neutral"}>{totalLearners}명 +
+ + +
+ +
+ {sortOptions.map((option) => ( + + ))} +
+
+ + {totalLearners === 0 ? ( + + ) : learners.length === 0 ? ( + + ) : ( +
+ + {learners.map((learner) => ( +
+
+ {learner.learner_label} + {learner.learner_id} +
+
+ + {learner.ended_sessions}/{learner.sessions} + + {formatDateTime(learner.latest_at)} +
+
+
+ {(learner.points ?? []).map((point) => ( + + ))} +
+ + {trendLabel(learner.trend)} + +
+
+ {formatScore(learner.latest_score)} +
+
+ {formatDelta(learner.score_delta)} +
+
+ {formatRapport(learner.avg_rapport)} +
+
+ {(learner.top_techniques ?? []).slice(0, 3).map((tag) => ( + {tag} + ))} + {(learner.top_techniques ?? []).length === 0 ? 기법 부족 : null} +
+
+ +
+
+ ))} +
+ )} +
+
+ ); +} + function LearnerAnalysisPanel({ analysis, onOpenSession, @@ -805,11 +965,17 @@ function LearnerAnalysisPanel({ analysis: TeacherLearnerAnalysisResponse; onOpenSession: (sessionId: string) => void; }) { + const [activeTab, setActiveTab] = useState("trend"); const points = analysis.points ?? []; const sessions = analysis.sessions ?? []; const pointBySession = new Map(points.map((point) => [point.session_id, point])); const stageBreakdown = analysis.stage_breakdown ?? []; const summary = analysis.summary; + const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [ + { key: "trend", label: "추이", count: `${points.length}점` }, + { key: "sessions", label: "전체 회기", count: `${sessions.length}건` }, + { key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` }, + ]; return (
@@ -848,7 +1014,23 @@ function LearnerAnalysisPanel({
-
+
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "trend" ? (
회기별 추이 @@ -864,7 +1046,9 @@ function LearnerAnalysisPanel({ )}
+ ) : null} + {activeTab === "stages" ? (
단계 분포 @@ -880,28 +1064,30 @@ function LearnerAnalysisPanel({ ))}
-
+ ) : null} -
-
- 전체 회기 - {sessions.length}건 -
- {sessions.length > 0 ? ( -
- {sessions.map((session) => ( - onOpenSession(session.session_id)} - /> - ))} + {activeTab === "sessions" ? ( +
+
+ 전체 회기 + {sessions.length}건
- ) : ( - - )} -
+ {sessions.length > 0 ? ( +
+ {sessions.map((session) => ( + onOpenSession(session.session_id)} + /> + ))} +
+ ) : ( + + )} +
+ ) : null} ); } @@ -962,91 +1148,6 @@ function LearnerSessionRow({ ); } -function GrowthCard({ - learner, - selected, - onSelect, -}: { - learner: TeacherLearnerGrowth; - selected: boolean; - onSelect: () => void; -}) { - const points = learner.points ?? []; - const topTechniques = learner.top_techniques ?? []; - const recentPoints = points.slice(-3).reverse(); - return ( - - ); -} - function GrowthBar({ point }: { point: TeacherGrowthPoint }) { const hasScore = typeof point.score === "number" && !Number.isNaN(point.score); const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10; diff --git a/apps/web/src/pages/professor.css b/apps/web/src/pages/professor.css index e8a0692..d34816d 100644 --- a/apps/web/src/pages/professor.css +++ b/apps/web/src/pages/professor.css @@ -181,17 +181,11 @@ gap:14px; min-width:0; } -.pf-analysis-shell{ +.pf-analysis-page{ order:2; - display:grid; - grid-template-columns:minmax(300px,360px) minmax(0,1fr); - align-items:start; - gap:14px; - min-width:0; -} -.pf-analysis-rail{ - position:sticky; - top:16px; + display:flex; + flex-direction:column; + gap:12px; min-width:0; } .pf-analysis-workbench{ @@ -200,9 +194,16 @@ flex-direction:column; gap:10px; } +.pf-analysis-detail-head{ + display:grid; + grid-template-columns:auto minmax(0,1fr); + gap:10px; + align-items:center; + min-width:0; +} .pf-depth-strip{ display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); + grid-template-columns:repeat(3,minmax(0,1fr)); min-width:0; border:1px solid var(--hair); border-radius:var(--radius); @@ -325,86 +326,217 @@ .pf-growth-panel{ background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface))); } -.pf-growth-list{ - max-height:min(420px,44vh); - overflow:auto; - scrollbar-gutter:stable; +.pf-learner-overview{ + background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface))); +} +.pf-learner-toolbar{ display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:12px; + grid-template-columns:minmax(240px,.72fr) auto; + gap:10px; + align-items:center; padding:12px; + border-bottom:1px solid var(--hair); + background:color-mix(in srgb,var(--bg-surface) 86%,var(--bg-surface-2)); } -.pf-analysis-rail .pf-growth-list{ - max-height:min(690px,calc(100vh - 250px)); - grid-template-columns:1fr; - gap:9px; - padding:10px; +.pf-search-field{ + min-width:0; + display:grid; + grid-template-columns:auto minmax(0,1fr); + gap:8px; + align-items:center; + padding:9px 10px; + border:1px solid var(--paper-2); + border-radius:var(--radius); + color:var(--text-muted); + background:var(--bg-surface-2); } -.pf-growth-card{ - width:100%; +.pf-search-field input{ + min-width:0; + border:0; + outline:0; + color:var(--text-strong); + background:transparent; font:inherit; - text-align:left; - color:inherit; + font-size:var(--fs-sm); +} +.pf-search-field input::placeholder{ + color:var(--text-muted); +} +.pf-sort-segment{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:0; + border:1px solid var(--paper-2); + border-radius:var(--radius); + overflow:hidden; + background:var(--bg-surface-2); +} +.pf-sort-segment button{ + min-height:38px; + padding:0 11px; + border:0; + border-left:1px solid var(--hair); + color:var(--text-muted); + background:transparent; + font:inherit; + font-size:var(--fs-xs); + font-weight:760; + cursor:pointer; + white-space:nowrap; +} +.pf-sort-segment button:first-child{ + border-left:0; +} +.pf-sort-segment button:hover, +.pf-sort-segment button:focus-visible{ + color:var(--text-strong); + background:var(--bg-surface); +} +.pf-sort-segment button:focus-visible{ + outline:2px solid var(--accent); + outline-offset:-2px; +} +.pf-sort-segment button.is-active{ + color:var(--text-on-accent); + background:var(--accent); +} +.pf-learner-table{ min-width:0; display:flex; flex-direction:column; - gap:12px; - padding:13px; - border:1px solid var(--hair); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2)); - box-shadow:none; - cursor:pointer; } -.pf-growth-card:hover{ - border-color:var(--accent); - background:var(--bg-surface); -} -.pf-growth-card:focus-visible{ - outline:2px solid var(--accent); - outline-offset:2px; -} -.pf-growth-card.is-selected{ - border-color:var(--accent); - background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface)); -} -.pf-analysis-rail .pf-growth-card{ +.pf-learner-table__head, +.pf-learner-row{ + display:grid; + grid-template-columns: + minmax(170px,1.25fr) + minmax(86px,.55fr) + minmax(164px,.9fr) + minmax(76px,.48fr) + minmax(64px,.42fr) + minmax(76px,.48fr) + minmax(118px,.75fr) + minmax(96px,.42fr); gap:9px; - padding:11px; - border-radius:var(--radius); -} -.pf-analysis-rail .pf-growth-card__points{ - display:none; -} -.pf-analysis-rail .pf-growth-card__tags{ - min-height:0; -} -.pf-analysis-rail .pf-growth-bars{ - height:54px; -} -.pf-growth-card__top{ - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:10px; + align-items:center; min-width:0; } -.pf-growth-card__id{ +.pf-learner-table__head{ + position:sticky; + top:0; + z-index:1; + padding:9px 12px; + border-bottom:1px solid var(--hair); + color:var(--text-muted); + background:var(--bg-surface); + font-size:var(--fs-xs); + font-weight:800; + white-space:nowrap; +} +.pf-learner-row{ + padding:11px 12px; + border-top:1px solid var(--paper-2); +} +.pf-learner-table__head + .pf-learner-row{ + border-top:0; +} +.pf-learner-row:hover{ + background:color-mix(in srgb,var(--accent-tint) 14%,transparent); +} +.pf-learner-row__id, +.pf-learner-row__metric, +.pf-learner-row__spark, +.pf-learner-row__tags{ min-width:0; +} +.pf-learner-row__id{ display:grid; gap:3px; } -.pf-growth-card__id b{ +.pf-learner-row__id b{ color:var(--text-strong); font-size:var(--fs-sm); line-height:1.35; +} +.pf-learner-row__id span, +.pf-learner-row__metric span{ + color:var(--text-muted); + font-size:11px; + line-height:1.3; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.pf-growth-card__id span, -.pf-growth-card__metrics small, -.pf-growth-point span, +.pf-learner-row__metric{ + display:grid; + gap:3px; +} +.pf-learner-row__metric::before, +.pf-learner-row__spark::before, +.pf-learner-row__tags::before{ + display:none; + content:attr(data-label); + color:var(--text-muted); + font-size:var(--fs-xs); + font-weight:800; +} +.pf-learner-row__metric b{ + color:var(--text-strong); + font-family:var(--font-num); + font-size:15px; + line-height:1.15; + white-space:nowrap; +} +.pf-learner-row__spark{ + display:grid; + grid-template-columns:minmax(0,1fr) auto; + gap:8px; + align-items:center; +} +.pf-learner-spark{ + min-width:0; + height:46px; + display:flex; + align-items:flex-end; + gap:4px; + padding:6px 6px 4px; + border:1px solid var(--paper-2); + border-radius:var(--radius-sm); + background:var(--bg-surface-2); +} +.pf-learner-spark .pf-growth-bar{ + min-width:10px; +} +.pf-learner-spark .pf-growth-bar small{ + display:none; +} +.pf-learner-row__tags{ + display:flex; + flex-wrap:wrap; + gap:5px; +} +.pf-learner-row__tags span{ + max-width:100%; + padding:4px 7px; + border:1px solid var(--paper-2); + border-radius:999px; + color:var(--text-body); + background:var(--bg-surface-2); + font-size:11px; + line-height:1.2; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; +} +.pf-learner-row__action{ + display:flex; + justify-content:flex-end; +} +.pf-learner-row__action .vg-btn{ + min-width:92px; + padding-inline:10px; +} .pf-recent__review-state{ color:var(--text-muted); font-size:12px; @@ -415,37 +547,6 @@ margin-top:3px; line-height:1.2; } -.pf-growth-card__metrics{ - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:8px; -} -.pf-growth-card__metrics span{ - min-width:0; - display:grid; - gap:3px; - padding:9px 10px; - border:1px solid var(--paper-2); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); -} -.pf-growth-card__metrics b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:15px; - line-height:1.15; - white-space:nowrap; -} -.pf-growth-bars{ - height:82px; - display:flex; - align-items:flex-end; - gap:6px; - padding:8px 8px 6px; - border:1px solid var(--paper-2); - border-radius:var(--radius-sm); - background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent); -} .pf-growth-bar{ flex:1 1 0; min-width:14px; @@ -472,50 +573,6 @@ text-align:center; line-height:1; } -.pf-growth-card__tags{ - min-height:26px; - display:flex; - flex-wrap:wrap; - gap:6px; - align-content:flex-start; -} -.pf-growth-card__tags span{ - max-width:100%; - padding:4px 7px; - border:1px solid var(--paper-2); - border-radius:999px; - color:var(--text-body); - background:var(--bg-surface-2); - font-size:11px; - line-height:1.2; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.pf-growth-card__points{ - display:grid; - gap:7px; -} -.pf-growth-point{ - min-width:0; - display:grid; - grid-template-columns:minmax(86px,.5fr) minmax(0,1fr); - gap:8px; - align-items:center; -} -.pf-growth-point b{ - color:var(--text-strong); - font-size:12px; - line-height:1.35; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} -.pf-growth-point span{ - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; -} .pf-analysis-panel{ background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface))); } @@ -525,6 +582,54 @@ gap:12px; padding:12px; } +.pf-analysis-tabs{ + min-width:0; + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + border:1px solid var(--paper-2); + border-radius:var(--radius-sm); + overflow:hidden; + background:var(--bg-surface-2); +} +.pf-analysis-tabs button{ + min-width:0; + min-height:48px; + display:grid; + gap:3px; + align-content:center; + padding:8px 12px; + border:0; + border-left:1px solid var(--hair); + color:var(--text-muted); + background:transparent; + font:inherit; + text-align:left; + cursor:pointer; +} +.pf-analysis-tabs button:first-child{ + border-left:0; +} +.pf-analysis-tabs button:hover, +.pf-analysis-tabs button:focus-visible{ + background:var(--bg-surface); +} +.pf-analysis-tabs button:focus-visible{ + outline:2px solid var(--accent); + outline-offset:-2px; +} +.pf-analysis-tabs button.is-active{ + color:var(--text-strong); + background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface)); +} +.pf-analysis-tabs b{ + font-size:var(--fs-sm); + line-height:1.2; +} +.pf-analysis-tabs span{ + color:var(--text-muted); + font-size:11px; + line-height:1.2; +} .pf-analysis__top{ min-width:0; display:grid; @@ -594,12 +699,6 @@ overflow:hidden; text-overflow:ellipsis; } -.pf-analysis__body{ - min-width:0; - display:grid; - grid-template-columns:minmax(0,1.25fr) minmax(240px,.75fr); - gap:12px; -} .pf-analysis__chart, .pf-analysis__stages, .pf-analysis__timeline{ @@ -1125,13 +1224,9 @@ } @media (max-width:1100px){ .pf-signal-strip, - .pf-workspace, - .pf-analysis-shell{ + .pf-workspace{ grid-template-columns:1fr; } - .pf-analysis-rail{ - position:static; - } .pf-triage{ grid-template-columns:auto minmax(0,max-content) auto; justify-content:start; @@ -1141,12 +1236,57 @@ } .pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);} .pf-kpi:nth-child(n+4){border-top:0;} - .pf-growth-list{ - grid-template-columns:repeat(2,minmax(0,1fr)); + .pf-learner-toolbar{ + grid-template-columns:1fr; } - .pf-analysis-rail .pf-growth-list{ - max-height:360px; - grid-template-columns:repeat(2,minmax(0,1fr)); + .pf-sort-segment{ + justify-content:flex-start; + overflow:auto; + scrollbar-gutter:stable; + } + .pf-learner-table{ + background:var(--bg-surface-2); + } + .pf-learner-table__head{ + display:none; + } + .pf-learner-row{ + margin:10px; + padding:12px; + border:1px solid var(--paper-2); + border-radius:var(--radius); + background:var(--bg-surface); + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:10px 12px; + } + .pf-learner-row + .pf-learner-row{ + margin-top:0; + } + .pf-learner-row__id, + .pf-learner-row__spark, + .pf-learner-row__tags, + .pf-learner-row__action{ + grid-column:1 / -1; + } + .pf-learner-row__metric::before, + .pf-learner-row__spark::before, + .pf-learner-row__tags::before{ + display:block; + } + .pf-learner-row__spark, + .pf-learner-row__tags{ + display:grid; + grid-template-columns:minmax(76px,.28fr) minmax(0,1fr); + align-items:center; + } + .pf-learner-row__tags{ + display:flex; + } + .pf-learner-row__tags::before{ + flex:0 0 76px; + } + .pf-learner-row__action{ + justify-content:flex-start; } .pf-analysis__top{ grid-template-columns:1fr; @@ -1160,9 +1300,6 @@ .pf-analysis__metrics span:nth-child(n+4){ border-top:1px solid var(--hair); } - .pf-analysis__body{ - grid-template-columns:1fr; - } .pf-timeline-row{ grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto; } @@ -1184,6 +1321,9 @@ width:100%; justify-content:flex-start; } + .pf-analysis-detail-head{ + grid-template-columns:1fr; + } .pf-depth-strip{ grid-template-columns:repeat(2,minmax(0,1fr)); } @@ -1204,15 +1344,19 @@ .pf-kpi:nth-child(n+2){border-left:0;} .pf-kpi:nth-child(even){border-left:1px solid var(--hair);} .pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);} - .pf-growth-list{ - grid-template-columns:1fr; - } - .pf-analysis-rail .pf-growth-list{ - grid-template-columns:1fr; - } .pf-analysis{ padding:10px; } + .pf-analysis-tabs{ + grid-template-columns:1fr; + } + .pf-analysis-tabs button, + .pf-analysis-tabs button:first-child{ + border-left:0; + } + .pf-analysis-tabs button:nth-child(n+2){ + border-top:1px solid var(--hair); + } .pf-analysis__metrics{ grid-template-columns:repeat(2,minmax(0,1fr)); } @@ -1345,11 +1489,38 @@ .pf-depth-strip span:nth-child(n+2){ border-top:1px solid var(--hair); } - .pf-growth-list{ + .pf-learner-toolbar{ padding:10px; } - .pf-growth-card__metrics{ + .pf-sort-segment{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + overflow:hidden; + } + .pf-sort-segment button, + .pf-sort-segment button:first-child{ + border-left:0; + } + .pf-sort-segment button:nth-child(even){ + border-left:1px solid var(--hair); + } + .pf-sort-segment button:nth-child(n+3){ + border-top:1px solid var(--hair); + } + .pf-learner-row{ + grid-template-columns:repeat(2,minmax(0,1fr)); + margin:8px; + } + .pf-learner-row__spark, + .pf-learner-row__tags{ grid-template-columns:1fr; + gap:6px; + } + .pf-learner-row__tags::before{ + flex-basis:100%; + } + .pf-learner-row__action .vg-btn{ + width:100%; } .pf-analysis__metrics{ grid-template-columns:1fr; @@ -1379,14 +1550,6 @@ .pf-timeline-row__dates{ grid-template-columns:1fr; } - .pf-growth-point{ - grid-template-columns:1fr; - gap:2px; - } - .pf-growth-point b, - .pf-growth-point span{ - white-space:normal; - } .pf-alert{ grid-template-columns:auto minmax(0,1fr); } diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e437cff..8d96233 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -11,7 +11,7 @@ - 공개 웹: `https://vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net` - 최신 앱 배포 소스: 2026-07-01 `origin/master` manual deploy snapshot. -- 최신 Cloudflare Pages production deploy: 2026-07-01 manual deploy, branch `main`, custom domain assets `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`. +- 최신 Cloudflare Pages production deploy: 2026-07-01 manual deploy, branch `main`, custom domain assets `assets/index-DgN8JVBw.js` + `assets/index--s3xqgO5.css`. - Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr` - 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q` → `305 passed` - X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부. @@ -33,7 +33,7 @@ - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다. -- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-07-01 KST 재확인 대상: `https://vignette.chanpaca.net/login?deploy=20260701` 200 with `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401. +- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-07-01 KST 재확인 대상: `https://vignette.chanpaca.net/login?deploy=20260701` 200 with `assets/index-DgN8JVBw.js` + `assets/index--s3xqgO5.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401. - `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료. - 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다. - 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite dev web, `127.0.0.1:5174` public preview, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 재검증 시 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200이었다. `alpaca-home.taile93291.ts.net`는 Vite 기본 `allowedHosts`에도 포함했다. @@ -170,7 +170,7 @@ - Public web deployment: - `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true` - 2026-06-29 manual deployment, preview `https://eb2ed257.vignette-b1q.pages.dev` - - `https://vignette.chanpaca.net/` serves `assets/index-B5dHolQ4.js` and `assets/index-PBfLFh-m.css` + - `https://vignette.chanpaca.net/` serves `assets/index-DgN8JVBw.js` and `assets/index--s3xqgO5.css` - `https://vignette.chanpaca.net/live2d/mao/Mao.model3.json`, Haru model, and Cubism core routes return 404 - Public login screen: - `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"` diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 60c713d..68fb13a 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -518,7 +518,7 @@

82차 적용(2026-06-29): refactor-governance P8로 Session.tsx의 음성/세션 UI 파생 상태 계산을 sessionVoiceStatusView() helper로 분리했다. 마이크 busy/label, 세션 상태 label, transcript live label, 텍스트 입력 차단, 응답/음성 상태 chip 계산을 named view-model 경계에서 유지한다. API/DB 계약, 세션 시작·전송·종료, voice WebSocket/TTS lifecycle, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: npm run typecheck, npm run build, session-mvp 1 passed, voice-success 2 passed, session-layout desktop/mobile 8 passed.

83차 적용(2026-06-29): refactor-governance P3로 학습자/교수자 성장 지표가 턴 평가 기법 라벨을 읽는 경계를 session_metrics.turn_technique_label()로 명시했다. DB 정규화 후 재수화되는 TechniqueTag.label_ko를 우선 사용하고, 기존 label/name/id/code legacy fallback은 유지한다. alternative_utterances는 future adapter 추출 전에 문자열 배열과 text/suggestion dict 배열을 모두 row로 정규화하는 호환 테스트로 고정했다. 검증: py_compile, evaluation/learner/teacher focused 59 passed.

84차 적용(2026-06-29): refactor-governance P2로 Session.tsx의 음성 guard/view-model 경계를 한 번 더 줄였다. isVoiceStatusBusy()가 마이크 busy 판정을 소유하고, sessionVoiceStatusView()가 마이크 disabled와 aria label까지 반환해 JSX와 toggleMic()의 중복 조건을 제거했다. recording은 계속 busy가 아니므로 두 번째 마이크 클릭으로 발화 전송이 가능하다. API/DB 계약, WebSocket/TTS lifecycle, STT/TTS provider 동작, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: npm run typecheck, npm run build, session-mvp 1 passed, voice-success 2 passed, session-layout desktop/mobile 8 passed.

-

85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 gateway-default sentinel 중복을 줄였다. scripts/check-engine-gateway-contract.mjs는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, engine_gateway_schema.v1.jsonx-engine-gateway-default-model-sentinel extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 assets/index-B5dHolQ4.js/assets/index-PBfLFh-m.css로 갱신했고, OAuth start는 동작하지만 인증된 /turn proof는 여전히 owner storageState gate로 남겼다. vnet.18ka.net/api-vnet.18ka.net DNS A record는 0건이라 DONE 처리하지 않는다.

+

85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 gateway-default sentinel 중복을 줄였다. scripts/check-engine-gateway-contract.mjs는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, engine_gateway_schema.v1.jsonx-engine-gateway-default-model-sentinel extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 assets/index-DgN8JVBw.js/assets/index--s3xqgO5.css로 갱신했고, OAuth start는 동작하지만 인증된 /turn proof는 여전히 owner storageState gate로 남겼다. vnet.18ka.net/api-vnet.18ka.net DNS A record는 0건이라 DONE 처리하지 않는다.

86차 적용(2026-06-29): refactor-governance P2/P3로 Session mic capture를 AudioWorklet-first 구조로 전환하고 기존 MediaRecorder를 fallback으로 남겼다. Worklet은 mono PCM16 chunk를 /voice/ws binary frame으로 보내고, control packet은 format=pcm, sample_rate, channels, sample_width를 명시한다. 백엔드는 pcm 업로드를 STT 호출 직전 RIFF/WAV로 정규화해 raw PCM을 audio/wav로 오인하던 계약 틈을 닫았다. 실제 Deepgram streaming, 물리 마이크, public WSS, 50분 양방향 실측은 계속 외부/실측 gate다. 검증: app/test_voice_ws.py 11 passed, npm run typecheck, voice-success chromium-single-run 2 passed.

87차 적용(2026-06-29): refactor-governance P3로 /voice/ws에 provider-neutral stt_result control을 추가해 EOT helper를 route 경계에 연결했다. 새 control은 text, final, silence_ms, provider_events를 받아 assess_end_of_turn()이 ready를 판단하고, pending이면 턴 실행 없이 state=listening으로 돌아간다. ready일 때만 transcript를 emit하고 _run_turn_and_speak()로 진행한다. 기존 audio_start→binary→audio_end, text_turn, ping/close 계약은 유지했다. 실제 Deepgram interim/final WSS 연결과 public WSS/물리 마이크/50분 실측은 여전히 남은 gate다. 검증: py_compile, app/test_voice_ws.py app/test_voice_service.py 27 passed.

88차 적용(2026-06-29): refactor-governance P3로 Node conformance runner의 stream event/payload 검증을 schema-driven으로 바꿨다. scripts/check-engine-gateway-contract.mjs는 더 이상 token/done/error 이벤트 목록과 payload 필드를 로컬 상수·분기로 들고 있지 않고, engine_gateway_schema.v1.jsonStreamPacket.oneOf, payload $ref, x-engine-gateway-sse-events를 읽어 golden fixture를 검증한다. raw token text fallback과 data: [DONE] compatibility line은 유지했다. 검증: node scripts\check-engine-gateway-contract.mjs --json, 당시 gateway/backend focused suite 통과, 이후 최신 L1 focused 120 passed로 재검증.