안전 이벤트 대시보드 보강
This commit is contained in:
parent
fe2796f05a
commit
51053af536
15 changed files with 1081 additions and 423 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "현재 기록된 실제 학습 세션만 표시합니다."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue