안전 이벤트 대시보드 보강

This commit is contained in:
Yun Chan 2026-07-01 12:34:46 +09:00
parent fe2796f05a
commit 51053af536
15 changed files with 1081 additions and 423 deletions

View file

@ -156,6 +156,7 @@ async def healthcheck() -> bool:
to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery, 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.sessions') IS NOT NULL AS has_sessions,
to_regclass('app.turns') IS NOT NULL AS has_turns, 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, to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
EXISTS ( EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
@ -171,6 +172,21 @@ async def healthcheck() -> bool:
GROUP BY table_schema, table_name GROUP BY table_schema, table_name
HAVING count(*) = 5 HAVING count(*) = 5
) AS has_turn_voice_metadata_columns, ) 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 ( EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app' WHERE table_schema = 'app'
@ -198,8 +214,10 @@ async def healthcheck() -> bool:
and row["has_notification_delivery"] and row["has_notification_delivery"]
and row["has_sessions"] and row["has_sessions"]
and row["has_turns"] and row["has_turns"]
and row["has_safety_events"]
and row["has_session_review_status"] and row["has_session_review_status"]
and row["has_turn_voice_metadata_columns"] and row["has_turn_voice_metadata_columns"]
and row["has_safety_event_columns"]
and row["has_session_review_worksheet_columns"] and row["has_session_review_worksheet_columns"]
) )
except Exception: except Exception:

View file

@ -1324,6 +1324,7 @@ async def stream_turn(
) )
async def event_generator(): async def event_generator():
finalized_turn = False
last_beat = asyncio.get_running_loop().time() last_beat = asyncio.get_running_loop().time()
final_reply = "" final_reply = ""
try: try:
@ -1338,15 +1339,43 @@ async def stream_turn(
yield {"event": "token", "data": text} yield {"event": "token", "data": text}
elif ev.event == "done": elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)} data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
evaluation = await _evaluate_stream_turn(ctx, final_reply) if not finalized_turn:
result = _stream_result_from_done(ctx, final_reply, data, evaluation) 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( await turn_runtime.finalize_completed_turn(
sess, sess,
ctx, ctx,
result, result,
context_prefix="session", 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: else:
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}

View file

@ -304,6 +304,8 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
) )
for item in raw_alerts for item in raw_alerts
] ]
else:
require_runtime_fallback_allowed("safety alert list")
if sessions: if sessions:
message = "현재 기록된 실제 학습 세션만 표시합니다." message = "현재 기록된 실제 학습 세션만 표시합니다."

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import time import time
import uuid import uuid
import hashlib import hashlib
import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Iterable, Protocol from typing import Any, Iterable, Protocol
@ -197,6 +198,27 @@ def _model_payload(value: Any) -> dict[str, Any]:
return {} 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: def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
text = (value or "").strip() text = (value or "").strip()
if not text: 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( await conn.execute(
""" """
CREATE TABLE IF NOT EXISTS app.session_review_status ( 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"), "trigger_type": str(row["trigger_type"] or "crisis"),
"ko_risk_level": int(row["ko_risk_level"] or 0), "ko_risk_level": int(row["ko_risk_level"] or 0),
"escalated": bool(row["escalated"]), "escalated": bool(row["escalated"]),
"detail": dict(row["detail"] or {}), "detail": _json_object_payload(row["detail"]),
"created_at": _iso_dt(row["created_at"]), "created_at": _iso_dt(row["created_at"]),
} }
for row in rows for row in rows

View file

@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException from fastapi import HTTPException
from . import auth_sessions, session_persistence from . import auth_sessions, db, session_persistence
from .config import Settings, settings from .config import Settings, settings
from .deps import Principal, Role from .deps import Principal, Role
from .routes import admin as admin_routes from .routes import admin as admin_routes
@ -229,6 +229,57 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertIn(column, conn.query) self.assertIn(column, conn.query)
self.assertIn("HAVING count(*) = 5", 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: async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
class EmptyConfigConn: class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs): async def fetchrow(self, *args, **kwargs):

View file

@ -7,6 +7,9 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .config import settings
from . import session_persistence, turn_runtime from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role 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: async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return 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) await turn_runtime.record_safety_event(sess, ctx, result)
acquire.assert_called_once_with(ai_context=True)
self.assertEqual(len(calls), 1) self.assertEqual(len(calls), 1)
query, args = calls[0] query, args = calls[0]
self.assertIn("INSERT INTO app.safety_events", query) self.assertIn("INSERT INTO app.safety_events", query)
self.assertEqual(args[0], sess.session_id) self.assertEqual(args[0], sess.session_id)
self.assertEqual(args[1], "learner_real") self.assertEqual(args[1], "learner_real")
self.assertGreaterEqual(args[2], 4) self.assertGreaterEqual(args[2], 4)
detail = json.loads(args[3]) detail = args[3]
self.assertTrue(detail["conversation_stopped"]) self.assertTrue(detail["conversation_stopped"])
self.assertEqual(detail["crisis_resource"]["number"], "109") self.assertEqual(detail["crisis_resource"]["number"], "109")
self.assertEqual(detail["alert_status"], "teacher_dashboard") 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: async def test_stream_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal() principal = _principal()
sess = _session(principal) sess = _session(principal)
@ -328,7 +386,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
def should_not_stream(*args, **kwargs): def should_not_stream(*args, **kwargs):
raise AssertionError("stream engine must not be called for learner_real crisis") 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( response = await sessions.stream_turn(
sess.session_id, sess.session_id,
sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."), sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."),
@ -341,8 +403,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("'event': 'done'", rendered) self.assertIn("'event': 'done'", rendered)
self.assertIn("109", rendered) self.assertIn("109", rendered)
self.assertIn("conversation_stopped", rendered) self.assertIn("conversation_stopped", rendered)
self.assertEqual(len(sess.turns), 1) completed_turn.assert_awaited_once()
self.assertEqual(sess.turns[0].speaker, "counselor") 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: async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
principal = _principal() principal = _principal()

View file

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException from fastapi import HTTPException
from .config import settings
from .deps import Principal, Role from .deps import Principal, Role
from .routes import teacher from .routes import teacher
from .services import state_machine from .services import state_machine
@ -186,6 +187,45 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(closed_summary.review_status, "closed") 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: async def test_teacher_can_mark_session_review_closed_with_note(self) -> None:
principal = _principal() principal = _principal()
sess = _session( sess = _session(

View file

@ -7,7 +7,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
import json import logging
from typing import Optional from typing import Optional
from . import db, session_persistence from . import db, session_persistence
@ -17,6 +17,8 @@ from .services import guardrail, orchestrator, state_machine
from .stage_contract import stage_label from .stage_contract import stage_label
from .store import InProcSession, TurnRecord, store from .store import InProcSession, TurnRecord, store
logger = logging.getLogger(__name__)
class SessionAccessError(str, Enum): class SessionAccessError(str, Enum):
NOT_FOUND = "not_found" NOT_FOUND = "not_found"
@ -147,8 +149,16 @@ async def record_safety_event(
if crisis is None or not getattr(crisis, "escalate", False): if crisis is None or not getattr(crisis, "escalate", False):
return return
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) 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: try:
async with db.acquire() as conn: async with db.acquire(ai_context=True) as conn:
await conn.execute( await conn.execute(
""" """
INSERT INTO app.safety_events INSERT INTO app.safety_events
@ -158,17 +168,15 @@ async def record_safety_event(
sess.session_id, sess.session_id,
kind, kind,
int(getattr(crisis, "risk_level", 0) or 0), int(getattr(crisis, "risk_level", 0) or 0),
json.dumps({ 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",
}),
) )
except Exception: 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( async def finalize_completed_turn(

View file

@ -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(); const fixture = teacherAnalysisFixture();
await page.route("**/api/teacher/dashboard", (route) => await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({ route.fulfill({
@ -576,12 +597,12 @@ test.describe("layout visual gate @single-run", () => {
}), }),
); );
await signInAsTeacher(page); await signInAsTeacher(page);
await page.goto("/teach/analysis"); await page.goto(`/teach/analysis?learner=${fixture.analysis.learner_id}`);
await gateScreen(page, "professor-analysis", async () => { await gateScreen(page, "professor-analysis-detail", async () => {
await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({ await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
await page.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5); await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
}); });
}); });

View file

@ -11,6 +11,17 @@ interface HealthResponse {
engine?: boolean; engine?: boolean;
} }
interface TeacherSafetyAlert {
session_id: string;
resource_title: string;
resource_number: string;
}
interface TeacherDashboardResponse {
source: string;
safety_alerts: TeacherSafetyAlert[];
}
interface ReviewTurn { interface ReviewTurn {
speaker: string; speaker: string;
text: string; text: string;
@ -492,6 +503,72 @@ test.describe("session persistence", () => {
).toBe(true); ).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("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
test.setTimeout(150_000); test.setTimeout(150_000);

View file

@ -640,16 +640,33 @@ test.describe("teacher console", () => {
await page.getByRole("link", { name: "학생 분석" }).click(); await page.getByRole("link", { name: "학생 분석" }).click();
await expect(page).toHaveURL(/\/teach\/analysis$/); 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"]'); const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
await expect(analysisPanel).toBeVisible(); await expect(analysisPanel).toBeVisible();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0);
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible(); await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(page.getByText("전체 회기 7건")).toBeVisible();
await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2); await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible(); await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0); 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); await expectNoHorizontalOverflow(page);
}); });

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui"; import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
import { import {
@ -20,6 +20,8 @@ import "./professor.css";
type LoadState = "loading" | "ready" | "error"; type LoadState = "loading" | "ready" | "error";
type ProfessorView = "console" | "analysis"; type ProfessorView = "console" | "analysis";
type LearnerSort = "latest" | "sessions" | "score" | "delta";
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
function formatDateTime(value: string | null): string { function formatDateTime(value: string | null): string {
if (!value) return "-"; if (!value) return "-";
@ -95,7 +97,9 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
} }
export default function Professor({ view = "console" }: { view?: ProfessorView }) { export default function Professor({ view = "console" }: { view?: ProfessorView }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isAnalysisView = view === "analysis"; const isAnalysisView = view === "analysis";
const selectedLearnerId = isAnalysisView ? searchParams.get("learner") : null;
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null); const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]); const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
const [loadState, setLoadState] = useState<LoadState>("loading"); const [loadState, setLoadState] = useState<LoadState>("loading");
@ -104,7 +108,8 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null); const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null); const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null); const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [selectedLearnerId, setSelectedLearnerId] = useState<string | null>(null); const [learnerSearch, setLearnerSearch] = useState("");
const [learnerSort, setLearnerSort] = useState<LearnerSort>("latest");
const [learnerAnalysis, setLearnerAnalysis] = const [learnerAnalysis, setLearnerAnalysis] =
useState<TeacherLearnerAnalysisResponse | null>(null); useState<TeacherLearnerAnalysisResponse | null>(null);
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready"); const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
@ -235,17 +240,49 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
loadState === "loading" || loadState === "loading" ||
(!isAnalysisView && personaReviewLoading) || (!isAnalysisView && personaReviewLoading) ||
(isAnalysisView && learnerAnalysisState === "loading"); (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(() => { useEffect(() => {
if (!isAnalysisView) return; if (!isAnalysisView) return;
if (learnerGrowth.length === 0) { if (
if (selectedLearnerId !== null) setSelectedLearnerId(null); selectedLearnerId &&
return; learnerGrowth.length > 0 &&
!learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)
) {
setSearchParams({});
} }
if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) { }, [isAnalysisView, learnerGrowth, selectedLearnerId, setSearchParams]);
setSelectedLearnerId(learnerGrowth[0].learner_id);
}
}, [isAnalysisView, learnerGrowth, selectedLearnerId]);
useEffect(() => { useEffect(() => {
if (!isAnalysisView) { if (!isAnalysisView) {
@ -293,10 +330,10 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker> <Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
{isAnalysisView ? ( {isAnalysisView ? (
<> <>
<h1> .</h1> <h1> , .</h1>
<p> <p>
, , , , ,
. · · .
</p> </p>
</> </>
) : ( ) : (
@ -408,103 +445,82 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
) : null} ) : null}
{isAnalysisView ? ( {isAnalysisView ? (
<section className="pf-analysis-shell" aria-label="학생 분석 작업면"> <section className="pf-analysis-page" aria-label="학생 분석 작업면">
<aside className="pf-analysis-rail"> {!selectedLearnerId ? (
<section className="pf-section pf-section--growth"> <LearnerOverviewTable
<div className="pf-section__head"> learners={visibleLearners}
<div> totalLearners={learnerGrowth.length}
<Kicker> </Kicker> query={learnerSearch}
<h2> </h2> sort={learnerSort}
</div> loadState={loadState}
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}> onSearch={setLearnerSearch}
{learnerGrowth.length} onSort={setLearnerSort}
</Badge> onOpen={openLearnerDetail}
</div>
<Card className="pf-panel pf-growth-panel">
{learnerGrowth.length > 0 ? (
<div className="pf-growth-list">
{learnerGrowth.map((learner) => (
<GrowthCard
learner={learner}
key={learner.learner_id}
selected={learner.learner_id === selectedLearnerId}
onSelect={() => setSelectedLearnerId(learner.learner_id)}
/>
))}
</div>
) : (
<EmptyState
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
/>
)}
</Card>
</section>
</aside>
<section className="pf-analysis-workbench">
<nav className="pf-depth-strip" aria-label="학생 분석 단계">
<span className="is-current">
<small>1</small>
</span>
<span>
<small>2</small>
</span>
<span>
<small>3</small>
</span>
<span>
<small>4</small>
</span>
</nav>
<section className="pf-section pf-section--learner-analysis">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
</Badge>
</div>
<Card className="pf-panel pf-analysis-panel">
{learnerGrowth.length === 0 ? (
<EmptyState
title={loadState === "loading" ? "이력 계산 중" : "분석할 학습자 없음"}
desc="실제 학습자 회기가 쌓이면 사용자별 전체 이력을 표시합니다."
/>
) : learnerAnalysisState === "loading" ? (
<EmptyState
title="학습자 이력 계산 중"
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
/>
) : learnerAnalysisError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b> </b>
<span>{learnerAnalysisError}</span>
</div>
) : learnerAnalysis ? (
<LearnerAnalysisPanel
analysis={learnerAnalysis}
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
/> />
) : ( ) : (
<EmptyState <section className="pf-section pf-section--learner-analysis">
title="선택된 학습자 없음" <div className="pf-analysis-detail-head">
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다." <Button
/> size="sm"
variant="secondary"
leading={<Icon name="chevron-left" size={14} />}
onClick={closeLearnerDetail}
>
</Button>
<nav className="pf-depth-strip" aria-label="학생 분석 깊이">
<span>
<small>1</small>
</span>
<span className="is-current">
<small>2</small>
</span>
<span>
<small>3</small>
</span>
</nav>
</div>
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
</Badge>
</div>
<Card className="pf-panel pf-analysis-panel">
{learnerAnalysisState === "loading" ? (
<EmptyState
title="학습자 이력 계산 중"
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
/>
) : learnerAnalysisError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b> </b>
<span>{learnerAnalysisError}</span>
</div>
) : learnerAnalysis ? (
<LearnerAnalysisPanel
analysis={learnerAnalysis}
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
/>
) : (
<EmptyState
title="선택된 학습자 없음"
desc="전체 목록에서 학습자를 선택하면 상세 분석이 열립니다."
/>
)}
</Card>
</section>
)} )}
</Card>
</section>
</section> </section>
</section>
) : null} ) : null}
{!isAnalysisView ? ( {!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 (
<section className="pf-section pf-section--learner-overview">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={totalLearners > 0 ? "accent" : "neutral"}>{totalLearners}</Badge>
</div>
<Card className="pf-panel pf-learner-overview">
<div className="pf-learner-toolbar">
<label className="pf-search-field">
<Icon name="users" size={15} />
<input
aria-label="학습자 검색"
placeholder="학습자, ID, 기법 검색"
value={query}
onChange={(event) => onSearch(event.currentTarget.value)}
/>
</label>
<div className="pf-sort-segment" aria-label="학습자 정렬">
{sortOptions.map((option) => (
<button
type="button"
className={sort === option.key ? "is-active" : ""}
key={option.key}
onClick={() => onSort(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
{totalLearners === 0 ? (
<EmptyState
title={loadState === "loading" ? "학습자 추이 계산 중" : "표시할 학습자 이력 없음"}
desc="실제 학습자 회기와 턴별 평가가 쌓이면 이 표에 전체 학습자 추이가 표시됩니다."
/>
) : learners.length === 0 ? (
<EmptyState
title="검색 결과 없음"
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
/>
) : (
<div className="pf-learner-table" data-learner-overview-table="true">
<div className="pf-learner-table__head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
{learners.map((learner) => (
<article
className="pf-learner-row"
data-learner-overview-row="true"
key={learner.learner_id}
>
<div className="pf-learner-row__id">
<b>{learner.learner_label}</b>
<span>{learner.learner_id}</span>
</div>
<div className="pf-learner-row__metric" data-label="회기">
<b>
{learner.ended_sessions}/{learner.sessions}
</b>
<span>{formatDateTime(learner.latest_at)}</span>
</div>
<div className="pf-learner-row__spark" data-label="추이">
<div className="pf-learner-spark" aria-label={`${learner.learner_label} 회기별 추이`}>
{(learner.points ?? []).map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-learner-row__metric" data-label="최근 적절성">
<b>{formatScore(learner.latest_score)}</b>
</div>
<div className="pf-learner-row__metric" data-label="변화">
<b>{formatDelta(learner.score_delta)}</b>
</div>
<div className="pf-learner-row__metric" data-label="평균 라포">
<b>{formatRapport(learner.avg_rapport)}</b>
</div>
<div className="pf-learner-row__tags" data-label="기법">
{(learner.top_techniques ?? []).slice(0, 3).map((tag) => (
<span key={tag}>{tag}</span>
))}
{(learner.top_techniques ?? []).length === 0 ? <span> </span> : null}
</div>
<div className="pf-learner-row__action">
<Button
size="sm"
variant="secondary"
leading={<Icon name="chevron-right" size={14} />}
aria-label={`${learner.learner_label} 상세 분석 열기`}
onClick={() => onOpen(learner.learner_id)}
>
</Button>
</div>
</article>
))}
</div>
)}
</Card>
</section>
);
}
function LearnerAnalysisPanel({ function LearnerAnalysisPanel({
analysis, analysis,
onOpenSession, onOpenSession,
@ -805,11 +965,17 @@ function LearnerAnalysisPanel({
analysis: TeacherLearnerAnalysisResponse; analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void; onOpenSession: (sessionId: string) => void;
}) { }) {
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
const points = analysis.points ?? []; const points = analysis.points ?? [];
const sessions = analysis.sessions ?? []; const sessions = analysis.sessions ?? [];
const pointBySession = new Map(points.map((point) => [point.session_id, point])); const pointBySession = new Map(points.map((point) => [point.session_id, point]));
const stageBreakdown = analysis.stage_breakdown ?? []; const stageBreakdown = analysis.stage_breakdown ?? [];
const summary = analysis.summary; 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 ( return (
<div className="pf-analysis" data-learner-analysis-panel="true"> <div className="pf-analysis" data-learner-analysis-panel="true">
@ -848,7 +1014,23 @@ function LearnerAnalysisPanel({
</div> </div>
</div> </div>
<div className="pf-analysis__body"> <div className="pf-analysis-tabs" role="tablist" aria-label="학습자 상세 분석 탭">
{tabs.map((tab) => (
<button
type="button"
role="tab"
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? "is-active" : ""}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
>
<b>{tab.label}</b>
<span>{tab.count}</span>
</button>
))}
</div>
{activeTab === "trend" ? (
<section className="pf-analysis__chart" aria-label="회기별 추이"> <section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead"> <div className="pf-analysis__subhead">
<b> </b> <b> </b>
@ -864,7 +1046,9 @@ function LearnerAnalysisPanel({
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." /> <EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
)} )}
</section> </section>
) : null}
{activeTab === "stages" ? (
<section className="pf-analysis__stages" aria-label="단계 분포"> <section className="pf-analysis__stages" aria-label="단계 분포">
<div className="pf-analysis__subhead"> <div className="pf-analysis__subhead">
<b> </b> <b> </b>
@ -880,28 +1064,30 @@ function LearnerAnalysisPanel({
))} ))}
</div> </div>
</section> </section>
</div> ) : null}
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인"> {activeTab === "sessions" ? (
<div className="pf-analysis__subhead"> <section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<b> </b> <div className="pf-analysis__subhead">
<span>{sessions.length}</span> <b> </b>
</div> <span>{sessions.length}</span>
{sessions.length > 0 ? (
<div className="pf-timeline">
{sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div> </div>
) : ( {sessions.length > 0 ? (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." /> <div className="pf-timeline">
)} {sessions.map((session) => (
</section> <LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
) : null}
</div> </div>
); );
} }
@ -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 (
<button
type="button"
className={`pf-growth-card trend-${learner.trend} ${selected ? "is-selected" : ""}`}
data-learner-growth-card="true"
aria-pressed={selected}
aria-label={`${learner.learner_label} 사용자별 분석`}
onClick={onSelect}
>
<div className="pf-growth-card__top">
<div className="pf-growth-card__id">
<b>{learner.learner_label}</b>
<span>
{learner.ended_sessions}/{learner.sessions} · {formatDateTime(learner.latest_at)}
</span>
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
<span>
<small> </small>
<b>{formatScore(learner.latest_score)}</b>
</span>
<span>
<small></small>
<b>{formatDelta(learner.score_delta)}</b>
</span>
<span>
<small> </small>
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
</span>
</div>
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
{points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{topTechniques.length > 0 ? (
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
) : (
<span> </span>
)}
</div>
<div className="pf-growth-card__points">
{recentPoints.length > 0 ? (
recentPoints.map((point) => (
<div className="pf-growth-point" key={point.session_id}>
<b>
{point.persona_code} · {point.session_no}
</b>
<span>
{formatScore(point.score)} · {point.technique_count} · {point.watch_count}
</span>
</div>
))
) : (
<div className="pf-growth-point">
<b> </b>
<span> .</span>
</div>
)}
</div>
</button>
);
}
function GrowthBar({ point }: { point: TeacherGrowthPoint }) { function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score); const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10; const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;

View file

@ -181,17 +181,11 @@
gap:14px; gap:14px;
min-width:0; min-width:0;
} }
.pf-analysis-shell{ .pf-analysis-page{
order:2; order:2;
display:grid; display:flex;
grid-template-columns:minmax(300px,360px) minmax(0,1fr); flex-direction:column;
align-items:start; gap:12px;
gap:14px;
min-width:0;
}
.pf-analysis-rail{
position:sticky;
top:16px;
min-width:0; min-width:0;
} }
.pf-analysis-workbench{ .pf-analysis-workbench{
@ -200,9 +194,16 @@
flex-direction:column; flex-direction:column;
gap:10px; 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{ .pf-depth-strip{
display:grid; display:grid;
grid-template-columns:repeat(4,minmax(0,1fr)); grid-template-columns:repeat(3,minmax(0,1fr));
min-width:0; min-width:0;
border:1px solid var(--hair); border:1px solid var(--hair);
border-radius:var(--radius); border-radius:var(--radius);
@ -325,86 +326,217 @@
.pf-growth-panel{ .pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface))); background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
} }
.pf-growth-list{ .pf-learner-overview{
max-height:min(420px,44vh); background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
overflow:auto; }
scrollbar-gutter:stable; .pf-learner-toolbar{
display:grid; display:grid;
grid-template-columns:repeat(3,minmax(0,1fr)); grid-template-columns:minmax(240px,.72fr) auto;
gap:12px; gap:10px;
align-items:center;
padding:12px; 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{ .pf-search-field{
max-height:min(690px,calc(100vh - 250px)); min-width:0;
grid-template-columns:1fr; display:grid;
gap:9px; grid-template-columns:auto minmax(0,1fr);
padding:10px; 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{ .pf-search-field input{
width:100%; min-width:0;
border:0;
outline:0;
color:var(--text-strong);
background:transparent;
font:inherit; font:inherit;
text-align:left; font-size:var(--fs-sm);
color:inherit; }
.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; min-width:0;
display:flex; display:flex;
flex-direction:column; 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{ .pf-learner-table__head,
border-color:var(--accent); .pf-learner-row{
background:var(--bg-surface); display:grid;
} grid-template-columns:
.pf-growth-card:focus-visible{ minmax(170px,1.25fr)
outline:2px solid var(--accent); minmax(86px,.55fr)
outline-offset:2px; minmax(164px,.9fr)
} minmax(76px,.48fr)
.pf-growth-card.is-selected{ minmax(64px,.42fr)
border-color:var(--accent); minmax(76px,.48fr)
background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface)); minmax(118px,.75fr)
} minmax(96px,.42fr);
.pf-analysis-rail .pf-growth-card{
gap:9px; gap:9px;
padding:11px; align-items:center;
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;
min-width:0; 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; min-width:0;
}
.pf-learner-row__id{
display:grid; display:grid;
gap:3px; gap:3px;
} }
.pf-growth-card__id b{ .pf-learner-row__id b{
color:var(--text-strong); color:var(--text-strong);
font-size:var(--fs-sm); font-size:var(--fs-sm);
line-height:1.35; 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; overflow:hidden;
text-overflow:ellipsis; text-overflow:ellipsis;
white-space:nowrap; white-space:nowrap;
} }
.pf-growth-card__id span, .pf-learner-row__metric{
.pf-growth-card__metrics small, display:grid;
.pf-growth-point span, 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{ .pf-recent__review-state{
color:var(--text-muted); color:var(--text-muted);
font-size:12px; font-size:12px;
@ -415,37 +547,6 @@
margin-top:3px; margin-top:3px;
line-height:1.2; 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{ .pf-growth-bar{
flex:1 1 0; flex:1 1 0;
min-width:14px; min-width:14px;
@ -472,50 +573,6 @@
text-align:center; text-align:center;
line-height:1; 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{ .pf-analysis-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface))); 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; gap:12px;
padding: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{ .pf-analysis__top{
min-width:0; min-width:0;
display:grid; display:grid;
@ -594,12 +699,6 @@
overflow:hidden; overflow:hidden;
text-overflow:ellipsis; 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__chart,
.pf-analysis__stages, .pf-analysis__stages,
.pf-analysis__timeline{ .pf-analysis__timeline{
@ -1125,13 +1224,9 @@
} }
@media (max-width:1100px){ @media (max-width:1100px){
.pf-signal-strip, .pf-signal-strip,
.pf-workspace, .pf-workspace{
.pf-analysis-shell{
grid-template-columns:1fr; grid-template-columns:1fr;
} }
.pf-analysis-rail{
position:static;
}
.pf-triage{ .pf-triage{
grid-template-columns:auto minmax(0,max-content) auto; grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start; justify-content:start;
@ -1141,12 +1236,57 @@
} }
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);} .pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;} .pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{ .pf-learner-toolbar{
grid-template-columns:repeat(2,minmax(0,1fr)); grid-template-columns:1fr;
} }
.pf-analysis-rail .pf-growth-list{ .pf-sort-segment{
max-height:360px; justify-content:flex-start;
grid-template-columns:repeat(2,minmax(0,1fr)); 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{ .pf-analysis__top{
grid-template-columns:1fr; grid-template-columns:1fr;
@ -1160,9 +1300,6 @@
.pf-analysis__metrics span:nth-child(n+4){ .pf-analysis__metrics span:nth-child(n+4){
border-top:1px solid var(--hair); border-top:1px solid var(--hair);
} }
.pf-analysis__body{
grid-template-columns:1fr;
}
.pf-timeline-row{ .pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto; grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
} }
@ -1184,6 +1321,9 @@
width:100%; width:100%;
justify-content:flex-start; justify-content:flex-start;
} }
.pf-analysis-detail-head{
grid-template-columns:1fr;
}
.pf-depth-strip{ .pf-depth-strip{
grid-template-columns:repeat(2,minmax(0,1fr)); 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(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);} .pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top: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{ .pf-analysis{
padding:10px; 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{ .pf-analysis__metrics{
grid-template-columns:repeat(2,minmax(0,1fr)); grid-template-columns:repeat(2,minmax(0,1fr));
} }
@ -1345,11 +1489,38 @@
.pf-depth-strip span:nth-child(n+2){ .pf-depth-strip span:nth-child(n+2){
border-top:1px solid var(--hair); border-top:1px solid var(--hair);
} }
.pf-growth-list{ .pf-learner-toolbar{
padding:10px; 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; 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{ .pf-analysis__metrics{
grid-template-columns:1fr; grid-template-columns:1fr;
@ -1379,14 +1550,6 @@
.pf-timeline-row__dates{ .pf-timeline-row__dates{
grid-template-columns:1fr; 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{ .pf-alert{
grid-template-columns:auto minmax(0,1fr); grid-template-columns:auto minmax(0,1fr);
} }

View file

@ -11,7 +11,7 @@
- 공개 웹: `https://vignette.chanpaca.net` - 공개 웹: `https://vignette.chanpaca.net`
- 공개 API: `https://api-vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net`
- 최신 앱 배포 소스: 2026-07-01 `origin/master` manual deploy snapshot. - 최신 앱 배포 소스: 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` - Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
- 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q``305 passed` - 최신 백엔드 회귀: `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 없으면 거부. - 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로 사용했다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개 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`로 떠 있다. - 공개용 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 확인 완료. - `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는 로컬 세션에 붙을 수 없다. - 로컬 웹이 `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`에도 포함했다. - 현재 확인용 프로세스는 `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: - Public web deployment:
- `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true` - `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true`
- 2026-06-29 manual deployment, preview `https://eb2ed257.vignette-b1q.pages.dev` - 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 - `https://vignette.chanpaca.net/live2d/mao/Mao.model3.json`, Haru model, and Cubism core routes return 404
- Public login screen: - Public login screen:
- `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"` - `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"`

View file

@ -518,7 +518,7 @@
<p class="dg-note">82차 적용(2026-06-29): refactor-governance P8로 <code>Session.tsx</code>의 음성/세션 UI 파생 상태 계산을 <code>sessionVoiceStatusView()</code> helper로 분리했다. 마이크 busy/label, 세션 상태 label, transcript live label, 텍스트 입력 차단, 응답/음성 상태 chip 계산을 named view-model 경계에서 유지한다. API/DB 계약, 세션 시작·전송·종료, voice WebSocket/TTS lifecycle, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p> <p class="dg-note">82차 적용(2026-06-29): refactor-governance P8로 <code>Session.tsx</code>의 음성/세션 UI 파생 상태 계산을 <code>sessionVoiceStatusView()</code> helper로 분리했다. 마이크 busy/label, 세션 상태 label, transcript live label, 텍스트 입력 차단, 응답/음성 상태 chip 계산을 named view-model 경계에서 유지한다. API/DB 계약, 세션 시작·전송·종료, voice WebSocket/TTS lifecycle, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
<p class="dg-note">83차 적용(2026-06-29): refactor-governance P3로 학습자/교수자 성장 지표가 턴 평가 기법 라벨을 읽는 경계를 <code>session_metrics.turn_technique_label()</code>로 명시했다. DB 정규화 후 재수화되는 <code>TechniqueTag.label_ko</code>를 우선 사용하고, 기존 <code>label/name/id/code</code> legacy fallback은 유지한다. <code>alternative_utterances</code>는 future adapter 추출 전에 문자열 배열과 <code>text/suggestion</code> dict 배열을 모두 row로 정규화하는 호환 테스트로 고정했다. 검증: py_compile, evaluation/learner/teacher focused 59 passed.</p> <p class="dg-note">83차 적용(2026-06-29): refactor-governance P3로 학습자/교수자 성장 지표가 턴 평가 기법 라벨을 읽는 경계를 <code>session_metrics.turn_technique_label()</code>로 명시했다. DB 정규화 후 재수화되는 <code>TechniqueTag.label_ko</code>를 우선 사용하고, 기존 <code>label/name/id/code</code> legacy fallback은 유지한다. <code>alternative_utterances</code>는 future adapter 추출 전에 문자열 배열과 <code>text/suggestion</code> dict 배열을 모두 row로 정규화하는 호환 테스트로 고정했다. 검증: py_compile, evaluation/learner/teacher focused 59 passed.</p>
<p class="dg-note">84차 적용(2026-06-29): refactor-governance P2로 <code>Session.tsx</code>의 음성 guard/view-model 경계를 한 번 더 줄였다. <code>isVoiceStatusBusy()</code>가 마이크 busy 판정을 소유하고, <code>sessionVoiceStatusView()</code>가 마이크 disabled와 aria label까지 반환해 JSX와 <code>toggleMic()</code>의 중복 조건을 제거했다. <code>recording</code>은 계속 busy가 아니므로 두 번째 마이크 클릭으로 발화 전송이 가능하다. API/DB 계약, WebSocket/TTS lifecycle, STT/TTS provider 동작, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p> <p class="dg-note">84차 적용(2026-06-29): refactor-governance P2로 <code>Session.tsx</code>의 음성 guard/view-model 경계를 한 번 더 줄였다. <code>isVoiceStatusBusy()</code>가 마이크 busy 판정을 소유하고, <code>sessionVoiceStatusView()</code>가 마이크 disabled와 aria label까지 반환해 JSX와 <code>toggleMic()</code>의 중복 조건을 제거했다. <code>recording</code>은 계속 busy가 아니므로 두 번째 마이크 클릭으로 발화 전송이 가능하다. API/DB 계약, WebSocket/TTS lifecycle, STT/TTS provider 동작, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
<p class="dg-note">85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 <code>gateway-default</code> sentinel 중복을 줄였다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code><code>x-engine-gateway-default-model-sentinel</code> extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 <code>assets/index-B5dHolQ4.js</code>/<code>assets/index-PBfLFh-m.css</code>로 갱신했고, OAuth start는 동작하지만 인증된 <code>/turn</code> proof는 여전히 owner storageState gate로 남겼다. <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS A record는 0건이라 DONE 처리하지 않는다.</p> <p class="dg-note">85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 <code>gateway-default</code> sentinel 중복을 줄였다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code><code>x-engine-gateway-default-model-sentinel</code> extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 <code>assets/index-DgN8JVBw.js</code>/<code>assets/index--s3xqgO5.css</code>로 갱신했고, OAuth start는 동작하지만 인증된 <code>/turn</code> proof는 여전히 owner storageState gate로 남겼다. <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS A record는 0건이라 DONE 처리하지 않는다.</p>
<p class="dg-note">86차 적용(2026-06-29): refactor-governance P2/P3로 Session mic capture를 <code>AudioWorklet</code>-first 구조로 전환하고 기존 <code>MediaRecorder</code>를 fallback으로 남겼다. Worklet은 mono PCM16 chunk를 <code>/voice/ws</code> binary frame으로 보내고, control packet은 <code>format=pcm</code>, <code>sample_rate</code>, <code>channels</code>, <code>sample_width</code>를 명시한다. 백엔드는 <code>pcm</code> 업로드를 STT 호출 직전 RIFF/WAV로 정규화해 raw PCM을 <code>audio/wav</code>로 오인하던 계약 틈을 닫았다. 실제 Deepgram streaming, 물리 마이크, public WSS, 50분 양방향 실측은 계속 외부/실측 gate다. 검증: <code>app/test_voice_ws.py</code> 11 passed, <code>npm run typecheck</code>, <code>voice-success</code> chromium-single-run 2 passed.</p> <p class="dg-note">86차 적용(2026-06-29): refactor-governance P2/P3로 Session mic capture를 <code>AudioWorklet</code>-first 구조로 전환하고 기존 <code>MediaRecorder</code>를 fallback으로 남겼다. Worklet은 mono PCM16 chunk를 <code>/voice/ws</code> binary frame으로 보내고, control packet은 <code>format=pcm</code>, <code>sample_rate</code>, <code>channels</code>, <code>sample_width</code>를 명시한다. 백엔드는 <code>pcm</code> 업로드를 STT 호출 직전 RIFF/WAV로 정규화해 raw PCM을 <code>audio/wav</code>로 오인하던 계약 틈을 닫았다. 실제 Deepgram streaming, 물리 마이크, public WSS, 50분 양방향 실측은 계속 외부/실측 gate다. 검증: <code>app/test_voice_ws.py</code> 11 passed, <code>npm run typecheck</code>, <code>voice-success</code> chromium-single-run 2 passed.</p>
<p class="dg-note">87차 적용(2026-06-29): refactor-governance P3로 <code>/voice/ws</code>에 provider-neutral <code>stt_result</code> control을 추가해 EOT helper를 route 경계에 연결했다. 새 control은 <code>text</code>, <code>final</code>, <code>silence_ms</code>, <code>provider_events</code>를 받아 <code>assess_end_of_turn()</code>이 ready를 판단하고, pending이면 턴 실행 없이 <code>state=listening</code>으로 돌아간다. ready일 때만 transcript를 emit하고 <code>_run_turn_and_speak()</code>로 진행한다. 기존 <code>audio_start</code>→binary→<code>audio_end</code>, <code>text_turn</code>, ping/close 계약은 유지했다. 실제 Deepgram interim/final WSS 연결과 public WSS/물리 마이크/50분 실측은 여전히 남은 gate다. 검증: <code>py_compile</code>, <code>app/test_voice_ws.py app/test_voice_service.py</code> 27 passed.</p> <p class="dg-note">87차 적용(2026-06-29): refactor-governance P3로 <code>/voice/ws</code>에 provider-neutral <code>stt_result</code> control을 추가해 EOT helper를 route 경계에 연결했다. 새 control은 <code>text</code>, <code>final</code>, <code>silence_ms</code>, <code>provider_events</code>를 받아 <code>assess_end_of_turn()</code>이 ready를 판단하고, pending이면 턴 실행 없이 <code>state=listening</code>으로 돌아간다. ready일 때만 transcript를 emit하고 <code>_run_turn_and_speak()</code>로 진행한다. 기존 <code>audio_start</code>→binary→<code>audio_end</code>, <code>text_turn</code>, ping/close 계약은 유지했다. 실제 Deepgram interim/final WSS 연결과 public WSS/물리 마이크/50분 실측은 여전히 남은 gate다. 검증: <code>py_compile</code>, <code>app/test_voice_ws.py app/test_voice_service.py</code> 27 passed.</p>
<p class="dg-note">88차 적용(2026-06-29): refactor-governance P3로 Node conformance runner의 stream event/payload 검증을 schema-driven으로 바꿨다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 <code>token/done/error</code> 이벤트 목록과 payload 필드를 로컬 상수·분기로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code><code>StreamPacket.oneOf</code>, payload <code>$ref</code>, <code>x-engine-gateway-sse-events</code>를 읽어 golden fixture를 검증한다. raw token text fallback과 <code>data: [DONE]</code> compatibility line은 유지했다. 검증: <code>node scripts\check-engine-gateway-contract.mjs --json</code>, 당시 gateway/backend focused suite 통과, 이후 최신 L1 focused 120 passed로 재검증.</p> <p class="dg-note">88차 적용(2026-06-29): refactor-governance P3로 Node conformance runner의 stream event/payload 검증을 schema-driven으로 바꿨다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 <code>token/done/error</code> 이벤트 목록과 payload 필드를 로컬 상수·분기로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code><code>StreamPacket.oneOf</code>, payload <code>$ref</code>, <code>x-engine-gateway-sse-events</code>를 읽어 golden fixture를 검증한다. raw token text fallback과 <code>data: [DONE]</code> compatibility line은 유지했다. 검증: <code>node scripts\check-engine-gateway-contract.mjs --json</code>, 당시 gateway/backend focused suite 통과, 이후 최신 L1 focused 120 passed로 재검증.</p>