안전 이벤트 대시보드 보강

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.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:

View file

@ -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)}

View file

@ -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 = "현재 기록된 실제 학습 세션만 표시합니다."

View file

@ -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

View file

@ -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):

View file

@ -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()

View file

@ -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(

View file

@ -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(

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();
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);
});
});

View file

@ -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);

View file

@ -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);
});

View file

@ -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<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
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 [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | 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] =
useState<TeacherLearnerAnalysisResponse | null>(null);
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("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 }
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
{isAnalysisView ? (
<>
<h1> .</h1>
<h1> , .</h1>
<p>
, ,
.
, , ,
· · .
</p>
</>
) : (
@ -408,103 +445,82 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
) : null}
{isAnalysisView ? (
<section className="pf-analysis-shell" aria-label="학생 분석 작업면">
<aside className="pf-analysis-rail">
<section className="pf-section pf-section--growth">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
{learnerGrowth.length}
</Badge>
</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`)}
<section className="pf-analysis-page" aria-label="학생 분석 작업면">
{!selectedLearnerId ? (
<LearnerOverviewTable
learners={visibleLearners}
totalLearners={learnerGrowth.length}
query={learnerSearch}
sort={learnerSort}
loadState={loadState}
onSearch={setLearnerSearch}
onSort={setLearnerSort}
onOpen={openLearnerDetail}
/>
) : (
<EmptyState
title="선택된 학습자 없음"
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다."
/>
<section className="pf-section pf-section--learner-analysis">
<div className="pf-analysis-detail-head">
<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>
) : 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 (
<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({
analysis,
onOpenSession,
@ -805,11 +965,17 @@ function LearnerAnalysisPanel({
analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void;
}) {
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("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 (
<div className="pf-analysis" data-learner-analysis-panel="true">
@ -848,7 +1014,23 @@ function LearnerAnalysisPanel({
</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="회기별 추이">
<div className="pf-analysis__subhead">
<b> </b>
@ -864,7 +1046,9 @@ function LearnerAnalysisPanel({
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
)}
</section>
) : null}
{activeTab === "stages" ? (
<section className="pf-analysis__stages" aria-label="단계 분포">
<div className="pf-analysis__subhead">
<b> </b>
@ -880,28 +1064,30 @@ function LearnerAnalysisPanel({
))}
</div>
</section>
</div>
) : null}
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<div className="pf-analysis__subhead">
<b> </b>
<span>{sessions.length}</span>
</div>
{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)}
/>
))}
{activeTab === "sessions" ? (
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<div className="pf-analysis__subhead">
<b> </b>
<span>{sessions.length}</span>
</div>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
{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>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
) : null}
</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 }) {
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;

View file

@ -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);
}