안전 이벤트 대시보드 보강
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.notification_delivery') IS NOT NULL AS has_notification_delivery,
|
||||||
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
|
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
|
||||||
to_regclass('app.turns') IS NOT NULL AS has_turns,
|
to_regclass('app.turns') IS NOT NULL AS has_turns,
|
||||||
|
to_regclass('app.safety_events') IS NOT NULL AS has_safety_events,
|
||||||
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
|
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
|
|
@ -171,6 +172,21 @@ async def healthcheck() -> bool:
|
||||||
GROUP BY table_schema, table_name
|
GROUP BY table_schema, table_name
|
||||||
HAVING count(*) = 5
|
HAVING count(*) = 5
|
||||||
) AS has_turn_voice_metadata_columns,
|
) AS has_turn_voice_metadata_columns,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'safety_events'
|
||||||
|
AND column_name IN (
|
||||||
|
'session_id',
|
||||||
|
'trigger_type',
|
||||||
|
'ko_risk_level',
|
||||||
|
'escalated',
|
||||||
|
'detail',
|
||||||
|
'created_at'
|
||||||
|
)
|
||||||
|
GROUP BY table_schema, table_name
|
||||||
|
HAVING count(*) = 6
|
||||||
|
) AS has_safety_event_columns,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'app'
|
WHERE table_schema = 'app'
|
||||||
|
|
@ -198,8 +214,10 @@ async def healthcheck() -> bool:
|
||||||
and row["has_notification_delivery"]
|
and row["has_notification_delivery"]
|
||||||
and row["has_sessions"]
|
and row["has_sessions"]
|
||||||
and row["has_turns"]
|
and row["has_turns"]
|
||||||
|
and row["has_safety_events"]
|
||||||
and row["has_session_review_status"]
|
and row["has_session_review_status"]
|
||||||
and row["has_turn_voice_metadata_columns"]
|
and row["has_turn_voice_metadata_columns"]
|
||||||
|
and row["has_safety_event_columns"]
|
||||||
and row["has_session_review_worksheet_columns"]
|
and row["has_session_review_worksheet_columns"]
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -1324,6 +1324,7 @@ async def stream_turn(
|
||||||
)
|
)
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
|
finalized_turn = False
|
||||||
last_beat = asyncio.get_running_loop().time()
|
last_beat = asyncio.get_running_loop().time()
|
||||||
final_reply = ""
|
final_reply = ""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1338,6 +1339,7 @@ async def stream_turn(
|
||||||
yield {"event": "token", "data": text}
|
yield {"event": "token", "data": text}
|
||||||
elif ev.event == "done":
|
elif ev.event == "done":
|
||||||
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
|
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
|
||||||
|
if not finalized_turn:
|
||||||
evaluation = await _evaluate_stream_turn(ctx, final_reply)
|
evaluation = await _evaluate_stream_turn(ctx, final_reply)
|
||||||
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
|
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
|
||||||
await turn_runtime.finalize_completed_turn(
|
await turn_runtime.finalize_completed_turn(
|
||||||
|
|
@ -1346,7 +1348,34 @@ async def stream_turn(
|
||||||
result,
|
result,
|
||||||
context_prefix="session",
|
context_prefix="session",
|
||||||
)
|
)
|
||||||
|
finalized_turn = True
|
||||||
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
|
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",
|
||||||
|
)
|
||||||
|
finalized_turn = True
|
||||||
|
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
|
||||||
else:
|
else:
|
||||||
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
|
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,8 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
|
||||||
)
|
)
|
||||||
for item in raw_alerts
|
for item in raw_alerts
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
require_runtime_fallback_allowed("safety alert list")
|
||||||
|
|
||||||
if sessions:
|
if sessions:
|
||||||
message = "현재 기록된 실제 학습 세션만 표시합니다."
|
message = "현재 기록된 실제 학습 세션만 표시합니다."
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Iterable, Protocol
|
from typing import Any, Iterable, Protocol
|
||||||
|
|
@ -197,6 +198,27 @@ def _model_payload(value: Any) -> dict[str, Any]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object_payload(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return {}
|
||||||
|
text = value.strip()
|
||||||
|
for _ in range(2):
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
if not isinstance(parsed, str):
|
||||||
|
return {}
|
||||||
|
text = parsed.strip()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
|
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
|
||||||
text = (value or "").strip()
|
text = (value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|
@ -1023,6 +1045,49 @@ async def ensure_review_tables() -> None:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app.safety_events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||||
|
turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL,
|
||||||
|
trigger_type TEXT NOT NULL,
|
||||||
|
ko_risk_level SMALLINT,
|
||||||
|
escalated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
latency_ms INT,
|
||||||
|
detail JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_safety_session
|
||||||
|
ON app.safety_events(session_id);
|
||||||
|
|
||||||
|
ALTER TABLE app.safety_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_safety_events_select ON app.safety_events;
|
||||||
|
DROP POLICY IF EXISTS p_safety_events_insert ON app.safety_events;
|
||||||
|
|
||||||
|
CREATE POLICY p_safety_events_select
|
||||||
|
ON app.safety_events FOR SELECT USING (
|
||||||
|
app.is_ai_context()
|
||||||
|
OR app.current_role_name() IN ('admin','instructor')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM app.sessions s
|
||||||
|
WHERE s.id = app.safety_events.session_id
|
||||||
|
AND s.learner_id = app.current_uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE POLICY p_safety_events_insert
|
||||||
|
ON app.safety_events FOR INSERT WITH CHECK (
|
||||||
|
app.is_ai_context()
|
||||||
|
OR app.current_role_name() IN ('admin','instructor')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM app.sessions s
|
||||||
|
WHERE s.id = app.safety_events.session_id
|
||||||
|
AND s.learner_id = app.current_uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS app.session_review_status (
|
CREATE TABLE IF NOT EXISTS app.session_review_status (
|
||||||
|
|
@ -2557,7 +2622,7 @@ async def list_safety_alerts(
|
||||||
"trigger_type": str(row["trigger_type"] or "crisis"),
|
"trigger_type": str(row["trigger_type"] or "crisis"),
|
||||||
"ko_risk_level": int(row["ko_risk_level"] or 0),
|
"ko_risk_level": int(row["ko_risk_level"] or 0),
|
||||||
"escalated": bool(row["escalated"]),
|
"escalated": bool(row["escalated"]),
|
||||||
"detail": dict(row["detail"] or {}),
|
"detail": _json_object_payload(row["detail"]),
|
||||||
"created_at": _iso_dt(row["created_at"]),
|
"created_at": _iso_dt(row["created_at"]),
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from . import auth_sessions, session_persistence
|
from . import auth_sessions, db, session_persistence
|
||||||
from .config import Settings, settings
|
from .config import Settings, settings
|
||||||
from .deps import Principal, Role
|
from .deps import Principal, Role
|
||||||
from .routes import admin as admin_routes
|
from .routes import admin as admin_routes
|
||||||
|
|
@ -229,6 +229,57 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertIn(column, conn.query)
|
self.assertIn(column, conn.query)
|
||||||
self.assertIn("HAVING count(*) = 5", conn.query)
|
self.assertIn("HAVING count(*) = 5", conn.query)
|
||||||
|
|
||||||
|
async def test_healthcheck_requires_safety_events_schema(self) -> None:
|
||||||
|
class MissingSafetyEventsConn:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.query = ""
|
||||||
|
|
||||||
|
async def fetchrow(self, query: str, *args, **kwargs):
|
||||||
|
self.query = query
|
||||||
|
return {
|
||||||
|
"has_user": True,
|
||||||
|
"has_auth_session": True,
|
||||||
|
"has_preferences": True,
|
||||||
|
"has_engine_config": True,
|
||||||
|
"has_admin_health_event": True,
|
||||||
|
"has_admin_health_daily_rollup": True,
|
||||||
|
"has_support_ticket": True,
|
||||||
|
"has_notification_event": True,
|
||||||
|
"has_notification_delivery": True,
|
||||||
|
"has_sessions": True,
|
||||||
|
"has_turns": True,
|
||||||
|
"has_safety_events": False,
|
||||||
|
"has_session_review_status": True,
|
||||||
|
"has_turn_voice_metadata_columns": True,
|
||||||
|
"has_safety_event_columns": False,
|
||||||
|
"has_session_review_worksheet_columns": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
class MissingSafetyEventsAcquire:
|
||||||
|
def __init__(self, conn: MissingSafetyEventsConn) -> None:
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
async def __aenter__(self) -> MissingSafetyEventsConn:
|
||||||
|
return self.conn
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class MissingSafetyEventsPool:
|
||||||
|
def __init__(self, conn: MissingSafetyEventsConn) -> None:
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
def acquire(self):
|
||||||
|
return MissingSafetyEventsAcquire(self.conn)
|
||||||
|
|
||||||
|
conn = MissingSafetyEventsConn()
|
||||||
|
with patch.object(db, "get_pool", return_value=MissingSafetyEventsPool(conn)):
|
||||||
|
healthy = await db.healthcheck()
|
||||||
|
|
||||||
|
self.assertFalse(healthy)
|
||||||
|
self.assertIn("to_regclass('app.safety_events')", conn.query)
|
||||||
|
self.assertIn("has_safety_event_columns", conn.query)
|
||||||
|
|
||||||
async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
|
async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
|
||||||
class EmptyConfigConn:
|
class EmptyConfigConn:
|
||||||
async def fetchrow(self, *args, **kwargs):
|
async def fetchrow(self, *args, **kwargs):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from . import session_persistence, turn_runtime
|
from . import session_persistence, turn_runtime
|
||||||
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
||||||
from .deps import Principal, Role
|
from .deps import Principal, Role
|
||||||
|
|
@ -267,20 +270,75 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()):
|
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire:
|
||||||
await turn_runtime.record_safety_event(sess, ctx, result)
|
await turn_runtime.record_safety_event(sess, ctx, result)
|
||||||
|
|
||||||
|
acquire.assert_called_once_with(ai_context=True)
|
||||||
self.assertEqual(len(calls), 1)
|
self.assertEqual(len(calls), 1)
|
||||||
query, args = calls[0]
|
query, args = calls[0]
|
||||||
self.assertIn("INSERT INTO app.safety_events", query)
|
self.assertIn("INSERT INTO app.safety_events", query)
|
||||||
self.assertEqual(args[0], sess.session_id)
|
self.assertEqual(args[0], sess.session_id)
|
||||||
self.assertEqual(args[1], "learner_real")
|
self.assertEqual(args[1], "learner_real")
|
||||||
self.assertGreaterEqual(args[2], 4)
|
self.assertGreaterEqual(args[2], 4)
|
||||||
detail = json.loads(args[3])
|
detail = args[3]
|
||||||
self.assertTrue(detail["conversation_stopped"])
|
self.assertTrue(detail["conversation_stopped"])
|
||||||
self.assertEqual(detail["crisis_resource"]["number"], "109")
|
self.assertEqual(detail["crisis_resource"]["number"], "109")
|
||||||
self.assertEqual(detail["alert_status"], "teacher_dashboard")
|
self.assertEqual(detail["alert_status"], "teacher_dashboard")
|
||||||
|
|
||||||
|
def test_safety_alert_detail_parser_handles_legacy_json_string(self) -> None:
|
||||||
|
detail = session_persistence._json_object_payload(
|
||||||
|
'"{\\"crisis_resource\\":{\\"number\\":\\"109\\"},\\"alert_status\\":\\"teacher_dashboard\\"}"'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(detail["crisis_resource"]["number"], "109")
|
||||||
|
self.assertEqual(detail["alert_status"], "teacher_dashboard")
|
||||||
|
|
||||||
|
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None:
|
||||||
|
principal = _principal()
|
||||||
|
sess = _session(principal)
|
||||||
|
ctx = orchestrator.prepare_turn(
|
||||||
|
session_id=sess.session_id,
|
||||||
|
case_id=sess.case_id,
|
||||||
|
card=sess.persona,
|
||||||
|
state=sess.state,
|
||||||
|
learner_text="저 지금 자살하고 싶어요. 도와주세요.",
|
||||||
|
theory_mode=sess.theory_mode,
|
||||||
|
)
|
||||||
|
result = orchestrator.TurnResult(
|
||||||
|
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
|
||||||
|
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
|
||||||
|
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
|
||||||
|
client_reply=None,
|
||||||
|
safety_flagged=True,
|
||||||
|
state_after=ctx.state_after or sess.state,
|
||||||
|
crisis_kind="learner_real",
|
||||||
|
crisis_resource={"title": "자살예방상담전화 109", "number": "109"},
|
||||||
|
conversation_stopped=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
async def execute(self, query: str, *args: object) -> str:
|
||||||
|
raise RuntimeError("safety_events unavailable")
|
||||||
|
|
||||||
|
class FakeAcquire:
|
||||||
|
async def __aenter__(self) -> FakeConn:
|
||||||
|
return FakeConn()
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
previous_environment = settings.environment
|
||||||
|
settings.environment = "staging"
|
||||||
|
try:
|
||||||
|
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
await turn_runtime.record_safety_event(sess, ctx, result)
|
||||||
|
finally:
|
||||||
|
settings.environment = previous_environment
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 503)
|
||||||
|
self.assertIn("safety event persistence unavailable", raised.exception.detail)
|
||||||
|
|
||||||
async def test_stream_turn_persists_client_engine_telemetry(self) -> None:
|
async def test_stream_turn_persists_client_engine_telemetry(self) -> None:
|
||||||
principal = _principal()
|
principal = _principal()
|
||||||
sess = _session(principal)
|
sess = _session(principal)
|
||||||
|
|
@ -328,7 +386,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
def should_not_stream(*args, **kwargs):
|
def should_not_stream(*args, **kwargs):
|
||||||
raise AssertionError("stream engine must not be called for learner_real crisis")
|
raise AssertionError("stream engine must not be called for learner_real crisis")
|
||||||
|
|
||||||
with patch.object(sessions.engine_client, "stream", should_not_stream):
|
with (
|
||||||
|
patch.object(sessions.engine_client, "stream", should_not_stream),
|
||||||
|
patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn,
|
||||||
|
patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event,
|
||||||
|
):
|
||||||
response = await sessions.stream_turn(
|
response = await sessions.stream_turn(
|
||||||
sess.session_id,
|
sess.session_id,
|
||||||
sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."),
|
sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."),
|
||||||
|
|
@ -341,8 +403,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertIn("'event': 'done'", rendered)
|
self.assertIn("'event': 'done'", rendered)
|
||||||
self.assertIn("109", rendered)
|
self.assertIn("109", rendered)
|
||||||
self.assertIn("conversation_stopped", rendered)
|
self.assertIn("conversation_stopped", rendered)
|
||||||
self.assertEqual(len(sess.turns), 1)
|
completed_turn.assert_awaited_once()
|
||||||
self.assertEqual(sess.turns[0].speaker, "counselor")
|
safety_event.assert_awaited_once()
|
||||||
|
saved_result = completed_turn.await_args.args[2]
|
||||||
|
self.assertTrue(saved_result.safety_flagged)
|
||||||
|
self.assertTrue(saved_result.conversation_stopped)
|
||||||
|
self.assertEqual(saved_result.crisis_resource["number"], "109")
|
||||||
|
|
||||||
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
|
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
|
||||||
principal = _principal()
|
principal = _principal()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .deps import Principal, Role
|
from .deps import Principal, Role
|
||||||
from .routes import teacher
|
from .routes import teacher
|
||||||
from .services import state_machine
|
from .services import state_machine
|
||||||
|
|
@ -186,6 +187,45 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(closed_summary.review_status, "closed")
|
self.assertEqual(closed_summary.review_status, "closed")
|
||||||
|
|
||||||
|
async def test_dashboard_fails_closed_when_safety_alerts_are_not_durable_outside_dev(self) -> None:
|
||||||
|
principal = _principal()
|
||||||
|
sess = _session(
|
||||||
|
session_id="00000000-0000-0000-0000-00000000b211",
|
||||||
|
session_no=1,
|
||||||
|
learner_id="00000000-0000-0000-0000-000000000333",
|
||||||
|
score="neutral",
|
||||||
|
rapport=0.1,
|
||||||
|
technique="reflection",
|
||||||
|
created_at=1_000.0,
|
||||||
|
)
|
||||||
|
previous_environment = settings.environment
|
||||||
|
settings.environment = "staging"
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
teacher.session_persistence,
|
||||||
|
"list_sessions",
|
||||||
|
AsyncMock(return_value=([sess], True)),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
teacher.session_persistence,
|
||||||
|
"list_safety_alerts",
|
||||||
|
AsyncMock(return_value=([], False)),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
teacher.session_persistence,
|
||||||
|
"list_session_review_statuses",
|
||||||
|
AsyncMock(return_value=({}, True)),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
await teacher.teacher_dashboard(principal)
|
||||||
|
finally:
|
||||||
|
settings.environment = previous_environment
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 503)
|
||||||
|
self.assertIn("safety alert list persistence unavailable", raised.exception.detail)
|
||||||
|
|
||||||
async def test_teacher_can_mark_session_review_closed_with_note(self) -> None:
|
async def test_teacher_can_mark_session_review_closed_with_note(self) -> None:
|
||||||
principal = _principal()
|
principal = _principal()
|
||||||
sess = _session(
|
sess = _session(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import json
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import db, session_persistence
|
from . import db, session_persistence
|
||||||
|
|
@ -17,6 +17,8 @@ from .services import guardrail, orchestrator, state_machine
|
||||||
from .stage_contract import stage_label
|
from .stage_contract import stage_label
|
||||||
from .store import InProcSession, TurnRecord, store
|
from .store import InProcSession, TurnRecord, store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SessionAccessError(str, Enum):
|
class SessionAccessError(str, Enum):
|
||||||
NOT_FOUND = "not_found"
|
NOT_FOUND = "not_found"
|
||||||
|
|
@ -147,8 +149,16 @@ async def record_safety_event(
|
||||||
if crisis is None or not getattr(crisis, "escalate", False):
|
if crisis is None or not getattr(crisis, "escalate", False):
|
||||||
return
|
return
|
||||||
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
|
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
|
||||||
|
detail = {
|
||||||
|
"matched": list(getattr(crisis, "matched", []) or []),
|
||||||
|
"stage": getattr(result, "stage", None),
|
||||||
|
"turn_seq": getattr(result, "turn_seq", None),
|
||||||
|
"conversation_stopped": getattr(result, "conversation_stopped", False),
|
||||||
|
"crisis_resource": getattr(result, "crisis_resource", None),
|
||||||
|
"alert_status": "teacher_dashboard",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
async with db.acquire() as conn:
|
async with db.acquire(ai_context=True) as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app.safety_events
|
INSERT INTO app.safety_events
|
||||||
|
|
@ -158,17 +168,15 @@ async def record_safety_event(
|
||||||
sess.session_id,
|
sess.session_id,
|
||||||
kind,
|
kind,
|
||||||
int(getattr(crisis, "risk_level", 0) or 0),
|
int(getattr(crisis, "risk_level", 0) or 0),
|
||||||
json.dumps({
|
detail,
|
||||||
"matched": list(getattr(crisis, "matched", []) or []),
|
|
||||||
"stage": getattr(result, "stage", None),
|
|
||||||
"turn_seq": getattr(result, "turn_seq", None),
|
|
||||||
"conversation_stopped": getattr(result, "conversation_stopped", False),
|
|
||||||
"crisis_resource": getattr(result, "crisis_resource", None),
|
|
||||||
"alert_status": "teacher_dashboard",
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception(
|
||||||
|
"safety event persistence failed: session_id=%s trigger_type=%s",
|
||||||
|
sess.session_id,
|
||||||
|
kind,
|
||||||
|
)
|
||||||
|
require_runtime_fallback_allowed("safety event")
|
||||||
|
|
||||||
|
|
||||||
async def finalize_completed_turn(
|
async def finalize_completed_turn(
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,28 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("professor student analysis stays contained across all widths", async ({ page }) => {
|
test("professor student analysis overview stays contained across all widths", async ({ page }) => {
|
||||||
|
const fixture = teacherAnalysisFixture();
|
||||||
|
await page.route("**/api/teacher/dashboard", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(fixture.dashboard),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.goto("/teach/analysis");
|
||||||
|
await gateScreen(page, "professor-analysis", async () => {
|
||||||
|
await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("학습자 검색")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("professor learner detail analysis stays contained across all widths", async ({ page }) => {
|
||||||
const fixture = teacherAnalysisFixture();
|
const fixture = teacherAnalysisFixture();
|
||||||
await page.route("**/api/teacher/dashboard", (route) =>
|
await page.route("**/api/teacher/dashboard", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
|
@ -576,12 +597,12 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await signInAsTeacher(page);
|
await signInAsTeacher(page);
|
||||||
await page.goto("/teach/analysis");
|
await page.goto(`/teach/analysis?learner=${fixture.analysis.learner_id}`);
|
||||||
await gateScreen(page, "professor-analysis", async () => {
|
await gateScreen(page, "professor-analysis-detail", async () => {
|
||||||
await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
|
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
await page.getByRole("tab", { name: /^전체 회기/ }).click();
|
||||||
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
|
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@ interface HealthResponse {
|
||||||
engine?: boolean;
|
engine?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeacherSafetyAlert {
|
||||||
|
session_id: string;
|
||||||
|
resource_title: string;
|
||||||
|
resource_number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeacherDashboardResponse {
|
||||||
|
source: string;
|
||||||
|
safety_alerts: TeacherSafetyAlert[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ReviewTurn {
|
interface ReviewTurn {
|
||||||
speaker: string;
|
speaker: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -492,6 +503,72 @@ test.describe("session persistence", () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
const healthResponse = await page.request.get("/api/health");
|
||||||
|
await expectResponseOk(healthResponse);
|
||||||
|
const health = (await healthResponse.json()) as HealthResponse;
|
||||||
|
test.skip(!health.db, "crisis safety queue persistence requires DB");
|
||||||
|
|
||||||
|
await signInAsLearner(page);
|
||||||
|
const persona = await fetchAvailablePersona(page, 1);
|
||||||
|
const crisisText = "저 지금 자살하고 싶어요. 도와주세요.";
|
||||||
|
|
||||||
|
await page.goto(`/learn/session/${persona.code}`);
|
||||||
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||||
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
|
||||||
|
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
|
||||||
|
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
|
||||||
|
|
||||||
|
const streamResponsePromise = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return (
|
||||||
|
response.request().method() === "POST" &&
|
||||||
|
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = page.getByLabel("학습자 발화 입력");
|
||||||
|
await input.fill(crisisText);
|
||||||
|
await page.getByRole("button", { name: "보내기" }).click();
|
||||||
|
|
||||||
|
const streamResponse = await streamResponsePromise;
|
||||||
|
await expectResponseOk(streamResponse);
|
||||||
|
await expect(page.locator(".sx-crisis-resource")).toContainText("자살예방상담전화 109", {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
|
||||||
|
await expect(input).toBeDisabled();
|
||||||
|
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
|
||||||
|
await expectResponseOk(dashboardResponse);
|
||||||
|
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
|
||||||
|
expect(dashboard.source).toBe("database");
|
||||||
|
return dashboard.safety_alerts.some(
|
||||||
|
(alert) => alert.session_id === sessionId && alert.resource_number === "109",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
intervals: [500, 1_000, 2_000],
|
||||||
|
message: "crisis safety event should reach the DB-backed teacher dashboard",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.goto("/teach");
|
||||||
|
await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109");
|
||||||
|
});
|
||||||
|
|
||||||
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
|
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -640,16 +640,33 @@ test.describe("teacher console", () => {
|
||||||
await page.getByRole("link", { name: "학생 분석" }).click();
|
await page.getByRole("link", { name: "학생 분석" }).click();
|
||||||
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||||
|
|
||||||
|
const overviewTable = page.locator('[data-learner-overview-table="true"]');
|
||||||
|
await expect(overviewTable).toBeVisible();
|
||||||
|
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(2);
|
||||||
|
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByLabel("학습자 검색").fill("사용자 B");
|
||||||
|
const learnerBRow = page
|
||||||
|
.locator('[data-learner-overview-row="true"]')
|
||||||
|
.filter({ hasText: "사용자 B" });
|
||||||
|
await expect(learnerBRow).toHaveCount(1);
|
||||||
|
await expect(learnerBRow.getByText(learnerB)).toBeVisible();
|
||||||
|
await learnerBRow.getByRole("button", { name: "사용자 B 상세 분석 열기" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerB}$`));
|
||||||
|
|
||||||
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
|
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
|
||||||
await expect(analysisPanel).toBeVisible();
|
await expect(analysisPanel).toBeVisible();
|
||||||
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7);
|
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0);
|
||||||
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible();
|
await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click();
|
||||||
await expect(page.getByText("전체 회기 7건")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click();
|
|
||||||
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
|
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
|
||||||
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
|
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
|
||||||
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0);
|
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0);
|
||||||
|
await expect(page.getByText("전체 회기 2건")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "전체 목록" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||||
|
await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { AppShell } from "../components/shell/AppShell";
|
import { AppShell } from "../components/shell/AppShell";
|
||||||
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
|
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +20,8 @@ import "./professor.css";
|
||||||
|
|
||||||
type LoadState = "loading" | "ready" | "error";
|
type LoadState = "loading" | "ready" | "error";
|
||||||
type ProfessorView = "console" | "analysis";
|
type ProfessorView = "console" | "analysis";
|
||||||
|
type LearnerSort = "latest" | "sessions" | "score" | "delta";
|
||||||
|
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
|
||||||
|
|
||||||
function formatDateTime(value: string | null): string {
|
function formatDateTime(value: string | null): string {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
|
|
@ -95,7 +97,9 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
|
||||||
}
|
}
|
||||||
export default function Professor({ view = "console" }: { view?: ProfessorView }) {
|
export default function Professor({ view = "console" }: { view?: ProfessorView }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isAnalysisView = view === "analysis";
|
const isAnalysisView = view === "analysis";
|
||||||
|
const selectedLearnerId = isAnalysisView ? searchParams.get("learner") : null;
|
||||||
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
|
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
|
||||||
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
|
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
|
|
@ -104,7 +108,8 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
||||||
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
|
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
|
||||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||||
const [selectedLearnerId, setSelectedLearnerId] = useState<string | null>(null);
|
const [learnerSearch, setLearnerSearch] = useState("");
|
||||||
|
const [learnerSort, setLearnerSort] = useState<LearnerSort>("latest");
|
||||||
const [learnerAnalysis, setLearnerAnalysis] =
|
const [learnerAnalysis, setLearnerAnalysis] =
|
||||||
useState<TeacherLearnerAnalysisResponse | null>(null);
|
useState<TeacherLearnerAnalysisResponse | null>(null);
|
||||||
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
|
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
|
||||||
|
|
@ -235,17 +240,49 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
loadState === "loading" ||
|
loadState === "loading" ||
|
||||||
(!isAnalysisView && personaReviewLoading) ||
|
(!isAnalysisView && personaReviewLoading) ||
|
||||||
(isAnalysisView && learnerAnalysisState === "loading");
|
(isAnalysisView && learnerAnalysisState === "loading");
|
||||||
|
const openLearnerDetail = useCallback(
|
||||||
|
(learnerId: string) => {
|
||||||
|
setSearchParams({ learner: learnerId });
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
const closeLearnerDetail = useCallback(() => {
|
||||||
|
setSearchParams({});
|
||||||
|
}, [setSearchParams]);
|
||||||
|
const visibleLearners = useMemo(() => {
|
||||||
|
const query = learnerSearch.trim().toLocaleLowerCase("ko-KR");
|
||||||
|
const filtered = query
|
||||||
|
? learnerGrowth.filter((learner) => {
|
||||||
|
const haystack = [
|
||||||
|
learner.learner_label,
|
||||||
|
learner.learner_id,
|
||||||
|
trendLabel(learner.trend),
|
||||||
|
...(learner.top_techniques ?? []),
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLocaleLowerCase("ko-KR");
|
||||||
|
return haystack.includes(query);
|
||||||
|
})
|
||||||
|
: [...learnerGrowth];
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
if (learnerSort === "sessions") return b.sessions - a.sessions;
|
||||||
|
if (learnerSort === "score") return (b.latest_score ?? -1) - (a.latest_score ?? -1);
|
||||||
|
if (learnerSort === "delta") return (b.score_delta ?? -999) - (a.score_delta ?? -999);
|
||||||
|
return Date.parse(b.latest_at ?? "") - Date.parse(a.latest_at ?? "");
|
||||||
|
});
|
||||||
|
}, [learnerGrowth, learnerSearch, learnerSort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalysisView) return;
|
if (!isAnalysisView) return;
|
||||||
if (learnerGrowth.length === 0) {
|
if (
|
||||||
if (selectedLearnerId !== null) setSelectedLearnerId(null);
|
selectedLearnerId &&
|
||||||
return;
|
learnerGrowth.length > 0 &&
|
||||||
|
!learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)
|
||||||
|
) {
|
||||||
|
setSearchParams({});
|
||||||
}
|
}
|
||||||
if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) {
|
}, [isAnalysisView, learnerGrowth, selectedLearnerId, setSearchParams]);
|
||||||
setSelectedLearnerId(learnerGrowth[0].learner_id);
|
|
||||||
}
|
|
||||||
}, [isAnalysisView, learnerGrowth, selectedLearnerId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalysisView) {
|
if (!isAnalysisView) {
|
||||||
|
|
@ -293,10 +330,10 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
|
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
|
||||||
{isAnalysisView ? (
|
{isAnalysisView ? (
|
||||||
<>
|
<>
|
||||||
<h1>학습자별 추이와 전체 회기를 분리해서 봅니다.</h1>
|
<h1>전체 학습자를 먼저 보고, 필요한 학습자만 상세로 들어갑니다.</h1>
|
||||||
<p>
|
<p>
|
||||||
왼쪽에서 학습자를 고르면 점수 변화, 단계 분포, 전체 회기 타임라인을 한 흐름으로
|
검색 테이블에서 학습자별 회기 수, 최근 점수, 변화폭, 라포와 미니 추이를 비교한 뒤
|
||||||
확인하고 각 회기의 상세 검토로 들어갑니다.
|
상세 화면에서 추이·전체 회기·단계 분석 탭으로 나눠 확인합니다.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -408,61 +445,45 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAnalysisView ? (
|
{isAnalysisView ? (
|
||||||
<section className="pf-analysis-shell" aria-label="학생 분석 작업면">
|
<section className="pf-analysis-page" aria-label="학생 분석 작업면">
|
||||||
<aside className="pf-analysis-rail">
|
{!selectedLearnerId ? (
|
||||||
<section className="pf-section pf-section--growth">
|
<LearnerOverviewTable
|
||||||
<div className="pf-section__head">
|
learners={visibleLearners}
|
||||||
<div>
|
totalLearners={learnerGrowth.length}
|
||||||
<Kicker>학습자 목록</Kicker>
|
query={learnerSearch}
|
||||||
<h2>사용자별 추이</h2>
|
sort={learnerSort}
|
||||||
</div>
|
loadState={loadState}
|
||||||
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
|
onSearch={setLearnerSearch}
|
||||||
{learnerGrowth.length}명
|
onSort={setLearnerSort}
|
||||||
</Badge>
|
onOpen={openLearnerDetail}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="pf-panel pf-growth-panel">
|
|
||||||
{learnerGrowth.length > 0 ? (
|
|
||||||
<div className="pf-growth-list">
|
|
||||||
{learnerGrowth.map((learner) => (
|
|
||||||
<GrowthCard
|
|
||||||
learner={learner}
|
|
||||||
key={learner.learner_id}
|
|
||||||
selected={learner.learner_id === selectedLearnerId}
|
|
||||||
onSelect={() => setSelectedLearnerId(learner.learner_id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<section className="pf-section pf-section--learner-analysis">
|
||||||
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
|
<div className="pf-analysis-detail-head">
|
||||||
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
|
<Button
|
||||||
/>
|
size="sm"
|
||||||
)}
|
variant="secondary"
|
||||||
</Card>
|
leading={<Icon name="chevron-left" size={14} />}
|
||||||
</section>
|
onClick={closeLearnerDetail}
|
||||||
</aside>
|
>
|
||||||
|
전체 목록
|
||||||
<section className="pf-analysis-workbench">
|
</Button>
|
||||||
<nav className="pf-depth-strip" aria-label="학생 분석 단계">
|
<nav className="pf-depth-strip" aria-label="학생 분석 깊이">
|
||||||
<span className="is-current">
|
|
||||||
<small>1</small>
|
|
||||||
학습자
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
|
<small>1</small>
|
||||||
|
검색 목록
|
||||||
|
</span>
|
||||||
|
<span className="is-current">
|
||||||
<small>2</small>
|
<small>2</small>
|
||||||
추이
|
학습자 상세
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<small>3</small>
|
<small>3</small>
|
||||||
전체 회기
|
회기 리뷰
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>4</small>
|
|
||||||
상세 검토
|
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
<section className="pf-section pf-section--learner-analysis">
|
</div>
|
||||||
|
|
||||||
<div className="pf-section__head">
|
<div className="pf-section__head">
|
||||||
<div>
|
<div>
|
||||||
<Kicker>사용자별 분석</Kicker>
|
<Kicker>사용자별 분석</Kicker>
|
||||||
|
|
@ -474,12 +495,7 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="pf-panel pf-analysis-panel">
|
<Card className="pf-panel pf-analysis-panel">
|
||||||
{learnerGrowth.length === 0 ? (
|
{learnerAnalysisState === "loading" ? (
|
||||||
<EmptyState
|
|
||||||
title={loadState === "loading" ? "이력 계산 중" : "분석할 학습자 없음"}
|
|
||||||
desc="실제 학습자 회기가 쌓이면 사용자별 전체 이력을 표시합니다."
|
|
||||||
/>
|
|
||||||
) : learnerAnalysisState === "loading" ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="학습자 이력 계산 중"
|
title="학습자 이력 계산 중"
|
||||||
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
|
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
|
||||||
|
|
@ -498,12 +514,12 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="선택된 학습자 없음"
|
title="선택된 학습자 없음"
|
||||||
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다."
|
desc="전체 목록에서 학습자를 선택하면 상세 분석이 열립니다."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -798,6 +814,150 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LearnerOverviewTable({
|
||||||
|
learners,
|
||||||
|
totalLearners,
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
loadState,
|
||||||
|
onSearch,
|
||||||
|
onSort,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
learners: TeacherLearnerGrowth[];
|
||||||
|
totalLearners: number;
|
||||||
|
query: string;
|
||||||
|
sort: LearnerSort;
|
||||||
|
loadState: LoadState;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
onSort: (value: LearnerSort) => void;
|
||||||
|
onOpen: (learnerId: string) => void;
|
||||||
|
}) {
|
||||||
|
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
|
||||||
|
{ key: "latest", label: "최근순" },
|
||||||
|
{ key: "sessions", label: "회기순" },
|
||||||
|
{ key: "score", label: "점수순" },
|
||||||
|
{ key: "delta", label: "변화순" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="pf-section pf-section--learner-overview">
|
||||||
|
<div className="pf-section__head">
|
||||||
|
<div>
|
||||||
|
<Kicker>전체 학습자</Kicker>
|
||||||
|
<h2>검색 가능한 학습자 추이 테이블</h2>
|
||||||
|
</div>
|
||||||
|
<Badge tone={totalLearners > 0 ? "accent" : "neutral"}>{totalLearners}명</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="pf-panel pf-learner-overview">
|
||||||
|
<div className="pf-learner-toolbar">
|
||||||
|
<label className="pf-search-field">
|
||||||
|
<Icon name="users" size={15} />
|
||||||
|
<input
|
||||||
|
aria-label="학습자 검색"
|
||||||
|
placeholder="학습자, ID, 기법 검색"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onSearch(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="pf-sort-segment" aria-label="학습자 정렬">
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={sort === option.key ? "is-active" : ""}
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => onSort(option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalLearners === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={loadState === "loading" ? "학습자 추이 계산 중" : "표시할 학습자 이력 없음"}
|
||||||
|
desc="실제 학습자 회기와 턴별 평가가 쌓이면 이 표에 전체 학습자 추이가 표시됩니다."
|
||||||
|
/>
|
||||||
|
) : learners.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="검색 결과 없음"
|
||||||
|
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="pf-learner-table" data-learner-overview-table="true">
|
||||||
|
<div className="pf-learner-table__head" aria-hidden="true">
|
||||||
|
<span>학습자</span>
|
||||||
|
<span>회기</span>
|
||||||
|
<span>추이</span>
|
||||||
|
<span>최근 적절성</span>
|
||||||
|
<span>변화</span>
|
||||||
|
<span>평균 라포</span>
|
||||||
|
<span>기법</span>
|
||||||
|
<span>상세</span>
|
||||||
|
</div>
|
||||||
|
{learners.map((learner) => (
|
||||||
|
<article
|
||||||
|
className="pf-learner-row"
|
||||||
|
data-learner-overview-row="true"
|
||||||
|
key={learner.learner_id}
|
||||||
|
>
|
||||||
|
<div className="pf-learner-row__id">
|
||||||
|
<b>{learner.learner_label}</b>
|
||||||
|
<span>{learner.learner_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__metric" data-label="회기">
|
||||||
|
<b>
|
||||||
|
{learner.ended_sessions}/{learner.sessions}
|
||||||
|
</b>
|
||||||
|
<span>{formatDateTime(learner.latest_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__spark" data-label="추이">
|
||||||
|
<div className="pf-learner-spark" aria-label={`${learner.learner_label} 회기별 추이`}>
|
||||||
|
{(learner.points ?? []).map((point) => (
|
||||||
|
<GrowthBar point={point} key={point.session_id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
|
||||||
|
{trendLabel(learner.trend)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__metric" data-label="최근 적절성">
|
||||||
|
<b>{formatScore(learner.latest_score)}</b>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__metric" data-label="변화">
|
||||||
|
<b>{formatDelta(learner.score_delta)}</b>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__metric" data-label="평균 라포">
|
||||||
|
<b>{formatRapport(learner.avg_rapport)}</b>
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__tags" data-label="기법">
|
||||||
|
{(learner.top_techniques ?? []).slice(0, 3).map((tag) => (
|
||||||
|
<span key={tag}>{tag}</span>
|
||||||
|
))}
|
||||||
|
{(learner.top_techniques ?? []).length === 0 ? <span>기법 부족</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pf-learner-row__action">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
leading={<Icon name="chevron-right" size={14} />}
|
||||||
|
aria-label={`${learner.learner_label} 상세 분석 열기`}
|
||||||
|
onClick={() => onOpen(learner.learner_id)}
|
||||||
|
>
|
||||||
|
상세 분석
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LearnerAnalysisPanel({
|
function LearnerAnalysisPanel({
|
||||||
analysis,
|
analysis,
|
||||||
onOpenSession,
|
onOpenSession,
|
||||||
|
|
@ -805,11 +965,17 @@ function LearnerAnalysisPanel({
|
||||||
analysis: TeacherLearnerAnalysisResponse;
|
analysis: TeacherLearnerAnalysisResponse;
|
||||||
onOpenSession: (sessionId: string) => void;
|
onOpenSession: (sessionId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
|
||||||
const points = analysis.points ?? [];
|
const points = analysis.points ?? [];
|
||||||
const sessions = analysis.sessions ?? [];
|
const sessions = analysis.sessions ?? [];
|
||||||
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
|
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
|
||||||
const stageBreakdown = analysis.stage_breakdown ?? [];
|
const stageBreakdown = analysis.stage_breakdown ?? [];
|
||||||
const summary = analysis.summary;
|
const summary = analysis.summary;
|
||||||
|
const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [
|
||||||
|
{ key: "trend", label: "추이", count: `${points.length}점` },
|
||||||
|
{ key: "sessions", label: "전체 회기", count: `${sessions.length}건` },
|
||||||
|
{ key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pf-analysis" data-learner-analysis-panel="true">
|
<div className="pf-analysis" data-learner-analysis-panel="true">
|
||||||
|
|
@ -848,7 +1014,23 @@ function LearnerAnalysisPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pf-analysis__body">
|
<div className="pf-analysis-tabs" role="tablist" aria-label="학습자 상세 분석 탭">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
className={activeTab === tab.key ? "is-active" : ""}
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
<b>{tab.label}</b>
|
||||||
|
<span>{tab.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "trend" ? (
|
||||||
<section className="pf-analysis__chart" aria-label="회기별 추이">
|
<section className="pf-analysis__chart" aria-label="회기별 추이">
|
||||||
<div className="pf-analysis__subhead">
|
<div className="pf-analysis__subhead">
|
||||||
<b>회기별 추이</b>
|
<b>회기별 추이</b>
|
||||||
|
|
@ -864,7 +1046,9 @@ function LearnerAnalysisPanel({
|
||||||
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
|
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "stages" ? (
|
||||||
<section className="pf-analysis__stages" aria-label="단계 분포">
|
<section className="pf-analysis__stages" aria-label="단계 분포">
|
||||||
<div className="pf-analysis__subhead">
|
<div className="pf-analysis__subhead">
|
||||||
<b>단계 분포</b>
|
<b>단계 분포</b>
|
||||||
|
|
@ -880,8 +1064,9 @@ function LearnerAnalysisPanel({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "sessions" ? (
|
||||||
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
|
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
|
||||||
<div className="pf-analysis__subhead">
|
<div className="pf-analysis__subhead">
|
||||||
<b>전체 회기</b>
|
<b>전체 회기</b>
|
||||||
|
|
@ -902,6 +1087,7 @@ function LearnerAnalysisPanel({
|
||||||
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
|
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -962,91 +1148,6 @@ function LearnerSessionRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GrowthCard({
|
|
||||||
learner,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
learner: TeacherLearnerGrowth;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const points = learner.points ?? [];
|
|
||||||
const topTechniques = learner.top_techniques ?? [];
|
|
||||||
const recentPoints = points.slice(-3).reverse();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`pf-growth-card trend-${learner.trend} ${selected ? "is-selected" : ""}`}
|
|
||||||
data-learner-growth-card="true"
|
|
||||||
aria-pressed={selected}
|
|
||||||
aria-label={`${learner.learner_label} 사용자별 분석`}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="pf-growth-card__top">
|
|
||||||
<div className="pf-growth-card__id">
|
|
||||||
<b>{learner.learner_label}</b>
|
|
||||||
<span>
|
|
||||||
{learner.ended_sessions}/{learner.sessions}회기 완료 · {formatDateTime(learner.latest_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
|
|
||||||
{trendLabel(learner.trend)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
|
|
||||||
<span>
|
|
||||||
<small>최근 적절성</small>
|
|
||||||
<b>{formatScore(learner.latest_score)}</b>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>변화</small>
|
|
||||||
<b>{formatDelta(learner.score_delta)}</b>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>평균 라포</small>
|
|
||||||
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
|
|
||||||
{points.map((point) => (
|
|
||||||
<GrowthBar point={point} key={point.session_id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pf-growth-card__tags">
|
|
||||||
{topTechniques.length > 0 ? (
|
|
||||||
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
|
|
||||||
) : (
|
|
||||||
<span>기법 태그 부족</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pf-growth-card__points">
|
|
||||||
{recentPoints.length > 0 ? (
|
|
||||||
recentPoints.map((point) => (
|
|
||||||
<div className="pf-growth-point" key={point.session_id}>
|
|
||||||
<b>
|
|
||||||
{point.persona_code} · {point.session_no}회기
|
|
||||||
</b>
|
|
||||||
<span>
|
|
||||||
{formatScore(point.score)} · 기법 {point.technique_count} · 점검 {point.watch_count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="pf-growth-point">
|
|
||||||
<b>회기 평가 부족</b>
|
|
||||||
<span>종료 회기와 턴별 평가가 필요합니다.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
|
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
|
||||||
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
|
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
|
||||||
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
|
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
|
||||||
|
|
|
||||||
|
|
@ -181,17 +181,11 @@
|
||||||
gap:14px;
|
gap:14px;
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
.pf-analysis-shell{
|
.pf-analysis-page{
|
||||||
order:2;
|
order:2;
|
||||||
display:grid;
|
display:flex;
|
||||||
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
|
flex-direction:column;
|
||||||
align-items:start;
|
gap:12px;
|
||||||
gap:14px;
|
|
||||||
min-width:0;
|
|
||||||
}
|
|
||||||
.pf-analysis-rail{
|
|
||||||
position:sticky;
|
|
||||||
top:16px;
|
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
.pf-analysis-workbench{
|
.pf-analysis-workbench{
|
||||||
|
|
@ -200,9 +194,16 @@
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
}
|
}
|
||||||
|
.pf-analysis-detail-head{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:auto minmax(0,1fr);
|
||||||
|
gap:10px;
|
||||||
|
align-items:center;
|
||||||
|
min-width:0;
|
||||||
|
}
|
||||||
.pf-depth-strip{
|
.pf-depth-strip{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
min-width:0;
|
min-width:0;
|
||||||
border:1px solid var(--hair);
|
border:1px solid var(--hair);
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius);
|
||||||
|
|
@ -325,86 +326,217 @@
|
||||||
.pf-growth-panel{
|
.pf-growth-panel{
|
||||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||||
}
|
}
|
||||||
.pf-growth-list{
|
.pf-learner-overview{
|
||||||
max-height:min(420px,44vh);
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
||||||
overflow:auto;
|
}
|
||||||
scrollbar-gutter:stable;
|
.pf-learner-toolbar{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
grid-template-columns:minmax(240px,.72fr) auto;
|
||||||
gap:12px;
|
gap:10px;
|
||||||
|
align-items:center;
|
||||||
padding:12px;
|
padding:12px;
|
||||||
|
border-bottom:1px solid var(--hair);
|
||||||
|
background:color-mix(in srgb,var(--bg-surface) 86%,var(--bg-surface-2));
|
||||||
}
|
}
|
||||||
.pf-analysis-rail .pf-growth-list{
|
.pf-search-field{
|
||||||
max-height:min(690px,calc(100vh - 250px));
|
min-width:0;
|
||||||
grid-template-columns:1fr;
|
display:grid;
|
||||||
gap:9px;
|
grid-template-columns:auto minmax(0,1fr);
|
||||||
padding:10px;
|
gap:8px;
|
||||||
|
align-items:center;
|
||||||
|
padding:9px 10px;
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
color:var(--text-muted);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
}
|
}
|
||||||
.pf-growth-card{
|
.pf-search-field input{
|
||||||
width:100%;
|
min-width:0;
|
||||||
|
border:0;
|
||||||
|
outline:0;
|
||||||
|
color:var(--text-strong);
|
||||||
|
background:transparent;
|
||||||
font:inherit;
|
font:inherit;
|
||||||
text-align:left;
|
font-size:var(--fs-sm);
|
||||||
color:inherit;
|
}
|
||||||
|
.pf-search-field input::placeholder{
|
||||||
|
color:var(--text-muted);
|
||||||
|
}
|
||||||
|
.pf-sort-segment{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:flex-end;
|
||||||
|
gap:0;
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
overflow:hidden;
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.pf-sort-segment button{
|
||||||
|
min-height:38px;
|
||||||
|
padding:0 11px;
|
||||||
|
border:0;
|
||||||
|
border-left:1px solid var(--hair);
|
||||||
|
color:var(--text-muted);
|
||||||
|
background:transparent;
|
||||||
|
font:inherit;
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:760;
|
||||||
|
cursor:pointer;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button:first-child{
|
||||||
|
border-left:0;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button:hover,
|
||||||
|
.pf-sort-segment button:focus-visible{
|
||||||
|
color:var(--text-strong);
|
||||||
|
background:var(--bg-surface);
|
||||||
|
}
|
||||||
|
.pf-sort-segment button:focus-visible{
|
||||||
|
outline:2px solid var(--accent);
|
||||||
|
outline-offset:-2px;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button.is-active{
|
||||||
|
color:var(--text-on-accent);
|
||||||
|
background:var(--accent);
|
||||||
|
}
|
||||||
|
.pf-learner-table{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
gap:12px;
|
|
||||||
padding:13px;
|
|
||||||
border:1px solid var(--hair);
|
|
||||||
border-radius:var(--radius-sm);
|
|
||||||
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
|
|
||||||
box-shadow:none;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
}
|
||||||
.pf-growth-card:hover{
|
.pf-learner-table__head,
|
||||||
border-color:var(--accent);
|
.pf-learner-row{
|
||||||
background:var(--bg-surface);
|
display:grid;
|
||||||
}
|
grid-template-columns:
|
||||||
.pf-growth-card:focus-visible{
|
minmax(170px,1.25fr)
|
||||||
outline:2px solid var(--accent);
|
minmax(86px,.55fr)
|
||||||
outline-offset:2px;
|
minmax(164px,.9fr)
|
||||||
}
|
minmax(76px,.48fr)
|
||||||
.pf-growth-card.is-selected{
|
minmax(64px,.42fr)
|
||||||
border-color:var(--accent);
|
minmax(76px,.48fr)
|
||||||
background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface));
|
minmax(118px,.75fr)
|
||||||
}
|
minmax(96px,.42fr);
|
||||||
.pf-analysis-rail .pf-growth-card{
|
|
||||||
gap:9px;
|
gap:9px;
|
||||||
padding:11px;
|
align-items:center;
|
||||||
border-radius:var(--radius);
|
|
||||||
}
|
|
||||||
.pf-analysis-rail .pf-growth-card__points{
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
.pf-analysis-rail .pf-growth-card__tags{
|
|
||||||
min-height:0;
|
|
||||||
}
|
|
||||||
.pf-analysis-rail .pf-growth-bars{
|
|
||||||
height:54px;
|
|
||||||
}
|
|
||||||
.pf-growth-card__top{
|
|
||||||
display:flex;
|
|
||||||
align-items:flex-start;
|
|
||||||
justify-content:space-between;
|
|
||||||
gap:10px;
|
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
.pf-growth-card__id{
|
.pf-learner-table__head{
|
||||||
|
position:sticky;
|
||||||
|
top:0;
|
||||||
|
z-index:1;
|
||||||
|
padding:9px 12px;
|
||||||
|
border-bottom:1px solid var(--hair);
|
||||||
|
color:var(--text-muted);
|
||||||
|
background:var(--bg-surface);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:800;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.pf-learner-row{
|
||||||
|
padding:11px 12px;
|
||||||
|
border-top:1px solid var(--paper-2);
|
||||||
|
}
|
||||||
|
.pf-learner-table__head + .pf-learner-row{
|
||||||
|
border-top:0;
|
||||||
|
}
|
||||||
|
.pf-learner-row:hover{
|
||||||
|
background:color-mix(in srgb,var(--accent-tint) 14%,transparent);
|
||||||
|
}
|
||||||
|
.pf-learner-row__id,
|
||||||
|
.pf-learner-row__metric,
|
||||||
|
.pf-learner-row__spark,
|
||||||
|
.pf-learner-row__tags{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
}
|
||||||
|
.pf-learner-row__id{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:3px;
|
gap:3px;
|
||||||
}
|
}
|
||||||
.pf-growth-card__id b{
|
.pf-learner-row__id b{
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
font-size:var(--fs-sm);
|
font-size:var(--fs-sm);
|
||||||
line-height:1.35;
|
line-height:1.35;
|
||||||
|
}
|
||||||
|
.pf-learner-row__id span,
|
||||||
|
.pf-learner-row__metric span{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:11px;
|
||||||
|
line-height:1.3;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
text-overflow:ellipsis;
|
text-overflow:ellipsis;
|
||||||
white-space:nowrap;
|
white-space:nowrap;
|
||||||
}
|
}
|
||||||
.pf-growth-card__id span,
|
.pf-learner-row__metric{
|
||||||
.pf-growth-card__metrics small,
|
display:grid;
|
||||||
.pf-growth-point span,
|
gap:3px;
|
||||||
|
}
|
||||||
|
.pf-learner-row__metric::before,
|
||||||
|
.pf-learner-row__spark::before,
|
||||||
|
.pf-learner-row__tags::before{
|
||||||
|
display:none;
|
||||||
|
content:attr(data-label);
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:800;
|
||||||
|
}
|
||||||
|
.pf-learner-row__metric b{
|
||||||
|
color:var(--text-strong);
|
||||||
|
font-family:var(--font-num);
|
||||||
|
font-size:15px;
|
||||||
|
line-height:1.15;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.pf-learner-row__spark{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(0,1fr) auto;
|
||||||
|
gap:8px;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.pf-learner-spark{
|
||||||
|
min-width:0;
|
||||||
|
height:46px;
|
||||||
|
display:flex;
|
||||||
|
align-items:flex-end;
|
||||||
|
gap:4px;
|
||||||
|
padding:6px 6px 4px;
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.pf-learner-spark .pf-growth-bar{
|
||||||
|
min-width:10px;
|
||||||
|
}
|
||||||
|
.pf-learner-spark .pf-growth-bar small{
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
.pf-learner-row__tags{
|
||||||
|
display:flex;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
gap:5px;
|
||||||
|
}
|
||||||
|
.pf-learner-row__tags span{
|
||||||
|
max-width:100%;
|
||||||
|
padding:4px 7px;
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:999px;
|
||||||
|
color:var(--text-body);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
font-size:11px;
|
||||||
|
line-height:1.2;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.pf-learner-row__action{
|
||||||
|
display:flex;
|
||||||
|
justify-content:flex-end;
|
||||||
|
}
|
||||||
|
.pf-learner-row__action .vg-btn{
|
||||||
|
min-width:92px;
|
||||||
|
padding-inline:10px;
|
||||||
|
}
|
||||||
.pf-recent__review-state{
|
.pf-recent__review-state{
|
||||||
color:var(--text-muted);
|
color:var(--text-muted);
|
||||||
font-size:12px;
|
font-size:12px;
|
||||||
|
|
@ -415,37 +547,6 @@
|
||||||
margin-top:3px;
|
margin-top:3px;
|
||||||
line-height:1.2;
|
line-height:1.2;
|
||||||
}
|
}
|
||||||
.pf-growth-card__metrics{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
|
||||||
gap:8px;
|
|
||||||
}
|
|
||||||
.pf-growth-card__metrics span{
|
|
||||||
min-width:0;
|
|
||||||
display:grid;
|
|
||||||
gap:3px;
|
|
||||||
padding:9px 10px;
|
|
||||||
border:1px solid var(--paper-2);
|
|
||||||
border-radius:var(--radius-sm);
|
|
||||||
background:var(--bg-surface-2);
|
|
||||||
}
|
|
||||||
.pf-growth-card__metrics b{
|
|
||||||
color:var(--text-strong);
|
|
||||||
font-family:var(--font-num);
|
|
||||||
font-size:15px;
|
|
||||||
line-height:1.15;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.pf-growth-bars{
|
|
||||||
height:82px;
|
|
||||||
display:flex;
|
|
||||||
align-items:flex-end;
|
|
||||||
gap:6px;
|
|
||||||
padding:8px 8px 6px;
|
|
||||||
border:1px solid var(--paper-2);
|
|
||||||
border-radius:var(--radius-sm);
|
|
||||||
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
|
|
||||||
}
|
|
||||||
.pf-growth-bar{
|
.pf-growth-bar{
|
||||||
flex:1 1 0;
|
flex:1 1 0;
|
||||||
min-width:14px;
|
min-width:14px;
|
||||||
|
|
@ -472,50 +573,6 @@
|
||||||
text-align:center;
|
text-align:center;
|
||||||
line-height:1;
|
line-height:1;
|
||||||
}
|
}
|
||||||
.pf-growth-card__tags{
|
|
||||||
min-height:26px;
|
|
||||||
display:flex;
|
|
||||||
flex-wrap:wrap;
|
|
||||||
gap:6px;
|
|
||||||
align-content:flex-start;
|
|
||||||
}
|
|
||||||
.pf-growth-card__tags span{
|
|
||||||
max-width:100%;
|
|
||||||
padding:4px 7px;
|
|
||||||
border:1px solid var(--paper-2);
|
|
||||||
border-radius:999px;
|
|
||||||
color:var(--text-body);
|
|
||||||
background:var(--bg-surface-2);
|
|
||||||
font-size:11px;
|
|
||||||
line-height:1.2;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.pf-growth-card__points{
|
|
||||||
display:grid;
|
|
||||||
gap:7px;
|
|
||||||
}
|
|
||||||
.pf-growth-point{
|
|
||||||
min-width:0;
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
|
|
||||||
gap:8px;
|
|
||||||
align-items:center;
|
|
||||||
}
|
|
||||||
.pf-growth-point b{
|
|
||||||
color:var(--text-strong);
|
|
||||||
font-size:12px;
|
|
||||||
line-height:1.35;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.pf-growth-point span{
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
}
|
|
||||||
.pf-analysis-panel{
|
.pf-analysis-panel{
|
||||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
|
||||||
}
|
}
|
||||||
|
|
@ -525,6 +582,54 @@
|
||||||
gap:12px;
|
gap:12px;
|
||||||
padding:12px;
|
padding:12px;
|
||||||
}
|
}
|
||||||
|
.pf-analysis-tabs{
|
||||||
|
min-width:0;
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
|
overflow:hidden;
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button{
|
||||||
|
min-width:0;
|
||||||
|
min-height:48px;
|
||||||
|
display:grid;
|
||||||
|
gap:3px;
|
||||||
|
align-content:center;
|
||||||
|
padding:8px 12px;
|
||||||
|
border:0;
|
||||||
|
border-left:1px solid var(--hair);
|
||||||
|
color:var(--text-muted);
|
||||||
|
background:transparent;
|
||||||
|
font:inherit;
|
||||||
|
text-align:left;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button:first-child{
|
||||||
|
border-left:0;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button:hover,
|
||||||
|
.pf-analysis-tabs button:focus-visible{
|
||||||
|
background:var(--bg-surface);
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button:focus-visible{
|
||||||
|
outline:2px solid var(--accent);
|
||||||
|
outline-offset:-2px;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button.is-active{
|
||||||
|
color:var(--text-strong);
|
||||||
|
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs b{
|
||||||
|
font-size:var(--fs-sm);
|
||||||
|
line-height:1.2;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs span{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:11px;
|
||||||
|
line-height:1.2;
|
||||||
|
}
|
||||||
.pf-analysis__top{
|
.pf-analysis__top{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:grid;
|
display:grid;
|
||||||
|
|
@ -594,12 +699,6 @@
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
text-overflow:ellipsis;
|
text-overflow:ellipsis;
|
||||||
}
|
}
|
||||||
.pf-analysis__body{
|
|
||||||
min-width:0;
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:minmax(0,1.25fr) minmax(240px,.75fr);
|
|
||||||
gap:12px;
|
|
||||||
}
|
|
||||||
.pf-analysis__chart,
|
.pf-analysis__chart,
|
||||||
.pf-analysis__stages,
|
.pf-analysis__stages,
|
||||||
.pf-analysis__timeline{
|
.pf-analysis__timeline{
|
||||||
|
|
@ -1125,13 +1224,9 @@
|
||||||
}
|
}
|
||||||
@media (max-width:1100px){
|
@media (max-width:1100px){
|
||||||
.pf-signal-strip,
|
.pf-signal-strip,
|
||||||
.pf-workspace,
|
.pf-workspace{
|
||||||
.pf-analysis-shell{
|
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
}
|
}
|
||||||
.pf-analysis-rail{
|
|
||||||
position:static;
|
|
||||||
}
|
|
||||||
.pf-triage{
|
.pf-triage{
|
||||||
grid-template-columns:auto minmax(0,max-content) auto;
|
grid-template-columns:auto minmax(0,max-content) auto;
|
||||||
justify-content:start;
|
justify-content:start;
|
||||||
|
|
@ -1141,12 +1236,57 @@
|
||||||
}
|
}
|
||||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||||
.pf-kpi:nth-child(n+4){border-top:0;}
|
.pf-kpi:nth-child(n+4){border-top:0;}
|
||||||
.pf-growth-list{
|
.pf-learner-toolbar{
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:1fr;
|
||||||
}
|
}
|
||||||
.pf-analysis-rail .pf-growth-list{
|
.pf-sort-segment{
|
||||||
max-height:360px;
|
justify-content:flex-start;
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
overflow:auto;
|
||||||
|
scrollbar-gutter:stable;
|
||||||
|
}
|
||||||
|
.pf-learner-table{
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.pf-learner-table__head{
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
.pf-learner-row{
|
||||||
|
margin:10px;
|
||||||
|
padding:12px;
|
||||||
|
border:1px solid var(--paper-2);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
background:var(--bg-surface);
|
||||||
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
|
gap:10px 12px;
|
||||||
|
}
|
||||||
|
.pf-learner-row + .pf-learner-row{
|
||||||
|
margin-top:0;
|
||||||
|
}
|
||||||
|
.pf-learner-row__id,
|
||||||
|
.pf-learner-row__spark,
|
||||||
|
.pf-learner-row__tags,
|
||||||
|
.pf-learner-row__action{
|
||||||
|
grid-column:1 / -1;
|
||||||
|
}
|
||||||
|
.pf-learner-row__metric::before,
|
||||||
|
.pf-learner-row__spark::before,
|
||||||
|
.pf-learner-row__tags::before{
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.pf-learner-row__spark,
|
||||||
|
.pf-learner-row__tags{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(76px,.28fr) minmax(0,1fr);
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.pf-learner-row__tags{
|
||||||
|
display:flex;
|
||||||
|
}
|
||||||
|
.pf-learner-row__tags::before{
|
||||||
|
flex:0 0 76px;
|
||||||
|
}
|
||||||
|
.pf-learner-row__action{
|
||||||
|
justify-content:flex-start;
|
||||||
}
|
}
|
||||||
.pf-analysis__top{
|
.pf-analysis__top{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
|
|
@ -1160,9 +1300,6 @@
|
||||||
.pf-analysis__metrics span:nth-child(n+4){
|
.pf-analysis__metrics span:nth-child(n+4){
|
||||||
border-top:1px solid var(--hair);
|
border-top:1px solid var(--hair);
|
||||||
}
|
}
|
||||||
.pf-analysis__body{
|
|
||||||
grid-template-columns:1fr;
|
|
||||||
}
|
|
||||||
.pf-timeline-row{
|
.pf-timeline-row{
|
||||||
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
|
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
|
||||||
}
|
}
|
||||||
|
|
@ -1184,6 +1321,9 @@
|
||||||
width:100%;
|
width:100%;
|
||||||
justify-content:flex-start;
|
justify-content:flex-start;
|
||||||
}
|
}
|
||||||
|
.pf-analysis-detail-head{
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
}
|
||||||
.pf-depth-strip{
|
.pf-depth-strip{
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -1204,15 +1344,19 @@
|
||||||
.pf-kpi:nth-child(n+2){border-left:0;}
|
.pf-kpi:nth-child(n+2){border-left:0;}
|
||||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||||
.pf-growth-list{
|
|
||||||
grid-template-columns:1fr;
|
|
||||||
}
|
|
||||||
.pf-analysis-rail .pf-growth-list{
|
|
||||||
grid-template-columns:1fr;
|
|
||||||
}
|
|
||||||
.pf-analysis{
|
.pf-analysis{
|
||||||
padding:10px;
|
padding:10px;
|
||||||
}
|
}
|
||||||
|
.pf-analysis-tabs{
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button,
|
||||||
|
.pf-analysis-tabs button:first-child{
|
||||||
|
border-left:0;
|
||||||
|
}
|
||||||
|
.pf-analysis-tabs button:nth-child(n+2){
|
||||||
|
border-top:1px solid var(--hair);
|
||||||
|
}
|
||||||
.pf-analysis__metrics{
|
.pf-analysis__metrics{
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -1345,11 +1489,38 @@
|
||||||
.pf-depth-strip span:nth-child(n+2){
|
.pf-depth-strip span:nth-child(n+2){
|
||||||
border-top:1px solid var(--hair);
|
border-top:1px solid var(--hair);
|
||||||
}
|
}
|
||||||
.pf-growth-list{
|
.pf-learner-toolbar{
|
||||||
padding:10px;
|
padding:10px;
|
||||||
}
|
}
|
||||||
.pf-growth-card__metrics{
|
.pf-sort-segment{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button,
|
||||||
|
.pf-sort-segment button:first-child{
|
||||||
|
border-left:0;
|
||||||
|
}
|
||||||
|
.pf-sort-segment button:nth-child(even){
|
||||||
|
border-left:1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.pf-sort-segment button:nth-child(n+3){
|
||||||
|
border-top:1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.pf-learner-row{
|
||||||
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
|
margin:8px;
|
||||||
|
}
|
||||||
|
.pf-learner-row__spark,
|
||||||
|
.pf-learner-row__tags{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
|
gap:6px;
|
||||||
|
}
|
||||||
|
.pf-learner-row__tags::before{
|
||||||
|
flex-basis:100%;
|
||||||
|
}
|
||||||
|
.pf-learner-row__action .vg-btn{
|
||||||
|
width:100%;
|
||||||
}
|
}
|
||||||
.pf-analysis__metrics{
|
.pf-analysis__metrics{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
|
|
@ -1379,14 +1550,6 @@
|
||||||
.pf-timeline-row__dates{
|
.pf-timeline-row__dates{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
}
|
}
|
||||||
.pf-growth-point{
|
|
||||||
grid-template-columns:1fr;
|
|
||||||
gap:2px;
|
|
||||||
}
|
|
||||||
.pf-growth-point b,
|
|
||||||
.pf-growth-point span{
|
|
||||||
white-space:normal;
|
|
||||||
}
|
|
||||||
.pf-alert{
|
.pf-alert{
|
||||||
grid-template-columns:auto minmax(0,1fr);
|
grid-template-columns:auto minmax(0,1fr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
- 공개 웹: `https://vignette.chanpaca.net`
|
- 공개 웹: `https://vignette.chanpaca.net`
|
||||||
- 공개 API: `https://api-vignette.chanpaca.net`
|
- 공개 API: `https://api-vignette.chanpaca.net`
|
||||||
- 최신 앱 배포 소스: 2026-07-01 `origin/master` manual deploy snapshot.
|
- 최신 앱 배포 소스: 2026-07-01 `origin/master` manual deploy snapshot.
|
||||||
- 최신 Cloudflare Pages production deploy: 2026-07-01 manual deploy, branch `main`, custom domain assets `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`.
|
- 최신 Cloudflare Pages production deploy: 2026-07-01 manual deploy, branch `main`, custom domain assets `assets/index-DgN8JVBw.js` + `assets/index--s3xqgO5.css`.
|
||||||
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
||||||
- 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q` → `305 passed`
|
- 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q` → `305 passed`
|
||||||
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
|
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
||||||
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
||||||
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
||||||
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-07-01 KST 재확인 대상: `https://vignette.chanpaca.net/login?deploy=20260701` 200 with `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401.
|
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-07-01 KST 재확인 대상: `https://vignette.chanpaca.net/login?deploy=20260701` 200 with `assets/index-DgN8JVBw.js` + `assets/index--s3xqgO5.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401.
|
||||||
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
|
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
|
||||||
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
|
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
|
||||||
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite dev web, `127.0.0.1:5174` public preview, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 재검증 시 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200이었다. `alpaca-home.taile93291.ts.net`는 Vite 기본 `allowedHosts`에도 포함했다.
|
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite dev web, `127.0.0.1:5174` public preview, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 재검증 시 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200이었다. `alpaca-home.taile93291.ts.net`는 Vite 기본 `allowedHosts`에도 포함했다.
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
- Public web deployment:
|
- Public web deployment:
|
||||||
- `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true`
|
- `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true`
|
||||||
- 2026-06-29 manual deployment, preview `https://eb2ed257.vignette-b1q.pages.dev`
|
- 2026-06-29 manual deployment, preview `https://eb2ed257.vignette-b1q.pages.dev`
|
||||||
- `https://vignette.chanpaca.net/` serves `assets/index-B5dHolQ4.js` and `assets/index-PBfLFh-m.css`
|
- `https://vignette.chanpaca.net/` serves `assets/index-DgN8JVBw.js` and `assets/index--s3xqgO5.css`
|
||||||
- `https://vignette.chanpaca.net/live2d/mao/Mao.model3.json`, Haru model, and Cubism core routes return 404
|
- `https://vignette.chanpaca.net/live2d/mao/Mao.model3.json`, Haru model, and Cubism core routes return 404
|
||||||
- Public login screen:
|
- Public login screen:
|
||||||
- `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"`
|
- `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"`
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,7 @@
|
||||||
<p class="dg-note">82차 적용(2026-06-29): refactor-governance P8로 <code>Session.tsx</code>의 음성/세션 UI 파생 상태 계산을 <code>sessionVoiceStatusView()</code> helper로 분리했다. 마이크 busy/label, 세션 상태 label, transcript live label, 텍스트 입력 차단, 응답/음성 상태 chip 계산을 named view-model 경계에서 유지한다. API/DB 계약, 세션 시작·전송·종료, voice WebSocket/TTS lifecycle, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
|
<p class="dg-note">82차 적용(2026-06-29): refactor-governance P8로 <code>Session.tsx</code>의 음성/세션 UI 파생 상태 계산을 <code>sessionVoiceStatusView()</code> helper로 분리했다. 마이크 busy/label, 세션 상태 label, transcript live label, 텍스트 입력 차단, 응답/음성 상태 chip 계산을 named view-model 경계에서 유지한다. API/DB 계약, 세션 시작·전송·종료, voice WebSocket/TTS lifecycle, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
|
||||||
<p class="dg-note">83차 적용(2026-06-29): refactor-governance P3로 학습자/교수자 성장 지표가 턴 평가 기법 라벨을 읽는 경계를 <code>session_metrics.turn_technique_label()</code>로 명시했다. DB 정규화 후 재수화되는 <code>TechniqueTag.label_ko</code>를 우선 사용하고, 기존 <code>label/name/id/code</code> legacy fallback은 유지한다. <code>alternative_utterances</code>는 future adapter 추출 전에 문자열 배열과 <code>text/suggestion</code> dict 배열을 모두 row로 정규화하는 호환 테스트로 고정했다. 검증: py_compile, evaluation/learner/teacher focused 59 passed.</p>
|
<p class="dg-note">83차 적용(2026-06-29): refactor-governance P3로 학습자/교수자 성장 지표가 턴 평가 기법 라벨을 읽는 경계를 <code>session_metrics.turn_technique_label()</code>로 명시했다. DB 정규화 후 재수화되는 <code>TechniqueTag.label_ko</code>를 우선 사용하고, 기존 <code>label/name/id/code</code> legacy fallback은 유지한다. <code>alternative_utterances</code>는 future adapter 추출 전에 문자열 배열과 <code>text/suggestion</code> dict 배열을 모두 row로 정규화하는 호환 테스트로 고정했다. 검증: py_compile, evaluation/learner/teacher focused 59 passed.</p>
|
||||||
<p class="dg-note">84차 적용(2026-06-29): refactor-governance P2로 <code>Session.tsx</code>의 음성 guard/view-model 경계를 한 번 더 줄였다. <code>isVoiceStatusBusy()</code>가 마이크 busy 판정을 소유하고, <code>sessionVoiceStatusView()</code>가 마이크 disabled와 aria label까지 반환해 JSX와 <code>toggleMic()</code>의 중복 조건을 제거했다. <code>recording</code>은 계속 busy가 아니므로 두 번째 마이크 클릭으로 발화 전송이 가능하다. API/DB 계약, WebSocket/TTS lifecycle, STT/TTS provider 동작, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
|
<p class="dg-note">84차 적용(2026-06-29): refactor-governance P2로 <code>Session.tsx</code>의 음성 guard/view-model 경계를 한 번 더 줄였다. <code>isVoiceStatusBusy()</code>가 마이크 busy 판정을 소유하고, <code>sessionVoiceStatusView()</code>가 마이크 disabled와 aria label까지 반환해 JSX와 <code>toggleMic()</code>의 중복 조건을 제거했다. <code>recording</code>은 계속 busy가 아니므로 두 번째 마이크 클릭으로 발화 전송이 가능하다. API/DB 계약, WebSocket/TTS lifecycle, STT/TTS provider 동작, live-coach payload, DOM/CSS는 변경하지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run build</code>, <code>session-mvp</code> 1 passed, <code>voice-success</code> 2 passed, <code>session-layout</code> desktop/mobile 8 passed.</p>
|
||||||
<p class="dg-note">85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 <code>gateway-default</code> sentinel 중복을 줄였다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code>의 <code>x-engine-gateway-default-model-sentinel</code> extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 <code>assets/index-B5dHolQ4.js</code>/<code>assets/index-PBfLFh-m.css</code>로 갱신했고, OAuth start는 동작하지만 인증된 <code>/turn</code> proof는 여전히 owner storageState gate로 남겼다. <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS A record는 0건이라 DONE 처리하지 않는다.</p>
|
<p class="dg-note">85차 적용(2026-06-29): refactor-governance P3로 Node 전환 계약 검증의 <code>gateway-default</code> sentinel 중복을 줄였다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 sentinel 문자열을 자체 상수로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code>의 <code>x-engine-gateway-default-model-sentinel</code> extension을 읽어 schema artifact를 SSOT로 검증한다. 공개 런타임 SSOT도 재확인해 custom domain asset을 <code>assets/index-DgN8JVBw.js</code>/<code>assets/index--s3xqgO5.css</code>로 갱신했고, OAuth start는 동작하지만 인증된 <code>/turn</code> proof는 여전히 owner storageState gate로 남겼다. <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS A record는 0건이라 DONE 처리하지 않는다.</p>
|
||||||
<p class="dg-note">86차 적용(2026-06-29): refactor-governance P2/P3로 Session mic capture를 <code>AudioWorklet</code>-first 구조로 전환하고 기존 <code>MediaRecorder</code>를 fallback으로 남겼다. Worklet은 mono PCM16 chunk를 <code>/voice/ws</code> binary frame으로 보내고, control packet은 <code>format=pcm</code>, <code>sample_rate</code>, <code>channels</code>, <code>sample_width</code>를 명시한다. 백엔드는 <code>pcm</code> 업로드를 STT 호출 직전 RIFF/WAV로 정규화해 raw PCM을 <code>audio/wav</code>로 오인하던 계약 틈을 닫았다. 실제 Deepgram streaming, 물리 마이크, public WSS, 50분 양방향 실측은 계속 외부/실측 gate다. 검증: <code>app/test_voice_ws.py</code> 11 passed, <code>npm run typecheck</code>, <code>voice-success</code> chromium-single-run 2 passed.</p>
|
<p class="dg-note">86차 적용(2026-06-29): refactor-governance P2/P3로 Session mic capture를 <code>AudioWorklet</code>-first 구조로 전환하고 기존 <code>MediaRecorder</code>를 fallback으로 남겼다. Worklet은 mono PCM16 chunk를 <code>/voice/ws</code> binary frame으로 보내고, control packet은 <code>format=pcm</code>, <code>sample_rate</code>, <code>channels</code>, <code>sample_width</code>를 명시한다. 백엔드는 <code>pcm</code> 업로드를 STT 호출 직전 RIFF/WAV로 정규화해 raw PCM을 <code>audio/wav</code>로 오인하던 계약 틈을 닫았다. 실제 Deepgram streaming, 물리 마이크, public WSS, 50분 양방향 실측은 계속 외부/실측 gate다. 검증: <code>app/test_voice_ws.py</code> 11 passed, <code>npm run typecheck</code>, <code>voice-success</code> chromium-single-run 2 passed.</p>
|
||||||
<p class="dg-note">87차 적용(2026-06-29): refactor-governance P3로 <code>/voice/ws</code>에 provider-neutral <code>stt_result</code> control을 추가해 EOT helper를 route 경계에 연결했다. 새 control은 <code>text</code>, <code>final</code>, <code>silence_ms</code>, <code>provider_events</code>를 받아 <code>assess_end_of_turn()</code>이 ready를 판단하고, pending이면 턴 실행 없이 <code>state=listening</code>으로 돌아간다. ready일 때만 transcript를 emit하고 <code>_run_turn_and_speak()</code>로 진행한다. 기존 <code>audio_start</code>→binary→<code>audio_end</code>, <code>text_turn</code>, ping/close 계약은 유지했다. 실제 Deepgram interim/final WSS 연결과 public WSS/물리 마이크/50분 실측은 여전히 남은 gate다. 검증: <code>py_compile</code>, <code>app/test_voice_ws.py app/test_voice_service.py</code> 27 passed.</p>
|
<p class="dg-note">87차 적용(2026-06-29): refactor-governance P3로 <code>/voice/ws</code>에 provider-neutral <code>stt_result</code> control을 추가해 EOT helper를 route 경계에 연결했다. 새 control은 <code>text</code>, <code>final</code>, <code>silence_ms</code>, <code>provider_events</code>를 받아 <code>assess_end_of_turn()</code>이 ready를 판단하고, pending이면 턴 실행 없이 <code>state=listening</code>으로 돌아간다. ready일 때만 transcript를 emit하고 <code>_run_turn_and_speak()</code>로 진행한다. 기존 <code>audio_start</code>→binary→<code>audio_end</code>, <code>text_turn</code>, ping/close 계약은 유지했다. 실제 Deepgram interim/final WSS 연결과 public WSS/물리 마이크/50분 실측은 여전히 남은 gate다. 검증: <code>py_compile</code>, <code>app/test_voice_ws.py app/test_voice_service.py</code> 27 passed.</p>
|
||||||
<p class="dg-note">88차 적용(2026-06-29): refactor-governance P3로 Node conformance runner의 stream event/payload 검증을 schema-driven으로 바꿨다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 <code>token/done/error</code> 이벤트 목록과 payload 필드를 로컬 상수·분기로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code>의 <code>StreamPacket.oneOf</code>, payload <code>$ref</code>, <code>x-engine-gateway-sse-events</code>를 읽어 golden fixture를 검증한다. raw token text fallback과 <code>data: [DONE]</code> compatibility line은 유지했다. 검증: <code>node scripts\check-engine-gateway-contract.mjs --json</code>, 당시 gateway/backend focused suite 통과, 이후 최신 L1 focused 120 passed로 재검증.</p>
|
<p class="dg-note">88차 적용(2026-06-29): refactor-governance P3로 Node conformance runner의 stream event/payload 검증을 schema-driven으로 바꿨다. <code>scripts/check-engine-gateway-contract.mjs</code>는 더 이상 <code>token/done/error</code> 이벤트 목록과 payload 필드를 로컬 상수·분기로 들고 있지 않고, <code>engine_gateway_schema.v1.json</code>의 <code>StreamPacket.oneOf</code>, payload <code>$ref</code>, <code>x-engine-gateway-sse-events</code>를 읽어 golden fixture를 검증한다. raw token text fallback과 <code>data: [DONE]</code> compatibility line은 유지했다. 검증: <code>node scripts\check-engine-gateway-contract.mjs --json</code>, 당시 gateway/backend focused suite 통과, 이후 최신 L1 focused 120 passed로 재검증.</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue