대시보드 이슈 정리 1차
This commit is contained in:
parent
94cc56592f
commit
f472883c31
13 changed files with 592 additions and 311 deletions
|
|
@ -1410,13 +1410,12 @@ async def submit_turn(
|
||||||
detail=f"engine unavailable: {exc}",
|
detail=f"engine unavailable: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
await turn_runtime.record_completed_turn(
|
await turn_runtime.finalize_completed_turn(
|
||||||
sess,
|
sess,
|
||||||
ctx,
|
ctx,
|
||||||
result,
|
result,
|
||||||
context_prefix="session",
|
context_prefix="session",
|
||||||
)
|
)
|
||||||
await turn_runtime.record_safety_event(sess, ctx, result)
|
|
||||||
|
|
||||||
return TurnResponse(
|
return TurnResponse(
|
||||||
turn_seq=result.turn_seq,
|
turn_seq=result.turn_seq,
|
||||||
|
|
@ -1473,13 +1472,12 @@ async def stream_turn(
|
||||||
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
|
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
|
||||||
evaluation = await _evaluate_stream_turn(ctx, final_reply)
|
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.record_completed_turn(
|
await turn_runtime.finalize_completed_turn(
|
||||||
sess,
|
sess,
|
||||||
ctx,
|
ctx,
|
||||||
result,
|
result,
|
||||||
context_prefix="session",
|
context_prefix="session",
|
||||||
)
|
)
|
||||||
await turn_runtime.record_safety_event(sess, ctx, result)
|
|
||||||
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
|
yield {"event": "done", "data": json.dumps(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)}
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ async def _run_turn_and_speak(
|
||||||
reply = result.client_reply or ""
|
reply = result.client_reply or ""
|
||||||
# Persist only after the client reply has been generated. A failed AI turn
|
# Persist only after the client reply has been generated. A failed AI turn
|
||||||
# must not leave a learner-only transcript in review or history.
|
# must not leave a learner-only transcript in review or history.
|
||||||
await turn_runtime.record_completed_turn(
|
await turn_runtime.finalize_completed_turn(
|
||||||
sess,
|
sess,
|
||||||
ctx,
|
ctx,
|
||||||
result,
|
result,
|
||||||
|
|
@ -347,7 +347,6 @@ async def _run_turn_and_speak(
|
||||||
evaluation=result.evaluation,
|
evaluation=result.evaluation,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await turn_runtime.record_safety_event(sess, ctx, result)
|
|
||||||
|
|
||||||
# Send the final client text before audio playback.
|
# Send the final client text before audio playback.
|
||||||
await _safe_send_json(
|
await _safe_send_json(
|
||||||
|
|
|
||||||
226
apps/api/app/test_phase3_artifact_checker.py
Normal file
226
apps/api/app/test_phase3_artifact_checker.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
CHECKER_PATH = REPO_ROOT / "scripts" / "check-phase3-artifacts.py"
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("phase3_artifact_checker", CHECKER_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
checker = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = checker
|
||||||
|
spec.loader.exec_module(checker)
|
||||||
|
|
||||||
|
|
||||||
|
def write_text(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class Phase3ArtifactCheckerTests(unittest.TestCase):
|
||||||
|
def make_root(self) -> Path:
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
root = Path(tmp.name)
|
||||||
|
|
||||||
|
write_text(
|
||||||
|
root / "00-intake" / "pilot_roster.csv",
|
||||||
|
"participant_id,cohort_id,consent_version,consent_signed_at,withdrawal_state,enrolled_at\n"
|
||||||
|
"P3-001,phase3,v1,2026-06-27T00:00:00Z,active,2026-06-27T00:00:00Z\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "00-intake" / "consent_receipts.csv",
|
||||||
|
"participant_id,consent_version,signed_at,signer_role,receipt_id\n"
|
||||||
|
"P3-001,v1,2026-06-27T00:00:00Z,self,R-001\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "01-sessions" / "session_completion.csv",
|
||||||
|
"participant_id,session_id,persona_id,started_at,ended_at,completion_state,turns_count,supervisor_reviewed_at\n"
|
||||||
|
"P3-001,S-001,P1,2026-06-27T00:00:00Z,2026-06-27T00:50:00Z,completed,12,2026-06-27T01:00:00Z\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "02-measures" / "prepost_measures.csv",
|
||||||
|
"participant_id,measure_name,timepoint,score,collected_at\n"
|
||||||
|
"P3-001,self_efficacy,pre,3,2026-06-27T00:00:00Z\n"
|
||||||
|
"P3-001,self_efficacy,post,4,2026-06-27T01:00:00Z\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "02-measures" / "sus_responses.csv",
|
||||||
|
"participant_id,item,response,collected_at\n"
|
||||||
|
"P3-001,1,5,2026-06-27T01:00:00Z\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "04-privacy" / "withdrawal_log.csv",
|
||||||
|
"participant_id,requested_at,effective_at,scope,status,attestation_path\n",
|
||||||
|
)
|
||||||
|
write_text(
|
||||||
|
root / "04-privacy" / "privacy_audit.md",
|
||||||
|
"# Privacy audit\n\nLegal/privacy reviewer: reviewer@example.invalid\n",
|
||||||
|
)
|
||||||
|
write_text(root / "03-export" / "anonymized_dataset.jsonl", "{}\n")
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
name: {
|
||||||
|
"value": 1,
|
||||||
|
"threshold": 1,
|
||||||
|
"pass": True,
|
||||||
|
"numerator": 1,
|
||||||
|
"denominator": 1,
|
||||||
|
"method": "fixture",
|
||||||
|
"source_files": ["fixture"],
|
||||||
|
}
|
||||||
|
for name in checker.KPI_METRICS
|
||||||
|
}
|
||||||
|
write_text(
|
||||||
|
root / "02-measures" / "kpi_report.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pilot_id": "phase3-fixture",
|
||||||
|
"generated_at": "2026-06-27T00:00:00Z",
|
||||||
|
"source_window": {
|
||||||
|
"started_at": "2026-06-27T00:00:00Z",
|
||||||
|
"ended_at": "2026-06-27T01:00:00Z",
|
||||||
|
},
|
||||||
|
"cohort_size": 1,
|
||||||
|
"metrics": metrics,
|
||||||
|
"exclusions": [],
|
||||||
|
"open_schema_gaps": [],
|
||||||
|
"review": {
|
||||||
|
"operator": "test",
|
||||||
|
"reviewed_at": "2026-06-27T01:00:00Z",
|
||||||
|
"decision": "fixture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
)
|
||||||
|
dataset_path = root / "03-export" / "anonymized_dataset.jsonl"
|
||||||
|
write_text(
|
||||||
|
root / "03-export" / "export_manifest.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"export_id": "phase3-fixture",
|
||||||
|
"dataset_name": "vignette_phase3_recursive_learning_seed",
|
||||||
|
"export_status": checker.APPROVED_EXPORT_STATUS,
|
||||||
|
"created_at": "2026-06-27T00:00:00Z",
|
||||||
|
"purpose": "test",
|
||||||
|
"source_window": {
|
||||||
|
"started_at": "2026-06-27T00:00:00Z",
|
||||||
|
"ended_at": "2026-06-27T01:00:00Z",
|
||||||
|
},
|
||||||
|
"source_tables": ["app.sessions"],
|
||||||
|
"selection_criteria": {
|
||||||
|
"include_withdrawn": False,
|
||||||
|
"min_completed_sessions": 2,
|
||||||
|
},
|
||||||
|
"consent_scope": {
|
||||||
|
"allowed_uses": ["education_quality_review", "recursive_learning_seed"],
|
||||||
|
"participants_included": 1,
|
||||||
|
"participants_excluded": 0,
|
||||||
|
},
|
||||||
|
"anonymization": {
|
||||||
|
"text_transform": "masked_text_only",
|
||||||
|
"direct_identifier_policy": "blocked",
|
||||||
|
},
|
||||||
|
"pii_scan": {"status": "pass"},
|
||||||
|
"agreement": {"kappa": 0.60, "icc": 0.75},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "03-export/anonymized_dataset.jsonl",
|
||||||
|
"rows": 1,
|
||||||
|
"sha256": sha256(dataset_path),
|
||||||
|
"schema": "phase3_dataset_item_v1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approvals": {
|
||||||
|
"data_steward": "steward",
|
||||||
|
"legal_or_privacy_reviewer": "privacy",
|
||||||
|
"technical_operator": "operator",
|
||||||
|
"approved_at": "2026-06-27T01:00:00Z",
|
||||||
|
},
|
||||||
|
"known_limitations": [],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_valid_approved_fixture_passes(self) -> None:
|
||||||
|
root = self.make_root()
|
||||||
|
report = checker.validate(root, max_scan_rows=100)
|
||||||
|
self.assertEqual([], report.errors)
|
||||||
|
|
||||||
|
def test_approved_manifest_requires_privacy_and_agreement_gates(self) -> None:
|
||||||
|
root = self.make_root()
|
||||||
|
manifest_path = root / "03-export" / "export_manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["pii_scan"]["status"] = "pending"
|
||||||
|
manifest["agreement"]["kappa"] = 0.59
|
||||||
|
manifest["agreement"]["icc"] = 0.74
|
||||||
|
manifest["selection_criteria"]["include_withdrawn"] = True
|
||||||
|
manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"]
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
report = checker.validate(root, max_scan_rows=100)
|
||||||
|
|
||||||
|
errors = "\n".join(report.errors)
|
||||||
|
self.assertIn("pii_scan.status='pass'", errors)
|
||||||
|
self.assertIn("agreement.kappa >= 0.60", errors)
|
||||||
|
self.assertIn("agreement.icc >= 0.75", errors)
|
||||||
|
self.assertIn("include_withdrawn=false", errors)
|
||||||
|
self.assertIn("recursive_learning_seed consent scope", errors)
|
||||||
|
|
||||||
|
def test_kpi_metric_required_fields_are_errors(self) -> None:
|
||||||
|
root = self.make_root()
|
||||||
|
report_path = root / "02-measures" / "kpi_report.json"
|
||||||
|
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
del data["metrics"]["sus"]["source_files"]
|
||||||
|
report_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
report = checker.validate(root, max_scan_rows=100)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("metric 'sus' missing 'source_files'" in error for error in report.errors),
|
||||||
|
report.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_csv_enum_values_are_validated(self) -> None:
|
||||||
|
root = self.make_root()
|
||||||
|
write_text(
|
||||||
|
root / "01-sessions" / "session_completion.csv",
|
||||||
|
"participant_id,session_id,persona_id,started_at,ended_at,completion_state,turns_count,supervisor_reviewed_at\n"
|
||||||
|
"P3-001,S-001,P1,2026-06-27T00:00:00Z,2026-06-27T00:50:00Z,done,12,2026-06-27T01:00:00Z\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = checker.validate(root, max_scan_rows=100)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("invalid completion_state 'done'" in error for error in report.errors),
|
||||||
|
report.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_approved_manifest_hash_must_match_existing_file(self) -> None:
|
||||||
|
root = self.make_root()
|
||||||
|
manifest_path = root / "03-export" / "export_manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["files"][0]["sha256"] = "0" * 64
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
report = checker.validate(root, max_scan_rows=100)
|
||||||
|
|
||||||
|
self.assertTrue(any("sha256 mismatch" in error for error in report.errors), report.errors)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -182,9 +182,29 @@ async def record_safety_event(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_completed_turn(
|
||||||
|
sess: InProcSession,
|
||||||
|
ctx: orchestrator.TurnContext,
|
||||||
|
result: orchestrator.TurnResult,
|
||||||
|
*,
|
||||||
|
context_prefix: str,
|
||||||
|
counselor_turn: TurnRecord | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a completed turn and emit any derived safety alert in route-safe order."""
|
||||||
|
await record_completed_turn(
|
||||||
|
sess,
|
||||||
|
ctx,
|
||||||
|
result,
|
||||||
|
context_prefix=context_prefix,
|
||||||
|
counselor_turn=counselor_turn,
|
||||||
|
)
|
||||||
|
await record_safety_event(sess, ctx, result)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SessionAccessError",
|
"SessionAccessError",
|
||||||
"append_completed_turn",
|
"append_completed_turn",
|
||||||
|
"finalize_completed_turn",
|
||||||
"load_owned_session",
|
"load_owned_session",
|
||||||
"record_safety_event",
|
"record_safety_event",
|
||||||
"record_completed_turn",
|
"record_completed_turn",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,32 @@ def _model_arg(args):
|
||||||
return args[args.index("--model") + 1]
|
return args[args.index("--model") + 1]
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_streaming_response(response):
|
||||||
|
chunks = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
chunks.append(chunk.decode("utf-8"))
|
||||||
|
else:
|
||||||
|
chunks.append(str(chunk))
|
||||||
|
return "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStreamSession:
|
||||||
|
def __init__(self, events, model="test-model"):
|
||||||
|
self.events = events
|
||||||
|
self.model = model
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def turn_stream(self, content, timeout=600.0):
|
||||||
|
self.content = content
|
||||||
|
self.timeout = timeout
|
||||||
|
for event in self.events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
class GatewayModelTest(unittest.TestCase):
|
class GatewayModelTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
gateway.SESSIONS.clear()
|
gateway.SESSIONS.clear()
|
||||||
|
|
@ -212,6 +238,50 @@ class GatewayModelTest(unittest.TestCase):
|
||||||
self.assertEqual(closed, [started[0]])
|
self.assertEqual(closed, [started[0]])
|
||||||
self.assertNotIn(started[0].id, gateway.SESSIONS)
|
self.assertNotIn(started[0].id, gateway.SESSIONS)
|
||||||
|
|
||||||
|
def test_v1_stream_frames_token_and_done_events(self):
|
||||||
|
session = _FakeStreamSession(
|
||||||
|
[
|
||||||
|
{"type": "delta", "text": "안녕"},
|
||||||
|
{"type": "done", "cost_usd": 0.03, "turns": 2},
|
||||||
|
],
|
||||||
|
model="stream-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_resolve(req, system_prompt):
|
||||||
|
return session, True
|
||||||
|
|
||||||
|
with patch.object(gateway, "_resolve_session", fake_resolve):
|
||||||
|
response = asyncio.run(gateway.v1_stream(_request()))
|
||||||
|
body = asyncio.run(_read_streaming_response(response))
|
||||||
|
|
||||||
|
self.assertIn("event: token", body)
|
||||||
|
self.assertIn('data: {"text": "안녕"}', body)
|
||||||
|
self.assertIn("event: done", body)
|
||||||
|
self.assertIn('"provider": "claude_cli"', body)
|
||||||
|
self.assertIn('"model": "stream-model"', body)
|
||||||
|
self.assertIn('"cost_usd": 0.03', body)
|
||||||
|
self.assertEqual(session.content, "hello")
|
||||||
|
self.assertEqual(session.timeout, 600.0)
|
||||||
|
self.assertTrue(session.closed)
|
||||||
|
|
||||||
|
def test_v1_stream_frames_engine_error_event(self):
|
||||||
|
session = _FakeStreamSession(
|
||||||
|
[
|
||||||
|
{"type": "done", "is_error": True, "error": "engine failed"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_resolve(req, system_prompt):
|
||||||
|
return session, False
|
||||||
|
|
||||||
|
with patch.object(gateway, "_resolve_session", fake_resolve):
|
||||||
|
response = asyncio.run(gateway.v1_stream(_request()))
|
||||||
|
body = asyncio.run(_read_streaming_response(response))
|
||||||
|
|
||||||
|
self.assertIn("event: error", body)
|
||||||
|
self.assertIn('data: {"detail": "engine failed"}', body)
|
||||||
|
self.assertFalse(session.closed)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -155,32 +155,9 @@ export const api = {
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
|
|
||||||
/** GET /auth/me — auth.py MeResponse */
|
/** GET /auth/me — auth.py MeResponse */
|
||||||
export interface MeResponse {
|
export type MeResponse = ApiSchema<"MeResponse">;
|
||||||
user_id: string;
|
export type ConsentResponse = ApiSchema<"ConsentResponse">;
|
||||||
email: string;
|
export type AuthConfigResponse = ApiSchema<"AuthConfigResponse">;
|
||||||
display_name: string;
|
|
||||||
role: string; // "learner" | "teacher" | "admin"
|
|
||||||
cohort_ids: string[];
|
|
||||||
consent_at: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsentResponse {
|
|
||||||
consent_at: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthConfigResponse {
|
|
||||||
google_oauth_configured: boolean;
|
|
||||||
saml_configured: boolean;
|
|
||||||
providers: Array<{
|
|
||||||
provider: "google" | "saml";
|
|
||||||
configured: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
login_path: string;
|
|
||||||
}>;
|
|
||||||
allowed_email_domains: string[];
|
|
||||||
redirect_uri: string;
|
|
||||||
dev_login_enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
||||||
|
|
@ -191,34 +168,11 @@ export const authApi = {
|
||||||
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
||||||
|
|
||||||
/** GET /personas — personas.py PersonaSummary */
|
/** GET /personas — personas.py PersonaSummary */
|
||||||
export interface PersonaSummary {
|
export type PersonaSummary = ApiSchema<"PersonaSummary">;
|
||||||
code: string;
|
|
||||||
display_name: string;
|
|
||||||
difficulty: "easy" | "moderate" | "hard" | string;
|
|
||||||
theory_target: string[];
|
|
||||||
demographics: Record<string, unknown>;
|
|
||||||
presenting_summary: string;
|
|
||||||
voice_preset: string | null;
|
|
||||||
source: string;
|
|
||||||
degraded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived";
|
export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived";
|
||||||
export type PersonaReviewAction = "approve" | "reject";
|
export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"];
|
||||||
|
export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">;
|
||||||
export interface PersonaReviewSummary {
|
|
||||||
persona_id: string;
|
|
||||||
code: string;
|
|
||||||
version: number;
|
|
||||||
status: PersonaReviewStatus;
|
|
||||||
display_name: string;
|
|
||||||
difficulty: "easy" | "moderate" | "hard" | string;
|
|
||||||
theory_target: string[];
|
|
||||||
source_provenance: string;
|
|
||||||
is_synthetic: boolean;
|
|
||||||
created_at: string | null;
|
|
||||||
approved_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
|
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
|
||||||
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
|
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
|
||||||
|
|
@ -240,39 +194,15 @@ export type TurnResponse = ApiSchema<"TurnResponse">;
|
||||||
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
||||||
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
||||||
|
|
||||||
export interface CrisisResource {
|
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
|
||||||
title: string;
|
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
|
||||||
number: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnerSessionSummary {
|
|
||||||
session_id: string;
|
|
||||||
persona_code: string;
|
|
||||||
persona_name: string;
|
|
||||||
session_no: number;
|
|
||||||
status: "active" | "ended";
|
|
||||||
stage: string;
|
|
||||||
turn_count: number;
|
|
||||||
learner_turn_count: number;
|
|
||||||
client_turn_count: number;
|
|
||||||
started_at: string;
|
|
||||||
ended_at: string | null;
|
|
||||||
review_ready: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnerSessionsResponse {
|
export interface LearnerSessionsResponse {
|
||||||
source: string;
|
source: string;
|
||||||
sessions: LearnerSessionSummary[];
|
sessions: LearnerSessionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDetailTurn {
|
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
|
||||||
turn_seq: number;
|
|
||||||
speaker: "learner" | "client";
|
|
||||||
stage: string;
|
|
||||||
text: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionDetailResponse {
|
export interface SessionDetailResponse {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|
@ -289,30 +219,10 @@ export interface SessionDetailResponse {
|
||||||
review_ready: boolean;
|
review_ready: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewClient {
|
export type ReviewClient = ApiSchema<"ReviewClient">;
|
||||||
name: string;
|
export type ReviewTechnique = ApiSchema<"ReviewTechnique">;
|
||||||
initial: string;
|
export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">;
|
||||||
persona: string;
|
export type ReviewNote = ApiSchema<"ReviewNote">;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewTechnique {
|
|
||||||
kind: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewNonverbalEvent {
|
|
||||||
kind: "audio" | "silence" | "pace" | "barge_in";
|
|
||||||
label: string;
|
|
||||||
detail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewNote {
|
|
||||||
author: "ai" | "instructor" | string;
|
|
||||||
tone: "good" | "watch";
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
quote?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewTurn {
|
export interface ReviewTurn {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -325,36 +235,11 @@ export interface ReviewTurn {
|
||||||
note?: ReviewNote | null;
|
note?: ReviewNote | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewPhaseSegment {
|
export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">;
|
||||||
key: string;
|
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
|
||||||
label: string;
|
export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
|
||||||
weight: number;
|
export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
||||||
}
|
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
||||||
|
|
||||||
export interface ReviewValencePoint {
|
|
||||||
t: number;
|
|
||||||
v: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewRubricRow {
|
|
||||||
name: string;
|
|
||||||
cluster: string;
|
|
||||||
ratio: number;
|
|
||||||
quality: "good" | "watch";
|
|
||||||
freq: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewPoint {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
jumpTo?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewWorksheetEvidence {
|
|
||||||
turnId: string;
|
|
||||||
speaker: "learner" | "client";
|
|
||||||
quote: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReviewWorksheetItem {
|
export interface ReviewWorksheetItem {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -608,82 +493,23 @@ export const sessionApi = {
|
||||||
stream: openSessionStream,
|
stream: openSessionStream,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminHealthStatus = "ok" | "degraded" | "down";
|
export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"];
|
||||||
|
export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">;
|
||||||
export interface AdminServiceHealth {
|
export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
|
||||||
key: string;
|
export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">;
|
||||||
name: string;
|
export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">;
|
||||||
status: AdminHealthStatus;
|
export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">;
|
||||||
detail: string;
|
|
||||||
metric: string;
|
|
||||||
load: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminHealthResponse {
|
|
||||||
status: AdminHealthStatus;
|
|
||||||
environment: string;
|
|
||||||
engine_mode: string;
|
|
||||||
services: AdminServiceHealth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsageBreakdown {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
turns: number;
|
|
||||||
tokens_in: number;
|
|
||||||
tokens_out: number;
|
|
||||||
cost_usd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsageBudget {
|
|
||||||
limit_usd: number;
|
|
||||||
used_ratio: number;
|
|
||||||
remaining_usd: number | null;
|
|
||||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsageResponse {
|
|
||||||
source: "database" | "server_session_registry";
|
|
||||||
durable: boolean;
|
|
||||||
window_days: number;
|
|
||||||
generated_at: number;
|
|
||||||
total_turns: number;
|
|
||||||
metered_turns: number;
|
|
||||||
tokens_in: number;
|
|
||||||
tokens_out: number;
|
|
||||||
cost_usd: number;
|
|
||||||
budget: AdminUsageBudget;
|
|
||||||
by_provider: AdminUsageBreakdown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
||||||
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
|
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AdminManagedUser {
|
export type AdminManagedUser = ApiSchema<"AdminUserResponse">;
|
||||||
user_id: string;
|
export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">;
|
||||||
email: string;
|
export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">;
|
||||||
display_name: string;
|
export type AdminUserPatchRequest = ApiSchema<"AdminUserPatch">;
|
||||||
role: "learner" | "teacher" | "admin";
|
export type AdminUserDeleteResponse = ApiSchema<"AdminUserDeleteResponse">;
|
||||||
cohort_ids: string[];
|
|
||||||
affiliation: string;
|
|
||||||
active_sessions: number;
|
|
||||||
created_at: number;
|
|
||||||
last_seen_at: number;
|
|
||||||
source: "database" | "server_session_registry";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersResponse {
|
|
||||||
source: "database" | "server_session_registry";
|
|
||||||
durable: boolean;
|
|
||||||
users: AdminManagedUser[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminUserCreateRequest = Pick<
|
|
||||||
AdminManagedUser,
|
|
||||||
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const adminUsersApi = {
|
export const adminUsersApi = {
|
||||||
list: () => api.get<AdminUsersResponse>("/admin/users"),
|
list: () => api.get<AdminUsersResponse>("/admin/users"),
|
||||||
|
|
@ -691,13 +517,13 @@ export const adminUsersApi = {
|
||||||
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
|
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
|
||||||
update: (
|
update: (
|
||||||
userId: string,
|
userId: string,
|
||||||
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
|
body: AdminUserPatchRequest,
|
||||||
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
|
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
deactivate: (userId: string) =>
|
deactivate: (userId: string) =>
|
||||||
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
|
apiFetch<AdminUserDeleteResponse>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -718,33 +544,8 @@ export interface TeacherSessionSummary {
|
||||||
ended_at: string | null;
|
ended_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherSafetyAlert {
|
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
||||||
id: string;
|
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
|
||||||
session_id: string;
|
|
||||||
learner_id: string;
|
|
||||||
learner_label: string;
|
|
||||||
persona_code: string;
|
|
||||||
session_no: number;
|
|
||||||
trigger_type: string;
|
|
||||||
ko_risk_level: number;
|
|
||||||
escalated: boolean;
|
|
||||||
created_at: string;
|
|
||||||
resource_title: string;
|
|
||||||
resource_number: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeacherGrowthPoint {
|
|
||||||
session_id: string;
|
|
||||||
session_no: number;
|
|
||||||
persona_code: string;
|
|
||||||
stage: string;
|
|
||||||
started_at: string;
|
|
||||||
ended_at: string | null;
|
|
||||||
score: number | null;
|
|
||||||
rapport: number | null;
|
|
||||||
technique_count: number;
|
|
||||||
watch_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeacherLearnerGrowth {
|
export interface TeacherLearnerGrowth {
|
||||||
learner_id: string;
|
learner_id: string;
|
||||||
|
|
@ -779,61 +580,30 @@ export const teacherApi = {
|
||||||
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UserProfileResponse {
|
export type UserProfileResponse = ApiSchema<"UserProfileResponse">;
|
||||||
user_id: string;
|
export type NotificationPreferences = ApiSchema<"NotificationPreferences">;
|
||||||
email: string;
|
export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">;
|
||||||
display_name: string;
|
export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">;
|
||||||
role: RoleString;
|
export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
|
||||||
cohort_ids: string[];
|
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
|
||||||
affiliation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationPreferences {
|
|
||||||
session_done: boolean;
|
|
||||||
safety_signal: boolean;
|
|
||||||
learner_progress: boolean;
|
|
||||||
product_news: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferencesResponse {
|
|
||||||
theme: "system" | "light" | "dark" | string;
|
|
||||||
voice_preset_id: string;
|
|
||||||
voice_rate: number;
|
|
||||||
notifications: NotificationPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoicePresetResponse {
|
|
||||||
id: string;
|
|
||||||
voice_id: string;
|
|
||||||
name: string;
|
|
||||||
desc: string;
|
|
||||||
persona_hint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoleString = "learner" | "teacher" | "admin" | string;
|
export type RoleString = "learner" | "teacher" | "admin" | string;
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
me: () => api.get<UserProfileResponse>("/users/me"),
|
me: () => api.get<UserProfileResponse>("/users/me"),
|
||||||
updateMe: (body: { display_name?: string; affiliation?: string }) =>
|
updateMe: (body: UserProfilePatchRequest) =>
|
||||||
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
|
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
|
||||||
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
|
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
|
||||||
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
|
updatePreferences: (body: UserPreferencesPatchRequest) =>
|
||||||
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
||||||
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AdminEngineConfigResponse {
|
export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
|
||||||
engine_mode: string;
|
export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">;
|
||||||
engine_url: string;
|
|
||||||
model: string;
|
|
||||||
updated_by: string | null;
|
|
||||||
updated_at: number | null;
|
|
||||||
durable: boolean;
|
|
||||||
source: "database" | "runtime_cache" | "runtime_default" | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminEngineApi = {
|
export const adminEngineApi = {
|
||||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||||
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
|
update: (body: AdminEngineConfigPatchRequest) =>
|
||||||
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
|
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import {
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
|
||||||
type UserDraft = Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">;
|
type UserDraft = Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">;
|
||||||
type NewUserDraft = AdminUserCreateRequest;
|
type NewUserDraft = Required<Pick<AdminUserCreateRequest, "email" | "display_name" | "role">> &
|
||||||
|
Pick<UserDraft, "affiliation" | "cohort_ids">;
|
||||||
const MAX_RENDERED_USERS = 40;
|
const MAX_RENDERED_USERS = 40;
|
||||||
|
|
||||||
const EMPTY_NEW_USER: NewUserDraft = {
|
const EMPTY_NEW_USER: NewUserDraft = {
|
||||||
|
|
|
||||||
|
|
@ -554,7 +554,7 @@ export default function Professor() {
|
||||||
<Badge tone={personaReviewTone(persona.status)}>
|
<Badge tone={personaReviewTone(persona.status)}>
|
||||||
{personaReviewStatusLabel(persona.status)}
|
{personaReviewStatusLabel(persona.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>{formatDateTime(persona.created_at)}</span>
|
<span>{formatDateTime(persona.created_at ?? null)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-persona__actions">
|
<div className="pf-persona__actions">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,22 @@ function SettingsSkeleton({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||||
|
session_done: true,
|
||||||
|
safety_signal: true,
|
||||||
|
learner_progress: true,
|
||||||
|
product_news: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function completeNotificationPreferences(
|
||||||
|
preferences?: NotificationPreferences | null,
|
||||||
|
): NotificationPreferences {
|
||||||
|
return {
|
||||||
|
...DEFAULT_NOTIFICATION_PREFERENCES,
|
||||||
|
...preferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const role = user?.role ?? "learner";
|
const role = user?.role ?? "learner";
|
||||||
|
|
@ -313,11 +329,12 @@ export default function Settings() {
|
||||||
|
|
||||||
const savePreferences = async (key: string) => {
|
const savePreferences = async (key: string) => {
|
||||||
if (!preferencesReady || !preferences) return;
|
if (!preferencesReady || !preferences) return;
|
||||||
|
const notifications = completeNotificationPreferences(preferences.notifications);
|
||||||
const next = await userApi.updatePreferences({
|
const next = await userApi.updatePreferences({
|
||||||
theme: dark ? "dark" : "light",
|
theme: dark ? "dark" : "light",
|
||||||
voice_preset_id: preferences.voice_preset_id,
|
voice_preset_id: preferences.voice_preset_id,
|
||||||
voice_rate: preferences.voice_rate,
|
voice_rate: preferences.voice_rate,
|
||||||
notifications: preferences.notifications,
|
notifications,
|
||||||
});
|
});
|
||||||
setPreferences(next);
|
setPreferences(next);
|
||||||
flashSaved(key);
|
flashSaved(key);
|
||||||
|
|
@ -348,7 +365,7 @@ export default function Settings() {
|
||||||
setPreferences((cur) => cur ? ({
|
setPreferences((cur) => cur ? ({
|
||||||
...cur,
|
...cur,
|
||||||
notifications: {
|
notifications: {
|
||||||
...cur.notifications,
|
...completeNotificationPreferences(cur.notifications),
|
||||||
[key]: value,
|
[key]: value,
|
||||||
},
|
},
|
||||||
}) : cur);
|
}) : cur);
|
||||||
|
|
@ -362,13 +379,12 @@ export default function Settings() {
|
||||||
: false;
|
: false;
|
||||||
const engineStorageLabel = !engineConfig
|
const engineStorageLabel = !engineConfig
|
||||||
? "확인 중"
|
? "확인 중"
|
||||||
: engineConfig.source === "unconfigured"
|
: engineConfig.durable
|
||||||
? "미구성"
|
|
||||||
: engineConfig.durable
|
|
||||||
? "DB 저장"
|
? "DB 저장"
|
||||||
: "런타임 적용";
|
: "런타임 적용";
|
||||||
const engineService = adminHealth?.services.find((service) => service.key === "engine");
|
const engineService = adminHealth?.services.find((service) => service.key === "engine");
|
||||||
const engineServiceStatus = engineService?.status ?? "degraded";
|
const engineServiceStatus = engineService?.status ?? "degraded";
|
||||||
|
const notificationPreferences = completeNotificationPreferences(preferences?.notifications);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
|
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
|
||||||
|
|
@ -828,7 +844,7 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={Boolean(
|
checked={Boolean(
|
||||||
preferences.notifications[item.id as keyof NotificationPreferences],
|
notificationPreferences[item.id as keyof NotificationPreferences],
|
||||||
)}
|
)}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
updateNotification(item.id as keyof NotificationPreferences, next)
|
updateNotification(item.id as keyof NotificationPreferences, next)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Vignette Handoff
|
# Vignette Handoff
|
||||||
|
|
||||||
> Updated: 2026-06-27 17:25 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
> Updated: 2026-06-27 18:05 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
- 최신 앱 배포 소스 commit: `0ec266a` (`동의 게이트와 런타임 안정화`)
|
- 최신 앱 배포 소스 commit: `0ec266a` (`동의 게이트와 런타임 안정화`)
|
||||||
- 최신 Cloudflare Pages production deploy: `12da49f3`, source `0ec266a`, branch `main`.
|
- 최신 Cloudflare Pages production deploy: `12da49f3`, source `0ec266a`, branch `main`.
|
||||||
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
||||||
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `125 passed`
|
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `130 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 없으면 거부.
|
||||||
- C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속.
|
- C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속.
|
||||||
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
|
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
||||||
- H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속.
|
- H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속.
|
||||||
- RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed.
|
- RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed.
|
||||||
|
- 계약 SSOT 2차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. 검증: `npm run check:api-types`, `npm run typecheck`.
|
||||||
|
- 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed.
|
||||||
|
- Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다.
|
||||||
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
|
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
|
||||||
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
|
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
|
||||||
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@
|
||||||
<div class="phase-rail" id="phase-rail" aria-label="Phase 진행 추정"></div>
|
<div class="phase-rail" id="phase-rail" aria-label="Phase 진행 추정"></div>
|
||||||
<div class="mstrip" aria-label="검증 지표">
|
<div class="mstrip" aria-label="검증 지표">
|
||||||
<span class="mchip good">Full Playwright <b>72</b> 통과</span>
|
<span class="mchip good">Full Playwright <b>72</b> 통과</span>
|
||||||
<span class="mchip good">Backend pytest <b>122</b> 통과</span>
|
<span class="mchip good">Backend pytest <b>130</b> 통과</span>
|
||||||
<span class="mchip good">Layout 시각게이트 <b>7/7</b> accept</span>
|
<span class="mchip good">Layout 시각게이트 <b>7/7</b> accept</span>
|
||||||
<span class="mchip">Pages prod <b>12da49f3</b></span>
|
<span class="mchip">Pages prod <b>12da49f3</b></span>
|
||||||
<span class="mchip good">Public API <b>prod OK</b></span>
|
<span class="mchip good">Public API <b>prod OK</b></span>
|
||||||
|
|
@ -596,7 +596,7 @@
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="done" data-cat="보안·PII" data-owner="0">
|
<article class="scard" data-status="done" data-cat="보안·PII" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사 + 동의 게이트</span><span class="scard-sum">metadata-only llm_call_log, learner consent_at 하드게이트. 전체 API 125.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사 + 동의 게이트</span><span class="scard-sum">metadata-only llm_call_log, learner consent_at 하드게이트. 전체 API 125.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
<div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 이번 패스에서 <code>app_user.consent_at</code> 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 125 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div>
|
<div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 이번 패스에서 <code>app_user.consent_at</code> 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 최신 130 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="planned" data-cat="리팩터·정리" data-owner="0">
|
<article class="scard" data-status="planned" data-cat="리팩터·정리" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">MED M1~M3</span><span class="scard-mid"><span class="scard-title">비언어 임상이벤트(M1)·다회기 케이스 아크(M2)·SSO claim/식별자/감사(M3)</span><span class="scard-sum">M1/M2 1차 완료. M3는 cohort map + provider external_id 1차 완료, 기관 SAML/거버넌스는 남음.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">MED M1~M3</span><span class="scard-mid"><span class="scard-title">비언어 임상이벤트(M1)·다회기 케이스 아크(M2)·SSO claim/식별자/감사(M3)</span><span class="scard-sum">M1/M2 1차 완료. M3는 cohort map + provider external_id 1차 완료, 기관 SAML/거버넌스는 남음.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
|
|
@ -631,12 +631,12 @@
|
||||||
<div class="scard-body"><div class="kv"><b>증상</b><p>라이브 회기에서 내담자(민재)가 "저는 Claude Code인데… Vignette 코드베이스… git status…"로 캐릭터를 깨고 개발 맥락 노출. 원인: ENGINE_MODE=claude_cli가 로컬 claude(=Claude Code)를 호출하는데 게이트웨이가 페르소나를 <code>--append-system-prompt</code>로 코딩 어시스턴트 정체성에 <b>덧붙이고</b>, cwd=repo라 CLAUDE.md·git status·env가 시스템프롬프트에 포함됨.</p></div><div class="kv"><b>수정·검증</b><p><code>engine_gateway/gateway.py</code>: <code>--append-system-prompt</code>→<code>--system-prompt</code>(페르소나만 <b>교체</b> 주입) + <code>--exclude-dynamic-system-prompt-sections</code>(cwd/env/git/메모리 섹션 제거). 라이브: 깨던 입력 그대로 → 민재 완벽 인캐릭터, 누출 0. 게이트웨이 7/7 + 전체 API 119 passed.</p></div></div>
|
<div class="scard-body"><div class="kv"><b>증상</b><p>라이브 회기에서 내담자(민재)가 "저는 Claude Code인데… Vignette 코드베이스… git status…"로 캐릭터를 깨고 개발 맥락 노출. 원인: ENGINE_MODE=claude_cli가 로컬 claude(=Claude Code)를 호출하는데 게이트웨이가 페르소나를 <code>--append-system-prompt</code>로 코딩 어시스턴트 정체성에 <b>덧붙이고</b>, cwd=repo라 CLAUDE.md·git status·env가 시스템프롬프트에 포함됨.</p></div><div class="kv"><b>수정·검증</b><p><code>engine_gateway/gateway.py</code>: <code>--append-system-prompt</code>→<code>--system-prompt</code>(페르소나만 <b>교체</b> 주입) + <code>--exclude-dynamic-system-prompt-sections</code>(cwd/env/git/메모리 섹션 제거). 라이브: 깨던 입력 그대로 → 민재 완벽 인캐릭터, 누출 0. 게이트웨이 7/7 + 전체 API 119 passed.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="doing" data-cat="리팩터·정리" data-owner="0">
|
<article class="scard" data-status="doing" data-cat="리팩터·정리" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">REFACTOR 1 1차 DONE</span><span class="scard-mid"><span class="scard-title">턴 처리 파이프라인 중복(REST/WS) 제거</span><span class="scard-sum"><code>turn_runtime.py</code>로 세션 로드·권한·턴 append·상태 갱신 공통화.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">REFACTOR 1 2차 DONE</span><span class="scard-mid"><span class="scard-title">턴 처리 파이프라인 중복(REST/WS) 제거</span><span class="scard-sum"><code>turn_runtime.py</code>로 세션 로드·권한·턴 append·상태 갱신·안전 이벤트 finalize를 공통화.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
<div class="scard-body"><div class="kv"><b>현재</b><p><code>app/turn_runtime.py</code>를 추가해 <code>load_owned_session</code>, <code>record_completed_turn</code>, <code>append_completed_turn</code>, <code>update_session_state</code>, <code>record_safety_event</code>를 공통화했다. <code>routes/sessions.py</code>의 submit/stream과 <code>routes/voice.py</code>가 같은 저장·위기 이벤트 경로를 사용하고, stream fast-loop 평가 누락도 닫았다. voice 경로도 세션 recall cache를 사용한다. 검증: 관련 회귀 45 passed, 전체 API 119 passed, gateway 7 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>더 큰 <code>finalize_turn</code>/<code>SessionRepository</code> 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.</p></div></div>
|
<div class="scard-body"><div class="kv"><b>현재</b><p><code>app/turn_runtime.py</code>를 추가해 <code>load_owned_session</code>, <code>record_completed_turn</code>, <code>append_completed_turn</code>, <code>update_session_state</code>, <code>record_safety_event</code>를 공통화했다. 이번 패스에서 <code>finalize_completed_turn</code>을 추가해 <code>record_completed_turn</code> + <code>record_safety_event</code> 호출쌍을 REST submit/stream과 voice WS 라우트에서 제거했다. stream fast-loop 평가 누락, voice 세션 recall cache, visible_to/evaluator turn 분리는 보존.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_session_turn_persistence.py app/test_voice_ws.py engine_gateway/test_gateway_model.py app/test_phase3_artifact_checker.py app/test_dataset_export.py -q</code> 40 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>더 큰 <code>finalize_turn</code>/<code>SessionRepository</code> 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="doing" data-cat="리팩터·정리" data-owner="0">
|
<article class="scard" data-status="doing" data-cat="리팩터·정리" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">REFACTOR 2 1차 DONE</span><span class="scard-mid"><span class="scard-title">백엔드 Pydantic ↔ 프론트 <code>lib/api.ts</code> 계약 SSOT화</span><span class="scard-sum">OpenAPI→<code>api.gen.ts</code> 생성 + stale 체크 게이트. <code>crisis_kind</code>/<code>end_state</code> 드리프트 해소.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">REFACTOR 2 2차 DONE</span><span class="scard-mid"><span class="scard-title">백엔드 Pydantic ↔ 프론트 <code>lib/api.ts</code> 계약 SSOT화</span><span class="scard-sum">OpenAPI→<code>api.gen.ts</code> 생성 + stale 체크 게이트. 수기 DTO 일부를 생성 타입 alias로 축소.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/api/scripts/export-openapi.py</code>와 <code>apps/web/scripts/generate-api-types.mjs</code>로 FastAPI OpenAPI를 <code>apps/web/src/lib/api.gen.ts</code>에 생성한다. <code>npm run check:api-types</code>가 재생성 누락을 잡고, <code>TurnResponse</code>/<code>SessionEndResponse</code>는 생성 타입 alias를 사용한다. 기존 누락 필드 <code>crisis_kind</code>, <code>end_state</code>, <code>crisis_resource</code>, <code>safety_alerts</code>, <code>learner_growth</code>, <code>PersonaDraftPayload</code>는 생성 타입에 포함된다. 검증: <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>pytest app/</code> 119 passed, <code>pytest engine_gateway/</code> 7 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>나머지 수기 미러 타입(<code>PersonaSummary</code>, review/admin/teacher/user DTO 등)을 생성 타입 alias로 점진 전환하고, 실제 CI 워크플로에 <code>npm run check:api-types</code>를 연결해야 한다.</p></div></div>
|
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/api/scripts/export-openapi.py</code>와 <code>apps/web/scripts/generate-api-types.mjs</code>로 FastAPI OpenAPI를 <code>apps/web/src/lib/api.gen.ts</code>에 생성한다. <code>npm run check:api-types</code>가 재생성 누락을 잡고, <code>TurnResponse</code>/<code>SessionEndResponse</code>는 생성 타입 alias를 사용한다. 이번 패스에서 <code>MeResponse</code>, <code>AuthConfigResponse</code>, <code>ConsentResponse</code>, <code>PersonaSummary</code>, <code>PersonaReviewSummary</code>, admin/user/engine DTO, review leaf DTO, <code>CrisisResourceResponse</code>, teacher safety/growth leaf DTO를 <code>ApiSchema<...></code> alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다.</p></div><div class="kv"><b>검증</b><p><code>npm run check:api-types</code>, <code>npm run typecheck</code> passed. Leaf DTO residual check에서 전환 대상은 <code>ApiSchema</code> alias로 확인.</p></div><div class="kv k-warn"><b>잔여</b><p>aggregate 응답(<code>SessionReviewResponse</code>, <code>TeacherDashboardResponse</code>, worksheet/review 배열 등)은 generated 배열 optional 차이를 UI fallback으로 먼저 흡수한 뒤 전환한다. 실제 CI 워크플로에 <code>npm run check:api-types</code>를 연결하는 작업도 남아 있다.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="done" data-cat="리팩터·정리" data-owner="0">
|
<article class="scard" data-status="done" data-cat="리팩터·정리" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 3 일부</span><span class="scard-mid"><span class="scard-title">죽은 확장점/계약 정리 — LogHook·tier 제거 적용</span><span class="scard-sum">caller 0 타입·분기·export 제거. 잔여는 패치 문서에 스테이징. 전체 API 119.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 3 일부</span><span class="scard-mid"><span class="scard-title">죽은 확장점/계약 정리 — LogHook·tier 제거 적용</span><span class="scard-sum">caller 0 타입·분기·export 제거. 잔여는 패치 문서에 스테이징. 전체 API 119.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
|
|
@ -721,9 +721,9 @@
|
||||||
<div class="tab-panel" id="panel-p1" role="tabpanel" aria-labelledby="tab-p1" hidden>
|
<div class="tab-panel" id="panel-p1" role="tabpanel" aria-labelledby="tab-p1" hidden>
|
||||||
<div class="detail-head"><div><h3>Phase 1 MVP 코어 루프 상세</h3><p>목표는 "P1 내담자와 실제 상담 1회 완주 후 피드백을 받는 공개 시연 가능 상태"다.</p></div><div class="mini-metrics"><span>우선순위: P0</span><span>범위: 텍스트+음성 1턴</span><span>완료보고: E2E 증거 후</span></div></div>
|
<div class="detail-head"><div><h3>Phase 1 MVP 코어 루프 상세</h3><p>목표는 "P1 내담자와 실제 상담 1회 완주 후 피드백을 받는 공개 시연 가능 상태"다.</p></div><div class="mini-metrics"><span>우선순위: P0</span><span>범위: 텍스트+음성 1턴</span><span>완료보고: E2E 증거 후</span></div></div>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>LLMEngine 정규화 계약 고정</b><p>gateway SSE <code>token/done/error</code>와 generate cost 계약은 회귀화했다. claude_cli와 Messages API 폴백 동일성 검증은 남아 있다.</p></div><div><b>산출물</b><p><code>token</code>, <code>done</code>, <code>error</code>, cost 이벤트 계약과 contract test</p></div><div><b>검증</b><p>stream error 미저장 + stream/generate client turn telemetry 저장</p></div></div>
|
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>LLMEngine 정규화 계약 고정</b><p>gateway SSE <code>token/done/error</code>와 generate cost 계약은 회귀화했다. 이번 패스에서 <code>/v1/stream</code> route 자체가 <code>event: token</code>, <code>event: done</code>, <code>event: error</code> 프레임을 내는지 fake session으로 직접 고정했다. claude_cli와 Messages API 폴백 동일성 검증은 라이브 키/환경 의존이라 남아 있다.</p></div><div><b>산출물</b><p><code>engine_gateway/test_gateway_model.py</code> stream frame contract tests</p></div><div><b>검증</b><p><code>pytest engine_gateway/test_gateway_model.py -q</code> 9 passed; focused backend 묶음 40 passed.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>상주 <code>claude -p</code> 엔진풀 실증</b><p><code>session_id</code>가 살아 있는 gateway session을 재사용하고 missing session은 ephemeral로 닫히는 구조를 회귀화했고, live 게이트웨이(9099)로 실측까지 마쳤다. <code>probe-engine-gateway.py</code>가 단일 session_id로 2회 reused stream을 돌려 TTFT 1667–4199ms, cost 누적 0.068→0.123(turns 1→2), engine=claude_p, model=opus-4-8을 측정했다.</p></div><div><b>산출물</b><p><code>engine_gateway/test_gateway_model.py</code>, <code>scripts/probe-engine-gateway.py</code>, live probe JSON</p></div><div><b>검증</b><p>session reuse 7 tests OK; live probe 2 reused streams, TTFT/cost 실측, session_id 재사용 확인</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>상주 <code>claude -p</code> 엔진풀 실증</b><p><code>session_id</code>가 살아 있는 gateway session을 재사용하고 missing session은 ephemeral로 닫히는 구조를 회귀화했고, live 게이트웨이(9099)로 실측까지 마쳤다. <code>probe-engine-gateway.py</code>가 단일 session_id로 2회 reused stream을 돌려 TTFT 1667–4199ms, cost 누적 0.068→0.123(turns 1→2), engine=claude_p, model=opus-4-8을 측정했다.</p></div><div><b>산출물</b><p><code>engine_gateway/test_gateway_model.py</code>, <code>scripts/probe-engine-gateway.py</code>, live probe JSON</p></div><div><b>검증</b><p>session reuse 7 tests OK; live probe 2 reused streams, TTFT/cost 실측, session_id 재사용 확인</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>마스킹 게이트 + LLM call audit</b><p>current turn, recall, pinned fact, recent turns가 Presidio/가명처리 후에만 engine request로 들어가게 막았다. 외부 LLM 호출은 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency metadata만 남기고 prompt/completion 본문은 저장하지 않는다.</p></div><div><b>산출물</b><p><code>app/test_orchestrator_masking.py</code>, <code>app/test_evaluation_persistence.py</code>, <code>session_persistence.record_llm_call_audit</code></p></div><div><b>검증</b><p>phone/email/RRN raw 값이 generate/stream payload에 없음, 감사 payload 본문 미포함, 관련 회귀 27 passed, 전체 API 122 passed, 로컬 live <code>/turn</code> smoke 후 <code>audit.llm_call_log</code> 8→11</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>마스킹 게이트 + LLM call audit</b><p>current turn, recall, pinned fact, recent turns가 Presidio/가명처리 후에만 engine request로 들어가게 막았다. 외부 LLM 호출은 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency metadata만 남기고 prompt/completion 본문은 저장하지 않는다.</p></div><div><b>산출물</b><p><code>app/test_orchestrator_masking.py</code>, <code>app/test_evaluation_persistence.py</code>, <code>session_persistence.record_llm_call_audit</code></p></div><div><b>검증</b><p>phone/email/RRN raw 값이 generate/stream payload에 없음, 감사 payload 본문 미포함, 관련 회귀 27 passed, 전체 API 최신 130 passed, 로컬 live <code>/turn</code> smoke 후 <code>audit.llm_call_log</code> 8→11</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>P1 상담 완주 플로우</b><p>통제된 UI E2E로 P1 선택, SSE 상담, 종료, 회기말 리뷰 피드백을 한 번에 통과시켰다. 실제 운영 로그인 proof는 P0 blocker와 함께 남아 있다.</p></div><div><b>산출물</b><p><code>e2e/session-mvp.spec.ts</code>, turn telemetry 저장 경로</p></div><div><b>검증</b><p><code>chromium-single-run</code> P1 MVP UI E2E 1 passed</p></div></div>
|
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>P1 상담 완주 플로우</b><p>통제된 UI E2E로 P1 선택, SSE 상담, 종료, 회기말 리뷰 피드백을 한 번에 통과시켰다. 실제 운영 로그인 proof는 P0 blocker와 함께 남아 있다.</p></div><div><b>산출물</b><p><code>e2e/session-mvp.spec.ts</code>, turn telemetry 저장 경로</p></div><div><b>검증</b><p><code>chromium-single-run</code> P1 MVP UI E2E 1 passed</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>저항 엔진 정량 검증</b><p>공감 발화와 조언점프 발화 5턴 세트를 순수 상태머신 회귀 테스트로 비교했다. DB 쿼리 기반 운영 증거는 남아 있다.</p></div><div><b>산출물</b><p><code>app/test_state_machine_resistance.py</code></p></div><div><b>검증</b><p>공감 시 탐색 전이/개방도 상승, 조언점프 시 라포 단계 유지/개방도 0</p></div></div>
|
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>저항 엔진 정량 검증</b><p>공감 발화와 조언점프 발화 5턴 세트를 순수 상태머신 회귀 테스트로 비교했다. DB 쿼리 기반 운영 증거는 남아 있다.</p></div><div><b>산출물</b><p><code>app/test_state_machine_resistance.py</code></p></div><div><b>검증</b><p>공감 시 탐색 전이/개방도 상승, 조언점프 시 라포 단계 유지/개방도 0</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>음성 왕복 1턴 PoC</b><p>Session UI 마이크 버튼 경로에서 synthetic <code>getUserMedia</code>/<code>MediaRecorder</code>로 실제 <code>/voice/ws</code>를 열고 audio_start, binary chunk, audio_end, transcript, reply, TTS 재생 상태까지 검증했다. 물리 마이크, 공개 WSS, live STT/TTS provider 증거는 아직 별도다.</p></div><div><b>산출물</b><p><code>apps/web/e2e/voice-success.spec.ts</code>, controlled OpenAI/engine providers, browser voice UI probe</p></div><div><b>검증</b><p><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> 2 passed</p></div></div>
|
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>음성 왕복 1턴 PoC</b><p>Session UI 마이크 버튼 경로에서 synthetic <code>getUserMedia</code>/<code>MediaRecorder</code>로 실제 <code>/voice/ws</code>를 열고 audio_start, binary chunk, audio_end, transcript, reply, TTS 재생 상태까지 검증했다. 물리 마이크, 공개 WSS, live STT/TTS provider 증거는 아직 별도다.</p></div><div><b>산출물</b><p><code>apps/web/e2e/voice-success.spec.ts</code>, controlled OpenAI/engine providers, browser voice UI probe</p></div><div><b>검증</b><p><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> 2 passed</p></div></div>
|
||||||
|
|
@ -755,9 +755,9 @@
|
||||||
<div class="tab-panel" id="panel-p3" role="tabpanel" aria-labelledby="tab-p3" hidden>
|
<div class="tab-panel" id="panel-p3" role="tabpanel" aria-labelledby="tab-p3" hidden>
|
||||||
<div class="detail-head"><div><h3>Phase 3 교육용 파일럿 / 효과성 상세</h3><p>IRB 승인 게이트가 아니라 교육 활용 파일럿과 데이터/개인정보 게이트로 관리한다.</p></div><div class="mini-metrics"><span>주차: 18-20+</span><span>표본: 20명</span><span>first measure</span></div></div>
|
<div class="detail-head"><div><h3>Phase 3 교육용 파일럿 / 효과성 상세</h3><p>IRB 승인 게이트가 아니라 교육 활용 파일럿과 데이터/개인정보 게이트로 관리한다.</p></div><div class="mini-metrics"><span>주차: 18-20+</span><span>표본: 20명</span><span>first measure</span></div></div>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>20명 교육용 파일럿 운영</b><p>20명 파일럿을 실행하기 위한 roster, consent, session completion evidence gate를 문서화했다. 실제 참여자 완주 증거는 아직 필요하다.</p></div><div><b>산출물</b><p><code>docs/phase3/pilot-runbook.md</code>, evidence root layout, operator gates</p></div><div><b>검증</b><p><code>scripts/check-phase3-artifacts.py --help</code>; 실제 evidence root validation은 파일럿 시점에 실행</p></div></div>
|
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>20명 교육용 파일럿 운영</b><p>20명 파일럿을 실행하기 위한 roster, consent, session completion evidence gate를 문서화했다. checker는 CSV enum(<code>withdrawal_state</code>, <code>completion_state</code>, <code>timepoint</code>, withdrawal <code>scope/status</code>)을 검증한다. 실제 참여자 완주 증거는 아직 필요하다.</p></div><div><b>산출물</b><p><code>docs/phase3/pilot-runbook.md</code>, evidence root layout, operator gates, <code>scripts/check-phase3-artifacts.py</code></p></div><div><b>검증</b><p><code>python scripts/check-phase3-artifacts.py --help</code>, <code>pytest app/test_phase3_artifact_checker.py -q</code> 5 passed. 실제 evidence root validation은 파일럿 시점에 실행.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>효과성/KPI 측정</b><p>SUS, 자기효능감, κ/ICC, 환각률, 피드백 타당도 기준과 report schema를 고정했다. 실제 수치 충족 여부는 파일럿 데이터 이후 판단한다.</p></div><div><b>산출물</b><p><code>docs/phase3/kpi-measurement-plan.md</code>, KPI report schema</p></div><div><b>검증</b><p>checker가 <code>prepost_measures</code>, <code>sus_responses</code>, <code>kpi_report.json</code> evidence를 요구</p></div></div>
|
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>효과성/KPI 측정</b><p>SUS, 자기효능감, κ/ICC, 환각률, 피드백 타당도 기준과 report schema를 고정했다. checker는 KPI report top-level keys와 metric 필수 필드(<code>value/threshold/pass/method/source_files</code> 등)를 error로 검증한다. 실제 수치 충족 여부는 파일럿 데이터 이후 판단한다.</p></div><div><b>산출물</b><p><code>docs/phase3/kpi-measurement-plan.md</code>, KPI report schema, checker tests</p></div><div><b>검증</b><p>checker가 <code>prepost_measures</code>, <code>sus_responses</code>, <code>kpi_report.json</code> evidence와 metric shape를 요구.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>재귀학습 데이터셋 1차 산출</b><p>masked-text JSONL dry-run exporter, PII scan, κ/ICC 계산, approved export 차단 게이트를 구현했다. 실제 approved export와 데이터 steward 검수는 남아 있다.</p></div><div><b>산출물</b><p><code>scripts/export-recursive-dataset.py</code>, <code>app.services.dataset_export</code>, <code>docs/phase3/dataset-export-manifest.md</code></p></div><div><b>검증</b><p><code>pytest app/test_dataset_export.py</code> 5 passed, DB dry-run 5 rows → <code>technical_dry_run</code> manifest + sha256 생성, PII finding 시 approved 불가</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>재귀학습 데이터셋 1차 산출</b><p>masked-text JSONL dry-run exporter, PII scan, κ/ICC 계산, approved export 차단 게이트를 구현했다. 이번 패스에서 artifact checker도 approved manifest false-positive를 막도록 강화했다. 실제 approved export와 데이터 steward 검수는 남아 있다.</p></div><div><b>산출물</b><p><code>scripts/export-recursive-dataset.py</code>, <code>app.services.dataset_export</code>, <code>docs/phase3/dataset-export-manifest.md</code>, <code>app/test_phase3_artifact_checker.py</code></p></div><div><b>검증</b><p><code>pytest app/test_dataset_export.py app/test_phase3_artifact_checker.py -q</code> 10 passed, DB dry-run 5 rows → <code>technical_dry_run</code> manifest + sha256 생성, approved manifest는 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 요구.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>개인정보/동의 감사</b><p>동의, 철회, privacy audit, 해외 전송 고지 evidence checklist를 만들었다. 실제 법무/개인정보 검토 서명은 파일럿 전 gate로 남아 있다.</p></div><div><b>산출물</b><p><code>docs/phase3/privacy-consent-audit.md</code>, withdrawal log contract</p></div><div><b>검증</b><p>동의 철회 후 session/export 제외 evidence를 checker 입력으로 요구</p></div></div>
|
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>개인정보/동의 감사</b><p>동의, 철회, privacy audit, 해외 전송 고지 evidence checklist를 만들었다. 실제 법무/개인정보 검토 서명은 파일럿 전 gate로 남아 있다.</p></div><div><b>산출물</b><p><code>docs/phase3/privacy-consent-audit.md</code>, withdrawal log contract</p></div><div><b>검증</b><p>동의 철회 후 session/export 제외 evidence를 checker 입력으로 요구</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -781,7 +781,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||||
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>122 passed</td></tr>
|
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>130 passed</td></tr>
|
||||||
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
|
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
|
||||||
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py -q</code> / <code>session-review.spec.ts</code></td><td>14 passed; session review exposes transcript-grounded <code>caseWorksheet</code> and UI evidence jump</td></tr>
|
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py -q</code> / <code>session-review.spec.ts</code></td><td>14 passed; session review exposes transcript-grounded <code>caseWorksheet</code> and UI evidence jump</td></tr>
|
||||||
<tr><td>M3 auth claim mapping</td><td><code>python -m pytest app/test_auth_providers.py -q</code></td><td>22 passed; Google/SAML cohort maps, provider external_id, provider error reason covered</td></tr>
|
<tr><td>M3 auth claim mapping</td><td><code>python -m pytest app/test_auth_providers.py -q</code></td><td>22 passed; Google/SAML cohort maps, provider external_id, provider error reason covered</td></tr>
|
||||||
|
|
@ -808,7 +808,7 @@
|
||||||
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
|
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
|
||||||
<tr><td>Resistance engine</td><td><code>app.test_state_machine_resistance</code></td><td>2 tests OK; empathy vs advice-jump curve fixed</td></tr>
|
<tr><td>Resistance engine</td><td><code>app.test_state_machine_resistance</code></td><td>2 tests OK; empathy vs advice-jump curve fixed</td></tr>
|
||||||
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Phase 3 artifact gates</td><td><code>python scripts\check-phase3-artifacts.py --help</code></td><td>Passed; runbook, KPI plan, dataset manifest, privacy audit checker documented</td></tr>
|
<tr><td>Phase 3 artifact gates</td><td><code>python scripts\check-phase3-artifacts.py --help</code> + <code>pytest app/test_phase3_artifact_checker.py -q</code></td><td>Passed; checker now enforces CSV enums, KPI metric required fields, approved export PII/agreement/consent/withdrawal/file-hash gates. Actual pilot evidence still external.</td></tr>
|
||||||
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
|
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td><code>12da49f3</code>, source <code>0ec266a</code></td></tr>
|
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td><code>12da49f3</code>, source <code>0ec266a</code></td></tr>
|
||||||
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login</code></td><td><code>index-DEgzFAUm.js</code>, <code>index-BGRtWhu_.css</code></td></tr>
|
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login</code></td><td><code>index-DEgzFAUm.js</code>, <code>index-BGRtWhu_.css</code></td></tr>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
## Phase 3 파일럿 게이트 (실참여자 필요)
|
## Phase 3 파일럿 게이트 (실참여자 필요)
|
||||||
|
|
||||||
- [ ] 20명 교육용 파일럿 운영 / 효과성·KPI 측정(SUS·자기효능감·κ/ICC·환각률) / 재귀학습 데이터셋 approved 산출 / 개인정보·동의 감사. 문서/checker와 dry-run exporter는 준비됨(`docs/phase3/*`, `scripts/check-phase3-artifacts.py`, `scripts/export-recursive-dataset.py`), 실제 approved evidence는 파일럿·steward/legal 검수·IAA 통과 시점.
|
- [ ] 20명 교육용 파일럿 운영 / 효과성·KPI 측정(SUS·자기효능감·κ/ICC·환각률) / 재귀학습 데이터셋 approved 산출 / 개인정보·동의 감사. 문서/checker와 dry-run exporter는 준비됨(`docs/phase3/*`, `scripts/check-phase3-artifacts.py`, `scripts/export-recursive-dataset.py`). 2026-06-27 checker 강화로 CSV enum, KPI metric required fields, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256 false-positive 차단을 추가했다. 실제 approved evidence는 파일럿·steward/legal 검수·IAA 통과 시점.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
- P1 서연 음성 아트 PoC: Higgs v3 무참조 synthetic seed + 5개 정서/속도 변주(mp3/wav) 생성. 실존 reference voice 미사용. `/voice/ws`에는 dev-only sample TTS provider로 연결했고 live text_turn smoke에서 `p1-sample-poc` binary 20 chunks/78,573 bytes 수신 확인. 이 provider는 TTS override만 담당한다(마이크/STT는 OpenAI 키 필요). 프로덕션 탑재는 라이선스/권리 결정 전 금지.
|
- P1 서연 음성 아트 PoC: Higgs v3 무참조 synthetic seed + 5개 정서/속도 변주(mp3/wav) 생성. 실존 reference voice 미사용. `/voice/ws`에는 dev-only sample TTS provider로 연결했고 live text_turn smoke에서 `p1-sample-poc` binary 20 chunks/78,573 bytes 수신 확인. 이 provider는 TTS override만 담당한다(마이크/STT는 OpenAI 키 필요). 프로덕션 탑재는 라이선스/권리 결정 전 금지.
|
||||||
- 부수: RLS smoke 스크립트 cleanup RLS 컨텍스트 버그 수정, `vignette_app`(NOBYPASSRLS) dev 역할 프로비저닝.
|
- 부수: RLS smoke 스크립트 cleanup RLS 컨텍스트 버그 수정, `vignette_app`(NOBYPASSRLS) dev 역할 프로비저닝.
|
||||||
- 평가 SSOP 2차: `TurnRecord.evaluation`을 `app.feedback_scores`/`turn_technique`/`turn_client_state`/`supervisor_comment`/`alternative_utterance`로 정규화 적재·복원하고, submit/stream/voice 저장 경로를 `app.turn_runtime`으로 공통화했다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. `app.turns` insert는 `RETURNING id` 확인으로 silent success 제거. **검증: `pytest app/test_evaluation_persistence.py -q` 6 passed, 관련 회귀 45 passed, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.**
|
- 평가 SSOP 2차: `TurnRecord.evaluation`을 `app.feedback_scores`/`turn_technique`/`turn_client_state`/`supervisor_comment`/`alternative_utterance`로 정규화 적재·복원하고, submit/stream/voice 저장 경로를 `app.turn_runtime`으로 공통화했다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. `app.turns` insert는 `RETURNING id` 확인으로 silent success 제거. **검증: `pytest app/test_evaluation_persistence.py -q` 6 passed, 관련 회귀 45 passed, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.**
|
||||||
- 계약 SSOT 1차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 추가했다. `TurnResponse`/`SessionEndResponse`는 생성 타입 alias를 사용해 기존 `crisis_kind`/`end_state` 드리프트를 닫았다. 나머지 수기 미러 DTO 전환과 CI 연결은 후속. **검증: `npm run check:api-types`, `npm run typecheck`, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.**
|
- 계약 SSOT 2차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. **검증: `npm run check:api-types`, `npm run typecheck`.**
|
||||||
- 위기게이트 109 1차: 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 `crisis_resource(109)`와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `app.safety_events` detail 적재와 교수자 대시보드 안전 알림 큐까지 연결했다. 안전 이벤트 DB insert payload도 회귀화했다. 임상 문안, 실시간 push/메일, 지역 자원 확장은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 20 passed, `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed.**
|
- 위기게이트 109 1차: 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 `crisis_resource(109)`와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `app.safety_events` detail 적재와 교수자 대시보드 안전 알림 큐까지 연결했다. 안전 이벤트 DB insert payload도 회귀화했다. 임상 문안, 실시간 push/메일, 지역 자원 확장은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 20 passed, `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed.**
|
||||||
- 교수자 성장 추적 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
- 교수자 성장 추적 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
||||||
- 페르소나 저작 CRUD 1차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. P4~P7 실제 임상 콘텐츠 입력은 후속. **검증: `pytest app/test_persona_review.py -q` 19 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
- 페르소나 저작 CRUD 1차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. P4~P7 실제 임상 콘텐츠 입력은 후속. **검증: `pytest app/test_persona_review.py -q` 19 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -39,6 +40,44 @@ FORBIDDEN_HEADER_TERMS = {
|
||||||
|
|
||||||
EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE)
|
EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE)
|
||||||
PHONE_RE = re.compile(r"(?:\+?\d[\d .()-]{7,}\d)")
|
PHONE_RE = re.compile(r"(?:\+?\d[\d .()-]{7,}\d)")
|
||||||
|
APPROVED_EXPORT_STATUS = "approved_for_recursive_learning_seed"
|
||||||
|
|
||||||
|
CSV_ALLOWED_VALUES = {
|
||||||
|
"00-intake/pilot_roster.csv": {
|
||||||
|
"withdrawal_state": {
|
||||||
|
"active",
|
||||||
|
"withdrawn_before_data_use",
|
||||||
|
"withdrawn_after_data_use",
|
||||||
|
"excluded_by_operator",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"01-sessions/session_completion.csv": {
|
||||||
|
"completion_state": {
|
||||||
|
"completed",
|
||||||
|
"abandoned",
|
||||||
|
"operator_cancelled",
|
||||||
|
"excluded_from_analysis",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"02-measures/prepost_measures.csv": {
|
||||||
|
"timepoint": {"pre", "post"},
|
||||||
|
},
|
||||||
|
"04-privacy/withdrawal_log.csv": {
|
||||||
|
"scope": {
|
||||||
|
"future_sessions_only",
|
||||||
|
"exclude_from_analysis",
|
||||||
|
"exclude_from_export",
|
||||||
|
"delete_where_policy_allows",
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"received",
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
"rejected_by_policy",
|
||||||
|
"needs_legal_review",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -104,6 +143,27 @@ KPI_METRICS = {
|
||||||
"top1",
|
"top1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KPI_REPORT_KEYS = {
|
||||||
|
"cohort_size",
|
||||||
|
"exclusions",
|
||||||
|
"generated_at",
|
||||||
|
"metrics",
|
||||||
|
"open_schema_gaps",
|
||||||
|
"pilot_id",
|
||||||
|
"review",
|
||||||
|
"source_window",
|
||||||
|
}
|
||||||
|
|
||||||
|
KPI_METRIC_REQUIRED_KEYS = {
|
||||||
|
"denominator",
|
||||||
|
"method",
|
||||||
|
"numerator",
|
||||||
|
"pass",
|
||||||
|
"source_files",
|
||||||
|
"threshold",
|
||||||
|
"value",
|
||||||
|
}
|
||||||
|
|
||||||
MANIFEST_KEYS = {
|
MANIFEST_KEYS = {
|
||||||
"agreement",
|
"agreement",
|
||||||
"anonymization",
|
"anonymization",
|
||||||
|
|
@ -114,8 +174,12 @@ MANIFEST_KEYS = {
|
||||||
"export_id",
|
"export_id",
|
||||||
"export_status",
|
"export_status",
|
||||||
"files",
|
"files",
|
||||||
|
"known_limitations",
|
||||||
"pii_scan",
|
"pii_scan",
|
||||||
"purpose",
|
"purpose",
|
||||||
|
"selection_criteria",
|
||||||
|
"source_tables",
|
||||||
|
"source_window",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -182,6 +246,29 @@ def scan_csv_values(path: Path, report: Report, max_rows: int) -> None:
|
||||||
report.error(f"{path}: cannot scan CSV: {exc}")
|
report.error(f"{path}: cannot scan CSV: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_csv_allowed_values(root: Path, spec: CsvSpec, report: Report) -> None:
|
||||||
|
allowed_by_column = CSV_ALLOWED_VALUES.get(spec.path)
|
||||||
|
if not allowed_by_column:
|
||||||
|
return
|
||||||
|
path = root / spec.path
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
for row_number, row in enumerate(reader, start=2):
|
||||||
|
for column, allowed_values in allowed_by_column.items():
|
||||||
|
value = (row.get(column) or "").strip()
|
||||||
|
if value and value not in allowed_values:
|
||||||
|
expected = ", ".join(sorted(allowed_values))
|
||||||
|
report.error(
|
||||||
|
f"{spec.path}:{row_number}: invalid {column} '{value}' "
|
||||||
|
f"(expected one of: {expected})"
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
|
||||||
|
except OSError as exc:
|
||||||
|
report.error(f"{path}: cannot validate CSV values: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int) -> None:
|
def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int) -> None:
|
||||||
path = root / spec.path
|
path = root / spec.path
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|
@ -202,6 +289,7 @@ def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int)
|
||||||
report.error(f"{spec.path}: forbidden identifier/secret-like headers: {', '.join(forbidden)}")
|
report.error(f"{spec.path}: forbidden identifier/secret-like headers: {', '.join(forbidden)}")
|
||||||
|
|
||||||
scan_csv_values(path, report, max_rows=max_scan_rows)
|
scan_csv_values(path, report, max_rows=max_scan_rows)
|
||||||
|
validate_csv_allowed_values(root, spec, report)
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, report: Report) -> dict[str, Any] | None:
|
def read_json(path: Path, report: Report) -> dict[str, Any] | None:
|
||||||
|
|
@ -233,6 +321,10 @@ def validate_kpi_report(root: Path, report: Report) -> None:
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
missing_top_level = sorted(KPI_REPORT_KEYS - set(data))
|
||||||
|
if missing_top_level:
|
||||||
|
report.error(f"{rel_path}: missing keys: {', '.join(missing_top_level)}")
|
||||||
|
|
||||||
metrics = data.get("metrics")
|
metrics = data.get("metrics")
|
||||||
if not isinstance(metrics, dict):
|
if not isinstance(metrics, dict):
|
||||||
report.error(f"{rel_path}: missing object key 'metrics'")
|
report.error(f"{rel_path}: missing object key 'metrics'")
|
||||||
|
|
@ -245,9 +337,70 @@ def validate_kpi_report(root: Path, report: Report) -> None:
|
||||||
if not isinstance(metric, dict):
|
if not isinstance(metric, dict):
|
||||||
report.error(f"{rel_path}: metric '{metric_name}' must be an object")
|
report.error(f"{rel_path}: metric '{metric_name}' must be an object")
|
||||||
continue
|
continue
|
||||||
for key in ("value", "threshold", "pass", "method", "source_files"):
|
for key in sorted(KPI_METRIC_REQUIRED_KEYS):
|
||||||
if key not in metric:
|
if key not in metric:
|
||||||
report.warn(f"{rel_path}: metric '{metric_name}' missing '{key}'")
|
report.error(f"{rel_path}: metric '{metric_name}' missing '{key}'")
|
||||||
|
if "source_files" in metric and not isinstance(metric["source_files"], list):
|
||||||
|
report.error(f"{rel_path}: metric '{metric_name}' source_files must be a list")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_value(value: Any) -> float | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manifest_file_entry(
|
||||||
|
root: Path,
|
||||||
|
rel_path: str,
|
||||||
|
item: dict[str, Any],
|
||||||
|
report: Report,
|
||||||
|
*,
|
||||||
|
approved: bool,
|
||||||
|
) -> None:
|
||||||
|
required_keys = ("path", "rows", "sha256", "schema")
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in item:
|
||||||
|
message = f"{rel_path}: file entry missing '{key}'"
|
||||||
|
if approved:
|
||||||
|
report.error(message)
|
||||||
|
else:
|
||||||
|
report.warn(message)
|
||||||
|
file_path = item.get("path")
|
||||||
|
expected_sha = item.get("sha256")
|
||||||
|
if not isinstance(file_path, str) or not file_path.strip():
|
||||||
|
if approved:
|
||||||
|
report.error(f"{rel_path}: approved file entry requires non-empty path")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
root_resolved = root.resolve()
|
||||||
|
candidate = (root / file_path).resolve()
|
||||||
|
candidate.relative_to(root_resolved)
|
||||||
|
except ValueError:
|
||||||
|
report.error(f"{rel_path}: file path escapes evidence root: {file_path}")
|
||||||
|
return
|
||||||
|
if not approved:
|
||||||
|
return
|
||||||
|
if not candidate.exists():
|
||||||
|
report.error(f"{rel_path}: approved file missing: {file_path}")
|
||||||
|
return
|
||||||
|
if not candidate.is_file():
|
||||||
|
report.error(f"{rel_path}: approved file path is not a file: {file_path}")
|
||||||
|
return
|
||||||
|
if isinstance(expected_sha, str) and expected_sha:
|
||||||
|
actual_sha = sha256_file(candidate)
|
||||||
|
if actual_sha != expected_sha:
|
||||||
|
report.error(f"{rel_path}: sha256 mismatch for {file_path}")
|
||||||
|
|
||||||
|
|
||||||
def validate_manifest(root: Path, report: Report) -> None:
|
def validate_manifest(root: Path, report: Report) -> None:
|
||||||
|
|
@ -265,7 +418,8 @@ def validate_manifest(root: Path, report: Report) -> None:
|
||||||
report.error(f"{rel_path}: missing keys: {', '.join(missing)}")
|
report.error(f"{rel_path}: missing keys: {', '.join(missing)}")
|
||||||
|
|
||||||
status = data.get("export_status")
|
status = data.get("export_status")
|
||||||
if status == "approved_for_recursive_learning_seed":
|
approved = status == APPROVED_EXPORT_STATUS
|
||||||
|
if approved:
|
||||||
approvals = data.get("approvals")
|
approvals = data.get("approvals")
|
||||||
if not isinstance(approvals, dict):
|
if not isinstance(approvals, dict):
|
||||||
report.error(f"{rel_path}: approved export requires approvals object")
|
report.error(f"{rel_path}: approved export requires approvals object")
|
||||||
|
|
@ -273,16 +427,40 @@ def validate_manifest(root: Path, report: Report) -> None:
|
||||||
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
|
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
|
||||||
if not approvals.get(key):
|
if not approvals.get(key):
|
||||||
report.error(f"{rel_path}: approved export missing approval '{key}'")
|
report.error(f"{rel_path}: approved export missing approval '{key}'")
|
||||||
|
pii_scan = data.get("pii_scan")
|
||||||
|
if not isinstance(pii_scan, dict):
|
||||||
|
report.error(f"{rel_path}: approved export requires pii_scan object")
|
||||||
|
elif pii_scan.get("status") != "pass":
|
||||||
|
report.error(f"{rel_path}: approved export requires pii_scan.status='pass'")
|
||||||
|
agreement = data.get("agreement")
|
||||||
|
if not isinstance(agreement, dict):
|
||||||
|
report.error(f"{rel_path}: approved export requires agreement object")
|
||||||
|
else:
|
||||||
|
kappa = numeric_value(agreement.get("kappa"))
|
||||||
|
icc = numeric_value(agreement.get("icc"))
|
||||||
|
if kappa is None or kappa < 0.60:
|
||||||
|
report.error(f"{rel_path}: approved export requires agreement.kappa >= 0.60")
|
||||||
|
if icc is None or icc < 0.75:
|
||||||
|
report.error(f"{rel_path}: approved export requires agreement.icc >= 0.75")
|
||||||
|
selection_criteria = data.get("selection_criteria")
|
||||||
|
if not isinstance(selection_criteria, dict):
|
||||||
|
report.error(f"{rel_path}: approved export requires selection_criteria object")
|
||||||
|
elif selection_criteria.get("include_withdrawn") is not False:
|
||||||
|
report.error(f"{rel_path}: approved export requires selection_criteria.include_withdrawn=false")
|
||||||
|
consent_scope = data.get("consent_scope")
|
||||||
|
allowed_uses = consent_scope.get("allowed_uses") if isinstance(consent_scope, dict) else None
|
||||||
|
if not isinstance(allowed_uses, list) or "recursive_learning_seed" not in allowed_uses:
|
||||||
|
report.error(f"{rel_path}: approved export requires recursive_learning_seed consent scope")
|
||||||
|
|
||||||
files = data.get("files")
|
files = data.get("files")
|
||||||
if isinstance(files, list):
|
if isinstance(files, list):
|
||||||
|
if approved and not files:
|
||||||
|
report.error(f"{rel_path}: approved export requires at least one file entry")
|
||||||
for item in files:
|
for item in files:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
report.error(f"{rel_path}: files[] entries must be objects")
|
report.error(f"{rel_path}: files[] entries must be objects")
|
||||||
continue
|
continue
|
||||||
for key in ("path", "rows", "sha256", "schema"):
|
validate_manifest_file_entry(root, rel_path, item, report, approved=approved)
|
||||||
if key not in item:
|
|
||||||
report.warn(f"{rel_path}: file entry missing '{key}'")
|
|
||||||
elif files is not None:
|
elif files is not None:
|
||||||
report.error(f"{rel_path}: 'files' must be a list")
|
report.error(f"{rel_path}: 'files' must be a list")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue