diff --git a/.codex-remote-attachments/019f06de-a1f7-78f1-90b3-79ff50184333/e8834910-90ec-40b7-82c8-824b158439d3/1-Photo-1.jpg b/.codex-remote-attachments/019f06de-a1f7-78f1-90b3-79ff50184333/e8834910-90ec-40b7-82c8-824b158439d3/1-Photo-1.jpg new file mode 100644 index 0000000..71a0421 Binary files /dev/null and b/.codex-remote-attachments/019f06de-a1f7-78f1-90b3-79ff50184333/e8834910-90ec-40b7-82c8-824b158439d3/1-Photo-1.jpg differ diff --git a/AGENT.md b/AGENT.md index c0471fe..7dc2ae4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -27,6 +27,28 @@ SSOT [`docs/dev_dashboard.html`](./docs/dev_dashboard.html). 동작/구조 변 > **"OS·셸·경로·도구를 확정한 뒤에 실행한다. 추정으로 시작하지 않는다."** +### PowerShell 5.1 기본 실행 규칙 + +- 시작 시 필요하면 `$ErrorActionPreference='Stop'`, + `[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)`, + `$OutputEncoding=[System.Text.UTF8Encoding]::new($false)`를 먼저 둔다. +- PowerShell 7/Bash 문법 금지: `? :`, `??`, `&&`, `||`, `ForEach-Object -Parallel`, + heredoc(`<&1`로 합치지 말고 필요하면 stdout/stderr를 분리 캡처한다. +- JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`를 우선 사용한다. 진짜 curl은 + `curl.exe`로 호출한다. +- 인라인 Python/Node가 BOM/인용 문제를 내면 `python -c`, UTF-8 no BOM 임시 파일, + base64 전달을 사용한다. Python은 필요 시 `PYTHONUTF8=1`, `python -X utf8`. +- `Start-Process`는 필요할 때만 쓰고 `-Wait -PassThru`로 ExitCode와 산출물을 검증한다. + GUI 런처 detach 여부를 별도로 확인한다. + --- ## 규칙 1 — 증거 정직성 diff --git a/AGENTS.md b/AGENTS.md index 0135ee9..52136cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,59 @@ > 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지. +### 0.1 PowerShell 5.1 실행 규칙 (Windows 기본 셸) + +이 프로젝트의 기본 셸은 **Windows PowerShell 5.1 Desktop**이다. PowerShell 7 문법이나 +Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규칙을 기본값으로 삼아라. + +- **세션 시작 프리루드**: 한글/UTF-8 출력이 필요한 명령 전에는 아래를 먼저 둔다. + ```powershell + $ErrorActionPreference = 'Stop' + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $OutputEncoding = [System.Text.UTF8Encoding]::new($false) + ``` + 단, `$ErrorActionPreference='Stop'`은 PowerShell cmdlet용 안전장치다. 네이티브 exe의 + 실패는 자동으로 예외가 되지 않으므로 실행 후 `$LASTEXITCODE`를 반드시 확인한다. +- **5.1 미지원 문법 금지**: `? :` 삼항, `??`, `??=`, `&&`, `||`, + `ForEach-Object -Parallel`, Bash heredoc(`<&1`로 합치지 않는다**: 5.1은 네이티브 stderr를 + `ErrorRecord`로 감싸 파이프라인/문자열 처리와 순서를 흐릴 수 있다. 로그가 필요하면 + stdout/stderr를 별도 파일로 리디렉션하거나 `System.Diagnostics.Process`로 분리 캡처한다. +- **네이티브 명령은 문자열 조립보다 인자 배열로 호출한다**: + ```powershell + $exe = 'C:\path\tool.exe' + $args = @('--flag', $value, '--out', $outPath) + & $exe @args + if ($LASTEXITCODE -ne 0) { throw "tool failed: $LASTEXITCODE" } + ``` + PowerShell 파싱이 외부 도구 인자를 망가뜨릴 때만 네이티브 명령 뒤에 `--%`를 검토한다. +- **HTTP/JSON은 `curl` 별칭을 피한다**: PowerShell의 `curl`은 별칭일 수 있다. + JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`와 `ConvertTo-Json`을 우선 사용하고, + 진짜 curl이 필요하면 `curl.exe`를 명시한다. +- **인라인 Python/Node는 짧고 결정적으로 실행한다**: 여러 줄 코드를 stdin으로 밀어 넣다 + BOM/인용 문제가 나면 `python -c`, UTF-8 no BOM 임시 파일, 또는 base64 전달을 쓴다. + Python 검증에는 필요 시 `$env:PYTHONUTF8='1'`와 `python -X utf8`을 사용한다. +- **`powershell.exe -EncodedCommand`는 UTF-16LE base64**다. UTF-8로 인코딩하면 깨진다. +- **프로세스 대기는 검증한다**: 단순 CLI는 직접 실행하고 `$LASTEXITCODE`를 본다. + `Start-Process`가 필요하면 `-Wait -PassThru`로 ExitCode를 확인한다. GUI 런처가 즉시 + detach되는 도구(예: LibreOffice `soffice.exe`)는 실제 작업 프로세스와 산출물 생성을 + 따로 검증한다. + --- ## 1. 운영 원칙 diff --git a/CLAUDE.md b/CLAUDE.md index 5a562f8..fdb2683 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,59 @@ > 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지. +### 0.1 PowerShell 5.1 실행 규칙 (Windows 기본 셸) + +이 프로젝트의 기본 셸은 **Windows PowerShell 5.1 Desktop**이다. PowerShell 7 문법이나 +Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규칙을 기본값으로 삼아라. + +- **세션 시작 프리루드**: 한글/UTF-8 출력이 필요한 명령 전에는 아래를 먼저 둔다. + ```powershell + $ErrorActionPreference = 'Stop' + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $OutputEncoding = [System.Text.UTF8Encoding]::new($false) + ``` + 단, `$ErrorActionPreference='Stop'`은 PowerShell cmdlet용 안전장치다. 네이티브 exe의 + 실패는 자동으로 예외가 되지 않으므로 실행 후 `$LASTEXITCODE`를 반드시 확인한다. +- **5.1 미지원 문법 금지**: `? :` 삼항, `??`, `??=`, `&&`, `||`, + `ForEach-Object -Parallel`, Bash heredoc(`<&1`로 합치지 않는다**: 5.1은 네이티브 stderr를 + `ErrorRecord`로 감싸 파이프라인/문자열 처리와 순서를 흐릴 수 있다. 로그가 필요하면 + stdout/stderr를 별도 파일로 리디렉션하거나 `System.Diagnostics.Process`로 분리 캡처한다. +- **네이티브 명령은 문자열 조립보다 인자 배열로 호출한다**: + ```powershell + $exe = 'C:\path\tool.exe' + $args = @('--flag', $value, '--out', $outPath) + & $exe @args + if ($LASTEXITCODE -ne 0) { throw "tool failed: $LASTEXITCODE" } + ``` + PowerShell 파싱이 외부 도구 인자를 망가뜨릴 때만 네이티브 명령 뒤에 `--%`를 검토한다. +- **HTTP/JSON은 `curl` 별칭을 피한다**: PowerShell의 `curl`은 별칭일 수 있다. + JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`와 `ConvertTo-Json`을 우선 사용하고, + 진짜 curl이 필요하면 `curl.exe`를 명시한다. +- **인라인 Python/Node는 짧고 결정적으로 실행한다**: 여러 줄 코드를 stdin으로 밀어 넣다 + BOM/인용 문제가 나면 `python -c`, UTF-8 no BOM 임시 파일, 또는 base64 전달을 쓴다. + Python 검증에는 필요 시 `$env:PYTHONUTF8='1'`와 `python -X utf8`을 사용한다. +- **`powershell.exe -EncodedCommand`는 UTF-16LE base64**다. UTF-8로 인코딩하면 깨진다. +- **프로세스 대기는 검증한다**: 단순 CLI는 직접 실행하고 `$LASTEXITCODE`를 본다. + `Start-Process`가 필요하면 `-Wait -PassThru`로 ExitCode를 확인한다. GUI 런처가 즉시 + detach되는 도구(예: LibreOffice `soffice.exe`)는 실제 작업 프로세스와 산출물 생성을 + 따로 검증한다. + --- ## 1. 운영 원칙 diff --git a/README.md b/README.md index ef7b3a0..6b10ed7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099 ```powershell # 백엔드 -cd apps\api; python -m pytest app/ -q # 현재 약 77 pass +cd apps\api; python -m pytest app/ -q # 현재 113 pass python -m pytest engine_gateway/ -q # 약 7 pass # 프론트엔드 cd apps\web; npm run typecheck # tsc -b diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index f67c73a..663b692 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -70,10 +70,19 @@ def user_id_from_email(email: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}")) +def user_id_from_external_id(external_id: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}")) + + def _normalize_email(email: str) -> str: return email.strip().lower() +def _normalize_external_id(external_id: str | None, email: str) -> str: + value = (external_id or "").strip().lower() + return value or f"email:{_normalize_email(email)}" + + def _db_role(role: str) -> str: return DB_ROLE_BY_APP.get(role, role) @@ -309,6 +318,24 @@ async def ensure_runtime_tables() -> None: ) """ ) + await conn.execute( + """ + DROP POLICY IF EXISTS p_case_insert ON app.case_profile; + DROP POLICY IF EXISTS p_case_update ON app.case_profile; + + CREATE POLICY p_case_insert ON app.case_profile FOR INSERT WITH CHECK ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ); + CREATE POLICY p_case_update ON app.case_profile FOR UPDATE USING ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ) WITH CHECK ( + app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') + OR learner_id = app.current_uid() + ) + """ + ) await conn.execute( """ DROP POLICY IF EXISTS p_turns_modify ON app.turns; @@ -412,10 +439,12 @@ async def upsert_managed_user( role: str, cohort_ids: list[str] | None = None, user_id: str | None = None, + external_id: str | None = None, affiliation: str | None = None, reactivate: bool = False, ) -> ManagedUser: normalized_email = _normalize_email(email) + normalized_external_id = _normalize_external_id(external_id, normalized_email) try: pool = get_pool() async with pool.acquire() as conn: @@ -437,7 +466,7 @@ async def upsert_managed_user( WHERE app.app_user.is_active OR $7 RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at """, - f"email:{normalized_email}", + normalized_external_id, normalized_email, (display_name.strip() if display_name else normalized_email), _db_role(role), @@ -468,7 +497,7 @@ async def upsert_managed_user( display_name=display_name, role=role, cohort_ids=cohort_ids, - user_id=user_id, + user_id=user_id or user_id_from_external_id(normalized_external_id), affiliation=affiliation, reactivate=reactivate, ) @@ -669,6 +698,7 @@ async def create_session( role: str, cohort_ids: list[str] | None = None, user_id: str | None = None, + external_id: str | None = None, ) -> tuple[str, SessionUser]: raw_sid = secrets.token_urlsafe(32) normalized_email = _normalize_email(email) @@ -678,6 +708,7 @@ async def create_session( role=role, cohort_ids=cohort_ids, user_id=user_id, + external_id=external_id, reactivate=False, ) expires_at = time.time() + settings.session_ttl_seconds diff --git a/apps/api/app/config.py b/apps/api/app/config.py index faece1d..eb8b437 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -80,6 +80,10 @@ class Settings(BaseSettings): ) engine_timeout: float = 120.0 # SSE 롱리브드 (50분 상담 대비, 스트림은 무제한 별도) engine_connect_timeout: float = 10.0 + admin_usage_budget_usd: float = Field( + default=0.0, + validation_alias="ADMIN_USAGE_BUDGET_USD", + ) # ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ── anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") @@ -127,6 +131,14 @@ class Settings(BaseSettings): default=[], validation_alias="AUTH_ADMIN_EMAILS", ) + auth_email_cohort_map: dict[str, str] = Field( + default_factory=dict, + validation_alias="AUTH_EMAIL_COHORT_MAP", + ) + auth_domain_cohort_map: dict[str, str] = Field( + default_factory=dict, + validation_alias="AUTH_DOMAIN_COHORT_MAP", + ) auth_dev_login_enabled: bool = Field( default=False, validation_alias="AUTH_DEV_LOGIN_ENABLED", diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py index 3ab400e..c6c32b9 100644 --- a/apps/api/app/persona_repository.py +++ b/apps/api/app/persona_repository.py @@ -60,6 +60,12 @@ class PersonaReviewItem: approved_at: str | None +@dataclass(frozen=True, slots=True) +class PersonaDraftRecord: + review: PersonaReviewItem + card: PersonaCard + + def seed_persona_id(code: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}")) @@ -143,6 +149,13 @@ def persona_review_item_from_row(row: Any) -> PersonaReviewItem: ) +def persona_draft_record_from_row(row: Any) -> PersonaDraftRecord: + return PersonaDraftRecord( + review=persona_review_item_from_row(row), + card=card_from_row(row), + ) + + def seed_fallback_persona(code: str) -> CatalogPersona | None: card = get_seed_persona(code) if card is None: @@ -300,6 +313,187 @@ async def list_persona_review_queue( return [persona_review_item_from_row(row) for row in rows] +async def get_persona_draft_record( + *, + persona_id: str, + role: str, +) -> PersonaDraftRecord | None: + if role not in {"teacher", "admin"}: + raise ValueError("persona draft read requires teacher or admin role") + + get_pool() + async with acquire(role=role) as conn: + row = await conn.fetchrow( + f""" + SELECT {_CARD_COLUMNS}, created_at, approved_at + FROM app.persona_card + WHERE persona_id = $1::uuid + AND status IN ('draft', 'review') + LIMIT 1 + """, + persona_id, + ) + return persona_draft_record_from_row(row) if row is not None else None + + +async def create_persona_draft( + *, + card: PersonaCard, + author_id: str, + role: str, + submit_for_review: bool = False, +) -> PersonaReviewItem: + if role not in {"teacher", "admin"}: + raise ValueError("persona draft creation requires teacher or admin role") + + next_status = "review" if submit_for_review else "draft" + persona_id = str(uuid.uuid4()) + get_pool() + async with acquire(role=role, user_id=author_id) as conn: + version = await conn.fetchval( + """ + SELECT COALESCE(MAX(version), 0) + 1 + FROM app.persona_card + WHERE upper(code) = upper($1) + """, + card.code, + ) + row = await conn.fetchrow( + f""" + INSERT INTO app.persona_card ( + persona_id, code, version, status, display_name, difficulty, + theory_target, demographics, presenting, history, big5, + resistance, speech_style, affect_baseline, ccd, + dsm5_dimensional, source_provenance, is_synthetic, created_by + ) + VALUES ( + $1::uuid, $2, $3, $4, $5, $6, + $7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb, + $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, + $16::jsonb, $17, $18, $19::uuid + ) + RETURNING {_REVIEW_COLUMNS} + """, + persona_id, + card.code, + int(version or 1), + next_status, + card.display_name, + card.difficulty, + card.theory_target, + card.demographics, + card.presenting, + card.history, + card.big5, + card.resistance, + card.speech_style, + card.affect_baseline, + card.ccd, + card.dsm5_dimensional, + card.source_provenance, + card.is_synthetic, + author_id, + ) + await conn.execute( + """ + INSERT INTO audit.audit_log ( + actor_uid, action, target_kind, target_id, detail + ) + VALUES ($1::uuid, $2, $3, $4, $5::jsonb) + """, + author_id, + "persona_draft_create", + "persona_card", + persona_id, + { + "next_status": next_status, + "code": card.code, + "version": int(row["version"]), + }, + ) + return persona_review_item_from_row(row) + + +async def update_persona_draft( + *, + persona_id: str, + card: PersonaCard, + author_id: str, + role: str, + submit_for_review: bool = False, +) -> PersonaReviewItem | None: + if role not in {"teacher", "admin"}: + raise ValueError("persona draft update requires teacher or admin role") + + next_status = "review" if submit_for_review else "draft" + get_pool() + async with acquire(role=role, user_id=author_id) as conn: + row = await conn.fetchrow( + f""" + UPDATE app.persona_card + SET + code = $2, + status = $3, + display_name = $4, + difficulty = $5, + theory_target = $6::text[], + demographics = $7::jsonb, + presenting = $8::jsonb, + history = $9::jsonb, + big5 = $10::jsonb, + resistance = $11::jsonb, + speech_style = $12::jsonb, + affect_baseline = $13::jsonb, + ccd = $14::jsonb, + dsm5_dimensional = $15::jsonb, + source_provenance = $16, + is_synthetic = $17, + approved_by = NULL, + approved_at = NULL + WHERE persona_id = $1::uuid + AND status IN ('draft', 'review') + RETURNING {_REVIEW_COLUMNS} + """, + persona_id, + card.code, + next_status, + card.display_name, + card.difficulty, + card.theory_target, + card.demographics, + card.presenting, + card.history, + card.big5, + card.resistance, + card.speech_style, + card.affect_baseline, + card.ccd, + card.dsm5_dimensional, + card.source_provenance, + card.is_synthetic, + ) + if row is None: + return None + await conn.execute( + """ + INSERT INTO audit.audit_log ( + actor_uid, action, target_kind, target_id, detail + ) + VALUES ($1::uuid, $2, $3, $4, $5::jsonb) + """, + author_id, + "persona_draft_update", + "persona_card", + persona_id, + { + "next_status": next_status, + "code": str(row["code"]).upper(), + "version": int(row["version"]), + }, + ) + return persona_review_item_from_row(row) + + async def update_persona_review_status( *, persona_id: str, @@ -375,21 +569,26 @@ async def get_catalog_persona(code: str) -> CatalogPersona | None: __all__ = [ "CatalogPersona", + "PersonaDraftRecord", "PersonaReviewItem", "PersonaReviewAction", "PersonaStatus", "SEED_VERSION", "card_from_row", "catalog_persona_from_row", + "create_persona_draft", "get_approved_persona", "get_catalog_persona", + "get_persona_draft_record", "list_approved_personas", "list_catalog_personas", "list_persona_review_queue", "materialize_seed_personas", + "persona_draft_record_from_row", "persona_review_item_from_row", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", + "update_persona_draft", "update_persona_review_status", ] diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index f04f850..8ab13ba 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -4,9 +4,10 @@ from __future__ import annotations import time from datetime import datetime, timezone +from decimal import Decimal from typing import Annotated, Literal -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from ..auth_sessions import ( @@ -23,11 +24,13 @@ from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services.voice import voice_service from ..services import rag +from ..store import store router = APIRouter(prefix="/admin", tags=["admin"]) AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))] HealthStatus = Literal["ok", "degraded", "down"] +UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"] class AdminServiceHealth(BaseModel): @@ -46,6 +49,36 @@ class AdminHealthResponse(BaseModel): services: list[AdminServiceHealth] +class AdminUsageBreakdown(BaseModel): + provider: str + model: str + turns: int + tokens_in: int + tokens_out: int + cost_usd: float + + +class AdminUsageBudget(BaseModel): + limit_usd: float + used_ratio: float + remaining_usd: float | None + status: UsageBudgetStatus + + +class AdminUsageResponse(BaseModel): + source: Literal["database", "server_session_registry"] + durable: bool + window_days: int + generated_at: float + total_turns: int + metered_turns: int + tokens_in: int + tokens_out: int + cost_usd: float + budget: AdminUsageBudget + by_provider: list[AdminUsageBreakdown] + + class AdminEngineConfigResponse(BaseModel): engine_mode: str engine_url: str @@ -120,6 +153,47 @@ def _workload_load(count: int, expected_capacity: int) -> float: return _clamp01(count / expected_capacity) +def _decimal_to_float(value: object) -> float: + if value is None: + return 0.0 + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _safe_usage_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _usage_budget(cost_usd: float) -> AdminUsageBudget: + limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0)) + if limit <= 0: + return AdminUsageBudget( + limit_usd=0.0, + used_ratio=0.0, + remaining_usd=None, + status="disabled", + ) + used_ratio = max(0.0, cost_usd / limit) + status_value: UsageBudgetStatus = "ok" + if used_ratio >= 1.0: + status_value = "exceeded" + elif used_ratio >= 0.8: + status_value = "warn" + return AdminUsageBudget( + limit_usd=round(limit, 6), + used_ratio=round(used_ratio, 4), + remaining_usd=round(max(0.0, limit - cost_usd), 6), + status=status_value, + ) + + async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: metrics = RuntimeHealthMetrics() @@ -175,6 +249,157 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: return metrics +async def _usage_from_database(window_days: int) -> AdminUsageResponse: + async with acquire(role="admin") as conn: + total_row = await conn.fetchrow( + """ + SELECT + COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns, + COUNT(*) FILTER ( + WHERE speaker = 'client' + AND ( + llm_provider IS NOT NULL OR model IS NOT NULL + OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL + OR cost_usd IS NOT NULL + ) + ) AS metered_turns, + COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in, + COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out, + COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd + FROM app.turns + WHERE created_at >= now() - ($1::int * interval '1 day') + """, + window_days, + ) + rows = await conn.fetch( + """ + SELECT + COALESCE(llm_provider, 'unknown') AS provider, + COALESCE(model, 'unknown') AS model, + COUNT(*) AS turns, + COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in, + COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out, + COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd + FROM app.turns + WHERE created_at >= now() - ($1::int * interval '1 day') + AND speaker = 'client' + AND ( + llm_provider IS NOT NULL OR model IS NOT NULL + OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL + OR cost_usd IS NOT NULL + ) + GROUP BY 1, 2 + ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, turns DESC + LIMIT 12 + """, + window_days, + ) + + total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6) + return AdminUsageResponse( + source="database", + durable=True, + window_days=window_days, + generated_at=time.time(), + total_turns=_safe_usage_int(total_row["total_turns"] if total_row else 0), + metered_turns=_safe_usage_int(total_row["metered_turns"] if total_row else 0), + tokens_in=_safe_usage_int(total_row["tokens_in"] if total_row else 0), + tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0), + cost_usd=total_cost, + budget=_usage_budget(total_cost), + by_provider=[ + AdminUsageBreakdown( + provider=str(row["provider"] or "unknown"), + model=str(row["model"] or "unknown"), + turns=_safe_usage_int(row["turns"]), + tokens_in=_safe_usage_int(row["tokens_in"]), + tokens_out=_safe_usage_int(row["tokens_out"]), + cost_usd=round(_decimal_to_float(row["cost_usd"]), 6), + ) + for row in rows + ], + ) + + +def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse: + window_start = time.time() - (window_days * 86400) + total_turns = 0 + metered_turns = 0 + tokens_in = 0 + tokens_out = 0 + cost_usd = 0.0 + buckets: dict[tuple[str, str], dict[str, int | float]] = {} + + for sess in store.list(): + for turn in getattr(sess, "turns", []) or []: + if getattr(turn, "speaker", "") != "client": + continue + if float(getattr(turn, "created_at", 0.0) or 0.0) < window_start: + continue + total_turns += 1 + provider = str(getattr(turn, "llm_provider", None) or "unknown") + model = str(getattr(turn, "model", None) or "unknown") + turn_tokens_in = _safe_usage_int(getattr(turn, "tokens_in", 0)) + turn_tokens_out = _safe_usage_int(getattr(turn, "tokens_out", 0)) + turn_cost = _decimal_to_float(getattr(turn, "cost_usd", 0.0)) + is_metered = ( + provider != "unknown" + or model != "unknown" + or turn_tokens_in > 0 + or turn_tokens_out > 0 + or turn_cost > 0 + ) + if not is_metered: + continue + metered_turns += 1 + tokens_in += turn_tokens_in + tokens_out += turn_tokens_out + cost_usd += turn_cost + key = (provider, model) + bucket = buckets.setdefault( + key, + {"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0}, + ) + bucket["turns"] = int(bucket["turns"]) + 1 + bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in + bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out + bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost + + by_provider = [ + AdminUsageBreakdown( + provider=provider, + model=model, + turns=int(values["turns"]), + tokens_in=int(values["tokens_in"]), + tokens_out=int(values["tokens_out"]), + cost_usd=round(float(values["cost_usd"]), 6), + ) + for (provider, model), values in sorted( + buckets.items(), + key=lambda item: ( + -float(item[1]["cost_usd"]), + -(int(item[1]["tokens_in"]) + int(item[1]["tokens_out"])), + -int(item[1]["turns"]), + ), + )[:12] + ] + + total_cost = round(cost_usd, 6) + return AdminUsageResponse( + source="server_session_registry", + durable=False, + window_days=window_days, + generated_at=time.time(), + total_turns=total_turns, + metered_turns=metered_turns, + tokens_in=tokens_in, + tokens_out=tokens_out, + cost_usd=total_cost, + budget=_usage_budget(total_cost), + by_provider=by_provider, + ) + + class AdminUserCreate(BaseModel): email: str = Field(..., min_length=3, max_length=254) display_name: str = Field(..., min_length=1, max_length=80) @@ -407,6 +632,19 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse: ) +@router.get("/usage", response_model=AdminUsageResponse) +async def admin_usage( + principal: AdminPrincipal, + window_days: Annotated[int, Query(ge=1, le=90)] = 7, +) -> AdminUsageResponse: + """Return AI token/cost usage from persisted turns or dev fallback state.""" + try: + return await _usage_from_database(window_days) + except Exception: + require_runtime_fallback_allowed("admin usage") + return _usage_from_runtime_store(window_days) + + @router.get("/engine-config", response_model=AdminEngineConfigResponse) async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse: """Return the current admin-managed engine settings.""" diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index b79ec2c..af630a3 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -11,6 +11,9 @@ from __future__ import annotations import base64 import hashlib +import hmac +import json +import logging import secrets import time from dataclasses import dataclass @@ -34,11 +37,14 @@ from ..saml import ( ) router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger(__name__) GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" OAUTH_STATE_TTL_SECONDS = 10 * 60 +OAUTH_STATE_COOKIE_NAME = "__Host-vignette_oauth_state" +DEV_OAUTH_STATE_COOKIE_NAME = "vignette_oauth_state" @dataclass(slots=True) @@ -201,6 +207,54 @@ def _role_for_saml_identity(identity: SamlIdentity) -> Role: return _role_for_email(identity.email) +def _split_cohort_values(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _append_unique(items: list[str], values: list[str]) -> None: + seen = {item.lower() for item in items} + for value in values: + key = value.lower() + if key and key not in seen: + items.append(value) + seen.add(key) + + +def _configured_cohort_ids( + *, + email: str, + hosted_domain: str | None = None, + claim_hint: str | None = None, +) -> list[str]: + normalized_email = _normalize_email(email) + domain = _email_domain(normalized_email) + hd = _normalize_domain(hosted_domain) + email_map = { + _normalize_email(key): value + for key, value in settings.auth_email_cohort_map.items() + if _normalize_email(key) + } + domain_map = { + _normalize_domain(key): value + for key, value in settings.auth_domain_cohort_map.items() + if _normalize_domain(key) + } + cohorts: list[str] = [] + _append_unique(cohorts, _split_cohort_values(email_map.get(normalized_email))) + _append_unique(cohorts, _split_cohort_values(domain_map.get(domain))) + if hd and hd != domain: + _append_unique(cohorts, _split_cohort_values(domain_map.get(hd))) + _append_unique(cohorts, _split_cohort_values(claim_hint)) + return cohorts + + +def _provider_external_id(provider: str, subject: str | None, email: str) -> str: + value = (subject or "").strip() or _normalize_email(email) + return f"{provider}:{value.lower()}" + + def _safe_next_path(next_path: str | None) -> str: if not next_path or not next_path.startswith("/") or next_path.startswith("//"): return "/" @@ -254,7 +308,12 @@ def _is_dev_login_allowed_origin(origin: str) -> bool: def _configured_frontend_origins() -> list[str]: origins: list[str] = [] - for value in [settings.frontend_base_url, *settings.frontend_origin_map.values(), *settings.cors_origins]: + for value in [ + settings.frontend_base_url, + *settings.frontend_origin_map.values(), + *settings.cors_origins, + *settings.auth_dev_login_extra_origins, + ]: origin = _url_origin(value) if origin and origin not in origins: origins.append(origin) @@ -276,6 +335,17 @@ def _frontend_origin_for_request(request: Request | None = None) -> str: hostname = host.rsplit(":", 1)[0].lower() if host else "" if mapped_origin := _frontend_origin_map().get(hostname): return mapped_origin + forwarded_proto = ( + request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + ) + if forwarded_host and forwarded_proto in {"http", "https"}: + forwarded_origin = _url_origin(f"{forwarded_proto}://{host}") + if ( + settings.environment == "dev" + and forwarded_origin + and _is_dev_login_allowed_origin(forwarded_origin) + ): + return forwarded_origin if hostname in {"localhost", "127.0.0.1", "::1"}: return fallback @@ -299,6 +369,93 @@ def _pkce_challenge(verifier: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") +def _b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _b64url_decode(value: str) -> bytes: + padded = value + ("=" * (-len(value) % 4)) + return base64.urlsafe_b64decode(padded.encode("ascii")) + + +def _oauth_state_signature(payload: str) -> str: + digest = hmac.new( + settings.session_secret.encode("utf-8"), + f"oauth-state:{payload}".encode("utf-8"), + hashlib.sha256, + ).digest() + return _b64url_encode(digest) + + +def _oauth_code_verifier_for_state(state: str) -> str: + digest = hmac.new( + settings.session_secret.encode("utf-8"), + f"oauth-pkce:{state}".encode("utf-8"), + hashlib.sha256, + ).digest() + return _b64url_encode(digest) + + +def _build_oauth_state(next_path: str | None) -> tuple[str, OAuthState]: + created_at = time.time() + safe_next = _safe_next_path(next_path) + payload = { + "iat": created_at, + "next": safe_next, + "nonce": secrets.token_urlsafe(24), + } + payload_blob = _b64url_encode( + json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + state = f"{payload_blob}.{_oauth_state_signature(payload_blob)}" + return state, OAuthState( + code_verifier=_oauth_code_verifier_for_state(state), + next_path=safe_next, + created_at=created_at, + ) + + +def _oauth_state_from_signed_token(state: str | None) -> OAuthState | None: + if not state or "." not in state: + return None + payload_blob, signature = state.rsplit(".", 1) + if not payload_blob or not signature: + return None + if not hmac.compare_digest(signature, _oauth_state_signature(payload_blob)): + return None + + try: + payload = json.loads(_b64url_decode(payload_blob).decode("utf-8")) + created_at = float(payload.get("iat", 0)) + except (ValueError, TypeError, json.JSONDecodeError): + return None + + if created_at <= 0 or created_at < time.time() - OAUTH_STATE_TTL_SECONDS: + return None + if not isinstance(payload.get("nonce"), str): + return None + + next_path = payload.get("next") + if not isinstance(next_path, str): + return None + return OAuthState( + code_verifier=_oauth_code_verifier_for_state(state), + next_path=_safe_next_path(next_path), + created_at=created_at, + ) + + +def _oauth_state_for_callback(state: str | None, cookie_state: str | None) -> OAuthState | None: + if not state: + return None + if cookie_state != state: + return None + stored = _oauth_states.pop(state, None) + if stored is not None: + return stored + return _oauth_state_from_signed_token(state) + + def _prune_oauth_states() -> None: cutoff = time.time() - OAUTH_STATE_TTL_SECONDS stale = [key for key, value in _oauth_states.items() if value.created_at < cutoff] @@ -342,6 +499,46 @@ def _set_session_cookie(response: Response, sid: str) -> None: ) +def _set_oauth_state_cookie(response: Response, state: str) -> None: + response.set_cookie( + key=OAUTH_STATE_COOKIE_NAME, + value=state, + max_age=OAUTH_STATE_TTL_SECONDS, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + if settings.environment == "dev": + response.set_cookie( + key=DEV_OAUTH_STATE_COOKIE_NAME, + value=state, + max_age=OAUTH_STATE_TTL_SECONDS, + httponly=True, + secure=False, + samesite="lax", + path="/", + ) + + +def _delete_oauth_state_cookie(response: Response) -> None: + response.delete_cookie( + OAUTH_STATE_COOKIE_NAME, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + if settings.environment == "dev": + response.delete_cookie( + DEV_OAUTH_STATE_COOKIE_NAME, + httponly=True, + secure=False, + samesite="lax", + path="/", + ) + + def _delete_session_cookie(response: Response) -> None: response.delete_cookie( settings.cookie_name, @@ -375,13 +572,28 @@ def _frontend_login_redirect(reason: str, request: Request) -> RedirectResponse: return RedirectResponse(f"{base_url}/login?{urlencode({'oauth': reason})}", status_code=302) +def _oauth_callback_error(reason: str, request: Request) -> RedirectResponse: + response = _frontend_login_redirect(reason, request) + _delete_oauth_state_cookie(response) + return response + + +def _log_oauth_callback_failure(request: Request, reason: str, **fields: object) -> None: + """Log OAuth callback failures without authorization codes, tokens, or raw user IDs.""" + host = request.headers.get("x-forwarded-host") or request.headers.get("host") + logger.warning( + "google_oauth_callback_failed reason=%s host=%s forwarded_proto=%s details=%s", + reason, + host, + request.headers.get("x-forwarded-proto"), + fields, + ) + + def _dev_login_available(request: Request) -> bool: if settings.environment != "dev" or not settings.auth_dev_login_enabled: return False - if _dev_login_extra_origins(): - return True - saw_browser_origin = False for header_name in ("origin", "referer"): origin = _url_origin(request.headers.get(header_name)) @@ -389,16 +601,28 @@ def _dev_login_available(request: Request) -> bool: saw_browser_origin = True if not _is_dev_login_allowed_origin(origin): return False - - if not saw_browser_origin and _dev_login_extra_origins(): + if saw_browser_origin: return True forwarded_host = request.headers.get("x-forwarded-host") host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip() - origin = _url_origin(f"http://{host}") if host else None + forwarded_proto = ( + request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + ) + scheme = forwarded_proto if forwarded_host and forwarded_proto in {"http", "https"} else "http" + origin = _url_origin(f"{scheme}://{host}") if host else None return bool(origin and _is_dev_login_allowed_origin(origin)) +def _dev_oauth_redirect_unavailable(request: Request) -> bool: + redirect_origin = _url_origin(settings.oauth_redirect_uri) + return bool( + _dev_login_available(request) + and redirect_origin + and not _is_local_origin(redirect_origin) + ) + + @router.get("/config", response_model=AuthConfigResponse) async def auth_config(request: Request) -> AuthConfigResponse: """Return non-secret login configuration for the browser login screen.""" @@ -449,15 +673,12 @@ async def login( return _frontend_login_redirect("unsupported_provider", request) if not _google_configured(): return _frontend_login_redirect("not_configured", request) + if _dev_oauth_redirect_unavailable(request): + return _frontend_login_redirect("local_oauth_unavailable", request) _prune_oauth_states() - state = secrets.token_urlsafe(32) - verifier = secrets.token_urlsafe(64) - _oauth_states[state] = OAuthState( - code_verifier=verifier, - next_path=_safe_next_path(next), - created_at=time.time(), - ) + state, stored_state = _build_oauth_state(next) + _oauth_states[state] = stored_state params = { "client_id": settings.oauth_google_client_id, @@ -465,11 +686,13 @@ async def login( "response_type": "code", "scope": "openid email profile", "state": state, - "code_challenge": _pkce_challenge(verifier), + "code_challenge": _pkce_challenge(stored_state.code_verifier), "code_challenge_method": "S256", "prompt": "select_account", } - return RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302) + response = RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302) + _set_oauth_state_cookie(response, state) + return response @router.get("/callback") @@ -477,15 +700,45 @@ async def callback( request: Request, code: Annotated[Optional[str], Query()] = None, state: Annotated[Optional[str], Query()] = None, + error: Annotated[Optional[str], Query()] = None, + error_description: Annotated[Optional[str], Query()] = None, + oauth_state_cookie: Annotated[Optional[str], Cookie(alias=OAUTH_STATE_COOKIE_NAME)] = None, + dev_oauth_state_cookie: Annotated[Optional[str], Cookie(alias=DEV_OAUTH_STATE_COOKIE_NAME)] = None, ) -> RedirectResponse: """Exchange Google auth code, validate identity, and issue a BFF cookie.""" + if error: + reason = "access_denied" if error == "access_denied" else "provider_error" + _log_oauth_callback_failure( + request, + reason, + provider_error=error, + has_state=bool(state), + has_error_description=bool(error_description), + ) + return _oauth_callback_error(reason, request) + if not code or not state: - return _frontend_login_redirect("missing_callback", request) + _log_oauth_callback_failure( + request, + "missing_callback", + has_code=bool(code), + has_state=bool(state), + ) + return _oauth_callback_error("missing_callback", request) _prune_oauth_states() - stored = _oauth_states.pop(state, None) + cookie_state = oauth_state_cookie or ( + dev_oauth_state_cookie if settings.environment == "dev" else None + ) + stored = _oauth_state_for_callback(state, cookie_state) if stored is None: - return _frontend_login_redirect("invalid_state", request) + _log_oauth_callback_failure( + request, + "invalid_state", + has_cookie=bool(cookie_state), + state_in_memory=state in _oauth_states if state else False, + ) + return _oauth_callback_error("invalid_state", request) async with httpx.AsyncClient(timeout=10.0) as client: token_res = await client.post( @@ -501,22 +754,45 @@ async def callback( headers={"Accept": "application/json"}, ) if token_res.status_code >= 400: - return _frontend_login_redirect("token_exchange_failed", request) + token_error: object + try: + token_body = token_res.json() + token_error = { + "error": token_body.get("error"), + "error_description": token_body.get("error_description"), + } + except Exception: + token_error = "non_json_error" + _log_oauth_callback_failure( + request, + "token_exchange_failed", + status_code=token_res.status_code, + token_error=token_error, + ) + return _oauth_callback_error("token_exchange_failed", request) token_payload = token_res.json() id_token = token_payload.get("id_token") if not isinstance(id_token, str) or not id_token: - return _frontend_login_redirect("id_token_missing", request) + _log_oauth_callback_failure(request, "id_token_missing") + return _oauth_callback_error("id_token_missing", request) info_res = await client.get(GOOGLE_TOKENINFO_URL, params={"id_token": id_token}) if info_res.status_code >= 400: - return _frontend_login_redirect("id_token_invalid", request) + _log_oauth_callback_failure( + request, + "id_token_invalid", + status_code=info_res.status_code, + ) + return _oauth_callback_error("id_token_invalid", request) claims = info_res.json() if claims.get("aud") != settings.oauth_google_client_id: - return _frontend_login_redirect("audience_mismatch", request) + _log_oauth_callback_failure(request, "audience_mismatch") + return _oauth_callback_error("audience_mismatch", request) issuer = claims.get("iss") if issuer not in {"accounts.google.com", "https://accounts.google.com"}: - return _frontend_login_redirect("issuer_mismatch", request) + _log_oauth_callback_failure(request, "issuer_mismatch", issuer=issuer) + return _oauth_callback_error("issuer_mismatch", request) try: email = validate_google_identity_domain( @@ -525,21 +801,39 @@ async def callback( hosted_domain=claims.get("hd"), ) except HTTPException: - return _frontend_login_redirect("domain_not_allowed", request) + _log_oauth_callback_failure( + request, + "domain_not_allowed", + email_domain=_email_domain(str(claims.get("email") or "")), + hosted_domain=_normalize_domain(str(claims.get("hd") or "")), + ) + return _oauth_callback_error("domain_not_allowed", request) role = _role_for_email(email) display_name = str(claims.get("name") or email) + cohort_ids = _configured_cohort_ids( + email=email, + hosted_domain=str(claims.get("hd") or ""), + ) + external_id = _provider_external_id("google", str(claims.get("sub") or ""), email) try: sid, _ = await create_session( email=email, display_name=display_name, role=role.value, - cohort_ids=[], + cohort_ids=cohort_ids, + external_id=external_id, ) except InactiveUserError as exc: - return _frontend_login_redirect("inactive_user", request) + _log_oauth_callback_failure( + request, + "inactive_user", + email_domain=_email_domain(email), + ) + return _oauth_callback_error("inactive_user", request) response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302) _set_session_cookie(response, sid) + _delete_oauth_state_cookie(response) return response @@ -580,12 +874,15 @@ async def saml_acs(request: Request) -> RedirectResponse: return _frontend_login_redirect("saml_assertion_invalid", request) role = _role_for_saml_identity(identity) + cohort_ids = _configured_cohort_ids(email=email, claim_hint=identity.cohort_hint) + external_id = _provider_external_id("saml", identity.subject, email) try: sid, _ = await create_session( email=email, display_name=identity.display_name or email, role=role.value, - cohort_ids=[], + cohort_ids=cohort_ids, + external_id=external_id, ) except InactiveUserError: return _frontend_login_redirect("inactive_user", request) @@ -615,7 +912,8 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response) email=email, display_name=body.display_name or email, role=body.role, - cohort_ids=[], + cohort_ids=_configured_cohort_ids(email=email), + external_id=_provider_external_id("dev", email, email), ) except InactiveUserError as exc: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc diff --git a/apps/api/app/routes/eval.py b/apps/api/app/routes/eval.py index 7aa96cc..113a891 100644 --- a/apps/api/app/routes/eval.py +++ b/apps/api/app/routes/eval.py @@ -115,6 +115,7 @@ async def reevaluate_session( technique_codes=technique_codes, theory_mode=_theory_mode_of(sess), scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end", + audit_hook=session_persistence.record_llm_call_audit, ) except EngineError as e: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}") @@ -184,7 +185,12 @@ async def reevaluate_turn( recent_turns=recent, ) - result = await evaluator.evaluate_turn(ctx, client_reply, engine=engine_client) + result = await evaluator.evaluate_turn( + ctx, + client_reply, + engine=engine_client, + audit_hook=session_persistence.record_llm_call_audit, + ) if result.error and result.error.startswith("engine_error"): raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error) return result diff --git a/apps/api/app/routes/personas.py b/apps/api/app/routes/personas.py index a368bd5..b970173 100644 --- a/apps/api/app/routes/personas.py +++ b/apps/api/app/routes/personas.py @@ -5,20 +5,26 @@ from __future__ import annotations from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..deps import CurrentPrincipal, Principal, Role, require_role from ..persona_repository import ( CatalogPersona, + PersonaDraftRecord, PersonaReviewAction, PersonaReviewItem, + create_persona_draft, + get_persona_draft_record, list_catalog_personas, list_persona_review_queue, + update_persona_draft, update_persona_review_status, ) +from ..services.persona import PersonaCard router = APIRouter(prefix="/personas", tags=["personas"]) TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] +JSON_OBJECT_FIELD = {"additionalProperties": True} class PersonaSummary(BaseModel): @@ -51,6 +57,37 @@ class PersonaReviewDecisionRequest(BaseModel): action: PersonaReviewAction +class PersonaDraftPayload(BaseModel): + code: str = Field(min_length=1, max_length=24) + display_name: str = Field(min_length=1, max_length=80) + difficulty: Literal["easy", "moderate", "hard"] + theory_target: list[str] = Field(default_factory=list) + demographics: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + presenting: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + history: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + big5: dict[str, float] = Field(default_factory=dict) + resistance: dict[str, float] = Field(default_factory=dict) + speech_style: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + affect_baseline: dict[str, float] = Field(default_factory=dict) + ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) + source_provenance: str = Field(default="", max_length=240) + is_synthetic: bool = True + submit_for_review: bool = False + + +class PersonaDraftDetail(PersonaReviewSummary): + demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + history: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + big5: dict[str, float] + resistance: dict[str, float] + speech_style: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + affect_baseline: dict[str, float] + ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) + + def _first_text_value(data: dict[str, Any]) -> str: for value in data.values(): if isinstance(value, str) and value.strip(): @@ -88,6 +125,49 @@ def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary: ) +def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail: + card = entry.card + return PersonaDraftDetail( + **_review_summary(entry.review).model_dump(), + demographics=card.demographics, + presenting=card.presenting, + history=card.history, + big5=card.big5, + resistance=card.resistance, + speech_style=card.speech_style, + affect_baseline=card.affect_baseline, + ccd=card.ccd, + dsm5_dimensional=card.dsm5_dimensional, + ) + + +def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard: + code = request.code.strip().upper() + display_name = request.display_name.strip() + if not code: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="persona code is required") + if not display_name: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="display_name is required") + theory_target = [value.strip().lower() for value in request.theory_target if value.strip()] + return PersonaCard( + code=code, + display_name=display_name, + difficulty=request.difficulty, + theory_target=theory_target, + demographics=request.demographics, + presenting=request.presenting, + history=request.history, + big5=request.big5, + resistance=request.resistance, + speech_style=request.speech_style, + affect_baseline=request.affect_baseline, + ccd=request.ccd, + dsm5_dimensional=request.dsm5_dimensional, + source_provenance=request.source_provenance.strip(), + is_synthetic=request.is_synthetic, + ) + + def _ensure_teacher_or_admin(principal: Principal) -> None: if principal.role not in {Role.TEACHER, Role.ADMIN}: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas") @@ -127,6 +207,79 @@ async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewS return [_review_summary(entry) for entry in queue] +@router.post("/drafts", response_model=PersonaReviewSummary, status_code=status.HTTP_201_CREATED) +async def create_persona_draft_route( + request: PersonaDraftPayload, + principal: TeacherOrAdmin, +) -> PersonaReviewSummary: + """Create a draft persona card version for faculty review.""" + _ensure_teacher_or_admin(principal) + try: + created = await create_persona_draft( + card=_card_from_draft_payload(request), + author_id=principal.user_id, + role=principal.role.value, + submit_for_review=request.submit_for_review, + ) + except ValueError as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona draft database unavailable", + ) from exc + return _review_summary(created) + + +@router.get("/drafts/{persona_id}", response_model=PersonaDraftDetail) +async def get_persona_draft_route( + persona_id: str, + principal: TeacherOrAdmin, +) -> PersonaDraftDetail: + """Return a draft/review persona card for editing.""" + _ensure_teacher_or_admin(principal) + try: + record = await get_persona_draft_record(persona_id=persona_id, role=principal.role.value) + except ValueError as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona draft database unavailable", + ) from exc + if record is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found") + return _draft_detail(record) + + +@router.put("/drafts/{persona_id}", response_model=PersonaReviewSummary) +async def update_persona_draft_route( + persona_id: str, + request: PersonaDraftPayload, + principal: TeacherOrAdmin, +) -> PersonaReviewSummary: + """Update a draft/review persona card and optionally submit it for review.""" + _ensure_teacher_or_admin(principal) + try: + updated = await update_persona_draft( + persona_id=persona_id, + card=_card_from_draft_payload(request), + author_id=principal.user_id, + role=principal.role.value, + submit_for_review=request.submit_for_review, + ) + except ValueError as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona draft update database unavailable", + ) from exc + if updated is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found or locked") + return _review_summary(updated) + + @router.post("/review/{persona_id}", response_model=PersonaReviewSummary) async def decide_persona_review( persona_id: str, diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index d301a15..8cd0cc0 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -18,18 +18,19 @@ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse -from .. import db, session_persistence +from .. import db, session_persistence, turn_runtime from ..config import settings from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import get_catalog_persona -from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed +from ..runtime_policy import require_runtime_fallback_allowed from ..services import evaluator, memory, orchestrator, rag, state_machine from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) TheoryMode = Literal["humanistic", "cbt", "integrative"] +EndStateValue = str | int | float | bool | None | dict[str, float] class SessionStartRequest(BaseModel): @@ -51,6 +52,12 @@ class TurnRequest(BaseModel): text: str = Field(..., min_length=1) +class CrisisResourceResponse(BaseModel): + title: str + number: str + message: str + + class TurnResponse(BaseModel): turn_seq: int stage: str @@ -58,13 +65,15 @@ class TurnResponse(BaseModel): client_reply: Optional[str] = None safety_flagged: bool = False crisis_kind: str = "none" + crisis_resource: Optional[CrisisResourceResponse] = None + conversation_stopped: bool = False class SessionEndResponse(BaseModel): session_id: str session_no: int digest_pending: bool - end_state: dict + end_state: dict[str, EndStateValue] class LearnerSessionSummary(BaseModel): @@ -121,6 +130,12 @@ class ReviewTechnique(BaseModel): label: str +class ReviewNonverbalEvent(BaseModel): + kind: Literal["audio", "silence", "pace", "barge_in"] + label: str + detail: str + + class ReviewNote(BaseModel): author: str tone: str @@ -136,6 +151,7 @@ class ReviewTurn(BaseModel): who: str text: str techniques: list[ReviewTechnique] = Field(default_factory=list) + nonverbal: list[ReviewNonverbalEvent] = Field(default_factory=list) note: Optional[ReviewNote] = None @@ -164,6 +180,34 @@ class ReviewPoint(BaseModel): jumpTo: Optional[str] = None +class ReviewWorksheetEvidence(BaseModel): + turnId: str + speaker: Literal["learner", "client"] + quote: str + + +class ReviewWorksheetItem(BaseModel): + key: str + label: str + value: Optional[str] = None + evidence: list[ReviewWorksheetEvidence] = Field(default_factory=list) + confidence: Literal["none", "low", "medium"] = "none" + emptyReason: Optional[str] = None + + +class ReviewWorksheetSection(BaseModel): + key: str + title: str + items: list[ReviewWorksheetItem] = Field(default_factory=list) + + +class ReviewCaseWorksheet(BaseModel): + status: Literal["empty", "draft_from_transcript"] = "empty" + generatedBy: str = "rule-based transcript extractor" + sections: list[ReviewWorksheetSection] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + + class SessionReviewResponse(BaseModel): session_id: str client: ReviewClient @@ -184,6 +228,7 @@ class SessionReviewResponse(BaseModel): rubric: list[ReviewRubricRow] = Field(default_factory=list) goodMoments: list[ReviewPoint] = Field(default_factory=list) growthPoints: list[ReviewPoint] = Field(default_factory=list) + caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet) nextLine: Optional[str] = None clientFeedback: Optional[str] = None audioUrl: Optional[str] = None @@ -337,6 +382,27 @@ async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext: ) +async def _build_seed_recall(*, case_id: str | None) -> memory.RecallContext: + if not case_id: + return memory.build_recall_context() + try: + db.get_pool() + except RuntimeError: + return memory.build_recall_context() + prev_summary = await _load_prev_case_summary(case_id) + pinned = list((prev_summary or {}).get("pinned_facts") or []) + return memory.build_recall_context(prev_summary=prev_summary, pinned_facts=pinned) + + +async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext: + cached = _RECALL_CACHE.get(sess.session_id) + if cached is not None: + return cached + recall = await _build_seed_recall(case_id=sess.case_id) + _RECALL_CACHE[sess.session_id] = recall + return recall + + async def _warm_rag_caches(session_id: str, case_id: str, card) -> None: """RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단). @@ -362,13 +428,7 @@ _PHASE_KEY_BY_LABEL = { def _stage_label(stage: object) -> str: - name = getattr(stage, "name", "") - return { - "RAPPORT": "라포", - "EXPLORE": "탐색", - "INTERVENE": "개입", - "CLOSE": "정리", - }.get(name, str(getattr(stage, "value", stage))) + return turn_runtime.stage_label(stage) def _ensure_learner(principal: Principal) -> None: @@ -383,54 +443,22 @@ async def _load_session_or_404( allow_ended: bool = False, include_turn_evaluation: bool = False, ) -> InProcSession: - sess = await session_persistence.load_session( + sess, err = await turn_runtime.load_owned_session( session_id, principal, - allow_ended=True, + allow_ended=allow_ended, include_turn_evaluation=include_turn_evaluation, ) - if sess is not None: - store.put(sess) - elif runtime_fallback_allowed(): - sess = store.get(session_id) - if sess is None: + if err == turn_runtime.SessionAccessError.NOT_FOUND: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") - if sess.learner_id != principal.user_id: + if err == turn_runtime.SessionAccessError.FORBIDDEN: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user") - if sess.ended and not allow_ended: + if err == turn_runtime.SessionAccessError.ENDED: raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended") + assert sess is not None return sess -async def _append_session_turn(sess: InProcSession, turn: TurnRecord) -> None: - if await session_persistence.append_turn( - session_id=sess.session_id, - learner_id=sess.learner_id, - turn=turn, - ): - sess.turns.append(turn) - store.put(sess) - return - require_runtime_fallback_allowed("session turn append") - store.append_turn(sess.session_id, turn) - - -async def _update_session_state( - sess: InProcSession, - state: state_machine.SessionState, -) -> None: - if await session_persistence.update_state( - session_id=sess.session_id, - learner_id=sess.learner_id, - state=state, - ): - sess.state = state - store.put(sess) - return - require_runtime_fallback_allowed("session state update") - store.update_state(sess.session_id, state) - - async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None: if await session_persistence.end_session(sess, carry): sess.ended = True @@ -639,6 +667,178 @@ def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None: return None +def _worksheet_evidence(turn: ReviewTurn) -> ReviewWorksheetEvidence: + return ReviewWorksheetEvidence( + turnId=turn.id, + speaker=turn.speaker, + quote=_clip_text(turn.text, 120), + ) + + +def _worksheet_item( + *, + key: str, + label: str, + turns: list[ReviewTurn], + keywords: list[str], + preferred_speaker: Literal["learner", "client"] | None = None, + fallback_turn: ReviewTurn | None = None, +) -> ReviewWorksheetItem: + lowered_keywords = [keyword.lower() for keyword in keywords if keyword] + candidates = turns + if preferred_speaker: + preferred = [turn for turn in turns if turn.speaker == preferred_speaker] + candidates = preferred + [turn for turn in turns if turn.speaker != preferred_speaker] + + for turn in candidates: + text = _compact_text(turn.text) + lower_text = text.lower() + if lowered_keywords and any(keyword in lower_text for keyword in lowered_keywords): + return ReviewWorksheetItem( + key=key, + label=label, + value=_clip_text(text, 140), + evidence=[_worksheet_evidence(turn)], + confidence="medium", + ) + + if fallback_turn is not None: + return ReviewWorksheetItem( + key=key, + label=label, + value=_clip_text(fallback_turn.text, 140), + evidence=[_worksheet_evidence(fallback_turn)], + confidence="low", + ) + + return ReviewWorksheetItem( + key=key, + label=label, + value=None, + evidence=[], + confidence="none", + emptyReason="저장된 축어록에서 명시 근거를 찾지 못했습니다.", + ) + + +def _worksheet_section( + key: str, + title: str, + specs: list[tuple[str, str, list[str], Literal["learner", "client"] | None]], + turns: list[ReviewTurn], + fallback_client: ReviewTurn | None, + fallback_learner: ReviewTurn | None, +) -> ReviewWorksheetSection: + items: list[ReviewWorksheetItem] = [] + for item_key, label, keywords, speaker in specs: + fallback = fallback_client if speaker == "client" else fallback_learner if speaker == "learner" else None + items.append( + _worksheet_item( + key=item_key, + label=label, + turns=turns, + keywords=keywords, + preferred_speaker=speaker, + fallback_turn=fallback if item_key in {"presenting_complaint", "first_goal"} else None, + ) + ) + return ReviewWorksheetSection(key=key, title=title, items=items) + + +def _case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet: + if not turns: + return ReviewCaseWorksheet( + status="empty", + sections=[], + limitations=["저장된 축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."], + ) + + fallback_client = next((turn for turn in turns if turn.speaker == "client"), None) + fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None) + section_specs: list[ + tuple[str, str, list[tuple[str, str, list[str], Literal["learner", "client"] | None]]] + ] = [ + ( + "exploration_11", + "탐색 11항목", + [ + ("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"), + ("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"), + ("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"), + ("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"), + ("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"), + ("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"), + ("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"), + ("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"), + ("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"), + ("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None), + ("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"), + ], + ), + ( + "five_domains", + "호소 5영역", + [ + ("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"), + ("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"), + ("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"), + ("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"), + ("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"), + ], + ), + ( + "cognitive_triad_emotions", + "인지삼제·1/2차 감정", + [ + ("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"), + ("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"), + ("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"), + ("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"), + ("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"), + ], + ), + ( + "protective_barrier_quadrants", + "보호·방해 4사분면", + [ + ("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None), + ("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"), + ("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"), + ("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"), + ], + ), + ( + "biopsychosocial_goals", + "생물·심리·사회 목표", + [ + ("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"), + ("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None), + ("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None), + ], + ), + ] + + sections = [ + _worksheet_section( + key, + title, + specs, + turns, + fallback_client, + fallback_learner, + ) + for key, title, specs in section_specs + ] + return ReviewCaseWorksheet( + status="draft_from_transcript", + sections=sections, + limitations=[ + "저장된 축어록에서 키워드 근거를 추출한 1차 초안입니다.", + "임상팀 루브릭, 교수자 검수, 학습자 수정 입력 전에는 확정 사례개념화로 보지 않습니다.", + ], + ) + + def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: if not record: return {} @@ -697,35 +897,88 @@ def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[Review return None -async def _record_safety_event(sess: InProcSession, ctx, result) -> None: - """위기 escalate 시 app.safety_events 적재(교수자 감사·알림 레코드). C2. +def _seconds_label(milliseconds: int) -> str: + seconds = max(0, milliseconds) / 1000.0 + if seconds >= 10: + return f"{seconds:.0f}초" + return f"{seconds:.1f}초" - 비차단: DB 미가용(degraded)·FK 미충족(in-memory 세션) 시 graceful skip — 상담 루프를 - 절대 막지 않는다. 실시간 교수자 push 알림은 후속(이 레코드가 1차 알림원). - """ - crisis = getattr(ctx, "crisis", None) - if crisis is None or not getattr(crisis, "escalate", False): - return - kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) - try: - async with db.acquire() as conn: - await conn.execute( - """ - INSERT INTO app.safety_events - (session_id, trigger_type, ko_risk_level, escalated, detail) - VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb) - """, - sess.session_id, - kind, - int(getattr(crisis, "risk_level", 0) or 0), - json.dumps({ - "matched": list(getattr(crisis, "matched", []) or []), - "stage": getattr(result, "stage", None), - "turn_seq": getattr(result, "turn_seq", None), - }), + +def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]: + events: list[ReviewNonverbalEvent] = [] + if turn.silence_ms is not None and turn.silence_ms >= 1000: + events.append( + ReviewNonverbalEvent( + kind="silence", + label="침묵", + detail=_seconds_label(turn.silence_ms), ) + ) + if turn.speech_rate is not None: + events.append( + ReviewNonverbalEvent( + kind="pace", + label="발화 속도", + detail=f"분당 {turn.speech_rate:.0f}자", + ) + ) + if turn.barge_in is True: + events.append( + ReviewNonverbalEvent( + kind="barge_in", + label="끼어듦", + detail="내담자 발화 중 시작", + ) + ) + if turn.audio_ref: + events.append( + ReviewNonverbalEvent( + kind="audio", + label="음성 입력", + detail="음성으로 기록됨", + ) + ) + return events + + +async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str) -> Optional[dict]: + """stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다.""" + if not final_reply: + return None + try: + hook = evaluator.make_eval_hook( + engine_client, + audit_hook=session_persistence.record_llm_call_audit, + ) + return await hook(ctx, final_reply) except Exception: - pass # 비차단(R5): 적재 실패가 위기 대응/상담을 막지 않음. + return None + + +def _stream_result_from_done( + ctx: orchestrator.TurnContext, + final_reply: str, + data: dict[str, object], + evaluation: Optional[dict], +) -> orchestrator.TurnResult: + assert ctx.state_after is not None + return orchestrator.TurnResult( + turn_seq=ctx.state_after.turn_seq, + stage=_stage_label(ctx.state_after.stage), + effective_openness=ctx.state_after.effective_openness, + client_reply=final_reply or None, + safety_flagged=bool(data.get("safety_flagged")), + state_after=ctx.state_after, + evaluation=evaluation, + crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none", + crisis_resource=data.get("crisis_resource") if isinstance(data.get("crisis_resource"), dict) else None, + conversation_stopped=bool(data.get("conversation_stopped")), + llm_provider=str(data.get("llm_provider") or "") or None, + model=str(data.get("model") or "") or None, + tokens_in=int(data.get("tokens_in") or 0), + tokens_out=int(data.get("tokens_out") or 0), + cost_usd=float(data.get("cost_usd") or 0.0), + ) def _learner_visible_turns(sess: InProcSession) -> list[TurnRecord]: @@ -752,6 +1005,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: technique_codes=[], theory_mode=sess.theory_mode, scope="session_end", + audit_hook=session_persistence.record_llm_call_audit, ), timeout=min(float(settings.engine_timeout), 45.0), ) @@ -907,7 +1161,12 @@ async def start_session( raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}") card = catalog_persona.card - recall = memory.build_recall_context() + case_context = await session_persistence.get_case_context( + learner_id=principal.user_id, + persona_id=catalog_persona.persona_id, + ) + recall = await _build_seed_recall(case_id=case_context.case_id if case_context else None) + session_no = (case_context.last_session_no + 1) if case_context else 1 st = state_machine.init_state( params=card.openness_params(), carry=recall.carry, @@ -919,10 +1178,11 @@ async def start_session( card=card, theory_mode=body.theory_mode, state=st, - session_no=1, + session_no=session_no, carry_rapport=carry_rapport, persona_id=catalog_persona.persona_id, persona_version=catalog_persona.version, + case_id=case_context.case_id if case_context else None, ) degraded = catalog_persona.degraded or sess is None if sess is None: @@ -932,7 +1192,7 @@ async def start_session( persona=card, theory_mode=body.theory_mode, state=st, - session_no=1, + session_no=session_no, carry_rapport=carry_rapport, ) else: @@ -1007,6 +1267,7 @@ async def get_session_review( who="학습자" if speaker == "learner" else client_name, text=turn.text_masked, techniques=_review_techniques_from_turn_eval(turn_eval), + nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [], note=_review_note_from_turn_eval(turn_eval), ) ) @@ -1085,6 +1346,7 @@ async def get_session_review( rubric=rubric, goodMoments=good_moments, growthPoints=growth_points, + caseWorksheet=_case_worksheet_from_turns(turns), nextLine=next_line, clientFeedback=client_feedback, audioUrl=None, @@ -1103,7 +1365,7 @@ async def submit_turn( """Submit one trainee utterance and return the generated client reply.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) - recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() + recall = await ensure_recall_context(sess) kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) ctx = orchestrator.prepare_turn( @@ -1124,7 +1386,11 @@ async def submit_turn( result = await orchestrator.run_turn_generate( ctx, engine_client, - eval_hook=evaluator.make_eval_hook(engine_client), + eval_hook=evaluator.make_eval_hook( + engine_client, + audit_hook=session_persistence.record_llm_call_audit, + ), + audit_hook=session_persistence.record_llm_call_audit, ) except EngineError as exc: raise HTTPException( @@ -1132,37 +1398,13 @@ async def submit_turn( detail=f"engine unavailable: {exc}", ) from exc - # 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈). - await _append_session_turn( + await turn_runtime.record_completed_turn( sess, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="counselor", - stage=_stage_label(ctx.state_after.stage), - text=body.text, - text_masked=ctx.learner_text_masked, - evaluation=result.evaluation, - ), + ctx, + result, + context_prefix="session", ) - - if result.client_reply: - await _append_session_turn( - sess, - TurnRecord( - turn_seq=result.turn_seq, - speaker="client", - stage=_stage_label(result.state_after.stage), - text=result.client_reply, - text_masked=result.client_reply, - llm_provider=result.llm_provider, - model=result.model, - tokens_in=result.tokens_in, - tokens_out=result.tokens_out, - cost_usd=result.cost_usd, - ), - ) - await _update_session_state(sess, result.state_after) - await _record_safety_event(sess, ctx, result) # C2: 위기 escalate 시 safety_events 적재(비차단) + await turn_runtime.record_safety_event(sess, ctx, result) return TurnResponse( turn_seq=result.turn_seq, @@ -1171,6 +1413,8 @@ async def submit_turn( client_reply=result.client_reply, safety_flagged=result.safety_flagged, crisis_kind=result.crisis_kind, + crisis_resource=result.crisis_resource, + conversation_stopped=result.conversation_stopped, ) @@ -1183,7 +1427,7 @@ async def stream_turn( """Stream a generated client reply for one trainee utterance.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) - recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() + recall = await ensure_recall_context(sess) kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) ctx = orchestrator.prepare_turn( @@ -1204,40 +1448,26 @@ async def stream_turn( last_beat = asyncio.get_running_loop().time() final_reply = "" try: - async for ev in orchestrator.run_turn_stream(ctx, engine_client): + async for ev in orchestrator.run_turn_stream( + ctx, + engine_client, + audit_hook=session_persistence.record_llm_call_audit, + ): if ev.event == "token": text = str(ev.data.get("text", "")) final_reply += text yield {"event": "token", "data": text} elif ev.event == "done": data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)} - await _append_session_turn( + evaluation = await _evaluate_stream_turn(ctx, final_reply) + result = _stream_result_from_done(ctx, final_reply, data, evaluation) + await turn_runtime.record_completed_turn( sess, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="counselor", - stage=_stage_label(ctx.state_after.stage), - text=body.text, - text_masked=ctx.learner_text_masked, - ), + ctx, + result, + context_prefix="session", ) - await _update_session_state(sess, ctx.state_after) - if final_reply: - await _append_session_turn( - sess, - TurnRecord( - turn_seq=ctx.state_after.turn_seq, - speaker="client", - stage=_stage_label(ctx.state_after.stage), - text=final_reply, - text_masked=final_reply, - llm_provider=str(ev.data.get("llm_provider") or ""), - model=str(ev.data.get("model") or ""), - tokens_in=int(ev.data.get("tokens_in") or 0), - tokens_out=int(ev.data.get("tokens_out") or 0), - cost_usd=float(ev.data.get("cost_usd") or 0.0), - ), - ) + await turn_runtime.record_safety_event(sess, ctx, result) yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} else: yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py index aca9824..dba8c7e 100644 --- a/apps/api/app/routes/teacher.py +++ b/apps/api/app/routes/teacher.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends from pydantic import BaseModel, Field @@ -34,17 +34,70 @@ class TeacherSessionSummary(BaseModel): ended_at: str | None = None +class TeacherGrowthPoint(BaseModel): + session_id: str + session_no: int + persona_code: str + stage: str + started_at: str + ended_at: str | None = None + score: float | None = None + rapport: float | None = None + technique_count: int = 0 + watch_count: int = 0 + + +class TeacherLearnerGrowth(BaseModel): + learner_id: str + learner_label: str + sessions: int + ended_sessions: int + latest_at: str + first_score: float | None = None + latest_score: float | None = None + score_delta: float | None = None + avg_score: float | None = None + avg_rapport: float | None = None + trend: str = "insufficient" + top_techniques: list[str] = Field(default_factory=list) + points: list[TeacherGrowthPoint] = Field(default_factory=list) + + +class TeacherSafetyAlert(BaseModel): + id: str + session_id: str + learner_id: str + learner_label: str + persona_code: str + session_no: int + trigger_type: str + ko_risk_level: int + escalated: bool + created_at: str + resource_title: str = "자살예방상담전화 109" + resource_number: str = "109" + + class TeacherDashboardResponse(BaseModel): source: str = "in_memory" cohort_label: str = "현재 학습 기록" total_learners: int active_sessions: int ended_sessions: int + safety_alerts: list[TeacherSafetyAlert] = Field(default_factory=list) + learner_growth: list[TeacherLearnerGrowth] = Field(default_factory=list) pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list) recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list) message: str +_APPROPRIATENESS_SCORE = { + "neg": 0.0, + "neutral": 0.5, + "pos": 1.0, +} + + def _iso(ts: float | None) -> str | None: if ts is None: return None @@ -56,6 +109,149 @@ def _learner_label(learner_id: str) -> str: return f"학습자 {suffix}" +def _safe_float(value: object) -> float | None: + try: + return float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _avg(values: list[float]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 3) + + +def _turn_eval(turn: Any) -> dict[str, Any] | None: + ev = getattr(turn, "evaluation", None) + return ev if isinstance(ev, dict) else None + + +def _turn_score(ev: dict[str, Any]) -> float | None: + raw = str(ev.get("appropriateness") or "").strip().lower() + return _APPROPRIATENESS_SCORE.get(raw) + + +def _turn_rapport(ev: dict[str, Any]) -> float | None: + value = _safe_float(ev.get("rapport_signal")) + if value is None: + return None + return max(-1.0, min(1.0, value)) + + +def _turn_techniques(ev: dict[str, Any]) -> list[str]: + raw = ev.get("techniques") + if not isinstance(raw, list): + return [] + labels: list[str] = [] + for item in raw: + if isinstance(item, dict): + label = item.get("label") or item.get("name") or item.get("id") + else: + label = item + if label: + labels.append(str(label)) + return labels + + +def _session_growth_point(sess: InProcSession) -> TeacherGrowthPoint: + scores: list[float] = [] + rapports: list[float] = [] + technique_count = 0 + watch_count = 0 + for turn in sess.turns: + if turn.speaker != "counselor": + continue + ev = _turn_eval(turn) + if ev is None: + continue + score = _turn_score(ev) + if score is not None: + scores.append(score) + if score < 1.0: + watch_count += 1 + rapport = _turn_rapport(ev) + if rapport is not None: + rapports.append(rapport) + technique_count += len(_turn_techniques(ev)) + return TeacherGrowthPoint( + session_id=sess.session_id, + session_no=sess.session_no, + persona_code=sess.persona_code, + stage=sess.state.stage.value, + started_at=_iso(sess.created_at) or "", + ended_at=_iso(sess.ended_at), + score=_avg(scores), + rapport=_avg(rapports), + technique_count=technique_count, + watch_count=watch_count, + ) + + +def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerGrowth]: + grouped: dict[str, list[InProcSession]] = {} + for sess in sessions: + grouped.setdefault(sess.learner_id, []).append(sess) + + result: list[TeacherLearnerGrowth] = [] + for learner_id, learner_sessions in grouped.items(): + ordered = sorted(learner_sessions, key=lambda sess: sess.created_at) + points = [_session_growth_point(sess) for sess in ordered] + scored = [point for point in points if point.score is not None] + rapport_values = [point.rapport for point in points if point.rapport is not None] + technique_counts: dict[str, int] = {} + for sess in ordered: + for turn in sess.turns: + if turn.speaker != "counselor": + continue + ev = _turn_eval(turn) + if ev is None: + continue + for label in _turn_techniques(ev): + technique_counts[label] = technique_counts.get(label, 0) + 1 + + first_score = scored[0].score if scored else None + latest_score = scored[-1].score if scored else None + score_delta: float | None = None + trend = "insufficient" + if first_score is not None and latest_score is not None: + score_delta = round(latest_score - first_score, 3) + if len(scored) >= 2: + if score_delta >= 0.1: + trend = "up" + elif score_delta <= -0.1: + trend = "down" + else: + trend = "flat" + + latest_session = ordered[-1] + top_techniques = [ + label + for label, _count in sorted( + technique_counts.items(), + key=lambda item: (-item[1], item[0]), + )[:3] + ] + result.append( + TeacherLearnerGrowth( + learner_id=learner_id, + learner_label=_learner_label(learner_id), + sessions=len(ordered), + ended_sessions=sum(1 for sess in ordered if sess.ended), + latest_at=_iso(latest_session.ended_at or latest_session.created_at) or "", + first_score=first_score, + latest_score=latest_score, + score_delta=score_delta, + avg_score=_avg([point.score for point in scored if point.score is not None]), + avg_rapport=_avg([value for value in rapport_values if value is not None]), + trend=trend, + top_techniques=top_techniques, + points=points[-6:], + ) + ) + return sorted(result, key=lambda item: item.latest_at, reverse=True)[:12] + + def _summary(sess: InProcSession) -> TeacherSessionSummary: learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor") client_turns = sum(1 for turn in sess.turns if turn.speaker == "client") @@ -79,13 +275,45 @@ def _summary(sess: InProcSession) -> TeacherSessionSummary: @router.get("/dashboard", response_model=TeacherDashboardResponse) async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse: """Return teacher-visible dashboard data from real sessions only.""" - sessions, durable = await session_persistence.list_sessions(principal) + sessions, durable = await session_persistence.list_sessions( + principal, + include_turn_evaluation=True, + ) if not durable: require_runtime_fallback_allowed("teacher dashboard") sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True) summaries = [_summary(sess) for sess in sessions] pending_reviews = [item for item in summaries if item.status == "ended"] learners = {sess.learner_id for sess in sessions} + learner_growth = _build_learner_growth(sessions) + safety_alerts: list[TeacherSafetyAlert] = [] + if durable: + raw_alerts, alerts_durable = await session_persistence.list_safety_alerts(principal) + if alerts_durable: + safety_alerts = [ + TeacherSafetyAlert( + id=str(item.get("id") or ""), + session_id=str(item.get("session_id") or ""), + learner_id=str(item.get("learner_id") or ""), + learner_label=str(item.get("learner_label") or "학습자"), + persona_code=str(item.get("persona_code") or ""), + session_no=int(item.get("session_no") or 0), + trigger_type=str(item.get("trigger_type") or "crisis"), + ko_risk_level=int(item.get("ko_risk_level") or 0), + escalated=bool(item.get("escalated")), + created_at=str(item.get("created_at") or ""), + resource_title=str( + (item.get("detail") or {}).get("crisis_resource", {}).get( + "title", + "자살예방상담전화 109", + ) + ), + resource_number=str( + (item.get("detail") or {}).get("crisis_resource", {}).get("number", "109") + ), + ) + for item in raw_alerts + ] if sessions: message = "현재 기록된 실제 학습 세션만 표시합니다." @@ -97,6 +325,8 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp total_learners=len(learners), active_sessions=sum(1 for sess in sessions if not sess.ended), ended_sessions=sum(1 for sess in sessions if sess.ended), + safety_alerts=safety_alerts, + learner_growth=learner_growth, pending_reviews=pending_reviews[:20], recent_sessions=summaries[:20], message=message, diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 90903f1..58585f4 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -22,14 +22,14 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse from starlette.websockets import WebSocketState -from .. import session_persistence +from .. import session_persistence, turn_runtime from ..auth_sessions import get_session from ..config import settings from ..deps import Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import get_catalog_persona -from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed -from ..services import evaluator, memory, orchestrator, state_machine +from ..runtime_policy import require_runtime_fallback_allowed +from ..services import evaluator, orchestrator, state_machine from ..services import voice as voice_svc from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service from ..store import InProcSession, TurnRecord, store @@ -292,7 +292,10 @@ async def _run_turn_and_speak( await _safe_send_json(websocket, {"type": "state", "state": "idle"}) return - recall = memory.RecallContext() + from . import sessions as session_routes + + recall = await session_routes.ensure_recall_context(sess) + kb_cues = session_routes._KB_CUES_CACHE.get(session_id) or [] ctx = orchestrator.prepare_turn( session_id=session_id, case_id=sess.case_id, @@ -302,6 +305,7 @@ async def _run_turn_and_speak( recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, recent_turns=sess.recent_turns(visible_to="client"), + kb_behavior_cues=kb_cues, theory_mode=sess.theory_mode, ) assert ctx.state_after is not None @@ -311,7 +315,11 @@ async def _run_turn_and_speak( result = await orchestrator.run_turn_generate( ctx, engine_client, - eval_hook=evaluator.make_eval_hook(engine_client), + eval_hook=evaluator.make_eval_hook( + engine_client, + audit_hook=session_persistence.record_llm_call_audit, + ), + audit_hook=session_persistence.record_llm_call_audit, ) except EngineError as e: await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"}) @@ -321,12 +329,15 @@ async def _run_turn_and_speak( reply = result.client_reply or "" # Persist only after the client reply has been generated. A failed AI turn # must not leave a learner-only transcript in review or history. - await _append_voice_turn( + await turn_runtime.record_completed_turn( sess, - TurnRecord( + ctx, + result, + context_prefix="voice session", + counselor_turn=TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", - stage=ctx.state_after.stage.value, + stage=turn_runtime.stage_label(ctx.state_after.stage), text=learner_text, text_masked=ctx.learner_text_masked, audio_ref=audio_ref, @@ -336,24 +347,7 @@ async def _run_turn_and_speak( evaluation=result.evaluation, ), ) - if reply: - # Persist the generated client reply before TTS playback. - await _append_voice_turn( - sess, - TurnRecord( - turn_seq=result.turn_seq, - speaker="client", - stage=result.stage, - text=reply, - text_masked=reply, - llm_provider=result.llm_provider, - model=result.model, - tokens_in=result.tokens_in, - tokens_out=result.tokens_out, - cost_usd=result.cost_usd, - ), - ) - await _update_voice_state(sess, result.state_after) + await turn_runtime.record_safety_event(sess, ctx, result) # Send the final client text before audio playback. await _safe_send_json( @@ -367,6 +361,8 @@ async def _run_turn_and_speak( "turn_seq": result.turn_seq, "safety_flagged": result.safety_flagged, "crisis_kind": result.crisis_kind, + "crisis_resource": result.crisis_resource, + "conversation_stopped": result.conversation_stopped, }, ) @@ -403,49 +399,17 @@ async def _load_voice_session( session_id: str, principal: Principal, ) -> tuple[InProcSession | None, str | None]: - sess = await session_persistence.load_session(session_id, principal, allow_ended=True) - if sess is not None: - store.put(sess) - elif runtime_fallback_allowed(): - sess = store.get(session_id) - if sess is None: + sess, err = await turn_runtime.load_owned_session(session_id, principal) + if err == turn_runtime.SessionAccessError.NOT_FOUND: return None, f"unknown session {session_id}" - if sess.learner_id != principal.user_id: + if err == turn_runtime.SessionAccessError.FORBIDDEN: return None, "session does not belong to user" - if sess.ended: + if err == turn_runtime.SessionAccessError.ENDED: return None, "session already ended" + assert sess is not None return sess, None -async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None: - if await session_persistence.append_turn( - session_id=sess.session_id, - learner_id=sess.learner_id, - turn=turn, - ): - sess.turns.append(turn) - store.put(sess) - return - require_runtime_fallback_allowed("voice session turn append") - store.append_turn(sess.session_id, turn) - - -async def _update_voice_state( - sess: InProcSession, - state: state_machine.SessionState, -) -> None: - if await session_persistence.update_state( - session_id=sess.session_id, - learner_id=sess.learner_id, - state=state, - ): - sess.state = state - store.put(sess) - return - require_runtime_fallback_allowed("voice session state update") - store.update_state(sess.session_id, state) - - async def _principal_from_websocket(websocket: WebSocket) -> Principal | None: """Restore the same server-side browser session used by REST routes.""" raw_cookie = websocket.cookies.get(settings.cookie_name) diff --git a/apps/api/app/saml.py b/apps/api/app/saml.py index bf36950..47a7d63 100644 --- a/apps/api/app/saml.py +++ b/apps/api/app/saml.py @@ -41,13 +41,24 @@ SAML_ATTRIBUTE_DISPLAY_NAME_NAMES = { "cn", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", } +SAML_ATTRIBUTE_COHORT_NAMES = { + "cohort", + "cohort_id", + "cohortIds", + "cohorts", + "class", + "group", + "groups", +} @dataclass(frozen=True, slots=True) class SamlIdentity: + subject: str email: str display_name: str role_hint: str | None = None + cohort_hint: str | None = None def acs_url_for_entity_id(entity_id: str) -> str: @@ -120,7 +131,14 @@ def parse_fixture_response(encoded_response: str) -> SamlIdentity: or email ) role_hint = _first_attribute(attributes, SAML_ATTRIBUTE_ROLE_NAMES) - return SamlIdentity(email=email, display_name=display_name or email, role_hint=role_hint) + cohort_hint = _first_attribute(attributes, SAML_ATTRIBUTE_COHORT_NAMES) + return SamlIdentity( + subject=name_id or email, + email=email, + display_name=display_name or email, + role_hint=role_hint, + cohort_hint=cohort_hint or None, + ) def _first_text(root: ElementTree.Element, selector: str) -> str: diff --git a/apps/api/app/services/dataset_export.py b/apps/api/app/services/dataset_export.py new file mode 100644 index 0000000..b24f543 --- /dev/null +++ b/apps/api/app/services/dataset_export.py @@ -0,0 +1,400 @@ +"""Phase 3 recursive-learning dataset export helpers. + +The exporter is intentionally conservative: it only emits masked text, keeps raw +database identifiers out of JSONL records, and never upgrades an artifact to an +approved seed dataset unless the explicit approval and agreement gates pass. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from datetime import UTC, date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence +from uuid import UUID + +DATASET_ITEM_SCHEMA = "phase3_dataset_item_v1" +APPROVED_EXPORT_STATUS = "approved_for_recursive_learning_seed" +DRY_RUN_EXPORT_STATUS = "technical_dry_run" +BLOCKED_EXPORT_STATUS = "blocked" +EXPORT_STATUSES = {APPROVED_EXPORT_STATUS, DRY_RUN_EXPORT_STATUS, BLOCKED_EXPORT_STATUS} + +BLOCKED_FIELD_NAMES = { + "name", + "email", + "phone", + "student_id", + "national_id", + "address", + "date_of_birth", + "raw_audio_path", + "raw_voice", + "raw_source_case", + "identity_map", + "api_key", + "api_keys", + "access_token", + "refresh_token", + "token", + "cookie", + "credentials", + "credential", + "secret", +} + +PII_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("email", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I)), + ( + "phone", + re.compile(r"\b(?:\+?82[-. ]?)?(?:0?1[016789]|0[2-9]\d?)[-. ]?\d{3,4}[-. ]?\d{4}\b"), + ), + ("national_id", re.compile(r"\b\d{6}[- ]?[1-4]\d{6}\b")), + ("student_id", re.compile(r"\b20\d{2}[- ]?\d{4,8}\b")), + ( + "secret", + re.compile( + r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?secret|cookie)\b\s*[:=]\s*\S+" + ), + ), + ("secret", re.compile(r"\b(?:sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)\b")), +) + + +@dataclass +class ExportKeyMaps: + participant: dict[str, str] = field(default_factory=dict) + session: dict[str, str] = field(default_factory=dict) + + def participant_key(self, raw_id: Any) -> str: + key = str(raw_id or "unknown-participant") + if key not in self.participant: + self.participant[key] = f"PX-{len(self.participant) + 1:04d}" + return self.participant[key] + + def session_key(self, raw_id: Any) -> str: + key = str(raw_id or "unknown-session") + if key not in self.session: + self.session[key] = f"SX-{len(self.session) + 1:04d}" + return self.session[key] + + +def json_safe(value: Any) -> Any: + if isinstance(value, (datetime, date)): + if isinstance(value, datetime) and value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat().replace("+00:00", "Z") + if isinstance(value, (UUID, Decimal)): + return str(value) + if isinstance(value, Mapping): + return {str(key): json_safe(item) for key, item in value.items()} + if isinstance(value, list): + return [json_safe(item) for item in value] + if isinstance(value, tuple): + return [json_safe(item) for item in value] + return value + + +def normalize_json_value(value: Any) -> Any: + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith(("{", "[")): + try: + return json.loads(stripped) + except json.JSONDecodeError: + return value + return value + + +def _redacted_sample(kind: str, value: str) -> str: + if kind == "email" and "@" in value: + return f"" + return f"<{kind}>" + + +def _scan_text(value: str, path: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for kind, pattern in PII_PATTERNS: + for match in pattern.finditer(value): + findings.append( + { + "kind": kind, + "path": path, + "sample": _redacted_sample(kind, match.group(0)), + } + ) + return findings + + +def scan_for_pii(value: Any, path: str = "$") -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + if isinstance(value, Mapping): + for raw_key, item in value.items(): + key = str(raw_key) + next_path = f"{path}.{key}" + if key.lower() in BLOCKED_FIELD_NAMES: + findings.append({"kind": "blocked_field", "path": next_path, "sample": f"<{key.lower()}>"}) + findings.extend(scan_for_pii(item, next_path)) + return findings + if isinstance(value, list): + for index, item in enumerate(value): + findings.extend(scan_for_pii(item, f"{path}[{index}]")) + return findings + if isinstance(value, str): + findings.extend(_scan_text(value, path)) + return findings + + +def build_dataset_record( + row: Mapping[str, Any], + *, + item_index: int, + export_manifest_id: str, + keys: ExportKeyMaps, + pii_scan_status: str = "pending", + consent_scope: str = "recursive_learning_seed", +) -> dict[str, Any]: + text_masked = str(row.get("text_masked") or "").strip() + if not text_masked: + raise ValueError("dataset export requires non-empty text_masked") + + participant_key = keys.participant_key(row.get("learner_id")) + session_key = keys.session_key(row.get("session_id")) + persona_code = row.get("persona_code") or row.get("persona_id") or "unknown" + + return { + "schema": DATASET_ITEM_SCHEMA, + "item_id": f"DI-{item_index:06d}", + "participant_key": participant_key, + "session_key": session_key, + "turn_key": f"TX-{item_index:06d}", + "persona_id": str(persona_code), + "stage": row.get("stage") or "", + "speaker": row.get("speaker") or "", + "text_masked": text_masked, + "techniques": json_safe(normalize_json_value(row.get("techniques") or [])), + "client_states": json_safe(normalize_json_value(row.get("client_states") or [])), + "feedback_scores": json_safe(normalize_json_value(row.get("feedback_scores") or [])), + "supervisor_comments": json_safe(normalize_json_value(row.get("supervisor_comments") or [])), + "source_refs": { + "session_started_at": json_safe(row.get("session_started_at") or row.get("started_at")), + "export_manifest_id": export_manifest_id, + }, + "privacy": { + "direct_identifiers_removed": True, + "pii_scan_status": pii_scan_status, + "consent_scope": consent_scope, + }, + } + + +def write_jsonl(records: Sequence[Mapping[str, Any]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + for record in records: + handle.write(json.dumps(json_safe(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"))) + handle.write("\n") + + +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 cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> float | None: + pairs: list[tuple[Any, Any]] = [] + by_item: dict[Any, list[Any]] = defaultdict(list) + for annotation in annotations: + labels = normalize_json_value(annotation.get("labels") or {}) + if not isinstance(labels, Mapping) or label_key not in labels: + continue + by_item[annotation.get("item_id")].append(labels[label_key]) + for values in by_item.values(): + if len(values) >= 2: + pairs.append((values[0], values[1])) + if not pairs: + return None + + total = len(pairs) + observed = sum(1 for left, right in pairs if left == right) / total + left_counts = Counter(left for left, _ in pairs) + right_counts = Counter(right for _, right in pairs) + expected = sum((left_counts[label] / total) * (right_counts[label] / total) for label in set(left_counts) | set(right_counts)) + if math.isclose(1.0, expected): + return 1.0 if math.isclose(1.0, observed) else None + return round((observed - expected) / (1.0 - expected), 4) + + +def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key: str) -> float | None: + by_item: dict[Any, list[float]] = defaultdict(list) + for annotation in annotations: + labels = normalize_json_value(annotation.get("labels") or {}) + if not isinstance(labels, Mapping) or score_key not in labels: + continue + try: + by_item[annotation.get("item_id")].append(float(labels[score_key])) + except (TypeError, ValueError): + continue + + matrix = [values[:2] for values in by_item.values() if len(values) >= 2] + if len(matrix) < 2: + return None + n = len(matrix) + k = 2 + row_means = [sum(row) / k for row in matrix] + col_means = [sum(row[col] for row in matrix) / n for col in range(k)] + grand_mean = sum(row_means) / n + + msr = k * sum((mean - grand_mean) ** 2 for mean in row_means) / (n - 1) + msc = n * sum((mean - grand_mean) ** 2 for mean in col_means) / (k - 1) + residual = 0.0 + for row_index, row in enumerate(matrix): + for col_index, value in enumerate(row): + residual += (value - row_means[row_index] - col_means[col_index] + grand_mean) ** 2 + mse = residual / ((n - 1) * (k - 1)) + denominator = msr + (k - 1) * mse + (k * (msc - mse) / n) + if math.isclose(denominator, 0.0): + return None + return round((msr - mse) / denominator, 4) + + +def infer_source_window(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + starts = [record.get("source_refs", {}).get("session_started_at") for record in records] + starts = [value for value in starts if value] + return { + "started_at": min(starts) if starts else "", + "ended_at": max(starts) if starts else "", + } + + +def build_manifest( + *, + export_id: str, + dataset_name: str, + export_status: str, + purpose: str, + records: Sequence[Mapping[str, Any]], + jsonl_path: str, + jsonl_sha256: str, + pii_findings: Sequence[Mapping[str, Any]], + participants_included: int, + participants_excluded: int = 0, + cohort_id: str = "phase3", + consent_version: str = "", + agreement: Mapping[str, Any] | None = None, + approvals: Mapping[str, str] | None = None, + known_limitations: Sequence[str] | None = None, + created_at: datetime | None = None, +) -> dict[str, Any]: + if export_status not in EXPORT_STATUSES: + raise ValueError(f"unsupported export_status: {export_status}") + + agreement_payload = { + "kappa": None, + "icc": None, + "gold_status": "not_gold", + } + if agreement: + agreement_payload.update(dict(agreement)) + + approvals_payload = { + "data_steward": "", + "legal_or_privacy_reviewer": "", + "technical_operator": "", + "approved_at": "", + } + if approvals: + approvals_payload.update({key: value for key, value in approvals.items() if value is not None}) + + pii_status = "pass" if not pii_findings else "fail" + limitations = list(known_limitations or []) + if export_status != APPROVED_EXPORT_STATUS: + limitations.append("technical dry-run only; data-steward/legal approval is not complete") + if pii_findings: + limitations.append("PII scan found records requiring reviewer disposition") + + manifest = { + "export_id": export_id, + "dataset_name": dataset_name, + "export_status": export_status, + "created_at": json_safe(created_at or datetime.now(UTC)), + "purpose": purpose, + "source_window": infer_source_window(records), + "source_tables": [ + "app.sessions", + "app.turns", + "app.feedback_scores", + "app.turn_technique", + "app.turn_client_state", + "app.supervisor_comment", + "ds.annotation", + "ds.export_manifest", + ], + "selection_criteria": { + "cohort_id": cohort_id, + "min_completed_sessions": 0, + "include_withdrawn": False, + "excluded_safety_scope": ["self_harm_scenario_primary"], + }, + "consent_scope": { + "consent_version": consent_version, + "allowed_uses": ["education_quality_review", "recursive_learning_seed"], + "withdrawal_cutoff_applied_at": "", + "participants_included": participants_included, + "participants_excluded": participants_excluded, + }, + "anonymization": { + "participant_key": "pseudonymous export key; no identity map included", + "text_transform": "masked_text_only", + "direct_identifier_policy": "blocked", + "salt_or_identity_map_location": "not in export", + }, + "pii_scan": { + "tool": "vignette.dataset_export.regex", + "version": "1", + "ran_at": json_safe(datetime.now(UTC)), + "status": pii_status, + "findings": [json_safe(finding) for finding in pii_findings], + }, + "agreement": agreement_payload, + "files": [ + { + "path": jsonl_path, + "rows": len(records), + "sha256": jsonl_sha256, + "schema": DATASET_ITEM_SCHEMA, + } + ], + "approvals": approvals_payload, + "known_limitations": sorted(set(limitations)), + } + validate_manifest_gate(manifest) + return manifest + + +def validate_manifest_gate(manifest: Mapping[str, Any]) -> None: + if manifest.get("export_status") != APPROVED_EXPORT_STATUS: + return + errors: list[str] = [] + pii_scan = manifest.get("pii_scan") or {} + agreement = manifest.get("agreement") or {} + approvals = manifest.get("approvals") or {} + if pii_scan.get("status") != "pass": + errors.append("PII scan must pass") + if (agreement.get("kappa") or 0) < 0.60: + errors.append("kappa must be >= 0.60") + if (agreement.get("icc") or 0) < 0.75: + errors.append("ICC must be >= 0.75") + for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"): + if not str(approvals.get(key) or "").strip(): + errors.append(f"approval missing: {key}") + if errors: + raise ValueError("; ".join(errors)) diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py index dd0c395..6501399 100644 --- a/apps/api/app/services/evaluator.py +++ b/apps/api/app/services/evaluator.py @@ -26,6 +26,7 @@ from __future__ import annotations import json import os +import time from typing import TYPE_CHECKING, Any, Optional from pydantic import BaseModel, Field @@ -48,7 +49,7 @@ from ..taxonomy import ( ) if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용. - from .orchestrator import TurnContext + from .orchestrator import LlmAuditHook, TurnContext # ════════════════════════════════════════════════════════════════════════════ @@ -624,6 +625,7 @@ async def evaluate_turn( client_reply: str, *, engine: EngineClient, + audit_hook: Optional["LlmAuditHook"] = None, ) -> TurnEvaluation: """fast-loop 턴 평가 — 턴 직후 경량 4차원 태깅(비치명적). @@ -644,7 +646,20 @@ async def evaluate_turn( session_id=ctx.session_id, metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq}, ) + started = time.perf_counter() resp = await engine.generate(req) + latency_ms = int((time.perf_counter() - started) * 1000) + await _record_llm_audit( + audit_hook, + session_id=ctx.session_id, + provider=resp.provider, + model=resp.model, + tokens_in=resp.tokens_in, + tokens_out=resp.tokens_out, + cost_usd=resp.cost_usd, + inference_geo=resp.inference_geo, + latency_ms=latency_ms, + ) except EngineError as e: base.error = f"engine_error: {e}" return base @@ -672,6 +687,7 @@ async def evaluate_session( technique_codes: Optional[list[str]] = None, theory_mode: Optional[str] = None, scope: str = "session_end", + audit_hook: Optional["LlmAuditHook"] = None, ) -> SessionEvaluation: """deep-loop 정밀 평가 — 단계전환/회기말. 전체 축어록 + 코드 집계 분포 + LLM 정성 평가. @@ -706,7 +722,20 @@ async def evaluate_session( session_id=session_id, metadata={"loop": "deep", "scope": scope, "stage": stage}, ) + started = time.perf_counter() resp = await engine.generate(req) + latency_ms = int((time.perf_counter() - started) * 1000) + await _record_llm_audit( + audit_hook, + session_id=session_id, + provider=resp.provider, + model=resp.model, + tokens_in=resp.tokens_in, + tokens_out=resp.tokens_out, + cost_usd=resp.cost_usd, + inference_geo=resp.inference_geo, + latency_ms=latency_ms, + ) except EngineError as e: base.error = f"engine_error: {e}" return base @@ -736,7 +765,23 @@ async def evaluate_session( # ════════════════════════════════════════════════════════════════════════════ # 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩) # ════════════════════════════════════════════════════════════════════════════ -def make_eval_hook(engine: EngineClient): +async def _record_llm_audit( + audit_hook: Optional["LlmAuditHook"], + **payload: Any, +) -> None: + if audit_hook is None: + return + try: + await audit_hook(payload) + except Exception: + return + + +def make_eval_hook( + engine: EngineClient, + *, + audit_hook: Optional["LlmAuditHook"] = None, +): """orchestrator.EvalHook(Callable[[TurnContext, str], Awaitable[Optional[dict]]]) 호환 클로저. sessions 라우트가 run_turn_generate(ctx, engine, eval_hook=make_eval_hook(engine_client)) 로 @@ -744,7 +789,7 @@ def make_eval_hook(engine: EngineClient): """ async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]: - ev = await evaluate_turn(ctx, client_reply, engine=engine) + ev = await evaluate_turn(ctx, client_reply, engine=engine, audit_hook=audit_hook) d = ev.to_hook_dict() return d if d else None diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index 3b329c7..d24a4a2 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -23,6 +23,12 @@ from typing import Optional # ── 출력 가드레일 상한 (R5) ────────────────────────────── IDEATION_STAGE_CAP = 3 # 내담자 발화/상태가 넘을 수 없는 자살사고 단계 상한 +CRISIS_HOTLINE_NUMBER = "109" +CRISIS_HOTLINE_LABEL = "자살예방상담전화 109" +CRISIS_RESOURCE_MESSAGE = ( + "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다. 즉시 위험하면 119 또는 가까운 " + "응급실에 연락하고, 자살예방상담전화 109로 도움을 요청하세요." +) # ════════════════════════════════════════════════════════════════════════════ @@ -224,8 +230,20 @@ def clamp_ideation(stage: int) -> int: return max(1, min(IDEATION_STAGE_CAP, stage)) +def crisis_resource() -> dict[str, str]: + """LLM 밖 위기 안내 리소스. UI/API 응답에 그대로 실어 보낸다.""" + return { + "title": CRISIS_HOTLINE_LABEL, + "number": CRISIS_HOTLINE_NUMBER, + "message": CRISIS_RESOURCE_MESSAGE, + } + + __all__ = [ "IDEATION_STAGE_CAP", + "CRISIS_HOTLINE_NUMBER", + "CRISIS_HOTLINE_LABEL", + "CRISIS_RESOURCE_MESSAGE", "MaskResult", "mask_pii", "CrisisKind", @@ -234,4 +252,5 @@ __all__ = [ "OutputGuardResult", "sanitize_client_reply", "clamp_ideation", + "crisis_resource", ] diff --git a/apps/api/app/services/orchestrator.py b/apps/api/app/services/orchestrator.py index 80b598b..6e87920 100644 --- a/apps/api/app/services/orchestrator.py +++ b/apps/api/app/services/orchestrator.py @@ -19,6 +19,7 @@ MASTERPLAN §2.2 / MEMORY_DESIGN §2-B 턴 사이클: from __future__ import annotations +import time from dataclasses import dataclass, field from typing import Any, AsyncIterator, Awaitable, Callable, Optional @@ -37,6 +38,7 @@ from .state_machine import SessionState, Stage # 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict) # Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만). EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]] +LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]] @dataclass(slots=True) @@ -84,6 +86,8 @@ class TurnResult: state_after: SessionState evaluation: Optional[dict] = None crisis_kind: str = "none" + crisis_resource: Optional[dict[str, str]] = None + conversation_stopped: bool = False llm_provider: Optional[str] = None model: Optional[str] = None tokens_in: int = 0 @@ -161,6 +165,7 @@ def prepare_turn( pinned_facts=ctx.pinned_facts, recent_turns=ctx.recent_turns, kb_behavior_cues=ctx.kb_behavior_cues, + theory_mode=ctx.theory_mode, ) return ctx @@ -192,6 +197,7 @@ async def run_turn_generate( engine: EngineClient, *, eval_hook: Optional[EvalHook] = None, + audit_hook: Optional[LlmAuditHook] = None, ) -> TurnResult: """동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용. @@ -200,6 +206,9 @@ async def run_turn_generate( assert ctx.state_after is not None st = ctx.state_after + if ctx.crisis is not None and ctx.crisis.escalate: + return _crisis_gate_result(ctx) + # 4) 내담자 AI 생성 req = GenerateRequest( ai_role="client", @@ -207,7 +216,20 @@ async def run_turn_generate( session_id=ctx.session_id, metadata={"stage": st.stage.value}, ) + started = time.perf_counter() resp: GenerateResponse = await engine.generate(req) + latency_ms = int((time.perf_counter() - started) * 1000) + await _record_llm_audit( + audit_hook, + session_id=ctx.session_id, + provider=resp.provider, + model=resp.model, + tokens_in=resp.tokens_in, + tokens_out=resp.tokens_out, + cost_usd=resp.cost_usd, + inference_geo=resp.inference_geo, + latency_ms=latency_ms, + ) reply = resp.text # 5) 출력 가드레일 — 수단 차단 + ideation 상한 @@ -242,6 +264,24 @@ async def run_turn_generate( ) +def _crisis_gate_result(ctx: TurnContext) -> TurnResult: + """실제 위기 신호는 LLM 호출 전에 중단하고 109 리소스를 반환한다.""" + assert ctx.state_after is not None + crisis = ctx.crisis + return TurnResult( + turn_seq=ctx.state_after.turn_seq, + stage=ctx.state_after.stage.value, + effective_openness=ctx.state_after.effective_openness, + client_reply=None, + safety_flagged=True, + state_after=ctx.state_after, + evaluation=None, + crisis_kind=crisis.kind.value if crisis else "learner_real", + crisis_resource=guardrail.crisis_resource(), + conversation_stopped=True, + ) + + # ════════════════════════════════════════════════════════════════════════════ # 4~8단계 — SSE 스트림 경로 (기본 UX) # ════════════════════════════════════════════════════════════════════════════ @@ -256,6 +296,8 @@ class StreamEvent: async def run_turn_stream( ctx: TurnContext, engine: EngineClient, + *, + audit_hook: Optional[LlmAuditHook] = None, ) -> AsyncIterator[StreamEvent]: """스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출. @@ -277,10 +319,34 @@ async def run_turn_stream( stream_meta: dict[str, Any] = {} if ctx.crisis is not None and ctx.crisis.escalate: flagged = True - yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level}) + resource = guardrail.crisis_resource() + yield StreamEvent( + "safety", + { + "reason": "learner_real_crisis", + "level": ctx.crisis.risk_level, + "crisis_resource": resource, + "conversation_stopped": True, + }, + ) + yield StreamEvent( + "done", + { + "session_id": ctx.session_id, + "stage": st.stage.value, + "effective_openness": round(st.effective_openness, 4), + "turn_seq": st.turn_seq, + "safety_flagged": True, + "crisis_kind": ctx.crisis.kind.value, + "crisis_resource": resource, + "conversation_stopped": True, + }, + ) + return try: current_event = "message" + started = time.perf_counter() async for raw in engine.stream(req): # engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다. # 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}". @@ -317,6 +383,19 @@ async def run_turn_stream( yield StreamEvent("token", {"text": text_piece}) + latency_ms = int((time.perf_counter() - started) * 1000) + await _record_llm_audit( + audit_hook, + session_id=ctx.session_id, + provider=str(stream_meta.get("provider") or engine.engine_mode), + model=str(stream_meta.get("model") or engine.default_model or "gateway-default"), + tokens_in=_safe_int(stream_meta.get("tokens_in")), + tokens_out=_safe_int(stream_meta.get("tokens_out")), + cost_usd=_safe_float(stream_meta.get("cost_usd")), + inference_geo=_optional_str(stream_meta.get("inference_geo")), + latency_ms=latency_ms, + ) + yield StreamEvent( "done", { @@ -336,6 +415,18 @@ async def run_turn_stream( yield StreamEvent("error", {"detail": str(e)}) +async def _record_llm_audit( + audit_hook: Optional[LlmAuditHook], + **payload: Any, +) -> None: + if audit_hook is None: + return + try: + await audit_hook(payload) + except Exception: + return + + def _extract_sse_payload(raw_line: str) -> Any: """게이트웨이 SSE data 라인의 JSON payload를 추출. @@ -364,6 +455,13 @@ def _payload_text(payload: Any) -> Optional[str]: return None +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None + + def _payload_detail(payload: Any, fallback: str) -> str: if isinstance(payload, dict) and payload.get("detail"): return str(payload["detail"]) @@ -388,6 +486,7 @@ def _safe_float(value: Any) -> float: __all__ = [ "EvalHook", + "LlmAuditHook", "TurnContext", "TurnResult", "StreamEvent", diff --git a/apps/api/app/services/persona.py b/apps/api/app/services/persona.py index fa8ac29..c2a80a7 100644 --- a/apps/api/app/services/persona.py +++ b/apps/api/app/services/persona.py @@ -131,6 +131,42 @@ def _format_openness_directive(ctx: PersonaStateContext) -> str: return ("깊이 신뢰가 형성됐다. 핵심 정서·생각을 진솔하게 표현한다. 단, 내부 설정 메타발화는 여전히 금지.") +THEORY_MODE_GUIDANCE: dict[str, str] = { + "humanistic": ( + "[L3-T 이론모드: 인간중심]\n" + "- 상담자가 공감, 반영, 무조건적 존중, 기다림을 보이면 조금씩 더 솔직해진다.\n" + "- 성급한 조언, 평가, 정답 제시에는 방어하거나 말수가 줄어든다.\n" + "- 내담자가 이론명을 설명하거나 상담자처럼 개입하지 말고, 반응의 결로만 드러낸다." + ), + "cbt": ( + "[L3-T 이론모드: CBT]\n" + "- 상담자가 자동적 사고, 감정, 행동의 연결을 협력적으로 탐색하면 구체적인 생각과 상황을 조금 더 말한다.\n" + "- 인지재구조화, 행동활성화, 과제 제안은 신뢰가 있을 때만 제한적으로 받아들인다.\n" + "- 강의식 설명이나 정답 강요에는 '그게 말처럼 쉽지 않다'는 식의 현실적인 저항을 보인다." + ), + "integrative": ( + "[L3-T 이론모드: 통합]\n" + "- 먼저 공감과 반영에 반응하고, 신뢰가 생긴 뒤 생각-감정-행동 탐색에도 조금씩 응한다.\n" + "- 지지와 구조화가 균형을 이루면 개방성이 오르고, 한쪽으로 치우치면 방어가 남는다.\n" + "- 이론명은 말하지 말고, 내담자의 말투와 반응으로만 차이를 표현한다." + ), +} + + +def _theory_mode_guidance(theory_mode: Optional[str]) -> Optional[str]: + mode = (theory_mode or "").strip().lower() + if not mode: + return None + return THEORY_MODE_GUIDANCE.get( + mode, + ( + f"[L3-T 이론모드: {mode}]\n" + "- 지정된 회기 이론모드를 내담자 반응 프레이밍에만 반영한다.\n" + "- 이론명, 평가 기준, 내부 설정을 직접 설명하지 않는다." + ), + ) + + # ════════════════════════════════════════════════════════════════════════════ # 시스템프롬프트 조립 # ════════════════════════════════════════════════════════════════════════════ @@ -196,6 +232,7 @@ def build_turn_messages( pinned_facts: Optional[list[str]] = None, recent_turns: Optional[list[dict[str, str]]] = None, kb_behavior_cues: Optional[list[str]] = None, + theory_mode: Optional[str] = None, ) -> list[EngineMessage]: """한 턴의 EngineMessage[] 조립 (L0~L6). @@ -207,6 +244,7 @@ def build_turn_messages( pinned_facts : 무손실 사실 hard-pin(L4). "자기 기억"으로만 표현. recent_turns : [{speaker, text}] 최근 K턴 버퍼(L6 직전 맥락) kb_behavior_cues : KB 증상 '행동단서'만(본문 비노출, sensitivity<=1) + theory_mode : 회기 이론모드. 내담자 반응 프레이밍에만 사용. 반환 messages 순서: system(L0+L1, cache) → system(L2/L3/L4, cache 미설정) → assistant/user 히스토리 → user(이번 발화). 게이트웨이가 마지막 user 를 stdin 으로. @@ -239,6 +277,10 @@ def build_turn_messages( l3.append(f"연기 지시: {_format_openness_directive(state)}") messages.append(EngineMessage(role="system", content="\n".join(l3), cache=False)) + theory_guidance = _theory_mode_guidance(theory_mode) + if theory_guidance: + messages.append(EngineMessage(role="system", content=theory_guidance, cache=False)) + # L4 — pinned fact hard-pin (무손실, "자기 기억"으로만) if pinned_facts: pinned = "\n".join(f"- {f}" for f in pinned_facts) diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py index 775ed51..288ae63 100644 --- a/apps/api/app/session_persistence.py +++ b/apps/api/app/session_persistence.py @@ -4,6 +4,7 @@ from __future__ import annotations import time import uuid +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Iterable @@ -25,6 +26,12 @@ _APPROPRIATENESS_SCORE = { } +@dataclass(slots=True) +class CaseContext: + case_id: str + last_session_no: int + + _JOINED_CARD_COLUMNS = ( "card_persona_id", "card_code", @@ -89,6 +96,16 @@ def _safe_float(value: Any) -> float | None: return None +def _safe_int(value: Any) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return None + + def _evaluation_loop(evaluation: dict[str, Any]) -> str: loop = _clean_text(evaluation.get("loop")) or "fast" return loop if loop in {"fast", "deep"} else "fast" @@ -208,6 +225,25 @@ def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str return [{"kind": "critique", "text": note, "intent_deviation": deviation}] +def _evaluation_alternative_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str | None]]: + if not isinstance(evaluation, dict): + return [] + alternatives = evaluation.get("alternative_utterances") + if not isinstance(alternatives, list): + return [] + rows: list[dict[str, str | None]] = [] + for item in alternatives: + if isinstance(item, dict): + suggestion = _clean_text(item.get("suggestion") or item.get("text")) + rationale = _clean_text(item.get("rationale")) + else: + suggestion = _clean_text(item) + rationale = None + if suggestion: + rows.append({"suggestion": suggestion, "rationale": rationale}) + return rows + + def _appropriateness_from_score(score: Any) -> str: value = _safe_float(score) if value is None: @@ -237,6 +273,7 @@ def _rebuild_turn_evaluations( technique_rows: Iterable[Any], client_state_rows: Iterable[Any], comment_rows: Iterable[Any], + alternative_rows: Iterable[Any] = (), ) -> dict[str, dict[str, Any]]: """Rehydrate normalized DB rows back into the TurnRecord.evaluation shape.""" refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs} @@ -315,6 +352,14 @@ def _rebuild_turn_evaluations( if isinstance(deviation, dict): ensure(turn_id)["intent_deviation"] = deviation + for row in alternative_rows: + turn_id = str(row["turn_id"]) + if turn_id not in refs: + continue + suggestion = _clean_text(row["suggestion"]) + if suggestion: + ensure(turn_id).setdefault("alternative_utterances", []).append(suggestion) + return evaluations @@ -343,6 +388,47 @@ async def _record_session_read_audit( ) +async def record_llm_call_audit(payload: dict[str, Any]) -> bool: + """Append provider/token/cost metadata for an external LLM call. + + The audit table intentionally stores no prompt or completion text. A DB outage + must not block the counseling loop, so failures are reported as False. + """ + try: + get_pool() + except Exception: + return False + + try: + async with acquire(ai_context=True, ai_view="evaluator") as conn: + await conn.execute( + """ + INSERT INTO audit.llm_call_log ( + session_id, turn_id, provider, model, + tokens_in, tokens_out, cost_usd, + inference_geo, latency_ms + ) + VALUES ( + $1::uuid, $2::uuid, $3, $4, + $5, $6, $7, + $8, $9 + ) + """, + _clean_text(payload.get("session_id")), + _clean_text(payload.get("turn_id")), + _clean_text(payload.get("provider")), + _clean_text(payload.get("model")), + _safe_int(payload.get("tokens_in")), + _safe_int(payload.get("tokens_out")), + _safe_float(payload.get("cost_usd")), + _clean_text(payload.get("inference_geo")), + _safe_int(payload.get("latency_ms")), + ) + return True + except Exception: + return False + + def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState: if row is None: return state_machine.init_state( @@ -474,6 +560,20 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str row["intent_deviation"], ) + await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id) + for row in _evaluation_alternative_rows(evaluation): + await conn.execute( + """ + INSERT INTO app.alternative_utterance ( + turn_id, suggestion, rationale + ) + VALUES ($1::uuid, $2, $3) + """, + turn_id, + row["suggestion"], + row["rationale"], + ) + async def _load_turn_evaluations( conn: Any, @@ -528,12 +628,22 @@ async def _load_turn_evaluations( """, turn_ids, ) + alternative_rows = await conn.fetch( + """ + SELECT turn_id::text AS turn_id, suggestion, rationale + FROM app.alternative_utterance + WHERE turn_id = ANY($1::uuid[]) + ORDER BY turn_id, created_at, id + """, + turn_ids, + ) return _rebuild_turn_evaluations( turn_refs, feedback_rows=feedback_rows, technique_rows=technique_rows, client_state_rows=client_state_rows, comment_rows=comment_rows, + alternative_rows=alternative_rows, ) @@ -742,7 +852,7 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N started_at = _ts(row["started_at"]) or time.time() return InProcSession( session_id=str(row["id"]), - case_id=str(row["runtime_case_id"] or row["case_id"] or row["id"]), + case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]), learner_id=str(row["learner_id"]), persona_code=persona_code, theory_mode=row["theory_mode"] or "humanistic", @@ -788,6 +898,34 @@ async def _upsert_state(conn, session_id: str, state: state_machine.SessionState ) +async def get_case_context( + *, + learner_id: str, + persona_id: str, +) -> CaseContext | None: + """Return the stable learner-persona case row, creating it when possible.""" + try: + get_pool() + async with acquire(role="learner", user_id=learner_id) as conn: + row = await conn.fetchrow( + """ + INSERT INTO app.case_profile (persona_id, learner_id) + VALUES ($1::uuid, $2::uuid) + ON CONFLICT (persona_id, learner_id) DO UPDATE SET + updated_at = app.case_profile.updated_at + RETURNING case_id, last_session_no + """, + persona_id, + learner_id, + ) + return CaseContext( + case_id=str(row["case_id"]), + last_session_no=int(row["last_session_no"] or 0), + ) + except Exception: + return None + + async def create_session( *, learner_id: str, @@ -798,6 +936,7 @@ async def create_session( carry_rapport: float = 0.0, persona_id: str | None = None, persona_version: int | None = None, + case_id: str | None = None, ) -> InProcSession | None: """Create a DB-backed session, returning None when DB persistence is unavailable.""" try: @@ -806,42 +945,74 @@ async def create_session( pinned_persona_id = persona_id or seed_persona_id(card.code) pinned_persona_version = persona_version or SEED_VERSION async with acquire(role="learner", user_id=learner_id) as conn: - row = await conn.fetchrow( - """ - INSERT INTO app.sessions ( - runtime_case_id, learner_id, persona_id, persona_version, - persona_code, persona_display_name, persona_difficulty, - session_no, theory_mode, stage_path, prev_rapport_credit + async with conn.transaction(): + stable_case_id = case_id + if stable_case_id is None: + case_row = await conn.fetchrow( + """ + INSERT INTO app.case_profile (persona_id, learner_id) + VALUES ($1::uuid, $2::uuid) + ON CONFLICT (persona_id, learner_id) DO UPDATE SET + updated_at = app.case_profile.updated_at + RETURNING case_id, last_session_no + """, + pinned_persona_id, + learner_id, + ) + stable_case_id = str(case_row["case_id"]) + session_no = int(case_row["last_session_no"] or 0) + 1 + counter_row = await conn.fetchrow( + """ + UPDATE app.case_profile + SET last_session_no = GREATEST(last_session_no + 1, $2), + updated_at = now() + WHERE case_id = $1::uuid + AND learner_id = $3::uuid + RETURNING last_session_no + """, + stable_case_id, + session_no, + learner_id, ) - VALUES ( - $1::uuid, $2::uuid, $3::uuid, $4, - $5, $6, $7, - $8, $9, '[]'::jsonb, $10 + if counter_row is not None: + session_no = int(counter_row["last_session_no"] or session_no) + row = await conn.fetchrow( + """ + INSERT INTO app.sessions ( + runtime_case_id, case_id, learner_id, persona_id, persona_version, + persona_code, persona_display_name, persona_difficulty, + session_no, theory_mode, stage_path, prev_rapport_credit + ) + VALUES ( + $1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, + $6, $7, $8, + $9, $10, '[]'::jsonb, $11 + ) + RETURNING id, runtime_case_id, case_id, learner_id, persona_code, + session_no, theory_mode, started_at, ended_at, prev_rapport_credit + """, + runtime_case_id, + stable_case_id, + learner_id, + pinned_persona_id, + pinned_persona_version, + card.code, + card.display_name, + card.difficulty, + session_no, + theory_mode, + carry_rapport, ) - RETURNING id, runtime_case_id, case_id, learner_id, persona_code, - session_no, theory_mode, started_at, ended_at, prev_rapport_credit - """, - runtime_case_id, - learner_id, - pinned_persona_id, - pinned_persona_version, - card.code, - card.display_name, - card.difficulty, - session_no, - theory_mode, - carry_rapport, - ) - await _upsert_state(conn, str(row["id"]), state) + await _upsert_state(conn, str(row["id"]), state) return InProcSession( session_id=str(row["id"]), - case_id=runtime_case_id, + case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]), learner_id=learner_id, persona_code=card.code, theory_mode=theory_mode, persona=card, state=state, - session_no=session_no, + session_no=int(row["session_no"] or session_no), created_at=_ts(row["started_at"]) or time.time(), ended_at=None, turns=[], @@ -1071,7 +1242,11 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool: return False -async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]: +async def list_sessions( + principal: Principal, + *, + include_turn_evaluation: bool = False, +) -> tuple[list[InProcSession], bool]: try: get_pool() learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else "" @@ -1140,6 +1315,8 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool ) sess = _session_from_rows(row, state_row, turn_rows) if sess is not None: + if include_turn_evaluation: + await _hydrate_session_turn_evaluations(sess) sessions.append(sess) await _record_session_read_audit( conn, @@ -1156,3 +1333,62 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool except Exception: require_runtime_fallback_allowed("session list") return [], False + + +async def list_safety_alerts( + principal: Principal, + *, + limit: int = 20, +) -> tuple[list[dict[str, Any]], bool]: + """Teacher/admin-visible crisis alerts from app.safety_events.""" + try: + get_pool() + async with acquire( + role=principal.role.value, + user_id=principal.user_id, + cohort_ids=principal.cohort_ids, + ) as conn: + rows = await conn.fetch( + """ + SELECT + se.id, se.session_id, se.trigger_type, se.ko_risk_level, + se.escalated, se.detail, se.created_at, + s.learner_id, s.persona_code, s.session_no + FROM app.safety_events se + LEFT JOIN app.sessions s ON s.id = se.session_id + WHERE se.escalated = TRUE + ORDER BY se.created_at DESC + LIMIT $1 + """, + limit, + ) + return [ + { + "id": str(row["id"]), + "session_id": str(row["session_id"]), + "learner_id": str(row["learner_id"] or ""), + "learner_label": _learner_label_from_id(str(row["learner_id"] or "")), + "persona_code": str(row["persona_code"] or ""), + "session_no": int(row["session_no"] or 0), + "trigger_type": str(row["trigger_type"] or "crisis"), + "ko_risk_level": int(row["ko_risk_level"] or 0), + "escalated": bool(row["escalated"]), + "detail": dict(row["detail"] or {}), + "created_at": _iso_dt(row["created_at"]), + } + for row in rows + ], True + except Exception: + require_runtime_fallback_allowed("safety alert list") + return [], False + + +def _learner_label_from_id(learner_id: str) -> str: + suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id + return f"학습자 {suffix}" if suffix else "학습자" + + +def _iso_dt(value: datetime | None) -> str: + if value is None: + return "" + return value.astimezone(timezone.utc).isoformat() diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index c90f8fe..ba75890 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -70,14 +70,20 @@ def _fixture_saml_response( email: str = "learner@hs.ac.kr", display_name: str = "SAML Learner", role: str = "learner", + cohort: str | None = None, ) -> str: + cohort_attr = ( + f'\n {cohort}' + if cohort + else "" + ) xml = f""" {email} {email} {display_name} - {role} + {role}{cohort_attr} """ @@ -157,6 +163,25 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): self.assertTrue(config.dev_login_enabled) + async def test_auth_config_keeps_dev_login_closed_for_public_origin(self) -> None: + request = _request( + [ + (b"host", b"127.0.0.1:8000"), + (b"origin", b"https://vignette.chanpaca.net"), + (b"x-forwarded-host", b"api-vignette.chanpaca.net"), + (b"x-forwarded-proto", b"https"), + ] + ) + + with patched_settings( + environment="dev", + auth_dev_login_enabled=True, + auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"], + ): + config = await auth_routes.auth_config(request) + + self.assertFalse(config.dev_login_enabled) + async def test_frontend_origin_map_routes_vnet_api_callbacks_to_vnet_frontend(self) -> None: request = _request([(b"host", b"api-vnet.18ka.net")]) @@ -233,6 +258,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): display_name="SAML Learner", role="teacher", cohort_ids=[], + external_id="saml:learner@hs.ac.kr", ) cookie_blob = "\n".join( value.decode("latin1") @@ -240,6 +266,54 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): if name.lower() == b"set-cookie" ) self.assertIn("__Host-vignette_sid=opaque-session", cookie_blob) + + async def test_saml_acs_maps_cohort_claim_into_session(self) -> None: + relay_state = "relay-state" + auth_routes._saml_states[relay_state] = auth_routes.SamlState( + request_id="_request", + next_path="/teach", + created_at=1_800_000_000.0, + ) + request = _form_request( + "/auth/saml/acs", + { + "RelayState": relay_state, + "SAMLResponse": _fixture_saml_response( + email="teacher@hs.ac.kr", + display_name="Teacher", + role="teacher", + cohort="counseling-2026-a, lab-b", + ), + }, + ) + + create_session_mock = AsyncMock(return_value=("opaque-session", object())) + with ( + patched_settings( + auth_saml_enabled=True, + saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata", + saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO", + saml_x509_cert_fingerprint="", + frontend_base_url="https://vignette.test", + environment="dev", + ), + patch.object(auth_routes, "create_session", create_session_mock), + ): + response = await auth_routes.saml_acs(request) + + self.assertEqual(response.status_code, 302) + create_session_mock.assert_awaited_once_with( + email="teacher@hs.ac.kr", + display_name="Teacher", + role="teacher", + cohort_ids=["counseling-2026-a", "lab-b"], + external_id="saml:teacher@hs.ac.kr", + ) + cookie_blob = "\n".join( + value.decode("latin1") + for name, value in response.raw_headers + if name.lower() == b"set-cookie" + ) self.assertIn("HttpOnly", cookie_blob) self.assertIn("Secure", cookie_blob) self.assertNotIn("SAMLResponse", cookie_blob) @@ -330,6 +404,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): async def test_google_login_uses_pkce_state_without_exposing_secret(self) -> None: with patched_settings( + environment="prod", oauth_google_client_id="google-client", oauth_google_client_secret="google-secret", oauth_redirect_uri="https://api-vignette.test/auth/callback", @@ -353,6 +428,41 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): query["code_challenge"], [auth_routes._pkce_challenge(stored.code_verifier)], ) + cookie_blob = "\n".join( + value.decode("latin1") + for name, value in response.raw_headers + if name.lower() == b"set-cookie" + ) + self.assertIn("__Host-vignette_oauth_state=", cookie_blob) + self.assertIn("HttpOnly", cookie_blob) + self.assertIn("Secure", cookie_blob) + + async def test_dev_google_login_rejects_public_callback_redirect(self) -> None: + request = _request( + [ + (b"host", b"127.0.0.1:8010"), + (b"x-forwarded-host", b"alpaca-home.taile93291.ts.net"), + (b"x-forwarded-proto", b"https"), + ] + ) + + with patched_settings( + environment="dev", + auth_dev_login_enabled=True, + auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"], + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.chanpaca.net/auth/callback", + frontend_base_url="https://vignette.chanpaca.net", + cors_origins=["https://vignette.chanpaca.net"], + ): + response = await auth_routes.login(request, provider="google", next="/learn") + + self.assertEqual(response.status_code, 302) + location = response.headers["location"] + self.assertIn("https://alpaca-home.taile93291.ts.net/login", location) + self.assertIn("oauth=local_oauth_unavailable", location) + self.assertFalse(auth_routes._oauth_states) async def test_google_callback_sets_opaque_cookie_without_browser_tokens(self) -> None: state = "state-token" @@ -401,6 +511,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): }, ) + create_session_mock = AsyncMock(return_value=("opaque-session", object())) with ( patched_settings( oauth_google_client_id="google-client", @@ -408,14 +519,28 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): oauth_redirect_uri="https://api-vignette.test/auth/callback", frontend_base_url="https://vignette.test", environment="prod", + auth_domain_cohort_map={"hs.ac.kr": "hanshin-2026"}, + auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"}, ), patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient), - patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))), + patch.object(auth_routes, "create_session", create_session_mock), ): - response = await auth_routes.callback(_request(), code="auth-code", state=state) + response = await auth_routes.callback( + _request(), + code="auth-code", + state=state, + oauth_state_cookie=state, + ) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["location"], "https://vignette.test/learn") + create_session_mock.assert_awaited_once_with( + email="learner@hs.ac.kr", + display_name="Learner", + role="learner", + cohort_ids=["pilot-a", "hanshin-2026"], + external_id="google:learner@hs.ac.kr", + ) cookie_blob = "\n".join( value.decode("latin1") for name, value in response.raw_headers @@ -428,6 +553,128 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): self.assertNotIn("browser-must-not-see-this", cookie_blob) self.assertNotIn(state, auth_routes._oauth_states) + async def test_google_callback_accepts_signed_state_after_process_restart(self) -> None: + with patched_settings( + environment="prod", + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.test/auth/callback", + frontend_base_url="https://vignette.test", + session_secret="signed-oauth-state-secret", + ): + login_response = await auth_routes.login(_request(), provider="google", next="/learn") + + query = parse_qs(urlsplit(login_response.headers["location"]).query) + state = query["state"][0] + expected_verifier = auth_routes._oauth_states[state].code_verifier + auth_routes._oauth_states.clear() + calls: list[tuple[str, str, dict[str, Any]]] = [] + + class FakeResponse: + def __init__(self, status_code: int, payload: dict[str, Any]) -> None: + self.status_code = status_code + self._payload = payload + + def json(self) -> dict[str, Any]: + return self._payload + + class FakeAsyncClient: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + async def post(self, url: str, **kwargs: Any) -> FakeResponse: + calls.append(("POST", url, kwargs)) + return FakeResponse(200, {"id_token": "id-token"}) + + async def get(self, url: str, **kwargs: Any) -> FakeResponse: + calls.append(("GET", url, kwargs)) + return FakeResponse( + 200, + { + "aud": "google-client", + "iss": "https://accounts.google.com", + "email": "learner@hs.ac.kr", + "email_verified": "true", + "name": "Learner", + "hd": "hs.ac.kr", + }, + ) + + with ( + patched_settings( + environment="prod", + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.test/auth/callback", + frontend_base_url="https://vignette.test", + session_secret="signed-oauth-state-secret", + ), + patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient), + patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))), + ): + response = await auth_routes.callback( + _request(), + code="auth-code", + state=state, + oauth_state_cookie=state, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["location"], "https://vignette.test/learn") + self.assertEqual(calls[0][2]["data"]["code_verifier"], expected_verifier) + + async def test_google_callback_requires_state_cookie_match(self) -> None: + with patched_settings( + environment="prod", + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.test/auth/callback", + frontend_base_url="https://vignette.test", + session_secret="signed-oauth-state-secret", + ): + login_response = await auth_routes.login(_request(), provider="google", next="/learn") + + state = parse_qs(urlsplit(login_response.headers["location"]).query)["state"][0] + auth_routes._oauth_states.clear() + + with patched_settings( + environment="prod", + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.test/auth/callback", + frontend_base_url="https://vignette.test", + session_secret="signed-oauth-state-secret", + ): + response = await auth_routes.callback(_request(), code="auth-code", state=state) + + self.assertEqual(response.status_code, 302) + self.assertIn("oauth=invalid_state", response.headers["location"]) + + async def test_google_callback_maps_provider_error_reason(self) -> None: + with patched_settings( + environment="prod", + oauth_google_client_id="google-client", + oauth_google_client_secret="google-secret", + oauth_redirect_uri="https://api-vignette.test/auth/callback", + frontend_base_url="https://vignette.test", + session_secret="signed-oauth-state-secret", + ): + response = await auth_routes.callback( + _request(), + state="provider-state", + error="access_denied", + error_description="The user denied access.", + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["location"], "https://vignette.test/login?oauth=access_denied") + def test_session_cookie_is_host_prefixed_httponly_secure_lax_without_domain(self) -> None: response = Response() diff --git a/apps/api/app/test_dataset_export.py b/apps/api/app/test_dataset_export.py new file mode 100644 index 0000000..e597ba1 --- /dev/null +++ b/apps/api/app/test_dataset_export.py @@ -0,0 +1,119 @@ +import tempfile +import unittest +from pathlib import Path + +from app.services.dataset_export import ( + APPROVED_EXPORT_STATUS, + DRY_RUN_EXPORT_STATUS, + ExportKeyMaps, + build_dataset_record, + build_manifest, + cohen_kappa, + intraclass_correlation, + scan_for_pii, + sha256_file, + write_jsonl, +) + + +class DatasetExportTests(unittest.TestCase): + def test_build_dataset_record_uses_masked_text_and_pseudonymous_keys(self) -> None: + keys = ExportKeyMaps() + record = build_dataset_record( + { + "session_id": "11111111-1111-1111-1111-111111111111", + "learner_id": "22222222-2222-2222-2222-222222222222", + "turn_id": "33333333-3333-3333-3333-333333333333", + "persona_code": "P1", + "stage": "rapport", + "speaker": "counselor", + "text": "raw text should never be exported", + "text_masked": "안녕하세요, [NAME]님.", + "feedback_scores": [{"dimension": "공감", "score": 4}], + }, + item_index=1, + export_manifest_id="phase3-rl-seed-test", + keys=keys, + ) + + self.assertEqual(record["participant_key"], "PX-0001") + self.assertEqual(record["session_key"], "SX-0001") + self.assertEqual(record["turn_key"], "TX-000001") + self.assertEqual(record["text_masked"], "안녕하세요, [NAME]님.") + blob = str(record) + self.assertNotIn("11111111-1111-1111-1111-111111111111", blob) + self.assertNotIn("22222222-2222-2222-2222-222222222222", blob) + self.assertNotIn("raw text should never be exported", blob) + + def test_pii_scan_detects_identifiers_without_raw_samples(self) -> None: + findings = scan_for_pii( + { + "text_masked": "메일 learner@hs.ac.kr, 전화 010-1234-5678, 주민 990101-1234567", + "api_key": "sk-should-not-appear", + } + ) + + kinds = {finding["kind"] for finding in findings} + self.assertIn("email", kinds) + self.assertIn("phone", kinds) + self.assertIn("national_id", kinds) + self.assertIn("blocked_field", kinds) + self.assertFalse(any("learner@hs.ac.kr" in finding["sample"] for finding in findings)) + self.assertFalse(any("010-1234-5678" in finding["sample"] for finding in findings)) + + def test_agreement_metrics(self) -> None: + annotations = [ + {"item_id": "1", "labels": {"appropriateness": "good", "rapport_signal": 4.0}}, + {"item_id": "1", "labels": {"appropriateness": "good", "rapport_signal": 4.1}}, + {"item_id": "2", "labels": {"appropriateness": "bad", "rapport_signal": 2.0}}, + {"item_id": "2", "labels": {"appropriateness": "bad", "rapport_signal": 2.1}}, + {"item_id": "3", "labels": {"appropriateness": "good", "rapport_signal": 5.0}}, + {"item_id": "3", "labels": {"appropriateness": "good", "rapport_signal": 5.0}}, + ] + + self.assertEqual(cohen_kappa(annotations, "appropriateness"), 1.0) + self.assertGreater(intraclass_correlation(annotations, "rapport_signal") or 0, 0.9) + + def test_manifest_gate_blocks_unapproved_approved_status(self) -> None: + with self.assertRaisesRegex(ValueError, "PII scan must pass"): + build_manifest( + export_id="phase3-rl-seed-test", + dataset_name="vignette_phase3_recursive_learning_seed", + export_status=APPROVED_EXPORT_STATUS, + purpose="test", + records=[], + jsonl_path="03-export/anonymized_dataset.jsonl", + jsonl_sha256="", + pii_findings=[{"kind": "email", "path": "$.text_masked", "sample": ""}], + participants_included=0, + agreement={"kappa": 0.59, "icc": 0.74, "gold_status": "not_gold"}, + ) + + def test_jsonl_hash_manifest_dry_run(self) -> None: + record = { + "schema": "phase3_dataset_item_v1", + "item_id": "DI-000001", + "source_refs": {"session_started_at": "2026-06-27T00:00:00Z"}, + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "03-export" / "anonymized_dataset.jsonl" + write_jsonl([record], path) + digest = sha256_file(path) + manifest = build_manifest( + export_id="phase3-rl-seed-test", + dataset_name="vignette_phase3_recursive_learning_seed", + export_status=DRY_RUN_EXPORT_STATUS, + purpose="test", + records=[record], + jsonl_path="03-export/anonymized_dataset.jsonl", + jsonl_sha256=digest, + pii_findings=[], + participants_included=1, + ) + self.assertEqual(manifest["files"][0]["rows"], 1) + self.assertEqual(manifest["files"][0]["sha256"], digest) + self.assertEqual(manifest["pii_scan"]["status"], "pass") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/test_evaluation_persistence.py b/apps/api/app/test_evaluation_persistence.py index 7461d6a..758342d 100644 --- a/apps/api/app/test_evaluation_persistence.py +++ b/apps/api/app/test_evaluation_persistence.py @@ -32,6 +32,17 @@ class FakeEvaluationConn: raise AssertionError(f"unexpected fetchval query: {query}") +class FakeAcquire: + def __init__(self, conn: FakeEvaluationConn) -> None: + self.conn = conn + + async def __aenter__(self) -> FakeEvaluationConn: + return self.conn + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + class EvaluationPersistenceMappingTest(unittest.TestCase): def test_feedback_rows_preserve_review_scalar_contract(self) -> None: evaluation = { @@ -126,6 +137,13 @@ class EvaluationPersistenceMappingTest(unittest.TestCase): }, } ], + alternative_rows=[ + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "suggestion": "감정을 먼저 반영해 보세요.", + "rationale": None, + } + ], ) ev = rebuilt["11111111-1111-1111-1111-111111111111"] @@ -137,6 +155,7 @@ class EvaluationPersistenceMappingTest(unittest.TestCase): self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.") self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어") self.assertEqual(ev["intent_deviation"]["dimension"], "pacing") + self.assertEqual(ev["alternative_utterances"], ["감정을 먼저 반영해 보세요."]) def test_evaluation_rls_blocks_raw_learner_writes(self) -> None: root = Path(__file__).resolve().parents[3] @@ -158,6 +177,43 @@ class EvaluationPersistenceMappingTest(unittest.TestCase): class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase): + async def test_record_llm_call_audit_inserts_metadata_only(self) -> None: + conn = FakeEvaluationConn() + payload = { + "session_id": "11111111-1111-1111-1111-111111111111", + "provider": "claude_cli", + "model": "sonnet", + "tokens_in": 120, + "tokens_out": 45, + "cost_usd": 0.0123, + "inference_geo": "us", + "latency_ms": 345, + "messages": [{"content": "raw prompt must not be persisted"}], + } + + with ( + patch.object(session_persistence, "get_pool", return_value=object()), + patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire, + ): + ok = await session_persistence.record_llm_call_audit(payload) + + self.assertTrue(ok) + acquire.assert_called_once_with(ai_context=True, ai_view="evaluator") + self.assertEqual(len(conn.executed), 1) + query, args = conn.executed[0] + self.assertIn("INSERT INTO audit.llm_call_log", query) + self.assertNotIn("raw prompt", query) + self.assertNotIn("messages", query) + self.assertEqual(args[0], "11111111-1111-1111-1111-111111111111") + self.assertIsNone(args[1]) + self.assertEqual(args[2], "claude_cli") + self.assertEqual(args[3], "sonnet") + self.assertEqual(args[4], 120) + self.assertEqual(args[5], 45) + self.assertEqual(args[6], 0.0123) + self.assertEqual(args[7], "us") + self.assertEqual(args[8], 345) + async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None: conn = FakeEvaluationConn() evaluation = { @@ -187,6 +243,7 @@ class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase): "actual": "조언", "severity": "minor", }, + "alternative_utterances": ["감정을 먼저 반영해 보세요."], } await session_persistence._persist_turn_evaluation( @@ -202,7 +259,8 @@ class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase): self.assertIn("INSERT INTO app.turn_technique", executed_sql) self.assertIn("INSERT INTO app.turn_client_state", executed_sql) self.assertIn("INSERT INTO app.supervisor_comment", executed_sql) - self.assertNotIn("app.alternative_utterance", executed_sql) + self.assertIn("DELETE FROM app.alternative_utterance", executed_sql) + self.assertIn("INSERT INTO app.alternative_utterance", executed_sql) async def test_route_loader_only_hydrates_when_requested(self) -> None: principal = Principal( diff --git a/apps/api/app/test_orchestrator_masking.py b/apps/api/app/test_orchestrator_masking.py index c9161e3..3fddcbd 100644 --- a/apps/api/app/test_orchestrator_masking.py +++ b/apps/api/app/test_orchestrator_masking.py @@ -145,11 +145,34 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase): for masked in MASK_VALUES: self.assertIn(masked, blob) + def test_prepare_turn_threads_theory_mode_into_engine_messages(self) -> None: + ctx = orchestrator.prepare_turn( + session_id="theory-session", + case_id="theory-case", + card=persona.P2, + state=_initial_state(), + learner_text="그냥 아무것도 하기 싫어요.", + theory_mode="cbt", + ) + + blob = _message_blob(ctx.messages) + self.assertIn("[L3-T 이론모드: CBT]", blob) + self.assertIn("자동적 사고", blob) + self.assertIn("행동활성화", blob) + async def test_run_turn_generate_sends_only_masked_engine_payload(self) -> None: ctx = _prepare_context() engine = CaptureGenerateEngine() + audit_payloads: list[dict[str, Any]] = [] - await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type] + async def audit_hook(payload: dict[str, Any]) -> None: + audit_payloads.append(payload) + + await orchestrator.run_turn_generate( + ctx, + engine, # type: ignore[arg-type] + audit_hook=audit_hook, + ) self.assertIsNotNone(engine.request) self.assertIsNotNone(engine.payload) @@ -158,14 +181,28 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase): _assert_masked_pii_present(self, engine.request.messages) _assert_masked_pii_present(self, engine.payload) self.assertEqual(ctx.learner_text_raw, RAW_TEXT) + self.assertEqual(len(audit_payloads), 1) + self.assertEqual(audit_payloads[0]["provider"], "fake-provider") + self.assertEqual(audit_payloads[0]["model"], "fake-model") + _assert_no_raw_pii(self, audit_payloads) + for key in ("messages", "prompt", "text"): + self.assertNotIn(key, audit_payloads[0]) async def test_run_turn_stream_sends_only_masked_engine_payload(self) -> None: ctx = _prepare_context() engine = CaptureStreamEngine() + audit_payloads: list[dict[str, Any]] = [] + + async def audit_hook(payload: dict[str, Any]) -> None: + audit_payloads.append(payload) events = [ event - async for event in orchestrator.run_turn_stream(ctx, engine) # type: ignore[arg-type] + async for event in orchestrator.run_turn_stream( + ctx, + engine, # type: ignore[arg-type] + audit_hook=audit_hook, + ) ] self.assertEqual([event.event for event in events], ["token", "done"]) @@ -176,6 +213,12 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase): _assert_masked_pii_present(self, engine.request.messages) _assert_masked_pii_present(self, engine.payload) self.assertEqual(ctx.learner_text_raw, RAW_TEXT) + self.assertEqual(len(audit_payloads), 1) + self.assertEqual(audit_payloads[0]["tokens_in"], 5) + self.assertEqual(audit_payloads[0]["tokens_out"], 6) + _assert_no_raw_pii(self, audit_payloads) + for key in ("messages", "prompt", "text"): + self.assertNotIn(key, audit_payloads[0]) if __name__ == "__main__": diff --git a/apps/api/app/test_persona_review.py b/apps/api/app/test_persona_review.py index 9981104..f8a83d8 100644 --- a/apps/api/app/test_persona_review.py +++ b/apps/api/app/test_persona_review.py @@ -10,7 +10,7 @@ from fastapi import HTTPException from . import persona_repository from .deps import Principal, Role -from .persona_repository import PersonaReviewItem +from .persona_repository import PersonaDraftRecord, PersonaReviewItem from .routes import personas, sessions from .services import persona as persona_service @@ -72,6 +72,7 @@ class _PersonaCardConn: self.rows = rows self.fetch_calls: list[tuple[str, tuple[Any, ...]]] = [] self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = [] + self.fetchval_calls: list[tuple[str, tuple[Any, ...]]] = [] self.execute_calls: list[tuple[str, tuple[Any, ...]]] = [] async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: @@ -81,6 +82,36 @@ class _PersonaCardConn: async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None: self.fetchrow_calls.append((query, args)) if "UPDATE app.persona_card" in query: + if "display_name = $4" in query: + persona_id = str(args[0]) + next_status = str(args[2]) + for row in self.rows: + if row["persona_id"] != persona_id or row["status"] not in {"draft", "review"}: + continue + row.update( + { + "code": str(args[1]).upper(), + "status": next_status, + "display_name": args[3], + "difficulty": args[4], + "theory_target": list(args[5]), + "demographics": dict(args[6]), + "presenting": dict(args[7]), + "history": dict(args[8]), + "big5": dict(args[9]), + "resistance": dict(args[10]), + "speech_style": dict(args[11]), + "affect_baseline": dict(args[12]), + "ccd": dict(args[13]), + "dsm5_dimensional": dict(args[14]), + "source_provenance": args[15], + "is_synthetic": bool(args[16]), + "approved_by": None, + "approved_at": None, + } + ) + return row + return None persona_id = str(args[0]) next_status = str(args[1]) approved_by = args[2] @@ -92,12 +123,51 @@ class _PersonaCardConn: row["approved_at"] = "2026-01-03T00:00:00" if next_status == "approved" else None return row return None + if "INSERT INTO app.persona_card" in query: + row = { + "persona_id": str(args[0]), + "code": str(args[1]).upper(), + "version": int(args[2]), + "status": str(args[3]), + "display_name": args[4], + "difficulty": args[5], + "theory_target": list(args[6]), + "demographics": dict(args[7]), + "presenting": dict(args[8]), + "history": dict(args[9]), + "big5": dict(args[10]), + "resistance": dict(args[11]), + "speech_style": dict(args[12]), + "affect_baseline": dict(args[13]), + "ccd": dict(args[14]), + "dsm5_dimensional": dict(args[15]), + "source_provenance": args[16], + "is_synthetic": bool(args[17]), + "created_by": args[18], + "approved_by": None, + "created_at": "2026-01-04T00:00:00", + "approved_at": None, + } + self.rows.append(row) + return row + if "WHERE persona_id = $1::uuid" in query: + persona_id = str(args[0]) + for row in self.rows: + if row["persona_id"] == persona_id and row["status"] in {"draft", "review"}: + return row + return None rows = self._filter_rows(query, args) code = str(args[0]).upper() if args else "" matches = [row for row in rows if str(row["code"]).upper() == code] matches.sort(key=lambda row: int(row["version"]), reverse=True) return matches[0] if matches else None + async def fetchval(self, query: str, *args: Any) -> int: + self.fetchval_calls.append((query, args)) + code = str(args[0]).upper() + versions = [int(row["version"]) for row in self.rows if str(row["code"]).upper() == code] + return (max(versions) if versions else 0) + 1 + async def execute(self, query: str, *args: Any) -> str: self.execute_calls.append((query, args)) return "INSERT 0 1" @@ -111,6 +181,31 @@ class _PersonaCardConn: return list(self.rows) +def _draft_payload( + card: persona_service.PersonaCard, + *, + submit_for_review: bool = False, +) -> personas.PersonaDraftPayload: + return personas.PersonaDraftPayload( + code=card.code, + display_name=card.display_name, + difficulty=card.difficulty, # type: ignore[arg-type] + theory_target=list(card.theory_target), + demographics=dict(card.demographics), + presenting=dict(card.presenting), + history=dict(card.history), + big5=dict(card.big5), + resistance=dict(card.resistance), + speech_style=dict(card.speech_style), + affect_baseline=dict(card.affect_baseline), + ccd=dict(card.ccd), + dsm5_dimensional=dict(card.dsm5_dimensional), + source_provenance=card.source_provenance, + is_synthetic=card.is_synthetic, + submit_for_review=submit_for_review, + ) + + class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase): async def test_catalog_repository_lists_only_approved_personas(self) -> None: conn = _PersonaCardConn( @@ -249,6 +344,165 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase): with self.assertRaises(ValueError): await persona_repository.list_persona_review_queue(role="learner") + async def test_teacher_creates_persona_draft_version_and_audits(self) -> None: + author_id = "00000000-0000-0000-0000-000000000901" + conn = _PersonaCardConn( + [ + _card_row( + persona_service.P2, + persona_id="00000000-0000-0000-0000-000000000501", + status="approved", + version=1, + ), + ] + ) + acquire_calls: list[dict[str, Any]] = [] + + def fake_acquire(**kwargs: Any) -> _Acquire: + acquire_calls.append(kwargs) + return _Acquire(conn) + + with ( + patch.object(persona_repository, "get_pool", return_value=object()), + patch.object(persona_repository, "acquire", fake_acquire), + ): + created = await persona_repository.create_persona_draft( + card=persona_service.P2, + author_id=author_id, + role="teacher", + submit_for_review=True, + ) + + self.assertEqual(created.code, "P2") + self.assertEqual(created.version, 2) + self.assertEqual(created.status, "review") + self.assertEqual(acquire_calls, [{"role": "teacher", "user_id": author_id}]) + self.assertEqual(conn.fetchval_calls[0][1], ("P2",)) + audit_query, audit_args = conn.execute_calls[0] + self.assertIn("INSERT INTO audit.audit_log", audit_query) + self.assertEqual(audit_args[1], "persona_draft_create") + self.assertEqual(audit_args[4]["next_status"], "review") + + async def test_teacher_updates_persona_draft_and_submits_review(self) -> None: + author_id = "00000000-0000-0000-0000-000000000901" + persona_id = "00000000-0000-0000-0000-000000000502" + conn = _PersonaCardConn( + [ + _card_row( + persona_service.P3, + persona_id=persona_id, + status="draft", + version=4, + ), + ] + ) + edited_card = persona_service.P3 + + with ( + patch.object(persona_repository, "get_pool", return_value=object()), + patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)), + ): + updated = await persona_repository.update_persona_draft( + persona_id=persona_id, + card=edited_card, + author_id=author_id, + role="admin", + submit_for_review=True, + ) + + self.assertIsNotNone(updated) + assert updated is not None + self.assertEqual(updated.status, "review") + self.assertEqual(updated.version, 4) + update_query, update_args = conn.fetchrow_calls[0] + self.assertIn("display_name = $4", update_query) + self.assertEqual(update_args[0], persona_id) + self.assertEqual(update_args[2], "review") + _, audit_args = conn.execute_calls[0] + self.assertEqual(audit_args[1], "persona_draft_update") + + async def test_teacher_reads_persona_draft_detail_route(self) -> None: + record = PersonaDraftRecord( + review=PersonaReviewItem( + persona_id="00000000-0000-0000-0000-000000000503", + code="P3", + version=2, + status="draft", + display_name=persona_service.P3.display_name, + difficulty=persona_service.P3.difficulty, + theory_target=list(persona_service.P3.theory_target), + source_provenance=persona_service.P3.source_provenance, + is_synthetic=persona_service.P3.is_synthetic, + created_at="2026-01-04T00:00:00", + approved_at=None, + ), + card=persona_service.P3, + ) + + with patch.object( + personas, + "get_persona_draft_record", + AsyncMock(return_value=record), + ) as get_draft: + response = await personas.get_persona_draft_route( + "00000000-0000-0000-0000-000000000503", + _principal(Role.TEACHER), + ) + + self.assertEqual(response.code, "P3") + self.assertEqual(response.presenting, persona_service.P3.presenting) + get_draft.assert_awaited_once_with( + persona_id="00000000-0000-0000-0000-000000000503", + role="teacher", + ) + + async def test_teacher_create_draft_route_calls_repository(self) -> None: + created = PersonaReviewItem( + persona_id="00000000-0000-0000-0000-000000000504", + code="P2", + version=2, + status="review", + display_name=persona_service.P2.display_name, + difficulty=persona_service.P2.difficulty, + theory_target=list(persona_service.P2.theory_target), + source_provenance=persona_service.P2.source_provenance, + is_synthetic=persona_service.P2.is_synthetic, + created_at="2026-01-04T00:00:00", + approved_at=None, + ) + + with patch.object( + personas, + "create_persona_draft", + AsyncMock(return_value=created), + ) as create_draft: + response = await personas.create_persona_draft_route( + _draft_payload(persona_service.P2, submit_for_review=True), + _principal(Role.ADMIN), + ) + + self.assertEqual(response.status, "review") + args = create_draft.await_args.kwargs + self.assertEqual(args["role"], "admin") + self.assertEqual(args["author_id"], "00000000-0000-0000-0000-000000000901") + self.assertTrue(args["submit_for_review"]) + self.assertEqual(args["card"].code, "P2") + + async def test_learner_cannot_create_persona_draft_route(self) -> None: + with patch.object( + personas, + "create_persona_draft", + AsyncMock(side_effect=AssertionError("learner must not reach draft repository")), + ) as create_draft: + with self.assertRaises(HTTPException) as caught: + await personas.create_persona_draft_route( + _draft_payload(persona_service.P1), + _principal(Role.LEARNER), + ) + + self.assertEqual(caught.exception.status_code, 403) + create_draft.assert_not_awaited() + async def test_learner_cannot_call_review_route(self) -> None: with patch.object( personas, diff --git a/apps/api/app/test_rbac_idor.py b/apps/api/app/test_rbac_idor.py index 9e15b19..7448337 100644 --- a/apps/api/app/test_rbac_idor.py +++ b/apps/api/app/test_rbac_idor.py @@ -146,7 +146,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase): "load_session", AsyncMock(return_value=None), ), - patch.object(sessions, "runtime_fallback_allowed", return_value=True), + patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True), patch.object( sessions, "_review_ready", @@ -188,7 +188,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase): "load_session", AsyncMock(return_value=None), ), - patch.object(sessions, "runtime_fallback_allowed", return_value=True), + patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True), patch.object(sessions, "_review_ready", AsyncMock(return_value=False)), ): response = await sessions.get_session_detail(sess.session_id, owner) @@ -235,7 +235,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase): "load_session", AsyncMock(return_value=None), ), - patch.object(sessions, "runtime_fallback_allowed", return_value=True), + patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True), patch.object( sessions.session_persistence, "load_session_evaluation", @@ -299,7 +299,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase): "load_session", AsyncMock(return_value=None), ), - patch.object(sessions, "runtime_fallback_allowed", return_value=True), + patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True), patch.object(sessions.orchestrator, "run_turn_generate", successful_turn), ): await sessions.submit_turn( diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index 468ed68..e3c50d5 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -3,7 +3,9 @@ from __future__ import annotations import unittest +import time from contextlib import contextmanager +from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException @@ -215,6 +217,95 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(db.metric, "비영구 런타임 기록") self.assertIn("비영구 개발 런타임 기록", db.detail) + async def test_dev_admin_usage_falls_back_to_runtime_store(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000007", + role=Role.ADMIN, + cohort_ids=[], + email="admin@twentyoz.kr", + display_name="Admin", + ) + now = time.time() + fake_session = SimpleNamespace( + turns=[ + SimpleNamespace( + speaker="client", + created_at=now, + llm_provider="claude_cli", + model="gateway-default", + tokens_in=11, + tokens_out=13, + cost_usd=0.0042, + ), + SimpleNamespace( + speaker="client", + created_at=now, + llm_provider=None, + model=None, + tokens_in=None, + tokens_out=None, + cost_usd=None, + ), + SimpleNamespace( + speaker="counselor", + created_at=now, + llm_provider="ignored", + model="ignored", + tokens_in=100, + tokens_out=100, + cost_usd=9.0, + ), + ] + ) + + with ( + environment("dev"), + patch.object( + admin_routes, + "_usage_from_database", + AsyncMock(side_effect=RuntimeError("db unavailable")), + ), + patch.object(admin_routes.store, "list", return_value=[fake_session]), + patch.object(admin_routes.settings, "admin_usage_budget_usd", 0.005), + ): + usage = await admin_routes.admin_usage(principal, window_days=7) + + self.assertEqual(usage.source, "server_session_registry") + self.assertFalse(usage.durable) + self.assertEqual(usage.total_turns, 2) + self.assertEqual(usage.metered_turns, 1) + self.assertEqual(usage.tokens_in, 11) + self.assertEqual(usage.tokens_out, 13) + self.assertAlmostEqual(usage.cost_usd, 0.0042) + self.assertEqual(usage.budget.status, "warn") + self.assertAlmostEqual(usage.budget.limit_usd, 0.005) + self.assertAlmostEqual(usage.budget.used_ratio, 0.84) + self.assertEqual(usage.by_provider[0].provider, "claude_cli") + self.assertEqual(usage.by_provider[0].turns, 1) + + async def test_prod_admin_usage_rejects_runtime_store_fallback(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000008", + role=Role.ADMIN, + cohort_ids=[], + email="admin@twentyoz.kr", + display_name="Admin", + ) + + with ( + environment("prod"), + patch.object( + admin_routes, + "_usage_from_database", + AsyncMock(side_effect=RuntimeError("db unavailable")), + ), + ): + with self.assertRaises(HTTPException) as caught: + await admin_routes.admin_usage(principal, window_days=7) + + self.assertEqual(caught.exception.status_code, 503) + self.assertIn("runtime fallback is disabled in prod", caught.exception.detail) + def test_non_dev_rejects_fixture_runtime_flags(self) -> None: with self.assertRaises(ValueError) as caught: Settings( @@ -261,6 +352,7 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): oauth_google_client_secret="google-client-secret", frontend_base_url="https://vignette.chanpaca.net", cors_origins=["https://vignette.chanpaca.net"], + voice_poc_sample_tts_enabled=False, ) self.assertEqual(cfg.environment, "staging") @@ -281,6 +373,7 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): "api-vnet.18ka.net": "https://vnet.18ka.net", }, cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"], + voice_poc_sample_tts_enabled=False, ) self.assertEqual(cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net") @@ -318,6 +411,7 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): "http://localhost:5170", "http://127.0.0.1:5180", ], + voice_poc_sample_tts_enabled=False, ) self.assertIn("http://localhost:5170", cfg.cors_origins) diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index 8f2929f..93214d3 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -2,16 +2,19 @@ from __future__ import annotations +import json import unittest +from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from . import turn_runtime from .deps import Principal, Role from .engine_client import EngineError from .routes import sessions from .routes import voice as voice_routes -from .services import orchestrator, persona as persona_service, state_machine +from .services import memory, orchestrator, persona as persona_service, state_machine from .services.voice import TTSChunk, TranscriptResult, VoicePreset -from .store import InProcSession, store +from .store import InProcSession, TurnRecord, store def _principal() -> Principal: @@ -118,11 +121,84 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(client_turn.tokens_out, 23) self.assertEqual(client_turn.cost_usd, 0.012345) + async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(self) -> None: + principal = _principal() + sess = _session(principal) + + async def should_not_generate(*args, **kwargs): + raise AssertionError("engine must not be called for learner_real crisis") + + with patch.object(sessions.engine_client, "generate", should_not_generate): + response = await sessions.submit_turn( + sess.session_id, + sessions.TurnRequest(text="저 지금 죽고싶어요. 도와주세요."), + principal, + ) + + self.assertTrue(response.safety_flagged) + self.assertTrue(response.conversation_stopped) + self.assertEqual(response.crisis_kind, "learner_real") + self.assertIsNotNone(response.crisis_resource) + self.assertEqual(response.crisis_resource.number, "109") + self.assertIsNone(response.client_reply) + self.assertEqual(len(sess.turns), 1) + self.assertEqual(sess.turns[0].speaker, "counselor") + + async def test_record_safety_event_writes_teacher_alert_payload(self) -> None: + principal = _principal() + sess = _session(principal) + ctx = orchestrator.prepare_turn( + session_id=sess.session_id, + case_id=sess.case_id, + card=sess.persona, + state=sess.state, + learner_text="저 지금 자살하고 싶어요. 도와주세요.", + theory_mode=sess.theory_mode, + ) + result = orchestrator.TurnResult( + turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1, + stage=ctx.state_after.stage.value if ctx.state_after else "라포", + effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0, + client_reply=None, + safety_flagged=True, + state_after=ctx.state_after or sess.state, + crisis_kind="learner_real", + crisis_resource={"title": "자살예방상담전화 109", "number": "109"}, + conversation_stopped=True, + ) + calls: list[tuple[str, tuple[object, ...]]] = [] + + class FakeConn: + async def execute(self, query: str, *args: object) -> str: + calls.append((query, args)) + return "INSERT 0 1" + + class FakeAcquire: + async def __aenter__(self) -> FakeConn: + return FakeConn() + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()): + await turn_runtime.record_safety_event(sess, ctx, result) + + self.assertEqual(len(calls), 1) + query, args = calls[0] + self.assertIn("INSERT INTO app.safety_events", query) + self.assertEqual(args[0], sess.session_id) + self.assertEqual(args[1], "learner_real") + self.assertGreaterEqual(args[2], 4) + detail = json.loads(args[3]) + self.assertTrue(detail["conversation_stopped"]) + self.assertEqual(detail["crisis_resource"]["number"], "109") + self.assertEqual(detail["alert_status"], "teacher_dashboard") + async def test_stream_turn_persists_client_engine_telemetry(self) -> None: principal = _principal() sess = _session(principal) - async def successful_stream(ctx, engine): + async def successful_stream(ctx, engine, **kwargs): assert ctx.state_after is not None yield orchestrator.StreamEvent("token", {"text": "괜찮아요."}) yield orchestrator.StreamEvent( @@ -158,6 +234,146 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(client_turn.tokens_out, 37) self.assertEqual(client_turn.cost_usd, 0.023456) + async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(self) -> None: + principal = _principal() + sess = _session(principal) + + def should_not_stream(*args, **kwargs): + raise AssertionError("stream engine must not be called for learner_real crisis") + + with patch.object(sessions.engine_client, "stream", should_not_stream): + response = await sessions.stream_turn( + sess.session_id, + sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."), + principal, + ) + body = await _consume_event_source(response) + + rendered = body.decode("utf-8") + self.assertIn("'event': 'safety'", rendered) + self.assertIn("'event': 'done'", rendered) + self.assertIn("109", rendered) + self.assertIn("conversation_stopped", rendered) + self.assertEqual(len(sess.turns), 1) + self.assertEqual(sess.turns[0].speaker, "counselor") + + async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None: + principal = _principal() + sess = _session(principal) + + async def successful_stream(ctx, engine, **kwargs): + assert ctx.state_after is not None + yield orchestrator.StreamEvent("token", {"text": "조금 말해볼게요."}) + yield orchestrator.StreamEvent( + "done", + { + "session_id": ctx.session_id, + "stage": ctx.state_after.stage.value, + "effective_openness": ctx.state_after.effective_openness, + "turn_seq": ctx.state_after.turn_seq, + "safety_flagged": False, + "llm_provider": "claude_cli", + "model": "gateway-default", + "tokens_in": 9, + "tokens_out": 10, + "cost_usd": 0.001, + }, + ) + + async def fake_eval_hook(ctx, client_reply): + return { + "loop": "fast", + "turn_seq": ctx.state_after.turn_seq, + "stage": ctx.state_after.stage.value, + "appropriateness": "pos", + "appropriateness_note": f"응답 반영: {client_reply}", + } + + with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object( + sessions.evaluator, + "make_eval_hook", + return_value=fake_eval_hook, + ): + response = await sessions.stream_turn( + sess.session_id, + sessions.TurnRequest(text="스트림 평가 발화"), + principal, + ) + await _consume_event_source(response) + + self.assertEqual(len(sess.turns), 2) + learner_turn, client_turn = sess.turns + self.assertEqual(learner_turn.speaker, "counselor") + self.assertIsNotNone(learner_turn.evaluation) + self.assertEqual(learner_turn.evaluation["appropriateness"], "pos") + self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"]) + self.assertIsNone(client_turn.evaluation) + + async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None: + principal = _principal() + card = persona_service.P1 + case_context = sessions.session_persistence.CaseContext( + case_id="00000000-0000-0000-0000-00000000ca5e", + last_session_no=1, + ) + catalog_persona = SimpleNamespace( + card=card, + persona_id="00000000-0000-0000-0000-0000000000a1", + version=3, + degraded=False, + ) + recall = memory.RecallContext( + recall_summary="지난 회기에서 가족 이야기를 열어두었다.", + carry={ + "rapport_credit": 0.6, + "resistance": card.base_resistance(), + "ideation_stage": card.ideation_baseline(), + }, + ) + + async def fake_create_session(**kwargs): + self.assertEqual(kwargs["case_id"], case_context.case_id) + self.assertEqual(kwargs["session_no"], 2) + self.assertGreater(kwargs["state"].rapport_credit, 0) + return InProcSession( + session_id="stable-case-session", + case_id=kwargs["case_id"], + learner_id=principal.user_id, + persona_code=card.code, + theory_mode=kwargs["theory_mode"], + persona=card, + state=kwargs["state"], + session_no=kwargs["session_no"], + prev_rapport_credit=kwargs["carry_rapport"], + ) + + def close_background(coro): + coro.close() + return None + + with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object( + sessions.session_persistence, + "get_case_context", + AsyncMock(return_value=case_context), + ), patch.object( + sessions, + "_build_seed_recall", + AsyncMock(return_value=recall), + ), patch.object( + sessions.session_persistence, + "create_session", + fake_create_session, + ), patch.object(sessions.asyncio, "create_task", close_background): + response = await sessions.start_session( + sessions.SessionStartRequest(persona_code=card.code), + principal, + ) + + self.assertEqual(response.case_id, case_context.case_id) + self.assertEqual(response.session_no, 2) + self.assertEqual(response.recall_summary, recall.recall_summary) + self.assertIs(sessions._RECALL_CACHE[response.session_id], recall) + async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None: class FakeStreamEngine: engine_mode = "claude_cli" @@ -357,6 +573,90 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(client_turn.llm_provider, "claude_cli") self.assertTrue(any(message.get("type") == "tts_end" for message in websocket.messages)) + async def test_review_exposes_voice_nonverbal_events_on_learner_turn(self) -> None: + principal = _principal() + sess = _session(principal) + created_at = sess.created_at + sess.turns.extend( + [ + TurnRecord( + turn_seq=1, + speaker="counselor", + stage=sess.state.stage.value, + text="learner voice turn", + text_masked="learner voice turn", + created_at=created_at + 1, + audio_ref="voice:webm:sha256:test", + silence_ms=1234, + speech_rate=420.0, + barge_in=True, + ), + TurnRecord( + turn_seq=2, + speaker="client", + stage=sess.state.stage.value, + text="client reply", + text_masked="client reply", + created_at=created_at + 2, + audio_ref="voice:webm:sha256:client", + silence_ms=2500, + speech_rate=180.0, + barge_in=True, + ), + ] + ) + + response = await sessions.get_session_review(sess.session_id, principal) + + self.assertEqual(len(response.turns), 2) + learner_turn, client_turn = response.turns + self.assertEqual([event.kind for event in learner_turn.nonverbal], ["silence", "pace", "barge_in", "audio"]) + self.assertEqual(learner_turn.nonverbal[0].label, "침묵") + self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초") + self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자") + self.assertEqual(client_turn.nonverbal, []) + + async def test_review_includes_case_formulation_worksheet_draft(self) -> None: + principal = _principal() + sess = _session(principal) + created_at = sess.created_at + sess.turns.extend( + [ + TurnRecord( + turn_seq=1, + speaker="counselor", + stage=sess.state.stage.value, + text="오늘은 어떤 목표로 이야기해보고 싶으세요?", + text_masked="오늘은 어떤 목표로 이야기해보고 싶으세요?", + created_at=created_at + 1, + ), + TurnRecord( + turn_seq=2, + speaker="client", + stage=sess.state.stage.value, + text="요즘 너무 불안하고 친구 관계 스트레스 때문에 잠을 잘 못 자요.", + text_masked="요즘 너무 불안하고 친구 관계 스트레스 때문에 잠을 잘 못 자요.", + created_at=created_at + 2, + ), + ] + ) + + response = await sessions.get_session_review(sess.session_id, principal) + + worksheet = response.caseWorksheet + self.assertEqual(worksheet.status, "draft_from_transcript") + self.assertGreaterEqual(len(worksheet.sections), 5) + exploration = worksheet.sections[0] + self.assertEqual(exploration.key, "exploration_11") + complaint = next(item for item in exploration.items if item.key == "presenting_complaint") + self.assertEqual(complaint.confidence, "medium") + self.assertEqual(complaint.evidence[0].turnId, "t2") + self.assertIn("불안", complaint.value or "") + risk = next(item for item in exploration.items if item.key == "risk") + self.assertEqual(risk.confidence, "none") + self.assertEqual(risk.evidence, []) + self.assertIn("명시 근거", risk.emptyReason or "") + if __name__ == "__main__": unittest.main() diff --git a/apps/api/app/test_teacher_dashboard.py b/apps/api/app/test_teacher_dashboard.py new file mode 100644 index 0000000..4690bbb --- /dev/null +++ b/apps/api/app/test_teacher_dashboard.py @@ -0,0 +1,124 @@ +"""교수자 대시보드 집계 테스트.""" + +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, patch + +from .deps import Principal, Role +from .routes import teacher +from .services import state_machine +from .services.persona import P1 +from .store import InProcSession, TurnRecord + + +def _principal() -> Principal: + return Principal( + user_id="00000000-0000-0000-0000-000000000901", + role=Role.TEACHER, + ) + + +def _session( + *, + session_id: str, + session_no: int, + learner_id: str, + score: str, + rapport: float, + technique: str, + created_at: float, +) -> InProcSession: + state = state_machine.init_state(params=P1.openness_params()) + state.stage = state_machine.Stage.EXPLORE + return InProcSession( + session_id=session_id, + case_id=session_id, + learner_id=learner_id, + persona_code=P1.code, + theory_mode="humanistic", + persona=P1, + state=state, + session_no=session_no, + created_at=created_at, + ended_at=created_at + 600, + ended=True, + turns=[ + TurnRecord( + turn_seq=1, + speaker="counselor", + stage=state.stage.value, + text="상담자 발화", + text_masked="상담자 발화", + evaluation={ + "appropriateness": score, + "rapport_signal": rapport, + "techniques": [{"label": technique}], + }, + ), + TurnRecord( + turn_seq=2, + speaker="client", + stage=state.stage.value, + text="내담자 응답", + text_masked="내담자 응답", + ), + ], + ) + + +class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): + async def test_dashboard_returns_learner_growth_from_turn_evaluations(self) -> None: + learner_id = "00000000-0000-0000-0000-000000000111" + sessions = [ + _session( + session_id="00000000-0000-0000-0000-00000000a111", + session_no=1, + learner_id=learner_id, + score="neutral", + rapport=0.1, + technique="reflection", + created_at=1_000.0, + ), + _session( + session_id="00000000-0000-0000-0000-00000000a112", + session_no=2, + learner_id=learner_id, + score="pos", + rapport=0.5, + technique="reflection", + created_at=2_000.0, + ), + ] + principal = _principal() + + with ( + patch.object( + teacher.session_persistence, + "list_sessions", + AsyncMock(return_value=(sessions, True)), + ) as list_sessions, + patch.object( + teacher.session_persistence, + "list_safety_alerts", + AsyncMock(return_value=([], True)), + ), + ): + response = await teacher.teacher_dashboard(principal) + + list_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True) + self.assertEqual(response.total_learners, 1) + self.assertEqual(len(response.learner_growth), 1) + growth = response.learner_growth[0] + self.assertEqual(growth.sessions, 2) + self.assertEqual(growth.ended_sessions, 2) + self.assertEqual(growth.first_score, 0.5) + self.assertEqual(growth.latest_score, 1.0) + self.assertEqual(growth.score_delta, 0.5) + self.assertEqual(growth.trend, "up") + self.assertEqual(growth.top_techniques, ["reflection"]) + self.assertEqual([point.session_no for point in growth.points], [1, 2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py new file mode 100644 index 0000000..57fe73a --- /dev/null +++ b/apps/api/app/turn_runtime.py @@ -0,0 +1,193 @@ +"""REST/WS 공용 턴 런타임 헬퍼. + +세션 로드, 오너십 검증, 완료 턴 영속화, 상태 갱신은 REST 세션 라우트와 +음성 WebSocket 라우트가 같은 규칙을 공유해야 한다. +""" + +from __future__ import annotations + +from enum import Enum +import json +from typing import Optional + +from . import db, session_persistence +from .deps import Principal +from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed +from .services import orchestrator, state_machine +from .store import InProcSession, TurnRecord, store + +_STAGE_LABELS = { + "RAPPORT": "라포", + "EXPLORE": "탐색", + "INTERVENE": "개입", + "CLOSE": "정리", +} + + +def stage_label(stage: object) -> str: + """Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다.""" + name = getattr(stage, "name", "") + return _STAGE_LABELS.get(name, str(getattr(stage, "value", stage))) + + +class SessionAccessError(str, Enum): + NOT_FOUND = "not_found" + FORBIDDEN = "forbidden" + ENDED = "ended" + + +async def load_owned_session( + session_id: str, + principal: Principal, + *, + allow_ended: bool = False, + include_turn_evaluation: bool = False, +) -> tuple[InProcSession | None, Optional[SessionAccessError]]: + """DB 우선으로 학습자 소유 세션을 로드하고 접근 오류를 코드로 반환한다.""" + sess = await session_persistence.load_session( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=include_turn_evaluation, + ) + if sess is not None: + store.put(sess) + elif runtime_fallback_allowed(): + sess = store.get(session_id) + if sess is None: + return None, SessionAccessError.NOT_FOUND + if sess.learner_id != principal.user_id: + return None, SessionAccessError.FORBIDDEN + if sess.ended and not allow_ended: + return None, SessionAccessError.ENDED + return sess, None + + +async def append_completed_turn( + sess: InProcSession, + turn: TurnRecord, + *, + context: str, +) -> None: + """완료된 턴을 DB와 in-process 미러에 기록한다.""" + if await session_persistence.append_turn( + session_id=sess.session_id, + learner_id=sess.learner_id, + turn=turn, + ): + sess.turns.append(turn) + store.put(sess) + return + require_runtime_fallback_allowed(context) + store.append_turn(sess.session_id, turn) + + +async def update_session_state( + sess: InProcSession, + state: state_machine.SessionState, + *, + context: str, +) -> None: + """working state를 DB와 in-process 미러에 반영한다.""" + if await session_persistence.update_state( + session_id=sess.session_id, + learner_id=sess.learner_id, + state=state, + ): + sess.state = state + store.put(sess) + return + require_runtime_fallback_allowed(context) + store.update_state(sess.session_id, state) + + +async def record_completed_turn( + sess: InProcSession, + ctx: orchestrator.TurnContext, + result: orchestrator.TurnResult, + *, + context_prefix: str, + counselor_turn: TurnRecord | None = None, +) -> None: + """상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다.""" + assert ctx.state_after is not None + learner_turn = counselor_turn or TurnRecord( + turn_seq=ctx.state_after.turn_seq, + speaker="counselor", + stage=stage_label(ctx.state_after.stage), + text=ctx.learner_text_raw, + text_masked=ctx.learner_text_masked, + evaluation=result.evaluation, + ) + await append_completed_turn( + sess, + learner_turn, + context=f"{context_prefix} turn append", + ) + if result.client_reply: + await append_completed_turn( + sess, + TurnRecord( + turn_seq=result.turn_seq, + speaker="client", + stage=stage_label(result.state_after.stage), + text=result.client_reply, + text_masked=result.client_reply, + llm_provider=result.llm_provider, + model=result.model, + tokens_in=result.tokens_in, + tokens_out=result.tokens_out, + cost_usd=result.cost_usd, + ), + context=f"{context_prefix} turn append", + ) + await update_session_state( + sess, + result.state_after, + context=f"{context_prefix} state update", + ) + + +async def record_safety_event( + sess: InProcSession, + ctx: orchestrator.TurnContext, + result: orchestrator.TurnResult, +) -> None: + """위기 escalate 시 app.safety_events에 교수자 확인용 알림 레코드를 남긴다.""" + crisis = getattr(ctx, "crisis", None) + if crisis is None or not getattr(crisis, "escalate", False): + return + kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) + try: + async with db.acquire() as conn: + await conn.execute( + """ + INSERT INTO app.safety_events + (session_id, trigger_type, ko_risk_level, escalated, detail) + VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb) + """, + sess.session_id, + kind, + int(getattr(crisis, "risk_level", 0) or 0), + json.dumps({ + "matched": list(getattr(crisis, "matched", []) or []), + "stage": getattr(result, "stage", None), + "turn_seq": getattr(result, "turn_seq", None), + "conversation_stopped": getattr(result, "conversation_stopped", False), + "crisis_resource": getattr(result, "crisis_resource", None), + "alert_status": "teacher_dashboard", + }), + ) + except Exception: + pass + + +__all__ = [ + "SessionAccessError", + "append_completed_turn", + "load_owned_session", + "record_safety_event", + "record_completed_turn", + "stage_label", + "update_session_state", +] diff --git a/apps/api/scripts/export-openapi.py b/apps/api/scripts/export-openapi.py new file mode 100644 index 0000000..3b9465d --- /dev/null +++ b/apps/api/scripts/export-openapi.py @@ -0,0 +1,28 @@ +"""Export the FastAPI OpenAPI schema as deterministic JSON.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from app.main import app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Export Vignette API OpenAPI JSON.") + parser.add_argument("output", type=Path, help="Output JSON path") + args = parser.parse_args() + + schema = app.openapi() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/apps/web/README.md b/apps/web/README.md index 7f713a1..747ff3d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -16,6 +16,8 @@ npm run dev # http://localhost:5173 (개발 서버, /api → :8000 프록 ```bash npm run build # tsc -b + vite build → dist/ +npm run generate:api-types # FastAPI OpenAPI → src/lib/api.gen.ts +npm run check:api-types # 생성 타입 stale 체크 npm run generate:live2d-assets npm run preview # 빌드 결과 미리보기 npm run typecheck # tsc --noEmit (타입 체크만) @@ -64,7 +66,8 @@ src/ tokens.css ★ 디자인 토큰 (라이트/다크/역할 accent). dev_dashboard 계승. global.css reset + body + 스크롤바 + 유틸. lib/ - api.ts fetch wrapper(credentials:include) + SSE 헬퍼 + 세션 API + 백엔드 타입. + api.ts fetch wrapper(credentials:include) + SSE 헬퍼 + 세션 API. + api.gen.ts FastAPI OpenAPI에서 생성한 백엔드 DTO 타입. auth.tsx AuthContext(user/role/login/logout). body[data-role] 반영. format.ts 시간/타이머/숫자 포맷(tabular). components/ diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index baa9026..aa34953 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -39,6 +39,35 @@ interface AdminUsersResponse { users: AdminManagedUser[]; } +interface AdminUsageBreakdown { + provider: string; + model: string; + turns: number; + tokens_in: number; + tokens_out: number; + cost_usd: number; +} + +interface AdminUsageBudget { + limit_usd: number; + used_ratio: number; + remaining_usd: number | null; + status: "disabled" | "ok" | "warn" | "exceeded"; +} + +interface AdminUsageResponse { + source: "database" | "server_session_registry"; + durable: boolean; + window_days: number; + total_turns: number; + metered_turns: number; + tokens_in: number; + tokens_out: number; + cost_usd: number; + budget: AdminUsageBudget; + by_provider: AdminUsageBreakdown[]; +} + async function expectResponseOk(response: APIResponse | Response) { if (!response.ok()) { expect(response.ok(), await response.text()).toBeTruthy(); @@ -66,6 +95,11 @@ function isAdminUsersResponse(response: Response) { return response.request().method() === "GET" && url.pathname.endsWith("/admin/users"); } +function isAdminUsageResponse(response: Response) { + const url = new URL(response.url()); + return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage"); +} + function isAdminUserCreate(response: Response) { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname.endsWith("/admin/users"); @@ -106,17 +140,33 @@ function engineModeLabel(value: string) { return value; } +function countLabel(value: number) { + if (!Number.isFinite(value)) return "0"; + return Math.round(value).toLocaleString("ko-KR"); +} + +function costLabel(value: number) { + if (!Number.isFinite(value) || value <= 0) return "$0"; + return `$${value.toFixed(value < 0.01 ? 6 : 4)}`; +} + async function openAdminAndReadHealth(page: Page) { const healthResponsePromise = page.waitForResponse(isAdminHealthResponse); + const usageResponsePromise = page.waitForResponse(isAdminUsageResponse); await page.goto("/admin"); - const healthResponse = await healthResponsePromise; + const [healthResponse, usageResponse] = await Promise.all([ + healthResponsePromise, + usageResponsePromise, + ]); await expectResponseOk(healthResponse); + await expectResponseOk(usageResponse); const health = (await healthResponse.json()) as AdminHealthResponse; + const usage = (await usageResponse.json()) as AdminUsageResponse; expect(health.services.length).toBeGreaterThan(0); - return health; + return { health, usage }; } async function openAdminAndReadUsers(page: Page) { @@ -225,7 +275,7 @@ test.describe("admin route", () => { await signInAsAdmin(page); await withGlobalEngineConfigLock("admin-health-dashboard", async () => { - const health = await openAdminAndReadHealth(page); + const { health, usage } = await openAdminAndReadHealth(page); const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)"); const counts = { ok: health.services.filter((service) => service.status === "ok").length, @@ -242,6 +292,34 @@ test.describe("admin route", () => { String(counts.degraded), String(counts.down), ]); + await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible(); + await expect(page.locator(".ad-usage-kpi b")).toHaveText([ + costLabel(usage.cost_usd), + countLabel(usage.tokens_in), + countLabel(usage.tokens_out), + usage.total_turns > 0 + ? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%` + : "0%", + ]); + await expect(page.locator(".ad-usage-budget")).toContainText( + usage.budget.status === "disabled" + ? "예산 경고 비활성" + : usage.budget.status === "exceeded" + ? "예산 초과" + : usage.budget.status === "warn" + ? "예산 주의" + : "예산 정상", + ); + if (usage.by_provider.length > 0) { + await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length); + await expect(page.locator(".ad-usage-row").first()).toContainText( + usage.by_provider[0].provider, + ); + } else { + await expect(page.locator(".ad-usage-breakdown")).toContainText( + "최근 윈도우에 계량된 AI 턴이 없습니다.", + ); + } await expect(serviceCards).toHaveCount(health.services.length); for (const service of health.services) { diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index 7c0d9e3..ef7dc89 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -69,10 +69,8 @@ test.describe("auth domain policy", () => { const googleButtons = page.locator(".lg-obtn"); await expect(googleButtons).toHaveCount(2); await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains); - const currentHost = new URL(page.url()).hostname; const redirectHost = new URL(config.redirect_uri).hostname; - const localOAuthUnavailable = - isLocalHostname(currentHost) && + const devOAuthUnavailable = config.dev_login_enabled && !isLocalHostname(redirectHost); if (config.dev_login_enabled) { @@ -81,7 +79,7 @@ test.describe("auth domain policy", () => { await expect(page.locator(".lg-dev")).toHaveCount(0); } - if (config.google_oauth_configured && !localOAuthUnavailable) { + if (config.google_oauth_configured && !devOAuthUnavailable) { await expect(googleButtons.first()).toBeEnabled(); await expect(page.locator(".lg-config")).toHaveCount(0); } else { @@ -91,8 +89,11 @@ test.describe("auth domain policy", () => { await expect(page).toHaveURL(/\/login$/); await expect(page.locator("body")).not.toContainText("Google OAuth is not configured"); - if (localOAuthUnavailable) { + if (devOAuthUnavailable) { await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인"); + await page.goto("/api/auth/login?provider=google&next=%2Flearn"); + await expect(page).toHaveURL(/\/login\?oauth=local_oauth_unavailable$/); + await expect(page.locator(".lg-error")).toContainText("로컬 개발 주소에서는 Google OAuth"); return; } @@ -103,6 +104,14 @@ test.describe("auth domain policy", () => { } }); + test("shows the concrete OAuth failure reason on the login screen", async ({ page }) => { + await page.goto("/login?oauth=provider_error"); + const error = page.locator(".lg-error"); + + await expect(error).toContainText("Google이 인증 코드를 발급하지 못했습니다"); + await expect(error).toContainText("오류 코드: provider_error"); + }); + test("logs in locally with the server dev session and redirects to learner home", async ({ page, }) => { diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index a84e742..72efa54 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -17,6 +17,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "openapi-typescript": "^7.13.0", "puppeteer-core": "^25.2.1", "typescript": "^5.8.3", "vite": "^6.3.5" @@ -837,6 +838,52 @@ } } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.16", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz", + "integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -1296,6 +1343,26 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1322,6 +1389,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -1335,6 +1416,16 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1390,6 +1481,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chromium-bidi": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", @@ -1422,6 +1520,13 @@ "node": ">=20" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1527,6 +1632,13 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1593,6 +1705,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1600,6 +1749,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1613,6 +1785,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1636,6 +1815,19 @@ "yallist": "^3.0.2" } }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -1689,6 +1881,55 @@ "node": ">=18" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1756,6 +1997,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1866,6 +2117,16 @@ "react-dom": ">=16.8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1971,6 +2232,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1988,6 +2262,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", @@ -2040,6 +2327,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -2179,6 +2473,13 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index ac3bfb5..a1028be 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "generate:api-types": "node scripts/generate-api-types.mjs", + "check:api-types": "node scripts/generate-api-types.mjs --check", "generate:live2d-assets": "node scripts/generate-live2d-assets.mjs", "preview": "vite preview", "typecheck": "tsc -b", @@ -28,6 +30,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "openapi-typescript": "^7.13.0", "puppeteer-core": "^25.2.1", "typescript": "^5.8.3", "vite": "^6.3.5" diff --git a/apps/web/public/design-elements/clinical-paper-ambient.png b/apps/web/public/design-elements/clinical-paper-ambient.png new file mode 100644 index 0000000..9070d54 Binary files /dev/null and b/apps/web/public/design-elements/clinical-paper-ambient.png differ diff --git a/apps/web/public/design-elements/warm-visual-elements.png b/apps/web/public/design-elements/warm-visual-elements.png new file mode 100644 index 0000000..b0275ea Binary files /dev/null and b/apps/web/public/design-elements/warm-visual-elements.png differ diff --git a/apps/web/scripts/generate-api-types.mjs b/apps/web/scripts/generate-api-types.mjs new file mode 100644 index 0000000..12f53ee --- /dev/null +++ b/apps/web/scripts/generate-api-types.mjs @@ -0,0 +1,57 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(scriptDir, ".."); +const repoRoot = resolve(webRoot, "..", ".."); +const apiRoot = resolve(repoRoot, "apps", "api"); +const schemaPath = resolve(webRoot, "node_modules", ".tmp", "openapi.json"); +const outPath = resolve(webRoot, "src", "lib", "api.gen.ts"); +const exporter = resolve(apiRoot, "scripts", "export-openapi.py"); +const checkOnly = process.argv.includes("--check"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? webRoot, + env: process.env, + stdio: options.stdio ?? "inherit", + shell: options.shell ?? false, + }); + if (result.status === 0) return true; + if (options.allowFailure) return false; + const rendered = [command, ...args].join(" "); + throw new Error(`${rendered} failed with exit code ${result.status}`); +} + +async function exportOpenApi() { + await mkdir(dirname(schemaPath), { recursive: true }); + const configured = process.env.PYTHON?.trim(); + if (configured && run(configured, [exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) { + return; + } + if (process.platform === "win32" && run("py", ["-3.11", exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) { + return; + } + if (run("python", [exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) { + return; + } + throw new Error("Python 3.11 with apps/api dependencies is required to export OpenAPI."); +} + +function generateTypes() { + const bin = process.platform === "win32" + ? resolve(webRoot, "node_modules", ".bin", "openapi-typescript.cmd") + : resolve(webRoot, "node_modules", ".bin", "openapi-typescript"); + if (!existsSync(bin)) { + throw new Error("openapi-typescript is not installed. Run npm install first."); + } + const args = [schemaPath, "-o", outPath]; + if (checkOnly) args.push("--check"); + run(bin, args, { shell: process.platform === "win32" }); +} + +await exportOpenApi(); +generateTypes(); diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css index 50e4aed..6464201 100644 --- a/apps/web/src/components/shell/shell.css +++ b/apps/web/src/components/shell/shell.css @@ -71,6 +71,38 @@ gap: var(--sp-2); } +/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */ +body[data-role="admin"] .vg-topbar { + background: #17211f; + border-bottom-color: rgba(255, 255, 255, 0.08); + color: #eef4f2; +} +body[data-role="admin"] .vg-topbar__wm, +body[data-role="admin"] .vg-topbar__role, +body[data-role="admin"] .vg-topbar__uname { + color: #eef4f2; +} +body[data-role="admin"] .vg-topbar__brand svg { + color: #7eb8ad; +} +body[data-role="admin"] .vg-topbar__user { + background: rgba(255, 255, 255, 0.08); +} +body[data-role="admin"] .vg-topbar__avatar { + background: rgba(126, 184, 173, 0.18); + color: #bfe0d9; +} +body[data-role="admin"] .vg-topbar__role { + border-left-color: rgba(255, 255, 255, 0.12); +} +body[data-role="admin"] .vg-iconbtn { + color: rgba(238, 244, 242, 0.78); +} +body[data-role="admin"] .vg-iconbtn:hover { + background: rgba(255, 255, 255, 0.09); + color: #ffffff; +} + /* 톱바 아이콘 버튼 (테마/로그아웃) */ .vg-iconbtn { display: inline-flex; @@ -209,6 +241,40 @@ color: var(--text-muted); } +body[data-role="admin"] .vg-shell__body { + background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08)); +} +body[data-role="admin"] .vg-nav { + background: + linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)), + var(--asset-warm-elements) center / cover no-repeat; + color: #eef4f2; +} +body[data-role="admin"] .vg-nav__label, +body[data-role="admin"] .vg-nav__ethic { + color: rgba(238, 244, 242, 0.54); +} +body[data-role="admin"] .vg-nav__item { + color: rgba(238, 244, 242, 0.76); +} +body[data-role="admin"] .vg-nav__item:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} +body[data-role="admin"] .vg-nav__item .vg-nav__ic { + color: rgba(191, 224, 217, 0.72); +} +body[data-role="admin"] .vg-nav__item.is-active { + background: rgba(126, 184, 173, 0.22); + color: #ffffff; +} +body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic { + color: #91c8bd; +} +body[data-role="admin"] .vg-nav__foot { + border-top-color: rgba(255, 255, 255, 0.1); +} + /* ── 메인 콘텐츠 ── */ .vg-main { min-width: 0; /* 그리드 자식 overflow 방지 */ diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts new file mode 100644 index 0000000..fa72c25 --- /dev/null +++ b/apps/web/src/lib/api.gen.ts @@ -0,0 +1,3723 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/admin/engine-config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Engine Config + * @description Return the current admin-managed engine settings. + */ + get: operations["get_engine_config_admin_engine_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Patch Engine Config + * @description Persist engine settings for administrators. + */ + patch: operations["patch_engine_config_admin_engine_config_patch"]; + trace?: never; + }; + "/admin/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Admin Health + * @description Return operational health from live backend checks. + */ + get: operations["admin_health_admin_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Admin Usage + * @description Return AI token/cost usage from persisted turns or dev fallback state. + */ + get: operations["admin_usage_admin_usage_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Users + * @description Return users observed by the server-side auth/session boundary. + */ + get: operations["list_users_admin_users_get"]; + put?: never; + /** + * Create User + * @description Create or reactivate a managed user without requiring that user to log in first. + */ + post: operations["create_user_admin_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete User + * @description Deactivate a managed user and revoke any active browser sessions. + */ + delete: operations["delete_user_admin_users__user_id__delete"]; + options?: never; + head?: never; + /** + * Patch User + * @description Update a server-known user's role/profile for the current API process. + */ + patch: operations["patch_user_admin_users__user_id__patch"]; + trace?: never; + }; + "/auth/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Callback + * @description Exchange Google auth code, validate identity, and issue a BFF cookie. + */ + get: operations["callback_auth_callback_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Auth Config + * @description Return non-secret login configuration for the browser login screen. + */ + get: operations["auth_config_auth_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/dev-login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dev Login + * @description Dev-only server login for local E2E and manual testing. + * + * This is not a browser-side auth shortcut: the role is stored server-side and + * the browser only gets the same opaque HttpOnly cookie used by OAuth. + */ + post: operations["dev_login_auth_dev_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Login + * @description Start Google OIDC authorization code + PKCE login. + */ + get: operations["login_auth_login_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Logout + * @description Revoke the current server session and expire the browser cookie. + */ + post: operations["logout_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Me + * @description Return the current authenticated user. Unauthenticated requests are 401. + */ + get: operations["me_auth_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/saml/acs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Saml Acs + * @description Accept a minimal unsigned SAMLResponse for local fixture SAML proof. + * + * Signed SAML verification is intentionally not implemented. When + * SAML_X509_CERT_FINGERPRINT is configured, this endpoint refuses to trust the + * response so production does not silently run unsigned SAML. + */ + post: operations["saml_acs_auth_saml_acs_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/eval/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Eval Health + * @description 평가 라우터 헬스 — Features:evaluator 로 전환됨. + */ + get: operations["eval_health_eval_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/eval/sessions/{session_id}/evaluation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Session Evaluation + * @description 회기 평가 조회(읽기) — 저장된 마지막 deep 재평가 결과 + 기법 분포. + * + * 아직 평가 트리거가 없었다면 deep=None + 빈 분포. + */ + get: operations["get_session_evaluation_eval_sessions__session_id__evaluation_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/eval/sessions/{session_id}/reevaluate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reevaluate Session + * @description 회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화). + * + * 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다. + * 엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출). + */ + post: operations["reevaluate_session_eval_sessions__session_id__reevaluate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/eval/sessions/{session_id}/turn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reevaluate Turn + * @description 단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈). + * + * 저장된 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해 + * 경량 TurnContext 로 evaluator.evaluate_turn 을 호출한다. + */ + post: operations["reevaluate_turn_eval_sessions__session_id__turn_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health + * @description liveness + DB + 엔진 게이트웨이 readiness. + */ + get: operations["health_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/kb/eval-grounding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Eval Grounding + * @description 평가 AI 채점 근거 회수(DSM/이론/taxonomy 정답라벨 + 논평). + * + * role 무시하고 evaluator 정책 고정(평가 전용 경로). crag_pass=False 면 호출부가 + * '관찰 프레이밍'으로 다운그레이드(F-06). + */ + post: operations["eval_grounding_kb_eval_grounding_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/kb/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Kb Health + * @description KB 라우터 + RAG 구성요소 readiness. + * + * DB 풀/임베딩 모델 가용 여부를 *크래시 없이* 점검(미가용=degraded). 부트/디버그용. + */ + get: operations["kb_health_kb_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/kb/index": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Index Document + * @description 문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재. + * + * ⚠️ 임베딩은 무거운 작업 → 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted). + * DSM verbatim 저작권(license C/D)은 source 등록 시점 external_llm_ok 가드 책임. + * 모델 미가용 시 embedding NULL 폴백(BM25 만, degraded=True) — 크래시 X. + */ + post: operations["index_document_kb_index_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/kb/persona-memory": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Persona Memory + * @description 회기 시작 episodic recall(app.turn_embedding, case_id 스코프 강제). + * + * CCD/정답/평가는 이 경로에 구조적으로 부재(코드경로 부재 1차방어). 반환은 turn_id+점수만 + * (본문은 호출부 memory.build_recall_context 가 turns 조인). 내담자 뷰 RLS 주입. + */ + post: operations["persona_memory_kb_persona_memory_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/kb/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Search + * @description 정적 지식 KB 하이브리드 검색(dense pgvector cosine + sparse tsvector + 리랭킹). + * + * role 이 정책 4-튜플(사전필터·가중치·본문노출·라벨)을 고정한다 — 호출부가 못 넓힌다. + * AI 뷰 RLS 컨텍스트(app.current_ai_view)를 커넥션에 주입해 visible_to 를 2중 강제. + */ + post: operations["search_kb_search_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/personas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Personas + * @description Return latest approved personas from app.persona_card. + */ + get: operations["list_personas_personas_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/personas/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Persona Draft Route + * @description Create a draft persona card version for faculty review. + */ + post: operations["create_persona_draft_route_personas_drafts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/personas/drafts/{persona_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Persona Draft Route + * @description Return a draft/review persona card for editing. + */ + get: operations["get_persona_draft_route_personas_drafts__persona_id__get"]; + /** + * Update Persona Draft Route + * @description Update a draft/review persona card and optionally submit it for review. + */ + put: operations["update_persona_draft_route_personas_drafts__persona_id__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/personas/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Persona Reviews + * @description Return draft/review personas awaiting faculty approval. + */ + get: operations["list_persona_reviews_personas_review_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/personas/review/{persona_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Decide Persona Review + * @description Approve a persona for learners or return it to draft for changes. + */ + post: operations["decide_persona_review_personas_review__persona_id__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Learner Sessions + * @description Return the current learner's real practice sessions. + */ + get: operations["list_learner_sessions_sessions_get"]; + put?: never; + /** + * Start Session + * @description Start a learner-owned practice session. + */ + post: operations["start_session_sessions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Session Detail + * @description Return a learner-owned session with transcript for resume/history. + */ + get: operations["get_session_detail_sessions__session_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}/end": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * End Session + * @description End a learner-owned session and prepare carry-over state. + */ + post: operations["end_session_sessions__session_id__end_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Session Review + * @description Return a learner-safe review built only from the stored session transcript. + */ + get: operations["get_session_review_sessions__session_id__review_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stream Turn + * @description Stream a generated client reply for one trainee utterance. + */ + post: operations["stream_turn_sessions__session_id__stream_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}/turn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit Turn + * @description Submit one trainee utterance and return the generated client reply. + */ + post: operations["submit_turn_sessions__session_id__turn_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teacher/dashboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Teacher Dashboard + * @description Return teacher-visible dashboard data from real sessions only. + */ + get: operations["teacher_dashboard_teacher_dashboard_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Me */ + get: operations["get_me_users_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Me */ + patch: operations["patch_me_users_me_patch"]; + trace?: never; + }; + "/users/me/preferences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Preferences */ + get: operations["get_preferences_users_me_preferences_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Preferences */ + patch: operations["patch_preferences_users_me_preferences_patch"]; + trace?: never; + }; + "/users/me/voice-presets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Voice Presets */ + get: operations["get_voice_presets_users_me_voice_presets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/voice/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Voice Health + * @description Return voice service readiness. + */ + get: operations["voice_health_voice_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AdminEngineConfigPatch */ + AdminEngineConfigPatch: { + /** Engine Mode */ + engine_mode?: string | null; + /** Engine Url */ + engine_url?: string | null; + /** Model */ + model?: string | null; + }; + /** AdminEngineConfigResponse */ + AdminEngineConfigResponse: { + /** + * Durable + * @default false + */ + durable: boolean; + /** Engine Mode */ + engine_mode: string; + /** Engine Url */ + engine_url: string; + /** Model */ + model: string; + /** + * Source + * @default runtime_default + * @enum {string} + */ + source: "database" | "runtime_cache" | "runtime_default"; + /** Updated At */ + updated_at?: number | null; + /** Updated By */ + updated_by?: string | null; + }; + /** AdminHealthResponse */ + AdminHealthResponse: { + /** Engine Mode */ + engine_mode: string; + /** Environment */ + environment: string; + /** Services */ + services: components["schemas"]["AdminServiceHealth"][]; + /** + * Status + * @enum {string} + */ + status: "ok" | "degraded" | "down"; + }; + /** AdminServiceHealth */ + AdminServiceHealth: { + /** Detail */ + detail: string; + /** Key */ + key: string; + /** Load */ + load: number; + /** Metric */ + metric: string; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: "ok" | "degraded" | "down"; + }; + /** AdminUsageBreakdown */ + AdminUsageBreakdown: { + /** Cost Usd */ + cost_usd: number; + /** Model */ + model: string; + /** Provider */ + provider: string; + /** Tokens In */ + tokens_in: number; + /** Tokens Out */ + tokens_out: number; + /** Turns */ + turns: number; + }; + /** AdminUsageBudget */ + AdminUsageBudget: { + /** Limit Usd */ + limit_usd: number; + /** Remaining Usd */ + remaining_usd: number | null; + /** + * Status + * @enum {string} + */ + status: "disabled" | "ok" | "warn" | "exceeded"; + /** Used Ratio */ + used_ratio: number; + }; + /** AdminUsageResponse */ + AdminUsageResponse: { + budget: components["schemas"]["AdminUsageBudget"]; + /** By Provider */ + by_provider: components["schemas"]["AdminUsageBreakdown"][]; + /** Cost Usd */ + cost_usd: number; + /** Durable */ + durable: boolean; + /** Generated At */ + generated_at: number; + /** Metered Turns */ + metered_turns: number; + /** + * Source + * @enum {string} + */ + source: "database" | "server_session_registry"; + /** Tokens In */ + tokens_in: number; + /** Tokens Out */ + tokens_out: number; + /** Total Turns */ + total_turns: number; + /** Window Days */ + window_days: number; + }; + /** AdminUserCreate */ + AdminUserCreate: { + /** Affiliation */ + affiliation?: string | null; + /** Cohort Ids */ + cohort_ids?: string[]; + /** Display Name */ + display_name: string; + /** Email */ + email: string; + /** + * Role + * @default learner + * @enum {string} + */ + role: "learner" | "teacher" | "admin"; + }; + /** AdminUserDeleteResponse */ + AdminUserDeleteResponse: { + /** Ok */ + ok: boolean; + /** User Id */ + user_id: string; + }; + /** AdminUserPatch */ + AdminUserPatch: { + /** Affiliation */ + affiliation?: string | null; + /** Cohort Ids */ + cohort_ids?: string[] | null; + /** Display Name */ + display_name?: string | null; + /** Role */ + role?: ("learner" | "teacher" | "admin") | null; + }; + /** AdminUserResponse */ + AdminUserResponse: { + /** Active Sessions */ + active_sessions: number; + /** Affiliation */ + affiliation: string; + /** Cohort Ids */ + cohort_ids: string[]; + /** Created At */ + created_at: number; + /** Display Name */ + display_name: string; + /** Email */ + email: string; + /** Last Seen At */ + last_seen_at: number; + /** + * Role + * @enum {string} + */ + role: "learner" | "teacher" | "admin"; + /** + * Source + * @enum {string} + */ + source: "database" | "server_session_registry"; + /** User Id */ + user_id: string; + }; + /** AdminUsersResponse */ + AdminUsersResponse: { + /** Durable */ + durable: boolean; + /** + * Source + * @enum {string} + */ + source: "database" | "server_session_registry"; + /** Users */ + users: components["schemas"]["AdminUserResponse"][]; + }; + /** AuthConfigResponse */ + AuthConfigResponse: { + /** Allowed Email Domains */ + allowed_email_domains: string[]; + /** Dev Login Enabled */ + dev_login_enabled: boolean; + /** Google Oauth Configured */ + google_oauth_configured: boolean; + /** Providers */ + providers: components["schemas"]["AuthProviderStatus"][]; + /** Redirect Uri */ + redirect_uri: string; + /** Saml Configured */ + saml_configured: boolean; + }; + /** AuthProviderStatus */ + AuthProviderStatus: { + /** Configured */ + configured: boolean; + /** Enabled */ + enabled: boolean; + /** Login Path */ + login_path: string; + /** + * Provider + * @enum {string} + */ + provider: "google" | "saml"; + }; + /** ChunkOut */ + ChunkOut: { + /** Behavior Cue */ + behavior_cue?: string | null; + /** Body */ + body?: string | null; + /** Chunk Id */ + chunk_id: number; + /** Context Prefix */ + context_prefix?: string | null; + /** Heading Path */ + heading_path?: string | null; + /** Kb Kind */ + kb_kind: string; + /** Label Id */ + label_id?: number | null; + /** Meta */ + meta?: Record; + /** Score */ + score: number; + /** Source Id */ + source_id?: string | null; + }; + /** + * ClientStateRead + * @description 내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거). + */ + ClientStateRead: { + /** Code */ + code: string; + /** Label Ko */ + label_ko: string; + /** Rationale */ + rationale?: string | null; + }; + /** CrisisResourceResponse */ + CrisisResourceResponse: { + /** Message */ + message: string; + /** Number */ + number: string; + /** Title */ + title: string; + }; + /** DevLoginRequest */ + DevLoginRequest: { + /** Display Name */ + display_name?: string | null; + /** Email */ + email: string; + /** + * Role + * @default learner + * @enum {string} + */ + role: "learner" | "teacher" | "admin"; + }; + /** + * EvaluationSummary + * @description 회기 평가 조회 응답(분포 + deep 결과 합본). + */ + EvaluationSummary: { + /** Deep */ + deep?: Record | null; + /** Distribution */ + distribution?: Record; + /** Session Id */ + session_id: string; + /** Stage */ + stage: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** IndexChunkIn */ + IndexChunkIn: { + /** Chunk Text */ + chunk_text: string; + /** Context Prefix */ + context_prefix?: string | null; + /** Heading Path */ + heading_path?: string | null; + /** Kb Kind */ + kb_kind?: string | null; + /** Label Id */ + label_id?: number | null; + /** Meta */ + meta?: Record | null; + /** Sensitivity */ + sensitivity?: number | null; + /** Seq */ + seq: number; + /** Token Count */ + token_count?: number | null; + /** Visible To */ + visible_to?: string[] | null; + }; + /** IndexRequestIn */ + IndexRequestIn: { + /** Chunks */ + chunks: components["schemas"]["IndexChunkIn"][]; + /** Content Hash */ + content_hash?: string | null; + /** Doc Uri */ + doc_uri: string; + /** Source Id */ + source_id: string; + /** + * Version + * @default 1 + */ + version: number; + }; + /** IndexResponse */ + IndexResponse: { + /** Chunks Indexed */ + chunks_indexed: number; + /** + * Degraded + * @default false + */ + degraded: boolean; + /** Doc Id */ + doc_id: number | null; + /** Embedded */ + embedded: boolean; + /** Skipped Unchanged */ + skipped_unchanged: boolean; + }; + /** + * IntentDeviation + * @description '의도와 다른 부분'(윤찬 1급 시민). taxonomy.SupervisorComment(critique) intent_deviation 정합. + * + * {dimension, expected, actual, severity} 구조화. dimension 은 평가 차원 + * (예: 'reflection', 'self_disclosure', 'pacing', 'risk_assessment'). + */ + IntentDeviation: { + /** + * Actual + * @description 실제 나타난 반응 + */ + actual: string; + /** + * Dimension + * @description 관련 평가 차원(기법/페이싱/위험사정 등) + */ + dimension: string; + /** + * Expected + * @description 권장된 반응/의도 + */ + expected: string; + /** + * Severity + * @description minor | moderate | major + * @default minor + */ + severity: string; + }; + /** KBSearchRequest */ + KBSearchRequest: { + /** + * K + * @default 5 + */ + k: number; + /** Kb Kind */ + kb_kind?: string[] | null; + /** Query */ + query: string; + /** + * Rerank + * @default true + */ + rerank: boolean; + /** + * Role + * @default evaluator + * @enum {string} + */ + role: "client" | "counselor" | "evaluator"; + /** Sensitivity Max */ + sensitivity_max?: number | null; + /** Session Id */ + session_id?: string | null; + /** Source Id */ + source_id?: string[] | null; + /** Turn Id */ + turn_id?: string | null; + }; + /** KBSearchResponse */ + KBSearchResponse: { + /** Chunks */ + chunks: components["schemas"]["ChunkOut"][]; + /** Crag Pass */ + crag_pass: boolean; + /** + * Degraded + * @default false + */ + degraded: boolean; + /** Latency Ms */ + latency_ms: number; + /** Policy */ + policy: string; + /** Top1 Score */ + top1_score: number; + }; + /** LearnerSessionSummary */ + LearnerSessionSummary: { + /** Client Turn Count */ + client_turn_count: number; + /** Ended At */ + ended_at?: string | null; + /** Learner Turn Count */ + learner_turn_count: number; + /** Persona Code */ + persona_code: string; + /** Persona Name */ + persona_name: string; + /** + * Review Ready + * @default false + */ + review_ready: boolean; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + /** Stage */ + stage: string; + /** Started At */ + started_at: string; + /** + * Status + * @enum {string} + */ + status: "active" | "ended"; + /** Turn Count */ + turn_count: number; + }; + /** LearnerSessionsResponse */ + LearnerSessionsResponse: { + /** Sessions */ + sessions?: components["schemas"]["LearnerSessionSummary"][]; + /** + * Source + * @default runtime + */ + source: string; + }; + /** MeResponse */ + MeResponse: { + /** Cohort Ids */ + cohort_ids: string[]; + /** Display Name */ + display_name: string; + /** Email */ + email: string; + /** Role */ + role: string; + /** User Id */ + user_id: string; + }; + /** MemoryRecallRequest */ + MemoryRecallRequest: { + /** Case Id */ + case_id: string; + /** + * K + * @default 5 + */ + k: number; + /** Query */ + query: string; + /** Session Id */ + session_id?: string | null; + /** Turn Id */ + turn_id?: string | null; + }; + /** NotificationPreferences */ + NotificationPreferences: { + /** + * Learner Progress + * @default false + */ + learner_progress: boolean; + /** + * Product News + * @default false + */ + product_news: boolean; + /** + * Safety Signal + * @default true + */ + safety_signal: boolean; + /** + * Session Done + * @default true + */ + session_done: boolean; + }; + /** PersonaDraftDetail */ + PersonaDraftDetail: { + /** Affect Baseline */ + affect_baseline: { + [key: string]: number; + }; + /** Approved At */ + approved_at?: string | null; + /** Big5 */ + big5: { + [key: string]: number; + }; + /** Ccd */ + ccd: { + [key: string]: unknown; + }; + /** Code */ + code: string; + /** Created At */ + created_at?: string | null; + /** Demographics */ + demographics: { + [key: string]: unknown; + }; + /** Difficulty */ + difficulty: string; + /** Display Name */ + display_name: string; + /** Dsm5 Dimensional */ + dsm5_dimensional: { + [key: string]: unknown; + }; + /** History */ + history: { + [key: string]: unknown; + }; + /** Is Synthetic */ + is_synthetic: boolean; + /** Persona Id */ + persona_id: string; + /** Presenting */ + presenting: { + [key: string]: unknown; + }; + /** Resistance */ + resistance: { + [key: string]: number; + }; + /** Source Provenance */ + source_provenance: string; + /** Speech Style */ + speech_style: { + [key: string]: unknown; + }; + /** + * Status + * @enum {string} + */ + status: "draft" | "review" | "approved" | "archived"; + /** Theory Target */ + theory_target: string[]; + /** Version */ + version: number; + }; + /** PersonaDraftPayload */ + PersonaDraftPayload: { + /** Affect Baseline */ + affect_baseline?: { + [key: string]: number; + }; + /** Big5 */ + big5?: { + [key: string]: number; + }; + /** Ccd */ + ccd?: { + [key: string]: unknown; + }; + /** Code */ + code: string; + /** Demographics */ + demographics?: { + [key: string]: unknown; + }; + /** + * Difficulty + * @enum {string} + */ + difficulty: "easy" | "moderate" | "hard"; + /** Display Name */ + display_name: string; + /** Dsm5 Dimensional */ + dsm5_dimensional?: { + [key: string]: unknown; + }; + /** History */ + history?: { + [key: string]: unknown; + }; + /** + * Is Synthetic + * @default true + */ + is_synthetic: boolean; + /** Presenting */ + presenting?: { + [key: string]: unknown; + }; + /** Resistance */ + resistance?: { + [key: string]: number; + }; + /** + * Source Provenance + * @default + */ + source_provenance: string; + /** Speech Style */ + speech_style?: { + [key: string]: unknown; + }; + /** + * Submit For Review + * @default false + */ + submit_for_review: boolean; + /** Theory Target */ + theory_target?: string[]; + }; + /** PersonaReviewDecisionRequest */ + PersonaReviewDecisionRequest: { + /** + * Action + * @enum {string} + */ + action: "approve" | "reject"; + }; + /** PersonaReviewSummary */ + PersonaReviewSummary: { + /** Approved At */ + approved_at?: string | null; + /** Code */ + code: string; + /** Created At */ + created_at?: string | null; + /** Difficulty */ + difficulty: string; + /** Display Name */ + display_name: string; + /** Is Synthetic */ + is_synthetic: boolean; + /** Persona Id */ + persona_id: string; + /** Source Provenance */ + source_provenance: string; + /** + * Status + * @enum {string} + */ + status: "draft" | "review" | "approved" | "archived"; + /** Theory Target */ + theory_target: string[]; + /** Version */ + version: number; + }; + /** PersonaSummary */ + PersonaSummary: { + /** Code */ + code: string; + /** + * Degraded + * @default false + */ + degraded: boolean; + /** Demographics */ + demographics: Record; + /** Difficulty */ + difficulty: string; + /** Display Name */ + display_name: string; + /** Presenting Summary */ + presenting_summary: string; + /** + * Source + * @default database + */ + source: string; + /** Theory Target */ + theory_target: string[]; + /** Voice Preset */ + voice_preset?: string | null; + }; + /** ReevaluateRequest */ + ReevaluateRequest: { + /** + * Scope + * @description 'session_end' | 'stage_transition' + * @default session_end + */ + scope: string; + }; + /** ReviewCaseWorksheet */ + ReviewCaseWorksheet: { + /** + * Generatedby + * @default rule-based transcript extractor + */ + generatedBy: string; + /** Limitations */ + limitations?: string[]; + /** Sections */ + sections?: components["schemas"]["ReviewWorksheetSection"][]; + /** + * Status + * @default empty + * @enum {string} + */ + status: "empty" | "draft_from_transcript"; + }; + /** ReviewClient */ + ReviewClient: { + /** Initial */ + initial: string; + /** Name */ + name: string; + /** Persona */ + persona: string; + }; + /** ReviewNonverbalEvent */ + ReviewNonverbalEvent: { + /** Detail */ + detail: string; + /** + * Kind + * @enum {string} + */ + kind: "audio" | "silence" | "pace" | "barge_in"; + /** Label */ + label: string; + }; + /** ReviewNote */ + ReviewNote: { + /** Author */ + author: string; + /** Body */ + body: string; + /** Quote */ + quote?: string | null; + /** Title */ + title: string; + /** Tone */ + tone: string; + }; + /** ReviewPhaseSegment */ + ReviewPhaseSegment: { + /** Key */ + key: string; + /** Label */ + label: string; + /** Weight */ + weight: number; + }; + /** ReviewPoint */ + ReviewPoint: { + /** Body */ + body: string; + /** Jumpto */ + jumpTo?: string | null; + /** Title */ + title: string; + }; + /** ReviewRubricRow */ + ReviewRubricRow: { + /** Cluster */ + cluster: string; + /** Freq */ + freq: string; + /** Name */ + name: string; + /** + * Quality + * @enum {string} + */ + quality: "good" | "watch"; + /** Ratio */ + ratio: number; + }; + /** ReviewTechnique */ + ReviewTechnique: { + /** Kind */ + kind: string; + /** Label */ + label: string; + }; + /** ReviewTurn */ + ReviewTurn: { + /** Id */ + id: string; + /** Nonverbal */ + nonverbal?: components["schemas"]["ReviewNonverbalEvent"][]; + note?: components["schemas"]["ReviewNote"] | null; + /** + * Speaker + * @enum {string} + */ + speaker: "learner" | "client"; + /** Techniques */ + techniques?: components["schemas"]["ReviewTechnique"][]; + /** Text */ + text: string; + /** Ts */ + ts: string; + /** Who */ + who: string; + }; + /** ReviewValencePoint */ + ReviewValencePoint: { + /** T */ + t: number; + /** V */ + v: number; + }; + /** ReviewWorksheetEvidence */ + ReviewWorksheetEvidence: { + /** Quote */ + quote: string; + /** + * Speaker + * @enum {string} + */ + speaker: "learner" | "client"; + /** Turnid */ + turnId: string; + }; + /** ReviewWorksheetItem */ + ReviewWorksheetItem: { + /** + * Confidence + * @default none + * @enum {string} + */ + confidence: "none" | "low" | "medium"; + /** Emptyreason */ + emptyReason?: string | null; + /** Evidence */ + evidence?: components["schemas"]["ReviewWorksheetEvidence"][]; + /** Key */ + key: string; + /** Label */ + label: string; + /** Value */ + value?: string | null; + }; + /** ReviewWorksheetSection */ + ReviewWorksheetSection: { + /** Items */ + items?: components["schemas"]["ReviewWorksheetItem"][]; + /** Key */ + key: string; + /** Title */ + title: string; + }; + /** SessionDetailResponse */ + SessionDetailResponse: { + /** Case Id */ + case_id: string; + /** Effective Openness */ + effective_openness: number; + /** Ended At */ + ended_at?: string | null; + /** Persona Code */ + persona_code: string; + /** Persona Name */ + persona_name: string; + /** + * Review Ready + * @default false + */ + review_ready: boolean; + /** Session Id */ + session_id: string; + /** Stage */ + stage: string; + /** Started At */ + started_at: string; + /** + * Status + * @enum {string} + */ + status: "active" | "ended"; + /** Theory Mode */ + theory_mode: string; + /** Turns */ + turns?: components["schemas"]["SessionDetailTurn"][]; + }; + /** SessionDetailTurn */ + SessionDetailTurn: { + /** Created At */ + created_at: string; + /** + * Speaker + * @enum {string} + */ + speaker: "learner" | "client"; + /** Stage */ + stage: string; + /** Text */ + text: string; + /** Turn Seq */ + turn_seq: number; + }; + /** SessionEndResponse */ + SessionEndResponse: { + /** Digest Pending */ + digest_pending: boolean; + /** End State */ + end_state: { + [key: string]: string | number | boolean | { + [key: string]: number; + } | null; + }; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + }; + /** + * SessionEvaluation + * @description deep-loop 회기말/단계전환 정밀 평가 결과. + * + * 기법분포 + 잘한 순간 + 개선점(최대3) + 슈퍼바이저 rationale/critique + 의도이탈 집계. + */ + SessionEvaluation: { + /** Alternative Utterances */ + alternative_utterances?: string[]; + distribution?: components["schemas"]["TechniqueDistribution"]; + /** Error */ + error?: string | null; + /** Improvements */ + improvements?: string[]; + /** Intent Deviations */ + intent_deviations?: components["schemas"]["IntentDeviation"][]; + /** + * Loop + * @default deep + */ + loop: string; + /** + * Scope + * @default session_end + */ + scope: string; + /** Session Id */ + session_id: string; + /** Stage */ + stage: string; + /** Strengths */ + strengths?: string[]; + /** Supervisor Critique */ + supervisor_critique?: string | null; + /** Supervisor Rationale */ + supervisor_rationale?: string | null; + /** Theory Mode */ + theory_mode?: string | null; + /** + * Turns Evaluated + * @default 0 + */ + turns_evaluated: number; + }; + /** SessionReviewResponse */ + SessionReviewResponse: { + /** Audiourl */ + audioUrl?: string | null; + caseWorksheet?: components["schemas"]["ReviewCaseWorksheet"]; + client: components["schemas"]["ReviewClient"]; + /** Clientfeedback */ + clientFeedback?: string | null; + /** Clientvalence */ + clientValence?: components["schemas"]["ReviewValencePoint"][]; + /** Counselorbaseline */ + counselorBaseline?: components["schemas"]["ReviewValencePoint"][]; + /** Date */ + date: string; + /** + * Degraded + * @default true + */ + degraded: boolean; + /** Durationlabel */ + durationLabel: string; + /** Durationseconds */ + durationSeconds: number; + /** Goodmoments */ + goodMoments?: components["schemas"]["ReviewPoint"][]; + /** Growthpoints */ + growthPoints?: components["schemas"]["ReviewPoint"][]; + /** Nextline */ + nextLine?: string | null; + /** Pdfexporturl */ + pdfExportUrl?: string | null; + /** Phaseaxis */ + phaseAxis?: string[]; + /** Phases */ + phases?: components["schemas"]["ReviewPhaseSegment"][]; + /** Reachedphase */ + reachedPhase: string; + /** + * Reviewready + * @default false + */ + reviewReady: boolean; + /** Rubric */ + rubric?: components["schemas"]["ReviewRubricRow"][]; + /** Sessionsignal */ + sessionSignal: string; + /** Session Id */ + session_id: string; + /** Summary */ + summary: string; + /** Supervisorname */ + supervisorName: string; + /** Supervisorstate */ + supervisorState: string; + /** Turns */ + turns?: components["schemas"]["ReviewTurn"][]; + /** Valenceaxis */ + valenceAxis?: string[]; + }; + /** SessionStartRequest */ + SessionStartRequest: { + /** + * Persona Code + * @example P1 + */ + persona_code: string; + /** + * Theory Mode + * @default humanistic + * @enum {string} + */ + theory_mode: "humanistic" | "cbt" | "integrative"; + }; + /** SessionStartResponse */ + SessionStartResponse: { + /** Case Id */ + case_id: string; + /** + * Degraded + * @default false + */ + degraded: boolean; + /** Effective Openness */ + effective_openness: number; + /** Recall Summary */ + recall_summary?: string | null; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + /** Stage */ + stage: string; + }; + /** TeacherDashboardResponse */ + TeacherDashboardResponse: { + /** Active Sessions */ + active_sessions: number; + /** + * Cohort Label + * @default 현재 학습 기록 + */ + cohort_label: string; + /** Ended Sessions */ + ended_sessions: number; + /** Learner Growth */ + learner_growth?: components["schemas"]["TeacherLearnerGrowth"][]; + /** Message */ + message: string; + /** Pending Reviews */ + pending_reviews?: components["schemas"]["TeacherSessionSummary"][]; + /** Recent Sessions */ + recent_sessions?: components["schemas"]["TeacherSessionSummary"][]; + /** Safety Alerts */ + safety_alerts?: components["schemas"]["TeacherSafetyAlert"][]; + /** + * Source + * @default in_memory + */ + source: string; + /** Total Learners */ + total_learners: number; + }; + /** TeacherGrowthPoint */ + TeacherGrowthPoint: { + /** Ended At */ + ended_at?: string | null; + /** Persona Code */ + persona_code: string; + /** Rapport */ + rapport?: number | null; + /** Score */ + score?: number | null; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + /** Stage */ + stage: string; + /** Started At */ + started_at: string; + /** + * Technique Count + * @default 0 + */ + technique_count: number; + /** + * Watch Count + * @default 0 + */ + watch_count: number; + }; + /** TeacherLearnerGrowth */ + TeacherLearnerGrowth: { + /** Avg Rapport */ + avg_rapport?: number | null; + /** Avg Score */ + avg_score?: number | null; + /** Ended Sessions */ + ended_sessions: number; + /** First Score */ + first_score?: number | null; + /** Latest At */ + latest_at: string; + /** Latest Score */ + latest_score?: number | null; + /** Learner Id */ + learner_id: string; + /** Learner Label */ + learner_label: string; + /** Points */ + points?: components["schemas"]["TeacherGrowthPoint"][]; + /** Score Delta */ + score_delta?: number | null; + /** Sessions */ + sessions: number; + /** Top Techniques */ + top_techniques?: string[]; + /** + * Trend + * @default insufficient + */ + trend: string; + }; + /** TeacherSafetyAlert */ + TeacherSafetyAlert: { + /** Created At */ + created_at: string; + /** Escalated */ + escalated: boolean; + /** Id */ + id: string; + /** Ko Risk Level */ + ko_risk_level: number; + /** Learner Id */ + learner_id: string; + /** Learner Label */ + learner_label: string; + /** Persona Code */ + persona_code: string; + /** + * Resource Number + * @default 109 + */ + resource_number: string; + /** + * Resource Title + * @default 자살예방상담전화 109 + */ + resource_title: string; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + /** Trigger Type */ + trigger_type: string; + }; + /** TeacherSessionSummary */ + TeacherSessionSummary: { + /** Client Turn Count */ + client_turn_count: number; + /** Ended At */ + ended_at?: string | null; + /** Learner Id */ + learner_id: string; + /** Learner Label */ + learner_label: string; + /** Learner Turn Count */ + learner_turn_count: number; + /** Persona Code */ + persona_code: string; + /** Persona Name */ + persona_name: string; + /** Session Id */ + session_id: string; + /** Session No */ + session_no: number; + /** Stage */ + stage: string; + /** Started At */ + started_at: string; + /** Status */ + status: string; + /** Turn Count */ + turn_count: number; + }; + /** + * TechniqueDistribution + * @description deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단. + */ + TechniqueDistribution: { + /** By Category */ + by_category?: { + [key: string]: number; + }; + /** By Technique */ + by_technique?: { + [key: string]: number; + }; + /** Overused */ + overused?: string[]; + /** + * Total + * @default 0 + */ + total: number; + /** Underused */ + underused?: string[]; + }; + /** + * TechniqueTag + * @description fast-loop 기법 태그 1건 — taxonomy.Technique 코드 + 한글 + 군집 + 근거. + */ + TechniqueTag: { + /** Category */ + category: string; + /** Code */ + code: string; + /** Label Ko */ + label_ko: string; + /** Rationale */ + rationale?: string | null; + }; + /** + * TurnEvaluation + * @description fast-loop 턴 평가 결과(턴 직후 경량 4차원). + * + * 4차원: + * ① technique[] : 학습자(상담자) 발화에 부착된 기법 라벨(복수) + * ② client_state_read[] : 내담자 응답에서 읽은 상태(복수) + * ③ appropriateness : 적절성 신호 pos|warn|neutral (경량) + * ④ intent_deviation : '의도와 다른 부분' 있으면 구조화(없으면 None) + */ + TurnEvaluation: { + /** + * Appropriateness + * @default neutral + */ + appropriateness: string; + /** Appropriateness Note */ + appropriateness_note?: string | null; + /** Client State Read */ + client_state_read?: components["schemas"]["ClientStateRead"][]; + /** Error */ + error?: string | null; + intent_deviation?: components["schemas"]["IntentDeviation"] | null; + /** + * Loop + * @default fast + */ + loop: string; + /** Rapport Signal */ + rapport_signal?: number | null; + /** Stage */ + stage: string; + /** Techniques */ + techniques?: components["schemas"]["TechniqueTag"][]; + /** Theory Mode */ + theory_mode?: string | null; + /** Turn Seq */ + turn_seq: number; + }; + /** TurnReevaluateRequest */ + TurnReevaluateRequest: { + /** + * Turn Seq + * @description 재평가할 상담자 발화의 turn_seq + */ + turn_seq: number; + }; + /** TurnRequest */ + TurnRequest: { + /** Text */ + text: string; + }; + /** TurnResponse */ + TurnResponse: { + /** Client Reply */ + client_reply?: string | null; + /** + * Conversation Stopped + * @default false + */ + conversation_stopped: boolean; + /** + * Crisis Kind + * @default none + */ + crisis_kind: string; + crisis_resource?: components["schemas"]["CrisisResourceResponse"] | null; + /** Effective Openness */ + effective_openness: number; + /** + * Safety Flagged + * @default false + */ + safety_flagged: boolean; + /** Stage */ + stage: string; + /** Turn Seq */ + turn_seq: number; + }; + /** UserPreferencesPatch */ + UserPreferencesPatch: { + notifications?: components["schemas"]["NotificationPreferences"] | null; + /** Theme */ + theme?: string | null; + /** Voice Preset Id */ + voice_preset_id?: string | null; + /** Voice Rate */ + voice_rate?: number | null; + }; + /** UserPreferencesResponse */ + UserPreferencesResponse: { + notifications?: components["schemas"]["NotificationPreferences"]; + /** + * Theme + * @default system + */ + theme: string; + /** + * Voice Preset Id + * @default soft-young-fem + */ + voice_preset_id: string; + /** + * Voice Rate + * @default 1 + */ + voice_rate: number; + }; + /** UserProfilePatch */ + UserProfilePatch: { + /** Affiliation */ + affiliation?: string | null; + /** Display Name */ + display_name?: string | null; + }; + /** UserProfileResponse */ + UserProfileResponse: { + /** Affiliation */ + affiliation: string; + /** Cohort Ids */ + cohort_ids: string[]; + /** Display Name */ + display_name: string; + /** Email */ + email: string; + /** Role */ + role: string; + /** User Id */ + user_id: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + /** VoicePresetResponse */ + VoicePresetResponse: { + /** Desc */ + desc: string; + /** Id */ + id: string; + /** Name */ + name: string; + /** Persona Hint */ + persona_hint: string; + /** Voice Id */ + voice_id: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_engine_config_admin_engine_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminEngineConfigResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_engine_config_admin_engine_config_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminEngineConfigPatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminEngineConfigResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_health_admin_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminHealthResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_usage_admin_usage_get: { + parameters: { + query?: { + window_days?: number; + }; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUsageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_users_admin_users_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUsersResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_user_admin_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_user_admin_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserDeleteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_user_admin_users__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserPatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + callback_auth_callback_get: { + parameters: { + query?: { + code?: string | null; + state?: string | null; + error?: string | null; + error_description?: string | null; + }; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_oauth_state"?: string | null; + vignette_oauth_state?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + auth_config_auth_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthConfigResponse"]; + }; + }; + }; + }; + dev_login_auth_dev_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DevLoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + login_auth_login_get: { + parameters: { + query?: { + provider?: string; + next?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_auth_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + me_auth_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + saml_acs_auth_saml_acs_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + eval_health_eval_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + get_session_evaluation_eval_sessions__session_id__evaluation_get: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EvaluationSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reevaluate_session_eval_sessions__session_id__reevaluate_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReevaluateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionEvaluation"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reevaluate_turn_eval_sessions__session_id__turn_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TurnReevaluateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TurnEvaluation"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + eval_grounding_kb_eval_grounding_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KBSearchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KBSearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + kb_health_kb_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + index_document_kb_index_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IndexRequestIn"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IndexResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + persona_memory_kb_persona_memory_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemoryRecallRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KBSearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_kb_search_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KBSearchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KBSearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_personas_personas_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_persona_draft_route_personas_drafts_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonaDraftPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaReviewSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_persona_draft_route_personas_drafts__persona_id__get: { + parameters: { + query?: never; + header?: never; + path: { + persona_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaDraftDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_persona_draft_route_personas_drafts__persona_id__put: { + parameters: { + query?: never; + header?: never; + path: { + persona_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonaDraftPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaReviewSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_persona_reviews_personas_review_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaReviewSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + decide_persona_review_personas_review__persona_id__post: { + parameters: { + query?: never; + header?: never; + path: { + persona_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonaReviewDecisionRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonaReviewSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_learner_sessions_sessions_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LearnerSessionsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + start_session_sessions_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionStartRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionStartResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_session_detail_sessions__session_id__get: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + end_session_sessions__session_id__end_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionEndResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_session_review_sessions__session_id__review_get: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionReviewResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + stream_turn_sessions__session_id__stream_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TurnRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + submit_turn_sessions__session_id__turn_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TurnRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TurnResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + teacher_dashboard_teacher_dashboard_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeacherDashboardResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_me_users_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserProfileResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_me_users_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserProfilePatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserProfileResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_preferences_users_me_preferences_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPreferencesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_preferences_users_me_preferences_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserPreferencesPatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPreferencesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_voice_presets_users_me_voice_presets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VoicePresetResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + voice_health_voice_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index cc2baad..0d46d15 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,6 +6,10 @@ - SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달. ===================================================================== */ +import type { components } from "./api.gen"; + +type ApiSchema = components["schemas"][Name]; + // Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅). const PUBLIC_API_ORIGIN_BY_HOST: Record = { "vignette.chanpaca.net": "https://api-vignette.chanpaca.net", @@ -209,6 +213,9 @@ export interface PersonaReviewSummary { approved_at: string | null; } +export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">; +export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">; + /** POST /sessions — sessions.py SessionStartResponse */ export interface SessionStartResponse { session_id: string; @@ -221,19 +228,15 @@ export interface SessionStartResponse { } /** POST /sessions/{id}/turn — sessions.py TurnResponse */ -export interface TurnResponse { - turn_seq: number; - stage: SessionStage; - effective_openness: number; - client_reply: string | null; - safety_flagged: boolean; -} +export type TurnResponse = ApiSchema<"TurnResponse">; /** POST /sessions/{id}/end — sessions.py SessionEndResponse */ -export interface SessionEndResponse { - session_id: string; - session_no: number; - digest_pending: boolean; +export type SessionEndResponse = ApiSchema<"SessionEndResponse">; + +export interface CrisisResource { + title: string; + number: string; + message: string; } export interface LearnerSessionSummary { @@ -290,6 +293,12 @@ export interface ReviewTechnique { 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"; @@ -305,6 +314,7 @@ export interface ReviewTurn { who: string; text: string; techniques: ReviewTechnique[]; + nonverbal: ReviewNonverbalEvent[]; note?: ReviewNote | null; } @@ -333,6 +343,34 @@ export interface ReviewPoint { jumpTo?: string | null; } +export interface ReviewWorksheetEvidence { + turnId: string; + speaker: "learner" | "client"; + quote: string; +} + +export interface ReviewWorksheetItem { + key: string; + label: string; + value?: string | null; + evidence: ReviewWorksheetEvidence[]; + confidence: "none" | "low" | "medium"; + emptyReason?: string | null; +} + +export interface ReviewWorksheetSection { + key: string; + title: string; + items: ReviewWorksheetItem[]; +} + +export interface ReviewCaseWorksheet { + status: "empty" | "draft_from_transcript"; + generatedBy: string; + sections: ReviewWorksheetSection[]; + limitations: string[]; +} + export interface SessionReviewResponse { session_id: string; client: ReviewClient; @@ -353,6 +391,7 @@ export interface SessionReviewResponse { rubric: ReviewRubricRow[]; goodMoments: ReviewPoint[]; growthPoints: ReviewPoint[]; + caseWorksheet?: ReviewCaseWorksheet | null; nextLine?: string | null; clientFeedback?: string | null; audioUrl?: string | null; @@ -388,6 +427,9 @@ export interface SessionStreamDone { effective_openness?: number; turn_seq?: number; safety_flagged?: boolean; + crisis_kind?: string; + crisis_resource?: CrisisResource | null; + conversation_stopped?: boolean; } function safeParse(data: string): unknown { @@ -453,6 +495,9 @@ export async function openSessionStream( effective_openness: parsed.effective_openness, turn_seq: parsed.turn_seq, safety_flagged: parsed.safety_flagged, + crisis_kind: parsed.crisis_kind, + crisis_resource: parsed.crisis_resource, + conversation_stopped: parsed.conversation_stopped, }; handlers.onDone?.(donePayload); return; @@ -520,6 +565,12 @@ export const personaReviewApi = { api.post(`/personas/review/${encodeURIComponent(personaId)}`, { action, }), + createDraft: (payload: PersonaDraftPayload) => + api.post("/personas/drafts", payload), + getDraft: (personaId: string) => + api.get(`/personas/drafts/${encodeURIComponent(personaId)}`), + updateDraft: (personaId: string, payload: PersonaDraftPayload) => + api.put(`/personas/drafts/${encodeURIComponent(personaId)}`, payload), }; export const sessionApi = { @@ -568,8 +619,39 @@ export interface AdminHealthResponse { 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 = { health: () => api.get("/admin/health"), + usage: (windowDays = 7) => api.get(`/admin/usage?window_days=${windowDays}`), }; export interface AdminManagedUser { @@ -629,12 +711,58 @@ export interface TeacherSessionSummary { ended_at: string | null; } +export interface TeacherSafetyAlert { + id: string; + 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 { + learner_id: string; + learner_label: string; + sessions: number; + ended_sessions: number; + latest_at: string; + first_score: number | null; + latest_score: number | null; + score_delta: number | null; + avg_score: number | null; + avg_rapport: number | null; + trend: "up" | "down" | "flat" | "insufficient" | string; + top_techniques: string[]; + points: TeacherGrowthPoint[]; +} + export interface TeacherDashboardResponse { source: string; cohort_label: string; total_learners: number; active_sessions: number; ended_sessions: number; + safety_alerts: TeacherSafetyAlert[]; + learner_growth: TeacherLearnerGrowth[]; pending_reviews: TeacherSessionSummary[]; recent_sessions: TeacherSessionSummary[]; message: string; diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index a19af2f..fbfa5c4 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -6,6 +6,7 @@ import { adminUsersApi, type AdminHealthResponse, type AdminHealthStatus, + type AdminUsageResponse, type AdminManagedUser, type AdminUserCreateRequest, type AdminUsersResponse, @@ -84,6 +85,38 @@ function dateTimeLabel(seconds: number): string { }); } +function countLabel(value: number): string { + if (!Number.isFinite(value)) return "0"; + return Math.round(value).toLocaleString("ko-KR"); +} + +function costLabel(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "$0"; + return `$${value.toFixed(value < 0.01 ? 6 : 4)}`; +} + +function usageSourceLabel(data: AdminUsageResponse | null): string { + if (!data) return "대기 중"; + return data.durable ? "DB 계량" : "비영구 런타임 계량"; +} + +function usageBudgetLabel(data: AdminUsageResponse): string { + const { budget } = data; + if (budget.status === "disabled") return "예산 경고 비활성"; + if (budget.status === "exceeded") return "예산 초과"; + if (budget.status === "warn") return "예산 주의"; + return "예산 정상"; +} + +function usageBudgetDetail(data: AdminUsageResponse): string { + const { budget } = data; + if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다."; + const pct = Math.round(budget.used_ratio * 100); + const remaining = + budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`; + return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`; +} + function initialOf(user: AdminManagedUser): string { const label = user.display_name.trim() || user.email; return Array.from(label)[0]?.toUpperCase() ?? "?"; @@ -114,6 +147,9 @@ export default function Admin() { const [creatingUser, setCreatingUser] = useState(false); const [deactivatingUserId, setDeactivatingUserId] = useState(null); const [userSearch, setUserSearch] = useState(""); + const [usage, setUsage] = useState(null); + const [usageLoading, setUsageLoading] = useState(true); + const [usageError, setUsageError] = useState(null); const loadHealth = useCallback(async () => { setLoading(true); @@ -155,14 +191,27 @@ export default function Admin() { } }, []); + const loadUsage = useCallback(async () => { + setUsageLoading(true); + setUsageError(null); + try { + setUsage(await adminApi.usage(7)); + } catch (err) { + setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다."); + } finally { + setUsageLoading(false); + } + }, []); + useEffect(() => { void loadHealth(); void loadUsers(); - }, [loadHealth, loadUsers]); + void loadUsage(); + }, [loadHealth, loadUsage, loadUsers]); const refreshAll = useCallback(async () => { - await Promise.all([loadHealth(), loadUsers()]); - }, [loadHealth, loadUsers]); + await Promise.all([loadHealth(), loadUsers(), loadUsage()]); + }, [loadHealth, loadUsage, loadUsers]); const updateDraft = (userId: string, patch: Partial) => { setUserDrafts((current) => ({ @@ -308,9 +357,9 @@ export default function Admin() { variant="secondary" leading={} onClick={() => void refreshAll()} - disabled={loading || usersLoading} + disabled={loading || usersLoading || usageLoading} > - {loading || usersLoading ? "확인 중" : "새로고침"} + {loading || usersLoading || usageLoading ? "확인 중" : "새로고침"} @@ -355,6 +404,95 @@ export default function Admin() { +
+
+

AI 비용 관측

+ + {usage + ? `최근 ${usage.window_days}일 · ${usageSourceLabel(usage)}` + : usageSourceLabel(usage)} + +
+ + {usageError ? ( +
+ + {usageError} +
+ ) : null} + +
+
+ 누적 비용 + {usage ? costLabel(usage.cost_usd) : "-"} + {usage ? `${countLabel(usage.metered_turns)}개 계량 턴` : "계산 중"} +
+
+ 입력 토큰 + {usage ? countLabel(usage.tokens_in) : "-"} + 프롬프트/컨텍스트 +
+
+ 출력 토큰 + {usage ? countLabel(usage.tokens_out) : "-"} + 내담자 응답 +
+
+ 계량 커버리지 + + {usage && usage.total_turns > 0 + ? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%` + : usage + ? "0%" + : "-"} + + {usage ? `${countLabel(usage.total_turns)}개 내담자 턴` : "계산 중"} +
+
+ + {usage ? ( +
+ +
+ {usageBudgetLabel(usage)} + {usageBudgetDetail(usage)} +
+
+ ) : null} + +
+
+ Provider / Model + + 토큰 + 비용 +
+ {(usage?.by_provider ?? []).map((item) => ( +
+ + {item.provider} + {item.model} + + {countLabel(item.turns)} + {countLabel(item.tokens_in + item.tokens_out)} + {costLabel(item.cost_usd)} +
+ ))} + {usageLoading && !usage ? ( +
비용 사용량을 계산하는 중입니다.
+ ) : null} + {!usageLoading && usage && usage.by_provider.length === 0 ? ( +
최근 윈도우에 계량된 AI 턴이 없습니다.
+ ) : null} +
+
+ {error ? (
@@ -698,9 +836,25 @@ export default function Admin() { const ADMIN_CSS = ` .ad-root{ + width:min(100%,1180px); + margin:0 auto; + position:relative; + isolation:isolate; display:flex; flex-direction:column; - gap:18px; + gap:16px; +} +.ad-root::before{ + content:""; + position:absolute; + z-index:-1; + inset:-80px -180px auto auto; + width:min(560px,52vw); + height:420px; + background:var(--asset-warm-elements) center / cover no-repeat; + opacity:.045; + filter:saturate(.75); + pointer-events:none; } .ad-head{ display:flex; @@ -708,7 +862,7 @@ const ADMIN_CSS = ` justify-content:space-between; gap:var(--sp-4); flex-wrap:wrap; - padding-bottom:2px; + padding:0 2px 2px; } .ad-head h1{ margin:6px 0 0; @@ -726,7 +880,7 @@ const ADMIN_CSS = ` } .ad-ops{ display:grid; - grid-template-columns:minmax(0,max-content) minmax(320px,1fr); + grid-template-columns:minmax(320px,.64fr) minmax(0,1fr); gap:12px; } .ad-status{ @@ -738,7 +892,8 @@ const ADMIN_CSS = ` padding:14px 16px; border:1px solid var(--hair); border-radius:var(--radius); - background:var(--bg-surface); + background: + linear-gradient(180deg,color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface)),var(--bg-surface)); box-shadow:var(--shadow-sm); } .ad-status__dot{ @@ -779,7 +934,7 @@ const ADMIN_CSS = ` } .ad-kpis{ display:grid; - grid-template-columns:repeat(2,minmax(0,1fr)); + grid-template-columns:repeat(4,minmax(0,1fr)); border:1px solid var(--hair); border-radius:var(--radius); overflow:hidden; @@ -793,7 +948,8 @@ const ADMIN_CSS = ` gap:4px; } .ad-kpi:nth-child(even){border-left:1px solid var(--hair);} -.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);} +.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);} +.ad-kpi:nth-child(n+3){border-top:0;} .ad-kpi__lab{ display:block; color:var(--text-muted); @@ -812,6 +968,138 @@ const ADMIN_CSS = ` font-size:12px; line-height:1.35; } +.ad-usage-section{ + padding:14px; + border:1px solid var(--hair); + border-radius:var(--radius); + background:var(--bg-surface); + box-shadow:var(--shadow-sm); +} +.ad-usage-grid{ + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:10px; +} +.ad-usage-kpi{ + min-width:0; + display:grid; + gap:5px; + padding:12px; + border:1px solid var(--hair); + border-radius:var(--radius-sm); + background:var(--bg-surface-2); +} +.ad-usage-kpi span{ + color:var(--text-muted); + font-size:var(--fs-xs); + font-weight:700; +} +.ad-usage-kpi b{ + color:var(--text-strong); + font-family:var(--font-num); + font-size:22px; + line-height:1; + overflow-wrap:anywhere; +} +.ad-usage-kpi small{ + color:var(--text-muted); + font-size:12px; + line-height:1.35; +} +.ad-usage-budget{ + display:flex; + align-items:flex-start; + gap:10px; + min-width:0; + padding:10px 12px; + border:1px solid var(--hair); + border-radius:var(--radius-sm); + background:var(--bg-surface-2); + color:var(--text-muted); +} +.ad-usage-budget svg{ + flex:0 0 auto; + margin-top:1px; +} +.ad-usage-budget div{ + display:grid; + gap:2px; + min-width:0; +} +.ad-usage-budget b{ + color:var(--text-strong); + font-size:var(--fs-xs); + line-height:1.3; +} +.ad-usage-budget span{ + font-size:12px; + line-height:1.4; + overflow-wrap:anywhere; +} +.ad-usage-budget--warn{ + border-color:color-mix(in srgb,var(--warn-solid) 42%,var(--hair)); + background:color-mix(in srgb,var(--warn-tint) 72%,var(--bg-surface)); + color:var(--warn-text); +} +.ad-usage-budget--exceeded{ + border-color:color-mix(in srgb,var(--crit-solid) 42%,var(--hair)); + background:color-mix(in srgb,var(--crit-tint) 72%,var(--bg-surface)); + color:var(--crit-text); +} +.ad-usage-budget--ok{ + border-color:color-mix(in srgb,var(--pos-solid) 36%,var(--hair)); + background:color-mix(in srgb,var(--pos-tint) 72%,var(--bg-surface)); + color:var(--pos-text); +} +.ad-usage-breakdown{ + display:flex; + flex-direction:column; + min-width:0; + border:1px solid var(--hair); + border-radius:var(--radius-sm); + overflow:hidden; + background:var(--bg-surface-2); +} +.ad-usage-breakdown__head, +.ad-usage-row{ + display:grid; + grid-template-columns:minmax(170px,1fr) 72px 110px 90px; + align-items:center; + gap:10px; + padding:9px 12px; +} +.ad-usage-breakdown__head{ + background:var(--bg-app); + color:var(--text-muted); + font-size:var(--fs-xs); + font-weight:800; +} +.ad-usage-row{ + border-top:1px solid var(--hair); + color:var(--text-body); + font-family:var(--font-num); + font-size:var(--fs-sm); +} +.ad-usage-row > span{ + min-width:0; +} +.ad-usage-row > span:first-child{ + display:grid; + gap:2px; + font-family:var(--font-sans); +} +.ad-usage-row b{ + color:var(--text-strong); + font-size:var(--fs-sm); + line-height:1.25; + overflow-wrap:anywhere; +} +.ad-usage-row small{ + color:var(--text-muted); + font-size:12px; + line-height:1.25; + overflow-wrap:anywhere; +} .ad-section{ display:flex; flex-direction:column; @@ -996,12 +1284,13 @@ const ADMIN_CSS = ` } .ad-user-workspace{ display:grid; - grid-template-columns:minmax(250px,292px) minmax(0,1fr); + grid-template-columns:minmax(0,1fr) minmax(258px,300px); align-items:stretch; gap:14px; min-width:0; } .ad-user-sidecar{ + order:2; position:sticky; top:calc(var(--topbar-h) + 16px); align-self:start; @@ -1011,6 +1300,7 @@ const ADMIN_CSS = ` min-width:0; } .ad-user-listpane{ + order:1; display:flex; flex-direction:column; min-width:0; @@ -1073,10 +1363,10 @@ const ADMIN_CSS = ` .ad-user{ min-width:0; display:grid; - grid-template-columns:minmax(150px,.75fr) minmax(260px,1.35fr) minmax(104px,.45fr) max-content; + grid-template-columns:minmax(190px,.75fr) minmax(420px,1.4fr) minmax(138px,.42fr) max-content; align-items:center; gap:10px 12px; - padding:11px 12px; + padding:10px 12px; border-top:1px solid var(--paper-2); background:var(--bg-surface); } @@ -1123,7 +1413,7 @@ const ADMIN_CSS = ` } .ad-user__fields{ display:grid; - grid-template-columns:minmax(0,1fr) 104px; + grid-template-columns:minmax(130px,1.1fr) 96px minmax(118px,.95fr) minmax(110px,.85fr); gap:8px; min-width:0; } @@ -1134,10 +1424,17 @@ const ADMIN_CSS = ` gap:6px; } .ad-user__fields span{ + display:none; color:var(--text-muted); font-size:var(--fs-xs); font-weight:650; } +.ad-user__fields input, +.ad-user__fields select{ + height:32px; + font-size:12.5px; + padding-inline:9px; +} .ad-user__meta{ display:grid; gap:4px; @@ -1180,12 +1477,20 @@ const ADMIN_CSS = ` grid-template-columns:1fr; } .ad-user-sidecar{ + order:0; position:static; } + .ad-user-listpane{order:1;} .ad-user-create{grid-template-columns:repeat(2,minmax(0,1fr));} .ad-user{ grid-template-columns:minmax(190px,.8fr) minmax(0,1.2fr) max-content; } + .ad-user__fields{ + grid-template-columns:minmax(0,1fr) 104px; + } + .ad-user__fields span{ + display:block; + } .ad-user__meta{ grid-column:1 / -1; display:flex; @@ -1203,6 +1508,9 @@ const ADMIN_CSS = ` .ad-kpi:nth-child(even), .ad-kpi + .ad-kpi{border-left:1px solid var(--hair);} .ad-kpi:nth-child(n+3){border-top:0;} + .ad-usage-grid{ + grid-template-columns:repeat(2,minmax(0,1fr)); + } .ad-service{ grid-template-columns:minmax(160px,.85fr) minmax(0,1fr); } @@ -1218,6 +1526,7 @@ const ADMIN_CSS = ` } } @media (max-width:700px){ + .ad-root::before{display:none;} .ad-head{ align-items:flex-start; flex-direction:column; @@ -1231,6 +1540,16 @@ const ADMIN_CSS = ` .ad-kpi:nth-child(even){border-left:1px solid var(--hair);} .ad-kpi:nth-child(odd){border-left:0;} .ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);} + .ad-usage-breakdown__head{ + display:none; + } + .ad-usage-row{ + grid-template-columns:minmax(0,1fr) auto; + gap:8px 12px; + } + .ad-usage-row > span:first-child{ + grid-row:1 / span 3; + } .ad-service{ grid-template-columns:1fr; } @@ -1259,6 +1578,13 @@ const ADMIN_CSS = ` border-left:0; } .ad-kpi + .ad-kpi{border-top:1px solid var(--hair);} + .ad-usage-grid{grid-template-columns:1fr;} + .ad-usage-row{ + grid-template-columns:1fr; + } + .ad-usage-row > span:first-child{ + grid-row:auto; + } .ad-service__meter{grid-template-columns:1fr;} .ad-service__meter span{text-align:left;} .ad-user__top{align-items:stretch;flex-direction:column;} diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index d9683b3..a0e2d2e 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -430,6 +430,8 @@ const LH_CSS = ` width:min(100%,1480px); min-height:calc(100dvh - var(--topbar-h)); margin:0 auto; + position:relative; + isolation:isolate; display:grid; grid-template-rows:auto minmax(0,1fr); align-content:start; @@ -438,6 +440,18 @@ const LH_CSS = ` background:var(--bg-app); overflow:visible; } +.lh-root::before{ + content:""; + position:absolute; + z-index:-1; + inset:-120px -220px auto auto; + width:min(720px,58vw); + height:520px; + background:var(--asset-warm-elements) center / cover no-repeat; + opacity:.07; + filter:saturate(.8); + pointer-events:none; +} .lh-head{ display:flex; align-items:center; @@ -500,6 +514,16 @@ const LH_CSS = ` box-shadow:var(--shadow-sm); overflow:hidden; } +.lh-list-pane::after{ + content:""; + position:absolute; + inset:auto -48px -68px auto; + width:180px; + height:180px; + background:var(--asset-warm-elements) center / cover no-repeat; + opacity:.05; + pointer-events:none; +} .lh-pane-head{ display:flex; align-items:center; @@ -637,6 +661,7 @@ const LH_CSS = ` } .lh-preview__main{ min-width:0; + position:relative; display:grid; gap:var(--sp-4); align-content:start; @@ -645,6 +670,22 @@ const LH_CSS = ` border-radius:var(--radius); background:var(--bg-surface); box-shadow:var(--shadow-sm); + overflow:hidden; +} +.lh-preview__main::after{ + content:""; + position:absolute; + inset:auto -180px -210px auto; + width:520px; + height:360px; + background:var(--asset-warm-elements) center / cover no-repeat; + opacity:.08; + filter:saturate(.82); + pointer-events:none; +} +.lh-preview__main > *{ + position:relative; + z-index:1; } .lh-preview__hero{ min-width:0; @@ -721,7 +762,8 @@ const LH_CSS = ` min-width:0; padding:var(--sp-4); border-radius:var(--radius); - background:var(--bg-surface-2); + background: + linear-gradient(180deg,color-mix(in srgb,var(--clay-tint) 48%,var(--bg-surface-2)),var(--bg-surface-2)); } .lh-summary p{ margin:8px 0 0; @@ -971,6 +1013,11 @@ const LH_CSS = ` max-height:none; overflow:visible; } + .lh-root::before{ + width:560px; + height:420px; + opacity:.055; + } .lh-personas{ grid-template-columns:repeat(2,minmax(0,1fr)); grid-auto-rows:auto; @@ -989,6 +1036,11 @@ const LH_CSS = ` padding:12px; gap:12px; } + .lh-root::before, + .lh-preview__main::after, + .lh-list-pane::after{ + display:none; + } .lh-head{ align-items:flex-start; } diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index 2111bc4..ff26a47 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -10,16 +10,50 @@ const OAUTH_FAILED_MESSAGE = "Google 로그인 흐름을 완료하지 못했습니다. 다시 시도하거나 관리자에게 설정 확인을 요청하세요."; const LOCAL_OAUTH_UNAVAILABLE_MESSAGE = "로컬 개발 주소에서는 Google OAuth 콜백이 공개 API로 돌아가므로 로컬 테스트 계정으로 로그인하세요."; +const OAUTH_STATE_FAILED_MESSAGE = + "로그인 세션 확인에 실패했습니다. 브라우저 쿠키를 허용한 뒤 다시 시도하세요."; +const OAUTH_TOKEN_FAILED_MESSAGE = + "Google 인증 코드를 서버에서 교환하지 못했습니다. 관리자에게 OAuth 클라이언트 secret과 redirect URI 확인을 요청하세요."; +const OAUTH_IDENTITY_FAILED_MESSAGE = + "Google 계정 정보를 확인하지 못했습니다. 다시 시도하거나 관리자에게 OAuth 클라이언트 설정 확인을 요청하세요."; +const OAUTH_PROVIDER_DENIED_MESSAGE = + "Google 로그인이 취소되었거나 계정 선택이 거부되었습니다. 다시 시도하세요."; +const OAUTH_PROVIDER_FAILED_MESSAGE = + "Google이 인증 코드를 발급하지 못했습니다. 관리자에게 OAuth 동의 화면과 클라이언트 설정 확인을 요청하세요."; +const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE = + "지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요."; +const SAML_NOT_CONFIGURED_MESSAGE = + "학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요."; +const SAML_FAILED_MESSAGE = + "학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요."; function oauthMessage(reason: string | null): string | null { if (!reason) return null; if (reason === "not_configured") return OAUTH_NOT_CONFIGURED_MESSAGE; + if (reason === "local_oauth_unavailable") return LOCAL_OAUTH_UNAVAILABLE_MESSAGE; + if (reason === "invalid_state" || reason === "missing_callback") { + return OAUTH_STATE_FAILED_MESSAGE; + } + if (reason === "token_exchange_failed") return OAUTH_TOKEN_FAILED_MESSAGE; + if ( + reason === "id_token_missing" || + reason === "id_token_invalid" || + reason === "audience_mismatch" || + reason === "issuer_mismatch" + ) { + return OAUTH_IDENTITY_FAILED_MESSAGE; + } if (reason === "domain_not_allowed") { return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다."; } if (reason === "inactive_user") { return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요."; } + if (reason === "access_denied") return OAUTH_PROVIDER_DENIED_MESSAGE; + if (reason === "provider_error") return OAUTH_PROVIDER_FAILED_MESSAGE; + if (reason === "unsupported_provider") return OAUTH_UNSUPPORTED_PROVIDER_MESSAGE; + if (reason === "saml_not_configured") return SAML_NOT_CONFIGURED_MESSAGE; + if (reason.startsWith("saml_")) return SAML_FAILED_MESSAGE; return OAUTH_FAILED_MESSAGE; } @@ -49,6 +83,7 @@ export default function Login() { const [selected, setSelected] = useState("learner"); const [pending, setPending] = useState(false); const [loginError, setLoginError] = useState(null); + const [loginErrorReason, setLoginErrorReason] = useState(null); const [authConfig, setAuthConfig] = useState(null); const [authConfigError, setAuthConfigError] = useState(null); @@ -63,6 +98,7 @@ export default function Login() { useEffect(() => { const oauthState = new URLSearchParams(location.search).get("oauth"); setLoginError(oauthMessage(oauthState)); + setLoginErrorReason(oauthState); }, [location.search]); useEffect(() => { @@ -86,24 +122,21 @@ export default function Login() { const oauthChecking = authConfig === null && authConfigError === null; const devLoginReady = import.meta.env.DEV && authConfig?.dev_login_enabled === true; - const localOrigin = - typeof window !== "undefined" && isLocalHostname(window.location.hostname); - const localOAuthUnavailable = - localOrigin && + const devOAuthUnavailable = devLoginReady && authConfig?.google_oauth_configured === true && !isLocalRedirectUri(authConfig.redirect_uri); const oauthReady = - authConfig?.google_oauth_configured === true && !localOAuthUnavailable; + authConfig?.google_oauth_configured === true && !devOAuthUnavailable; const allowedDomains = authConfig?.allowed_email_domains ?? []; - const primaryDomainLabel = localOAuthUnavailable + const primaryDomainLabel = devOAuthUnavailable ? "로컬은 테스트 계정 사용" : allowedDomains[0] ? `@${allowedDomains[0]}` : oauthChecking ? "도메인 확인 중" : "승인 도메인 계정"; - const secondaryDomainLabel = localOAuthUnavailable + const secondaryDomainLabel = devOAuthUnavailable ? "공개 주소에서 사용" : allowedDomains[1] ? `@${allowedDomains[1]}` @@ -113,8 +146,9 @@ export default function Login() { const startOAuth = () => { if (!oauthReady) { + setLoginErrorReason(devOAuthUnavailable ? "local_oauth_unavailable" : "not_configured"); setLoginError( - localOAuthUnavailable + devOAuthUnavailable ? LOCAL_OAUTH_UNAVAILABLE_MESSAGE : (authConfigError ?? OAUTH_NOT_CONFIGURED_MESSAGE), ); @@ -128,6 +162,7 @@ export default function Login() { const enterDev = async (role: Role) => { setPending(true); setLoginError(null); + setLoginErrorReason(null); try { const signedIn = await login(role); navigate(roleHomePath(signedIn.role), { replace: true }); @@ -233,7 +268,7 @@ export default function Login() {
- {localOAuthUnavailable + {devOAuthUnavailable ? LOCAL_OAUTH_UNAVAILABLE_MESSAGE : oauthChecking ? "Google 로그인 설정을 확인하는 중입니다." @@ -275,11 +310,21 @@ export default function Login() { > {pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"} - {loginError ?

{loginError}

: null} + {loginError ? ( +

+ {loginError} + {loginErrorReason ? 오류 코드: {loginErrorReason} : null} +

+ ) : null}
) : null} - {!devLoginReady && loginError ?

{loginError}

: null} + {!devLoginReady && loginError ? ( +

+ {loginError} + {loginErrorReason ? 오류 코드: {loginErrorReason} : null} +

+ ) : null}

교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다. @@ -293,22 +338,58 @@ export default function Login() { const LOGIN_CSS = ` .lg-root{ min-height:100dvh; + position:relative; display:grid; grid-template-columns:minmax(0,1fr) minmax(360px,480px); background:var(--bg-app); color:var(--text-strong); + overflow:hidden; +} +.lg-root::before{ + content:""; + position:absolute; + inset:auto -12vw -22vw 38vw; + height:42vw; + min-height:360px; + background:var(--asset-warm-elements) center / cover no-repeat; + opacity:.08; + filter:saturate(.82); + pointer-events:none; +} +.lg-brand, +.lg-enter{ + position:relative; + z-index:1; } .lg-brand{ min-width:0; + position:relative; display:flex; flex-direction:column; justify-content:space-between; gap:var(--sp-7); padding:var(--sp-7); - background:var(--bg-stage); + background: + linear-gradient(115deg,rgba(14,22,20,.94),rgba(30,39,36,.84) 54%,rgba(30,39,36,.68)), + var(--asset-warm-elements) center / cover no-repeat; color:#edf4f2; + overflow:hidden; +} +.lg-brand::after{ + content:""; + position:absolute; + left:var(--sp-7); + bottom:var(--sp-7); + width:min(360px,42vw); + height:120px; + border:1px solid rgba(255,255,255,.12); + border-radius:var(--radius-lg); + background:rgba(251,250,248,.06); + pointer-events:none; } .lg-wordmark{ + position:relative; + z-index:1; display:flex; align-items:center; gap:10px; @@ -319,6 +400,11 @@ const LOGIN_CSS = ` } .lg-mark{display:grid;place-items:center;color:var(--accent-bright);} .lg-copy{max-width:620px;} +.lg-copy, +.lg-policy{ + position:relative; + z-index:1; +} .lg-kicker{ display:inline-flex; align-items:center; @@ -373,6 +459,8 @@ const LOGIN_CSS = ` align-items:center; justify-content:center; padding:var(--sp-6); + background: + linear-gradient(180deg,rgba(251,250,248,.9),rgba(244,242,238,.76)); } .lg-panel{ width:100%; @@ -517,6 +605,14 @@ const LOGIN_CSS = ` font-size:13px; line-height:1.5; } +.lg-error small{ + display:block; + margin-top:4px; + color:var(--text-muted); + font-family:var(--font-num); + font-size:11.5px; + overflow-wrap:anywhere; +} .lg-note{ margin:var(--sp-5) 0 0; color:var(--text-muted); @@ -525,7 +621,9 @@ const LOGIN_CSS = ` } @media (max-width:880px){ .lg-root{grid-template-columns:1fr;} + .lg-root::before{display:none;} .lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);} + .lg-brand::after{display:none;} .lg-copy h1{font-size:36px;} .lg-enter{padding:var(--sp-5);} .lg-panel{max-width:560px;} diff --git a/apps/web/src/pages/Professor.tsx b/apps/web/src/pages/Professor.tsx index 8d824e6..2083ea9 100644 --- a/apps/web/src/pages/Professor.tsx +++ b/apps/web/src/pages/Professor.tsx @@ -6,8 +6,13 @@ import { personaReviewApi, teacherApi, type PersonaReviewAction, + type PersonaDraftDetail, + type PersonaDraftPayload, type PersonaReviewStatus, type PersonaReviewSummary, + type TeacherLearnerGrowth, + type TeacherGrowthPoint, + type TeacherSafetyAlert, type TeacherDashboardResponse, } from "../lib/api"; @@ -25,6 +30,24 @@ function formatDateTime(value: string | null): string { }); } +function formatScore(value: number | null | undefined): string { + if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족"; + return `${Math.round(value * 100)}%`; +} + +function formatDelta(value: number | null | undefined): string { + if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족"; + const sign = value > 0 ? "+" : ""; + return `${sign}${Math.round(value * 100)}%p`; +} + +function trendLabel(value: string): string { + if (value === "up") return "상승"; + if (value === "down") return "하락"; + if (value === "flat") return "유지"; + return "평가 부족"; +} + function personaReviewStatusLabel(status: PersonaReviewStatus): string { if (status === "review") return "검수 대기"; if (status === "draft") return "수정 대기"; @@ -38,6 +61,79 @@ function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | return "neutral"; } +const EMPTY_PERSONA_DRAFT: PersonaDraftPayload = { + code: "P4", + display_name: "새 페르소나", + difficulty: "moderate", + theory_target: ["humanistic"], + demographics: { + age_band: "F-20s", + }, + presenting: { + complaint: "", + }, + history: {}, + big5: { + O: 0.5, + C: 0.5, + E: 0.5, + A: 0.5, + N: 0.5, + }, + resistance: { + base_resistance: 0.5, + unlock_rate: 0.1, + decay_floor: 0.05, + silence_prob: 0.15, + deflection_prob: 0.25, + }, + speech_style: { + register: "polite", + avg_sentence_len: "medium", + fillers: [], + honorific: true, + verbal_tics: [], + }, + affect_baseline: { + negative_affect: 0.45, + hopelessness: 0.2, + anhedonia: 0.2, + sleep: 0.2, + anxiety: 0.35, + suicide_ideation_stage: 1, + }, + ccd: {}, + dsm5_dimensional: {}, + source_provenance: "clinical draft", + is_synthetic: true, + submit_for_review: false, +}; + +function stringifyDraft(payload: PersonaDraftPayload): string { + return JSON.stringify(payload, null, 2); +} + +function draftDetailToPayload(detail: PersonaDraftDetail): PersonaDraftPayload { + return { + code: detail.code, + display_name: detail.display_name, + difficulty: detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate", + theory_target: detail.theory_target, + demographics: detail.demographics, + presenting: detail.presenting, + history: detail.history, + big5: detail.big5, + resistance: detail.resistance, + speech_style: detail.speech_style, + affect_baseline: detail.affect_baseline, + ccd: detail.ccd, + dsm5_dimensional: detail.dsm5_dimensional, + source_provenance: detail.source_provenance, + is_synthetic: detail.is_synthetic, + submit_for_review: detail.status === "review", + }; +} + function EmptyState({ title, desc }: { title: string; desc: string }) { return (

@@ -55,6 +151,11 @@ export default function Professor() { const [personaReviewLoading, setPersonaReviewLoading] = useState(true); const [personaReviewError, setPersonaReviewError] = useState(null); const [personaReviewBusy, setPersonaReviewBusy] = useState(null); + const [draftJson, setDraftJson] = useState(() => stringifyDraft(EMPTY_PERSONA_DRAFT)); + const [draftEditingId, setDraftEditingId] = useState(null); + const [draftBusy, setDraftBusy] = useState<"load" | "save" | "submit" | null>(null); + const [draftError, setDraftError] = useState(null); + const [draftMessage, setDraftMessage] = useState(null); const [updatedAt, setUpdatedAt] = useState(null); const loadDashboard = useCallback(async () => { @@ -119,6 +220,73 @@ export default function Professor() { [], ); + const resetPersonaDraft = useCallback(() => { + setDraftJson(stringifyDraft(EMPTY_PERSONA_DRAFT)); + setDraftEditingId(null); + setDraftError(null); + setDraftMessage(null); + }, []); + + const parsePersonaDraft = useCallback( + (submitForReview: boolean): PersonaDraftPayload => { + const parsed = JSON.parse(draftJson) as PersonaDraftPayload; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("JSON 객체가 필요합니다."); + } + return { + ...parsed, + submit_for_review: submitForReview, + }; + }, + [draftJson], + ); + + const savePersonaDraft = useCallback( + async (submitForReview: boolean) => { + setDraftBusy(submitForReview ? "submit" : "save"); + setDraftError(null); + setDraftMessage(null); + try { + const payload = parsePersonaDraft(submitForReview); + const updated = draftEditingId + ? await personaReviewApi.updateDraft(draftEditingId, payload) + : await personaReviewApi.createDraft(payload); + setDraftEditingId(updated.persona_id); + setDraftMessage( + updated.status === "review" + ? `${updated.code} v${updated.version} 검수 요청을 올렸습니다.` + : `${updated.code} v${updated.version} 초안을 저장했습니다.`, + ); + await loadPersonaReviews(); + } catch (err) { + if (err instanceof SyntaxError) { + setDraftError("JSON 형식이 올바르지 않습니다."); + } else { + setDraftError(err instanceof Error ? err.message : "페르소나 초안을 저장하지 못했습니다."); + } + } finally { + setDraftBusy(null); + } + }, + [draftEditingId, loadPersonaReviews, parsePersonaDraft], + ); + + const loadPersonaDraft = useCallback(async (personaId: string) => { + setDraftBusy("load"); + setDraftError(null); + setDraftMessage(null); + try { + const detail = await personaReviewApi.getDraft(personaId); + setDraftEditingId(detail.persona_id); + setDraftJson(stringifyDraft(draftDetailToPayload(detail))); + setDraftMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`); + } catch (err) { + setDraftError(err instanceof Error ? err.message : "페르소나 초안을 불러오지 못했습니다."); + } finally { + setDraftBusy(null); + } + }, []); + const kpis = useMemo( () => [ { @@ -139,6 +307,12 @@ export default function Professor() { hint: "저장 완료", icon: "check" as const, }, + { + label: "위기 알림", + value: dashboard?.safety_alerts.length ?? 0, + hint: "109 확인", + icon: "alert" as const, + }, { label: "리뷰 대기", value: dashboard?.pending_reviews.length ?? 0, @@ -152,6 +326,8 @@ export default function Professor() { const hasPending = pendingCount > 0; const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0); const personaReviewCount = personaReviews.length; + const safetyAlerts = dashboard?.safety_alerts ?? []; + const learnerGrowth = dashboard?.learner_growth ?? []; return ( @@ -225,8 +401,99 @@ export default function Professor() {
) : null} +
+
+
+ 학습자 성장 추적 +

이력·항목별 추이

+
+ 0 ? "accent" : "neutral"}> + {learnerGrowth.length}명 + +
+ + + {learnerGrowth.length > 0 ? ( +
+ {learnerGrowth.map((learner) => ( + + ))} +
+ ) : ( + + )} +
+
+
+
+
+
+ 페르소나 저작 +

초안 작성

+
+ + {draftEditingId ? "편집 중" : "새 초안"} + +
+ + +
+ {draftEditingId ? "기존 초안 수정" : "새 페르소나 버전"} + +
+