G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -37,8 +37,13 @@ from ..db import acquire, get_pool, healthcheck
|
|||
from ..deps import Principal, require_admin_access
|
||||
from ..engine_client import engine_client
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import voice_service
|
||||
from ..services import evaluator, notifications, rag
|
||||
from ..services.llm_pricing import (
|
||||
estimate_reference_cost,
|
||||
provider_uses_reference_cost,
|
||||
)
|
||||
from ..services.voice import voice_service
|
||||
from ..services.voice_runtime import VoiceRuntimeSnapshot, voice_runtime_metrics
|
||||
from ..store import store
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
|
@ -46,6 +51,12 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
|||
AdminPrincipal = Annotated[Principal, Depends(require_admin_access())]
|
||||
HealthStatus = Literal["ok", "degraded", "down"]
|
||||
UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"]
|
||||
UsageCostBasis = Literal[
|
||||
"provider_estimate",
|
||||
"provider_reported",
|
||||
"reference_rate",
|
||||
"unavailable",
|
||||
]
|
||||
TicketCategory = Literal[
|
||||
"account_access",
|
||||
"session_review",
|
||||
|
|
@ -69,9 +80,24 @@ METERED_CLIENT_TURN_FILTER_SQL = """
|
|||
|
||||
USAGE_AGGREGATE_COLUMNS_SQL = """
|
||||
COUNT(*) AS turns,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(tokens_in, 0) > 0 OR COALESCE(tokens_out, 0) > 0
|
||||
) AS token_metered_turns,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(tokens_in, 0) <= 0 AND COALESCE(tokens_out, 0) <= 0
|
||||
) AS token_unmetered_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
|
||||
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd,
|
||||
COUNT(*) FILTER (WHERE COALESCE(cost_usd, 0) <= 0) AS unpriced_turns,
|
||||
COALESCE(
|
||||
SUM(tokens_in) FILTER (WHERE COALESCE(cost_usd, 0) <= 0),
|
||||
0
|
||||
)::bigint AS unpriced_tokens_in,
|
||||
COALESCE(
|
||||
SUM(tokens_out) FILTER (WHERE COALESCE(cost_usd, 0) <= 0),
|
||||
0
|
||||
)::bigint AS unpriced_tokens_out
|
||||
"""
|
||||
|
||||
SUPPORT_TICKET_DETAIL_FROM_SQL = """
|
||||
|
|
@ -143,9 +169,16 @@ class AdminUsageBreakdown(BaseModel):
|
|||
provider: str
|
||||
model: str
|
||||
turns: int
|
||||
token_metered_turns: int = 0
|
||||
token_unmetered_turns: int = 0
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
recorded_cost_usd: float = 0.0
|
||||
estimated_cost_usd: float = 0.0
|
||||
cost_basis: UsageCostBasis = "provider_reported"
|
||||
rate_label: str | None = None
|
||||
rate_source_url: str | None = None
|
||||
|
||||
|
||||
class AdminUsageDailyCost(BaseModel):
|
||||
|
|
@ -181,9 +214,13 @@ class AdminUsageResponse(BaseModel):
|
|||
generated_at: float
|
||||
total_turns: int
|
||||
metered_turns: int
|
||||
token_metered_turns: int = 0
|
||||
token_unmetered_turns: int = 0
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
recorded_cost_usd: float = 0.0
|
||||
estimated_cost_usd: float = 0.0
|
||||
budget: AdminUsageBudget
|
||||
evaluator_cache: AdminUsageEvaluatorCache
|
||||
by_provider: list[AdminUsageBreakdown]
|
||||
|
|
@ -419,6 +456,70 @@ def _safe_usage_int(value: object) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _usage_breakdown(
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
turns: int,
|
||||
token_metered_turns: int,
|
||||
token_unmetered_turns: int,
|
||||
tokens_in: int,
|
||||
tokens_out: int,
|
||||
stored_cost_usd: float,
|
||||
unpriced_tokens_in: int,
|
||||
unpriced_tokens_out: int,
|
||||
) -> AdminUsageBreakdown:
|
||||
"""저장된 공급자 비용 추정치와 공식 참조단가를 한 원장 행으로 정규화한다."""
|
||||
|
||||
fallback = estimate_reference_cost(
|
||||
provider=provider,
|
||||
model=model,
|
||||
tokens_in=unpriced_tokens_in,
|
||||
tokens_out=unpriced_tokens_out,
|
||||
)
|
||||
rate_info = fallback or estimate_reference_cost(
|
||||
provider=provider,
|
||||
model=model,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
)
|
||||
fallback_cost = fallback.cost_usd if fallback is not None else 0.0
|
||||
effective_cost = max(0.0, stored_cost_usd) + fallback_cost
|
||||
reference_basis = provider_uses_reference_cost(provider)
|
||||
|
||||
if reference_basis:
|
||||
recorded_cost = 0.0
|
||||
estimated_cost = effective_cost
|
||||
basis: UsageCostBasis = (
|
||||
"reference_rate" if rate_info is not None or effective_cost > 0 else "unavailable"
|
||||
)
|
||||
else:
|
||||
recorded_cost = max(0.0, stored_cost_usd)
|
||||
estimated_cost = fallback_cost
|
||||
if recorded_cost > 0:
|
||||
basis = "provider_estimate" if provider == "claude_cli" else "provider_reported"
|
||||
elif fallback is not None:
|
||||
basis = "reference_rate"
|
||||
else:
|
||||
basis = "unavailable"
|
||||
|
||||
return AdminUsageBreakdown(
|
||||
provider=provider,
|
||||
model=model,
|
||||
turns=max(0, turns),
|
||||
token_metered_turns=max(0, token_metered_turns),
|
||||
token_unmetered_turns=max(0, token_unmetered_turns),
|
||||
tokens_in=max(0, tokens_in),
|
||||
tokens_out=max(0, tokens_out),
|
||||
cost_usd=round(effective_cost, 6),
|
||||
recorded_cost_usd=round(recorded_cost, 6),
|
||||
estimated_cost_usd=round(estimated_cost, 6),
|
||||
cost_basis=basis,
|
||||
rate_label=rate_info.rate_label if rate_info is not None else None,
|
||||
rate_source_url=rate_info.source_url if rate_info is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _usage_budget(cost_usd: float) -> AdminUsageBudget:
|
||||
limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0))
|
||||
if limit <= 0:
|
||||
|
|
@ -527,6 +628,15 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
|
|||
SELECT
|
||||
COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns,
|
||||
COUNT(*) FILTER (WHERE {METERED_CLIENT_TURN_FILTER_SQL}) AS metered_turns,
|
||||
COUNT(*) FILTER (
|
||||
WHERE {METERED_CLIENT_TURN_FILTER_SQL}
|
||||
AND (COALESCE(tokens_in, 0) > 0 OR COALESCE(tokens_out, 0) > 0)
|
||||
) AS token_metered_turns,
|
||||
COUNT(*) FILTER (
|
||||
WHERE {METERED_CLIENT_TURN_FILTER_SQL}
|
||||
AND COALESCE(tokens_in, 0) <= 0
|
||||
AND COALESCE(tokens_out, 0) <= 0
|
||||
) AS token_unmetered_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
|
||||
|
|
@ -545,11 +655,6 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
|
|||
WHERE created_at >= now() - ($1::int * interval '1 day')
|
||||
AND {METERED_CLIENT_TURN_FILTER_SQL}
|
||||
GROUP BY 1, 2
|
||||
ORDER BY
|
||||
cost_usd DESC,
|
||||
COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC,
|
||||
turns DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
window_days,
|
||||
)
|
||||
|
|
@ -557,17 +662,70 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
|
|||
f"""
|
||||
SELECT
|
||||
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
|
||||
COALESCE(llm_provider, 'unknown') AS provider,
|
||||
COALESCE(model, 'unknown') AS model,
|
||||
{USAGE_AGGREGATE_COLUMNS_SQL}
|
||||
FROM app.turns
|
||||
WHERE created_at >= now() - ($1::int * interval '1 day')
|
||||
AND {METERED_CLIENT_TURN_FILTER_SQL}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY 1, 2, 3
|
||||
""",
|
||||
window_days,
|
||||
)
|
||||
|
||||
total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6)
|
||||
all_breakdowns = [
|
||||
_usage_breakdown(
|
||||
provider=str(row["provider"] or "unknown"),
|
||||
model=str(row["model"] or "unknown"),
|
||||
turns=_safe_usage_int(row["turns"]),
|
||||
token_metered_turns=_safe_usage_int(row["token_metered_turns"]),
|
||||
token_unmetered_turns=_safe_usage_int(row["token_unmetered_turns"]),
|
||||
tokens_in=_safe_usage_int(row["tokens_in"]),
|
||||
tokens_out=_safe_usage_int(row["tokens_out"]),
|
||||
stored_cost_usd=_decimal_to_float(row["cost_usd"]),
|
||||
unpriced_tokens_in=_safe_usage_int(row["unpriced_tokens_in"]),
|
||||
unpriced_tokens_out=_safe_usage_int(row["unpriced_tokens_out"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
all_breakdowns.sort(
|
||||
key=lambda item: (
|
||||
-item.cost_usd,
|
||||
-(item.tokens_in + item.tokens_out),
|
||||
-item.turns,
|
||||
item.provider,
|
||||
item.model,
|
||||
)
|
||||
)
|
||||
recorded_cost = round(sum(item.recorded_cost_usd for item in all_breakdowns), 6)
|
||||
estimated_cost = round(sum(item.estimated_cost_usd for item in all_breakdowns), 6)
|
||||
total_cost = round(recorded_cost + estimated_cost, 6)
|
||||
|
||||
daily_buckets: dict[str, dict[str, int | float]] = {}
|
||||
for row in daily_rows:
|
||||
breakdown = _usage_breakdown(
|
||||
provider=str(row["provider"] or "unknown"),
|
||||
model=str(row["model"] or "unknown"),
|
||||
turns=_safe_usage_int(row["turns"]),
|
||||
token_metered_turns=_safe_usage_int(row["token_metered_turns"]),
|
||||
token_unmetered_turns=_safe_usage_int(row["token_unmetered_turns"]),
|
||||
tokens_in=_safe_usage_int(row["tokens_in"]),
|
||||
tokens_out=_safe_usage_int(row["tokens_out"]),
|
||||
stored_cost_usd=_decimal_to_float(row["cost_usd"]),
|
||||
unpriced_tokens_in=_safe_usage_int(row["unpriced_tokens_in"]),
|
||||
unpriced_tokens_out=_safe_usage_int(row["unpriced_tokens_out"]),
|
||||
)
|
||||
day = str(row["day"])
|
||||
bucket = daily_buckets.setdefault(
|
||||
day,
|
||||
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
|
||||
)
|
||||
bucket["turns"] = int(bucket["turns"]) + breakdown.turns
|
||||
bucket["tokens_in"] = int(bucket["tokens_in"]) + breakdown.tokens_in
|
||||
bucket["tokens_out"] = int(bucket["tokens_out"]) + breakdown.tokens_out
|
||||
bucket["cost_usd"] = float(bucket["cost_usd"]) + breakdown.cost_usd
|
||||
|
||||
return AdminUsageResponse(
|
||||
source="database",
|
||||
durable=True,
|
||||
|
|
@ -575,31 +733,29 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
|
|||
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),
|
||||
token_metered_turns=_safe_usage_int(
|
||||
total_row["token_metered_turns"] if total_row else 0
|
||||
),
|
||||
token_unmetered_turns=_safe_usage_int(
|
||||
total_row["token_unmetered_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,
|
||||
recorded_cost_usd=recorded_cost,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
budget=_usage_budget(total_cost),
|
||||
evaluator_cache=_usage_evaluator_cache(),
|
||||
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
|
||||
],
|
||||
by_provider=all_breakdowns[:12],
|
||||
daily_cost=[
|
||||
AdminUsageDailyCost(
|
||||
day=str(row["day"]),
|
||||
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),
|
||||
day=day,
|
||||
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 row in daily_rows
|
||||
for day, values in sorted(daily_buckets.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -873,9 +1029,13 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
window_start = time.time() - (window_days * 86400)
|
||||
total_turns = 0
|
||||
metered_turns = 0
|
||||
token_metered_turns = 0
|
||||
token_unmetered_turns = 0
|
||||
tokens_in = 0
|
||||
tokens_out = 0
|
||||
cost_usd = 0.0
|
||||
recorded_cost_usd = 0.0
|
||||
estimated_cost_usd = 0.0
|
||||
buckets: dict[tuple[str, str], dict[str, int | float]] = {}
|
||||
daily_buckets: dict[str, dict[str, int | float]] = {}
|
||||
|
||||
|
|
@ -902,18 +1062,57 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
if not is_metered:
|
||||
continue
|
||||
metered_turns += 1
|
||||
is_token_metered = turn_tokens_in > 0 or turn_tokens_out > 0
|
||||
if is_token_metered:
|
||||
token_metered_turns += 1
|
||||
else:
|
||||
token_unmetered_turns += 1
|
||||
tokens_in += turn_tokens_in
|
||||
tokens_out += turn_tokens_out
|
||||
cost_usd += turn_cost
|
||||
turn_breakdown = _usage_breakdown(
|
||||
provider=provider,
|
||||
model=model,
|
||||
turns=1,
|
||||
token_metered_turns=1 if is_token_metered else 0,
|
||||
token_unmetered_turns=0 if is_token_metered else 1,
|
||||
tokens_in=turn_tokens_in,
|
||||
tokens_out=turn_tokens_out,
|
||||
stored_cost_usd=turn_cost,
|
||||
unpriced_tokens_in=turn_tokens_in if turn_cost <= 0 else 0,
|
||||
unpriced_tokens_out=turn_tokens_out if turn_cost <= 0 else 0,
|
||||
)
|
||||
cost_usd += turn_breakdown.cost_usd
|
||||
recorded_cost_usd += turn_breakdown.recorded_cost_usd
|
||||
estimated_cost_usd += turn_breakdown.estimated_cost_usd
|
||||
key = (provider, model)
|
||||
bucket = buckets.setdefault(
|
||||
key,
|
||||
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
|
||||
{
|
||||
"turns": 0,
|
||||
"token_metered_turns": 0,
|
||||
"token_unmetered_turns": 0,
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"stored_cost_usd": 0.0,
|
||||
"unpriced_tokens_in": 0,
|
||||
"unpriced_tokens_out": 0,
|
||||
},
|
||||
)
|
||||
bucket["turns"] = int(bucket["turns"]) + 1
|
||||
if is_token_metered:
|
||||
bucket["token_metered_turns"] = int(bucket["token_metered_turns"]) + 1
|
||||
else:
|
||||
bucket["token_unmetered_turns"] = int(bucket["token_unmetered_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
|
||||
bucket["stored_cost_usd"] = float(bucket["stored_cost_usd"]) + turn_cost
|
||||
if turn_cost <= 0:
|
||||
bucket["unpriced_tokens_in"] = (
|
||||
int(bucket["unpriced_tokens_in"]) + turn_tokens_in
|
||||
)
|
||||
bucket["unpriced_tokens_out"] = (
|
||||
int(bucket["unpriced_tokens_out"]) + turn_tokens_out
|
||||
)
|
||||
day = datetime.fromtimestamp(created_at, timezone.utc).strftime("%Y-%m-%d")
|
||||
daily_bucket = daily_buckets.setdefault(
|
||||
day,
|
||||
|
|
@ -922,26 +1121,34 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
daily_bucket["turns"] = int(daily_bucket["turns"]) + 1
|
||||
daily_bucket["tokens_in"] = int(daily_bucket["tokens_in"]) + turn_tokens_in
|
||||
daily_bucket["tokens_out"] = int(daily_bucket["tokens_out"]) + turn_tokens_out
|
||||
daily_bucket["cost_usd"] = float(daily_bucket["cost_usd"]) + turn_cost
|
||||
daily_bucket["cost_usd"] = (
|
||||
float(daily_bucket["cost_usd"]) + turn_breakdown.cost_usd
|
||||
)
|
||||
|
||||
by_provider = [
|
||||
AdminUsageBreakdown(
|
||||
_usage_breakdown(
|
||||
provider=provider,
|
||||
model=model,
|
||||
turns=int(values["turns"]),
|
||||
token_metered_turns=int(values["token_metered_turns"]),
|
||||
token_unmetered_turns=int(values["token_unmetered_turns"]),
|
||||
tokens_in=int(values["tokens_in"]),
|
||||
tokens_out=int(values["tokens_out"]),
|
||||
cost_usd=round(float(values["cost_usd"]), 6),
|
||||
stored_cost_usd=float(values["stored_cost_usd"]),
|
||||
unpriced_tokens_in=int(values["unpriced_tokens_in"]),
|
||||
unpriced_tokens_out=int(values["unpriced_tokens_out"]),
|
||||
)
|
||||
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]
|
||||
for (provider, model), values in buckets.items()
|
||||
]
|
||||
by_provider.sort(
|
||||
key=lambda item: (
|
||||
-item.cost_usd,
|
||||
-(item.tokens_in + item.tokens_out),
|
||||
-item.turns,
|
||||
item.provider,
|
||||
item.model,
|
||||
)
|
||||
)
|
||||
|
||||
total_cost = round(cost_usd, 6)
|
||||
return AdminUsageResponse(
|
||||
|
|
@ -951,12 +1158,16 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
generated_at=time.time(),
|
||||
total_turns=total_turns,
|
||||
metered_turns=metered_turns,
|
||||
token_metered_turns=token_metered_turns,
|
||||
token_unmetered_turns=token_unmetered_turns,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
cost_usd=total_cost,
|
||||
recorded_cost_usd=round(recorded_cost_usd, 6),
|
||||
estimated_cost_usd=round(estimated_cost_usd, 6),
|
||||
budget=_usage_budget(total_cost),
|
||||
evaluator_cache=_usage_evaluator_cache(),
|
||||
by_provider=by_provider,
|
||||
by_provider=by_provider[:12],
|
||||
daily_cost=[
|
||||
AdminUsageDailyCost(
|
||||
day=day,
|
||||
|
|
@ -1485,6 +1696,15 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
|
|||
return response
|
||||
|
||||
|
||||
@router.get("/voice-runtime", response_model=VoiceRuntimeSnapshot)
|
||||
async def admin_voice_runtime(
|
||||
principal: AdminPrincipal,
|
||||
) -> VoiceRuntimeSnapshot:
|
||||
"""Return one API worker's metadata-only voice high-water snapshot."""
|
||||
|
||||
return voice_runtime_metrics.snapshot()
|
||||
|
||||
|
||||
@router.get("/usage", response_model=AdminUsageResponse)
|
||||
async def admin_usage(
|
||||
principal: AdminPrincipal,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from urllib.parse import urlencode, urlsplit
|
|||
import httpx
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth_types import AccountStatus, RoleName
|
||||
from ..auth_sessions import (
|
||||
|
|
@ -124,6 +124,7 @@ class DevLoginRequest(BaseModel):
|
|||
email: str
|
||||
role: RoleName = "learner"
|
||||
display_name: str | None = None
|
||||
cohort_ids: list[str] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
def _normalize_domain(domain: str | None) -> str:
|
||||
|
|
@ -1066,6 +1067,13 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
|
|||
email_verified=True,
|
||||
hosted_domain=_email_domain(str(body.email)),
|
||||
)
|
||||
requested_cohort_ids: list[str] = []
|
||||
_append_unique(
|
||||
requested_cohort_ids,
|
||||
[value.strip() for value in body.cohort_ids if value.strip()],
|
||||
)
|
||||
if not requested_cohort_ids:
|
||||
requested_cohort_ids = _configured_cohort_ids(email=email)
|
||||
try:
|
||||
sid, user = await create_session(
|
||||
email=email,
|
||||
|
|
@ -1073,7 +1081,7 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
|
|||
role=_role_for_managed_user(managed_user, Role(body.role)).value,
|
||||
cohort_ids=_cohort_ids_for_managed_user(
|
||||
managed_user,
|
||||
_configured_cohort_ids(email=email),
|
||||
requested_cohort_ids,
|
||||
),
|
||||
external_id=_provider_external_id("dev", email, email),
|
||||
)
|
||||
|
|
|
|||
736
apps/api/app/routes/calibration_transfer.py
Normal file
736
apps/api/app/routes/calibration_transfer.py
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
"""Typed standalone HTTP boundary for G5 Calibration Mirror & Transfer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..contracts.calibration_transfer import (
|
||||
ActualTransferAssessment,
|
||||
ActualTransferExecution,
|
||||
CompetencyCalibrationAssessment,
|
||||
MetacognitivePrescription,
|
||||
SubgroupDriftReport,
|
||||
TransferAssessment,
|
||||
TransferSuiteInput,
|
||||
)
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import calibration_transfer_store, session_learning_producer
|
||||
|
||||
|
||||
router = APIRouter(tags=["calibration-transfer"])
|
||||
logger = logging.getLogger(__name__)
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Calibration-Transfer-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
|
||||
|
||||
|
||||
async def calibration_transfer_internal_evaluator_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None, Header(alias=INTERNAL_TOKEN_HEADER)
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Fail closed before acquiring evaluator-view DB state."""
|
||||
|
||||
configured_token = settings.calibration_transfer_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal calibration transfer ingestion is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
async for conn in _evaluator_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
EvaluatorDB = Annotated[
|
||||
asyncpg.Connection,
|
||||
Depends(calibration_transfer_internal_evaluator_db),
|
||||
]
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))
|
||||
]
|
||||
|
||||
|
||||
def _unique(values: list[UUID], field_name: str) -> list[UUID]:
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError(f"{field_name} must be unique")
|
||||
return values
|
||||
|
||||
|
||||
def _forbid_raw_or_total(payload: Any) -> None:
|
||||
serialized = str(payload).lower()
|
||||
forbidden = (
|
||||
"raw_transcript",
|
||||
"transcript",
|
||||
"text_masked",
|
||||
"utterance_text",
|
||||
"total_score",
|
||||
"overall_score",
|
||||
)
|
||||
if any(item in serialized for item in forbidden):
|
||||
raise ValueError(
|
||||
"payload cannot contain transcript text or aggregate score fields"
|
||||
)
|
||||
|
||||
|
||||
class PredictionRevisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
prediction_revision_id: UUID
|
||||
history_id: UUID
|
||||
session_id: UUID
|
||||
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
|
||||
practice_block_id: str = Field(pattern=r"^oas-g5-block-[a-z0-9-]+$")
|
||||
scenario_variant_id: str = Field(min_length=1, max_length=180)
|
||||
phrase_family_id: str = Field(min_length=1, max_length=180)
|
||||
revision_no: int = Field(ge=1)
|
||||
supersedes_prediction_revision_id: UUID | None = None
|
||||
predicted_success_probability: float = Field(ge=0.0, le=1.0)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
recorded_sequence: int = Field(ge=1)
|
||||
revision_reason: str = Field(min_length=1, max_length=300)
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=40)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=24)
|
||||
|
||||
@field_validator("revision_reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("revision_reason must not be blank")
|
||||
return stripped
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
return _unique(value, "evidence_turn_ids")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def preserve_revision_chain(self) -> "PredictionRevisionRequest":
|
||||
if self.revision_no == 1 and self.supersedes_prediction_revision_id:
|
||||
raise ValueError("first revision cannot supersede another revision")
|
||||
if self.revision_no > 1 and not self.supersedes_prediction_revision_id:
|
||||
raise ValueError("later revision must supersede its predecessor")
|
||||
return self
|
||||
|
||||
|
||||
class PredictionRevisionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
prediction_revision_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class PredictionLockRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
lock_id: UUID
|
||||
prediction_revision_id: UUID
|
||||
locked_sequence: int = Field(ge=1)
|
||||
|
||||
|
||||
class PredictionLockResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
lock_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class PerformanceObservationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
observation_id: UUID
|
||||
history_id: UUID
|
||||
status: Literal["passed", "failed", "insufficient_evidence"]
|
||||
source_kind: Literal["model_inferred", "observed_runtime"]
|
||||
perspective: Literal["independent_observer", "runtime_observation"]
|
||||
model_run_id: UUID | None = None
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=40)
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=36)
|
||||
counterevidence: list[str] = Field(default_factory=list, max_length=36)
|
||||
revealed_sequence: int = Field(ge=1)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_observation_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
return _unique(value, "evidence_turn_ids")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def preserve_independent_provenance(self) -> "PerformanceObservationRequest":
|
||||
pairs = {
|
||||
("model_inferred", "independent_observer"),
|
||||
("observed_runtime", "runtime_observation"),
|
||||
}
|
||||
if (self.source_kind, self.perspective) not in pairs:
|
||||
raise ValueError("source_kind and perspective are incompatible")
|
||||
if self.source_kind == "model_inferred" and self.model_run_id is None:
|
||||
raise ValueError("model-inferred observation requires model_run_id")
|
||||
if self.status == "insufficient_evidence":
|
||||
if self.evidence_turn_ids or self.uncertainty != 1.0:
|
||||
raise ValueError(
|
||||
"insufficient observation must remain evidence-free"
|
||||
)
|
||||
elif not self.evidence_turn_ids:
|
||||
raise ValueError("ready observation requires turn UUID evidence")
|
||||
if self.status == "failed" and not self.counterevidence:
|
||||
raise ValueError("failed observation requires counterevidence")
|
||||
return self
|
||||
|
||||
|
||||
class PerformanceObservationResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
observation_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class CalibrationAssessmentSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
assessment_snapshot_id: UUID
|
||||
prescription_id: UUID
|
||||
assessment: CompetencyCalibrationAssessment
|
||||
prescription: MetacognitivePrescription
|
||||
source_observation_ids: list[UUID] = Field(min_length=1, max_length=100)
|
||||
model_run_id: UUID
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=40)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=100)
|
||||
|
||||
@field_validator("source_observation_ids", "evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_assessment_sources(
|
||||
cls, value: list[UUID], info: Any
|
||||
) -> list[UUID]:
|
||||
return _unique(value, info.field_name)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def preserve_competency_and_evidence(
|
||||
self,
|
||||
) -> "CalibrationAssessmentSubmissionRequest":
|
||||
if self.assessment.competency_id != self.prescription.competency_id:
|
||||
raise ValueError("assessment and prescription competency must match")
|
||||
if self.assessment.pair_count > 0 and not self.evidence_turn_ids:
|
||||
raise ValueError("observed calibration assessment requires turn evidence")
|
||||
_forbid_raw_or_total(self.assessment.model_dump(mode="json"))
|
||||
_forbid_raw_or_total(self.prescription.model_dump(mode="json"))
|
||||
return self
|
||||
|
||||
|
||||
class CalibrationAssessmentSubmissionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
assessment_snapshot_id: UUID
|
||||
prescription_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class TransferSuiteSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
transfer_suite_record_id: UUID
|
||||
suite: TransferSuiteInput
|
||||
model_run_id: UUID
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=40)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def evidence_refs_are_uuid_only(self) -> "TransferSuiteSubmissionRequest":
|
||||
for trial in self.suite.trials:
|
||||
for ref in trial.evidence_refs:
|
||||
try:
|
||||
UUID(ref)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"transfer evidence refs must be transcript turn UUIDs"
|
||||
) from exc
|
||||
_forbid_raw_or_total(self.suite.model_dump(mode="json"))
|
||||
return self
|
||||
|
||||
|
||||
class TransferSuiteSubmissionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
transfer_suite_record_id: UUID
|
||||
trial_count: int = Field(ge=1)
|
||||
assessment_count: int = Field(ge=1)
|
||||
drift_report_count: int = Field(ge=1)
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class TeacherReviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
review_id: UUID
|
||||
target_kind: Literal[
|
||||
"calibration_assessment", "transfer_assessment", "drift_report"
|
||||
]
|
||||
target_id: UUID
|
||||
disposition: Literal["confirmed", "corrected", "needs_more_evidence"]
|
||||
correction_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
review_reason: str = Field(min_length=1, max_length=1000)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list, max_length=36)
|
||||
counterevidence: list[str] = Field(default_factory=list, max_length=36)
|
||||
|
||||
@field_validator("review_reason")
|
||||
@classmethod
|
||||
def strip_review_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("review_reason must not be blank")
|
||||
return stripped
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_review_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
return _unique(value, "evidence_turn_ids")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def correction_only_for_corrected(self) -> "TeacherReviewRequest":
|
||||
if self.disposition != "corrected" and self.correction_payload:
|
||||
raise ValueError("only corrected review may carry correction_payload")
|
||||
_forbid_raw_or_total(self.correction_payload)
|
||||
return self
|
||||
|
||||
|
||||
class TeacherReviewResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
review_id: UUID
|
||||
review_no: int = Field(ge=1)
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class ActualTransferExecutionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
original_transfer_trial_record_id: UUID
|
||||
practice_session_id: UUID
|
||||
|
||||
|
||||
class ActualTransferExecutionResponse(BaseModel):
|
||||
execution: ActualTransferExecution
|
||||
assessment: ActualTransferAssessment
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class PredictionRevisionItem(BaseModel):
|
||||
prediction_revision_id: UUID
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
supersedes_prediction_revision_id: UUID | None = None
|
||||
predicted_success_probability: float = Field(ge=0.0, le=1.0)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
recorded_sequence: int = Field(ge=1)
|
||||
revision_reason: str
|
||||
source_kind: Literal["learner_reported"]
|
||||
perspective: Literal["learner_self_report"]
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
evidence_turn_ids: list[UUID]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PredictionLockItem(BaseModel):
|
||||
lock_id: UUID
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
prediction_revision_id: UUID
|
||||
locked_sequence: int = Field(ge=1)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PerformanceObservationItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
observation_id: UUID
|
||||
submission_id: UUID
|
||||
history_id: UUID
|
||||
status: Literal["passed", "failed", "insufficient_evidence"]
|
||||
source_kind: Literal["model_inferred", "observed_runtime"]
|
||||
perspective: Literal["independent_observer", "runtime_observation"]
|
||||
model_run_id: UUID | None = None
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
revealed_sequence: int = Field(ge=1)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PredictionHistoryItem(BaseModel):
|
||||
history_id: UUID
|
||||
session_id: UUID
|
||||
competency_id: str
|
||||
practice_block_id: str
|
||||
scenario_variant_id: str
|
||||
phrase_family_id: str
|
||||
created_at: datetime
|
||||
revisions: list[PredictionRevisionItem]
|
||||
lock: PredictionLockItem | None = None
|
||||
external_observation: PerformanceObservationItem | None = None
|
||||
|
||||
|
||||
class CalibrationAssessmentItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
assessment_snapshot_id: UUID
|
||||
submission_id: UUID
|
||||
session_id: UUID
|
||||
competency_id: str
|
||||
snapshot_no: int = Field(ge=1)
|
||||
supersedes_assessment_snapshot_id: UUID | None = None
|
||||
source_observation_ids: list[UUID]
|
||||
assessment_payload: CompetencyCalibrationAssessment
|
||||
model_run_id: UUID
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
evidence_turn_ids: list[UUID]
|
||||
created_at: datetime
|
||||
prescription_id: UUID
|
||||
prescription_payload: MetacognitivePrescription
|
||||
|
||||
|
||||
class TransferTrialItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
transfer_trial_record_id: UUID
|
||||
transfer_suite_record_id: UUID
|
||||
trial_key: str
|
||||
competency_id: str
|
||||
scenario_variant_id: str
|
||||
scenario_novelty: Literal["unseen_transfer"]
|
||||
context_variant: str
|
||||
relationship_style: Literal[
|
||||
"collaborative", "withdrawn", "confrontational", "ambivalent"
|
||||
]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
expression_variant: str
|
||||
synthetic_subgroup: str
|
||||
scenario_family_id: str
|
||||
phrase_family_id: str
|
||||
status: Literal["passed", "failed", "insufficient_evidence"]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
model_run_id: UUID
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TransferAssessmentItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
transfer_assessment_id: UUID
|
||||
transfer_suite_record_id: UUID
|
||||
competency_id: str
|
||||
source_trial_ids: list[UUID]
|
||||
assessment_payload: TransferAssessment
|
||||
evidence_turn_ids: list[UUID]
|
||||
model_run_id: UUID
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DriftReportItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
drift_report_id: UUID
|
||||
transfer_suite_record_id: UUID
|
||||
competency_id: str
|
||||
source_trial_ids: list[UUID]
|
||||
report_payload: SubgroupDriftReport
|
||||
model_run_id: UUID
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
data_classification: Literal["synthetic_educational"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TransferSuiteItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
transfer_suite_record_id: UUID
|
||||
submission_id: UUID
|
||||
suite_key: str
|
||||
session_id: UUID
|
||||
training_phrase_family_ids: list[str]
|
||||
model_run_id: UUID
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
data_classification: Literal["synthetic_educational"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
created_at: datetime
|
||||
trials: list[TransferTrialItem]
|
||||
assessments: list[TransferAssessmentItem]
|
||||
drift_reports: list[DriftReportItem]
|
||||
|
||||
|
||||
class TeacherReviewItem(BaseModel):
|
||||
review_id: UUID
|
||||
submission_id: UUID
|
||||
target_kind: Literal[
|
||||
"calibration_assessment", "transfer_assessment", "drift_report"
|
||||
]
|
||||
target_id: UUID
|
||||
review_no: int = Field(ge=1)
|
||||
supersedes_review_id: UUID | None = None
|
||||
disposition: Literal["confirmed", "corrected", "needs_more_evidence"]
|
||||
correction_payload: dict[str, Any]
|
||||
review_reason: str
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
created_by_uid: UUID
|
||||
created_by_role: Literal["instructor", "admin"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CalibrationTransferReadModelResponse(BaseModel):
|
||||
learner_id: UUID
|
||||
requested_view: Literal["learner", "supervisor"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
prediction_histories: list[PredictionHistoryItem]
|
||||
calibration_assessments: list[CalibrationAssessmentItem]
|
||||
transfer_suites: list[TransferSuiteItem]
|
||||
teacher_reviews: list[TeacherReviewItem]
|
||||
actual_executions: list[ActualTransferExecution] = Field(default_factory=list)
|
||||
actual_transfer_assessments: list[ActualTransferAssessment] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(
|
||||
exc, calibration_transfer_store.CalibrationTransferNotFoundError
|
||||
):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, calibration_transfer_store.CalibrationTransferConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, calibration_transfer_store.CalibrationTransferStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
_STORE_ERRORS = (
|
||||
calibration_transfer_store.CalibrationTransferNotFoundError,
|
||||
calibration_transfer_store.CalibrationTransferConflictError,
|
||||
calibration_transfer_store.CalibrationTransferStateError,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calibration/predictions/revisions",
|
||||
response_model=PredictionRevisionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_prediction_revision(
|
||||
body: PredictionRevisionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PredictionRevisionResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_prediction_revision(
|
||||
principal=principal, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PredictionRevisionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calibration/predictions/{history_id}/lock",
|
||||
response_model=PredictionLockResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def lock_prediction_history(
|
||||
history_id: UUID,
|
||||
body: PredictionLockRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PredictionLockResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_prediction_lock(
|
||||
principal=principal,
|
||||
history_id=history_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
try:
|
||||
await session_learning_producer.produce_locked_prediction_history(history_id)
|
||||
except Exception:
|
||||
# lock 원장은 이미 별도 트랜잭션으로 커밋됐다. 외부 관찰 파생 실패는
|
||||
# 잠금 응답을 실패시키거나 자기예측을 되돌리지 않는다.
|
||||
logger.exception(
|
||||
"calibration observation production failed after lock: history_id=%s",
|
||||
history_id,
|
||||
)
|
||||
return PredictionLockResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/calibration/performance-observations",
|
||||
response_model=PerformanceObservationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_performance_observation(
|
||||
body: PerformanceObservationRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> PerformanceObservationResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_performance_observation(
|
||||
conn=conn, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PerformanceObservationResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/calibration/assessments",
|
||||
response_model=CalibrationAssessmentSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_calibration_assessment(
|
||||
session_id: UUID,
|
||||
body: CalibrationAssessmentSubmissionRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> CalibrationAssessmentSubmissionResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_calibration_assessment(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return CalibrationAssessmentSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/calibration/transfer-suites",
|
||||
response_model=TransferSuiteSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_transfer_suite(
|
||||
session_id: UUID,
|
||||
body: TransferSuiteSubmissionRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> TransferSuiteSubmissionResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_transfer_suite(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
transfer_suite_record_id=body.transfer_suite_record_id,
|
||||
suite=body.suite,
|
||||
model_run_id=body.model_run_id,
|
||||
instrument_id=body.instrument_id,
|
||||
instrument_version=body.instrument_version,
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return TransferSuiteSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calibration/transfer-executions",
|
||||
response_model=ActualTransferExecutionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_actual_transfer_execution(
|
||||
body: ActualTransferExecutionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> ActualTransferExecutionResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return ActualTransferExecutionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calibration/reviews",
|
||||
response_model=TeacherReviewResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_teacher_review(
|
||||
body: TeacherReviewRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> TeacherReviewResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_teacher_review(
|
||||
principal=principal, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return TeacherReviewResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/calibration/learners/me",
|
||||
response_model=CalibrationTransferReadModelResponse,
|
||||
)
|
||||
async def get_my_calibration_transfer(
|
||||
principal: LearnerPrincipal,
|
||||
) -> CalibrationTransferReadModelResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.read_calibration_transfer(
|
||||
principal=principal
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return CalibrationTransferReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/calibration/learners/{learner_id}",
|
||||
response_model=CalibrationTransferReadModelResponse,
|
||||
)
|
||||
async def get_learner_calibration_transfer(
|
||||
learner_id: UUID,
|
||||
principal: TeacherPrincipal,
|
||||
) -> CalibrationTransferReadModelResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.read_calibration_transfer(
|
||||
principal=principal, learner_id=learner_id
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return CalibrationTransferReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
809
apps/api/app/routes/continuous_improvement.py
Normal file
809
apps/api/app/routes/continuous_improvement.py
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
"""Standalone secure HTTP boundary for G8 Continuous Improvement OS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from .. import db
|
||||
from ..config import Settings, get_settings
|
||||
from ..contracts.continuous_improvement import (
|
||||
AgenticReleaseManifest,
|
||||
ContentBenchmarkQualification,
|
||||
ContentSourceArtifact,
|
||||
GeneratedContentDraft,
|
||||
IndependentRedTeamReview,
|
||||
ModelCalibrationSnapshot,
|
||||
OperationalIncident,
|
||||
)
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..engine_client import engine_client
|
||||
from ..services import continuous_improvement_store
|
||||
from ..services import continuous_improvement_agentic
|
||||
|
||||
|
||||
router = APIRouter(tags=["continuous-improvement"])
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Continuous-Improvement-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
SyntheticDataClassification = Literal["synthetic_replay_red_team_coverage_drift"]
|
||||
|
||||
|
||||
async def _research_db_provider() -> AsyncIterator[asyncpg.Connection]:
|
||||
async with db.acquire(ai_view="research", ai_context=True) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def _authenticate_internal(settings: Settings, presented_token: str | None) -> None:
|
||||
configured_token = settings.continuous_improvement_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="continuous improvement automation is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
|
||||
|
||||
async def continuous_improvement_internal_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[str | None, Header(alias=INTERNAL_TOKEN_HEADER)] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Fail closed before acquiring the research-view database connection."""
|
||||
|
||||
_authenticate_internal(settings, presented_token)
|
||||
async for conn in _research_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
ResearchDB = Annotated[asyncpg.Connection, Depends(continuous_improvement_internal_db)]
|
||||
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
|
||||
|
||||
|
||||
async def continuous_improvement_admin_db(
|
||||
principal: AdminPrincipal,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Acquire DB state with the effective admin role selected by the role gate."""
|
||||
|
||||
async with db.acquire(
|
||||
role=Role.ADMIN.value,
|
||||
user_id=principal.user_id,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
AdminDB = Annotated[asyncpg.Connection, Depends(continuous_improvement_admin_db)]
|
||||
|
||||
|
||||
class ContentPipelineRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
pipeline_id: UUID
|
||||
benchmark_record_id: UUID
|
||||
qualification_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
draft: GeneratedContentDraft
|
||||
sources: list[ContentSourceArtifact] = Field(min_length=1, max_length=100)
|
||||
reviews: list[IndependentRedTeamReview] = Field(min_length=2, max_length=20)
|
||||
benchmark: ContentBenchmarkQualification
|
||||
|
||||
|
||||
class ContentPipelineResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
pipeline_id: UUID
|
||||
qualification_id: UUID
|
||||
candidate_catalog_entry_id: str
|
||||
state: Literal["pending_human_approval"]
|
||||
human_approval_required: Literal[True]
|
||||
catalog_promoted: Literal[False]
|
||||
idempotent_replay: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class AgenticContentPipelineRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
pipeline_id: UUID
|
||||
benchmark_record_id: UUID
|
||||
qualification_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
source_packs: list[continuous_improvement_agentic.AgenticSourcePack] = Field(
|
||||
min_length=1, max_length=20
|
||||
)
|
||||
content_kind: Literal["case", "rupture", "practice", "benchmark"]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
variant_count: int = Field(default=3, ge=3, le=12)
|
||||
prompt_version: str = Field(default="1.0.0", min_length=1, max_length=80)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_source_packs(self) -> "AgenticContentPipelineRequest":
|
||||
source_ids = [item.artifact.source_id for item in self.source_packs]
|
||||
if len(source_ids) != len(set(source_ids)):
|
||||
raise ValueError("agentic source pack ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgenticContentPipelineResponse(ContentPipelineResponse):
|
||||
draft_id: str
|
||||
benchmark_id: str
|
||||
red_team_review_count: int = Field(ge=2)
|
||||
benchmark_variant_count: int = Field(ge=3)
|
||||
agent_calls_executed: int = Field(ge=0)
|
||||
trigger_kind: Literal["source_pack", "operational_incident"]
|
||||
|
||||
|
||||
class IncidentAdversarialPipelineRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
pipeline_id: UUID
|
||||
benchmark_record_id: UUID
|
||||
qualification_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
difficulty_level: int = Field(default=5, ge=1, le=5)
|
||||
variant_count: int = Field(default=3, ge=3, le=12)
|
||||
prompt_version: str = Field(default="1.0.0", min_length=1, max_length=80)
|
||||
|
||||
|
||||
class GateArtifact(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
artifact_record_id: UUID
|
||||
artifact_id: str = Field(min_length=1, max_length=180)
|
||||
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
provenance_uri: str = Field(pattern=r"^(repo|db|audit)://[a-zA-Z0-9_./:-]+$")
|
||||
|
||||
|
||||
class CompleteGateArtifacts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
baseline: GateArtifact
|
||||
threshold: GateArtifact
|
||||
provenance: list[GateArtifact] = Field(min_length=1, max_length=100)
|
||||
rollback: GateArtifact
|
||||
|
||||
@model_validator(mode="after")
|
||||
def artifact_ids_are_unique(self) -> "CompleteGateArtifacts":
|
||||
values = [
|
||||
self.baseline.artifact_record_id,
|
||||
self.threshold.artifact_record_id,
|
||||
*(item.artifact_record_id for item in self.provenance),
|
||||
self.rollback.artifact_record_id,
|
||||
]
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("gate artifact UUIDs must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class ModelChangeGateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
gate_id: UUID
|
||||
baseline_snapshot_record_id: UUID
|
||||
candidate_snapshot_record_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
baseline: ModelCalibrationSnapshot
|
||||
candidate: ModelCalibrationSnapshot
|
||||
artifacts: CompleteGateArtifacts
|
||||
|
||||
@model_validator(mode="after")
|
||||
def baseline_and_candidate_are_distinct(self) -> "ModelChangeGateRequest":
|
||||
if self.baseline.snapshot_id == self.candidate.snapshot_id:
|
||||
raise ValueError("baseline and candidate snapshots must be distinct")
|
||||
if self.baseline_snapshot_record_id == self.candidate_snapshot_record_id:
|
||||
raise ValueError("baseline and candidate record UUIDs must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class ModelChangeGateResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
gate_id: UUID
|
||||
gate_decision: Literal["promote", "rollback", "quarantine"]
|
||||
state: Literal["pending_human_approval"]
|
||||
human_approval_required: Literal[True]
|
||||
promotion_executed: Literal[False]
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class ReleaseGateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
gate_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
manifest: AgenticReleaseManifest
|
||||
artifacts: CompleteGateArtifacts
|
||||
|
||||
|
||||
class ReleaseGateResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
gate_id: UUID
|
||||
qualified: bool
|
||||
state: Literal["pending_human_approval"]
|
||||
human_approval_required: Literal[True]
|
||||
promotion_executed: Literal[False]
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class IncidentDagRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
incident_record_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
incident: OperationalIncident
|
||||
|
||||
|
||||
class IncidentDagResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
incident_record_id: UUID
|
||||
node_count: Literal[4]
|
||||
idempotent_replay: bool
|
||||
pii_included: Literal[False] = False
|
||||
|
||||
|
||||
class HumanApprovalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
approval_event_id: UUID
|
||||
effect_record_id: UUID
|
||||
target_kind: Literal["content_qualification", "model_change_gate", "release_gate"]
|
||||
target_id: UUID
|
||||
decision: Literal[
|
||||
"approve_content",
|
||||
"approve_promotion",
|
||||
"authorize_rollback",
|
||||
"reject",
|
||||
"keep_quarantine",
|
||||
]
|
||||
reason_code: str = Field(min_length=1, max_length=180)
|
||||
evidence_refs: list[str] = Field(min_length=1, max_length=100)
|
||||
|
||||
@field_validator("evidence_refs")
|
||||
@classmethod
|
||||
def evidence_refs_are_unique(cls, value: list[str]) -> list[str]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("approval evidence refs must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class HumanApprovalResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
approval_event_id: UUID
|
||||
target_kind: str
|
||||
target_id: UUID
|
||||
decision: str
|
||||
effect_record_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MonitorEventRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
lifecycle_event_id: UUID
|
||||
data_classification: SyntheticDataClassification
|
||||
target_kind: Literal["model_change_gate", "release_gate"]
|
||||
target_id: UUID
|
||||
event_status: Literal[
|
||||
"healthy", "drift_detected", "rollback_recommended", "rollback_verified"
|
||||
]
|
||||
evidence_refs: list[str] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class MonitorEventResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
lifecycle_event_id: UUID
|
||||
event_status: str
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class CatalogGroundedClaim(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
claim: str = Field(min_length=1, max_length=600)
|
||||
source_ref: str = Field(min_length=1, max_length=180)
|
||||
|
||||
|
||||
class CatalogVisiblePayload(BaseModel):
|
||||
"""Explicit allowlist for content that may cross the approved catalog boundary."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str = Field(min_length=1, max_length=180)
|
||||
synthetic_profile: str = Field(min_length=1, max_length=1200)
|
||||
scenario: str = Field(min_length=1, max_length=6000)
|
||||
rupture_or_challenge: str = Field(min_length=1, max_length=2400)
|
||||
learner_task: str = Field(min_length=1, max_length=2000)
|
||||
success_criteria: list[str] = Field(min_length=1, max_length=10)
|
||||
source_refs: list[str] = Field(min_length=1, max_length=100)
|
||||
grounded_claims: list[CatalogGroundedClaim] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class ContentQualificationView(BaseModel):
|
||||
qualification_id: UUID
|
||||
pipeline_id: UUID
|
||||
catalog_entry_id: str
|
||||
payload_sha256: str
|
||||
content_kind: Literal["case", "rupture", "practice", "benchmark"]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
synthetic_identity_id: str
|
||||
source_count: int = Field(ge=1)
|
||||
red_team_review_count: int = Field(ge=2)
|
||||
benchmark_variant_count: int = Field(ge=3)
|
||||
benchmark_pass_rate: float = Field(ge=0.85, le=1.0)
|
||||
source_provenance_uris: list[str] = Field(min_length=1)
|
||||
draft_payload: CatalogVisiblePayload | None = None
|
||||
gate_state: Literal["pending_human_approval"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ModelChangeGateView(BaseModel):
|
||||
gate_id: UUID
|
||||
gate_decision: Literal["promote", "rollback", "quarantine"]
|
||||
reasons: list[str]
|
||||
state: Literal["pending_human_approval"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReleaseGateView(BaseModel):
|
||||
gate_id: UUID
|
||||
release_id: str
|
||||
qualified: bool
|
||||
state: Literal["pending_human_approval"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GateArtifactView(BaseModel):
|
||||
artifact_record_id: UUID
|
||||
owner_kind: Literal["model_change_gate", "release_gate"]
|
||||
owner_id: UUID
|
||||
artifact_kind: Literal["baseline", "threshold", "provenance", "rollback"]
|
||||
artifact_id: str
|
||||
content_sha256: str
|
||||
provenance_uri: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HumanApprovalView(BaseModel):
|
||||
approval_event_id: UUID
|
||||
target_kind: Literal["content_qualification", "model_change_gate", "release_gate"]
|
||||
target_id: UUID
|
||||
decision: Literal[
|
||||
"approve_content",
|
||||
"approve_promotion",
|
||||
"authorize_rollback",
|
||||
"reject",
|
||||
"keep_quarantine",
|
||||
]
|
||||
reason_code: str
|
||||
evidence_refs: list[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CatalogEntryView(BaseModel):
|
||||
catalog_record_id: UUID
|
||||
qualification_id: UUID
|
||||
catalog_entry_id: str
|
||||
status: Literal["approved"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApprovedCatalogConsumerEntry(BaseModel):
|
||||
catalog_record_id: UUID
|
||||
qualification_id: UUID
|
||||
catalog_entry_id: str
|
||||
payload_sha256: str
|
||||
content_kind: Literal["case", "rupture", "practice", "benchmark"]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
synthetic_identity_id: str
|
||||
source_provenance_uris: list[str] = Field(min_length=1)
|
||||
payload: CatalogVisiblePayload
|
||||
status: Literal["approved"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
approved_at: datetime
|
||||
|
||||
|
||||
class ApprovedCatalogConsumerResponse(BaseModel):
|
||||
entries: list[ApprovedCatalogConsumerEntry]
|
||||
data_classification: SyntheticDataClassification
|
||||
human_approval_required: Literal[True] = True
|
||||
raw_transcript_included: Literal[False] = False
|
||||
pii_included: Literal[False] = False
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class LifecycleEventView(BaseModel):
|
||||
lifecycle_event_id: UUID
|
||||
target_kind: Literal["model_change_gate", "release_gate"]
|
||||
target_id: UUID
|
||||
event_type: Literal["promotion", "rollback", "monitor"]
|
||||
event_status: Literal[
|
||||
"approved",
|
||||
"requested",
|
||||
"executed",
|
||||
"failed",
|
||||
"healthy",
|
||||
"drift_detected",
|
||||
"rollback_recommended",
|
||||
"rollback_verified",
|
||||
]
|
||||
approval_event_id: UUID | None = None
|
||||
artifact_record_id: UUID | None = None
|
||||
evidence_refs: list[str]
|
||||
executor_receipt_id: str | None = None
|
||||
executor_evidence_refs: list[str] | None = None
|
||||
created_at: datetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def enforce_rollback_receipt_boundary(self) -> "LifecycleEventView":
|
||||
if self.event_type != "rollback":
|
||||
if self.executor_receipt_id is not None or self.executor_evidence_refs:
|
||||
raise ValueError("non-rollback lifecycle event cannot carry a receipt")
|
||||
return self
|
||||
if self.approval_event_id is None or self.artifact_record_id is None:
|
||||
raise ValueError("rollback requires approval and pinned artifact")
|
||||
if self.event_status == "executed":
|
||||
if not (self.executor_receipt_id or "").strip():
|
||||
raise ValueError("executed rollback requires executor receipt id")
|
||||
if not self.executor_evidence_refs:
|
||||
raise ValueError("executed rollback requires executor evidence")
|
||||
if not set(self.executor_evidence_refs).issubset(self.evidence_refs):
|
||||
raise ValueError("executor evidence must be included in lifecycle evidence")
|
||||
elif self.executor_receipt_id is not None or self.executor_evidence_refs:
|
||||
raise ValueError("non-executed rollback cannot carry executor receipt evidence")
|
||||
return self
|
||||
|
||||
|
||||
class OperationalIncidentView(BaseModel):
|
||||
incident_record_id: UUID
|
||||
incident_id: str
|
||||
error_fingerprint: str
|
||||
affected_contract: str
|
||||
evidence_refs: list[str]
|
||||
pii_included: Literal[False]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RegressionDagNodeView(BaseModel):
|
||||
node_record_id: UUID
|
||||
incident_record_id: UUID
|
||||
node_id: str
|
||||
node_type: Literal["reproduction_test", "implementation", "e2e", "runtime_proof"]
|
||||
depends_on_record_ids: list[UUID]
|
||||
evidence_ref: str | None = None
|
||||
node_status: Literal["pending", "passed", "failed"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ContinuousImprovementViewResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
content_qualifications: list[ContentQualificationView]
|
||||
model_change_gates: list[ModelChangeGateView]
|
||||
release_gates: list[ReleaseGateView]
|
||||
gate_artifacts: list[GateArtifactView]
|
||||
approvals: list[HumanApprovalView]
|
||||
catalog_entries: list[CatalogEntryView]
|
||||
lifecycle_events: list[LifecycleEventView]
|
||||
incidents: list[OperationalIncidentView]
|
||||
regression_dag_nodes: list[RegressionDagNodeView]
|
||||
data_classification: SyntheticDataClassification
|
||||
silent_auto_promotion_allowed: Literal[False]
|
||||
raw_transcript_included: Literal[False]
|
||||
pii_included: Literal[False]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
|
||||
|
||||
def _artifact_dict(value: GateArtifact) -> dict[str, object]:
|
||||
return value.model_dump(mode="python")
|
||||
|
||||
|
||||
def _raise_store_error(exc: Exception) -> None:
|
||||
if isinstance(exc, continuous_improvement_store.ContinuousImprovementConflictError):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, continuous_improvement_store.ContinuousImprovementNotFoundError):
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/agentic-content-pipelines",
|
||||
response_model=AgenticContentPipelineResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_agentic_content_pipeline(
|
||||
request: AgenticContentPipelineRequest, conn: ResearchDB
|
||||
) -> AgenticContentPipelineResponse:
|
||||
"""Run real model-owned generation/review/judging before pending approval."""
|
||||
|
||||
try:
|
||||
result = await continuous_improvement_agentic.run_agentic_content_pipeline(
|
||||
conn=conn,
|
||||
engine=engine_client,
|
||||
submission_id=request.submission_id,
|
||||
pipeline_id=request.pipeline_id,
|
||||
benchmark_record_id=request.benchmark_record_id,
|
||||
qualification_id=request.qualification_id,
|
||||
source_packs=request.source_packs,
|
||||
content_kind=request.content_kind,
|
||||
difficulty_level=request.difficulty_level,
|
||||
variant_count=request.variant_count,
|
||||
prompt_version=request.prompt_version,
|
||||
trigger_kind="source_pack",
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
except continuous_improvement_agentic.AgenticPipelineRejectedError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except continuous_improvement_agentic.AgenticPipelineExecutionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return AgenticContentPipelineResponse.model_validate(result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/incidents/{incident_record_id}/adversarial-content-pipelines",
|
||||
response_model=AgenticContentPipelineResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_incident_adversarial_content_pipeline(
|
||||
incident_record_id: UUID,
|
||||
request: IncidentAdversarialPipelineRequest,
|
||||
conn: ResearchDB,
|
||||
) -> AgenticContentPipelineResponse:
|
||||
"""Turn a persisted metadata-only operational failure into a gated benchmark."""
|
||||
|
||||
try:
|
||||
incident = await continuous_improvement_store.read_operational_incident(
|
||||
conn, incident_record_id=incident_record_id
|
||||
)
|
||||
source_pack = (
|
||||
continuous_improvement_agentic.source_pack_from_operational_incident(
|
||||
incident
|
||||
)
|
||||
)
|
||||
result = await continuous_improvement_agentic.run_agentic_content_pipeline(
|
||||
conn=conn,
|
||||
engine=engine_client,
|
||||
submission_id=request.submission_id,
|
||||
pipeline_id=request.pipeline_id,
|
||||
benchmark_record_id=request.benchmark_record_id,
|
||||
qualification_id=request.qualification_id,
|
||||
source_packs=[source_pack],
|
||||
content_kind="benchmark",
|
||||
difficulty_level=request.difficulty_level,
|
||||
variant_count=request.variant_count,
|
||||
prompt_version=request.prompt_version,
|
||||
trigger_kind="operational_incident",
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
except continuous_improvement_agentic.AgenticPipelineRejectedError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except continuous_improvement_agentic.AgenticPipelineExecutionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return AgenticContentPipelineResponse.model_validate(result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/content-pipelines",
|
||||
response_model=ContentPipelineResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_content_pipeline(
|
||||
request: ContentPipelineRequest, conn: ResearchDB
|
||||
) -> ContentPipelineResponse:
|
||||
try:
|
||||
result = await continuous_improvement_store.submit_content_pipeline(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
pipeline_id=request.pipeline_id,
|
||||
benchmark_record_id=request.benchmark_record_id,
|
||||
qualification_id=request.qualification_id,
|
||||
draft=request.draft,
|
||||
sources=request.sources,
|
||||
reviews=request.reviews,
|
||||
benchmark=request.benchmark,
|
||||
)
|
||||
except (ValueError, continuous_improvement_store.ContinuousImprovementError) as exc:
|
||||
_raise_store_error(exc)
|
||||
return ContentPipelineResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/model-change-gates",
|
||||
response_model=ModelChangeGateResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_model_change_gate(
|
||||
request: ModelChangeGateRequest, conn: ResearchDB
|
||||
) -> ModelChangeGateResponse:
|
||||
try:
|
||||
result = await continuous_improvement_store.submit_model_change_gate(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
gate_id=request.gate_id,
|
||||
baseline_snapshot_record_id=request.baseline_snapshot_record_id,
|
||||
candidate_snapshot_record_id=request.candidate_snapshot_record_id,
|
||||
baseline=request.baseline,
|
||||
candidate=request.candidate,
|
||||
baseline_artifact=_artifact_dict(request.artifacts.baseline),
|
||||
threshold_artifact=_artifact_dict(request.artifacts.threshold),
|
||||
provenance_artifacts=[
|
||||
_artifact_dict(item) for item in request.artifacts.provenance
|
||||
],
|
||||
rollback_artifact=_artifact_dict(request.artifacts.rollback),
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
return ModelChangeGateResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/release-gates",
|
||||
response_model=ReleaseGateResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_release_gate(
|
||||
request: ReleaseGateRequest, conn: ResearchDB
|
||||
) -> ReleaseGateResponse:
|
||||
try:
|
||||
result = await continuous_improvement_store.submit_release_gate(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
gate_id=request.gate_id,
|
||||
manifest=request.manifest,
|
||||
baseline_artifact=_artifact_dict(request.artifacts.baseline),
|
||||
threshold_artifact=_artifact_dict(request.artifacts.threshold),
|
||||
provenance_artifacts=[
|
||||
_artifact_dict(item) for item in request.artifacts.provenance
|
||||
],
|
||||
rollback_artifact=_artifact_dict(request.artifacts.rollback),
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
return ReleaseGateResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/incidents",
|
||||
response_model=IncidentDagResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_incident_dag(
|
||||
request: IncidentDagRequest, conn: ResearchDB
|
||||
) -> IncidentDagResponse:
|
||||
try:
|
||||
result = await continuous_improvement_store.submit_incident_dag(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
incident_record_id=request.incident_record_id,
|
||||
incident=request.incident,
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
return IncidentDagResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/continuous-improvement/approvals",
|
||||
response_model=HumanApprovalResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_human_approval(
|
||||
request: HumanApprovalRequest,
|
||||
principal: AdminPrincipal,
|
||||
conn: AdminDB,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> HumanApprovalResponse:
|
||||
try:
|
||||
rollback_executor = (
|
||||
continuous_improvement_agentic.build_configured_rollback_executor(settings)
|
||||
if request.decision == "authorize_rollback"
|
||||
else None
|
||||
)
|
||||
result = await continuous_improvement_store.append_human_approval(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
approval_event_id=request.approval_event_id,
|
||||
effect_record_id=request.effect_record_id,
|
||||
target_kind=request.target_kind,
|
||||
target_id=request.target_id,
|
||||
decision=request.decision,
|
||||
actor_uid=UUID(principal.user_id),
|
||||
reason_code=request.reason_code,
|
||||
evidence_refs=request.evidence_refs,
|
||||
rollback_executor=rollback_executor,
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
return HumanApprovalResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/continuous-improvement/monitor-events",
|
||||
response_model=MonitorEventResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_monitor_event(
|
||||
request: MonitorEventRequest, conn: ResearchDB
|
||||
) -> MonitorEventResponse:
|
||||
try:
|
||||
result = await continuous_improvement_store.append_monitor_event(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
lifecycle_event_id=request.lifecycle_event_id,
|
||||
target_kind=request.target_kind,
|
||||
target_id=request.target_id,
|
||||
event_status=request.event_status,
|
||||
evidence_refs=request.evidence_refs,
|
||||
)
|
||||
except continuous_improvement_store.ContinuousImprovementError as exc:
|
||||
_raise_store_error(exc)
|
||||
return MonitorEventResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/internal/continuous-improvement",
|
||||
response_model=ContinuousImprovementViewResponse,
|
||||
)
|
||||
async def read_internal_continuous_improvement(
|
||||
conn: ResearchDB,
|
||||
) -> ContinuousImprovementViewResponse:
|
||||
result = await continuous_improvement_store.read_continuous_improvement_view(conn)
|
||||
return ContinuousImprovementViewResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/continuous-improvement",
|
||||
response_model=ContinuousImprovementViewResponse,
|
||||
)
|
||||
async def read_admin_continuous_improvement(
|
||||
_principal: AdminPrincipal,
|
||||
conn: AdminDB,
|
||||
) -> ContinuousImprovementViewResponse:
|
||||
result = await continuous_improvement_store.read_continuous_improvement_view(conn)
|
||||
return ContinuousImprovementViewResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/continuous-improvement/catalog",
|
||||
response_model=ApprovedCatalogConsumerResponse,
|
||||
)
|
||||
async def read_admin_approved_catalog(
|
||||
_principal: AdminPrincipal,
|
||||
conn: AdminDB,
|
||||
) -> ApprovedCatalogConsumerResponse:
|
||||
entries = await continuous_improvement_store.read_approved_catalog_entries(conn)
|
||||
return ApprovedCatalogConsumerResponse(
|
||||
entries=[ApprovedCatalogConsumerEntry.model_validate(item) for item in entries],
|
||||
data_classification="synthetic_replay_red_team_coverage_drift",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTERNAL_TOKEN_HEADER",
|
||||
"continuous_improvement_internal_db",
|
||||
"continuous_improvement_admin_db",
|
||||
"router",
|
||||
]
|
||||
406
apps/api/app/routes/deliberate_practices.py
Normal file
406
apps/api/app/routes/deliberate_practices.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"""Typed HTTP boundary for G4 deliberate-practice ledgers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..contracts.deliberate_practice import (
|
||||
CoachingCard,
|
||||
CompetencyGraph,
|
||||
CurriculumDecision,
|
||||
PracticeEpisodeInput,
|
||||
PracticePrescription,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import deliberate_practice_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["deliberate-practice"])
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Practice-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
|
||||
|
||||
|
||||
async def practice_internal_evaluator_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None,
|
||||
Header(alias=INTERNAL_TOKEN_HEADER),
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Authenticate the internal caller before acquiring evaluator-view DB state."""
|
||||
|
||||
configured_token = settings.practice_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal practice ingestion is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
|
||||
async for conn in _evaluator_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
EvaluatorDB = Annotated[
|
||||
asyncpg.Connection,
|
||||
Depends(practice_internal_evaluator_db),
|
||||
]
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class PracticePrescriptionSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
coaching_cards: list[CoachingCard] = Field(min_length=1, max_length=12)
|
||||
competency_graph: CompetencyGraph
|
||||
evidence_turn_ids: list[UUID] = Field(min_length=1, max_length=36)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class PracticePrescriptionSubmissionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
prescription_ids: list[str] = Field(min_length=1)
|
||||
snapshot_id: UUID
|
||||
decision_id: UUID
|
||||
next_prescription_id: str
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class PracticeAttemptSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
episode: PracticeEpisodeInput
|
||||
|
||||
|
||||
class PracticeAttemptSubmissionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
progress: Literal["practicing", "transfer_pending", "mastered"]
|
||||
mastery_allowed: bool
|
||||
snapshot_id: UUID
|
||||
decision_id: UUID
|
||||
next_prescription_id: str
|
||||
idempotent_replay: bool
|
||||
|
||||
@model_validator(mode="after")
|
||||
def keep_mastery_explicit(self) -> "PracticeAttemptSubmissionResponse":
|
||||
if (self.progress == "mastered") != self.mastery_allowed:
|
||||
raise ValueError("only mastered practice may allow mastery")
|
||||
return self
|
||||
|
||||
|
||||
class PracticeTeacherCorrectionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
corrected_outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
|
||||
correction_reason: str = Field(min_length=1, max_length=1000)
|
||||
evidence_turn_ids: list[UUID] = Field(min_length=1, max_length=24)
|
||||
counterevidence: list[str] = Field(default_factory=list, max_length=24)
|
||||
|
||||
@field_validator("correction_reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("correction_reason must not be blank")
|
||||
return stripped
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_correction_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class PracticeTeacherCorrectionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
correction_id: UUID
|
||||
correction_no: int = Field(ge=1)
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class PracticeTeacherCorrectionItem(BaseModel):
|
||||
correction_id: UUID
|
||||
submission_id: UUID
|
||||
attempt_record_id: UUID
|
||||
correction_no: int = Field(ge=1)
|
||||
supersedes_correction_id: UUID | None = None
|
||||
corrected_outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
|
||||
correction_reason: str
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
created_by_uid: UUID
|
||||
created_by_role: Literal["instructor", "admin"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PracticeAttemptItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
attempt_record_id: UUID
|
||||
attempt_key: str
|
||||
episode_submission_id: UUID
|
||||
sequence_no: int = Field(ge=1)
|
||||
scenario_variant_id: str
|
||||
scenario_novelty: Literal["familiar", "unseen_transfer"]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
criterion_status: Literal["observed", "not_observed", "error"]
|
||||
client_response: str | None = None
|
||||
outcome: Literal["passed", "needs_retry", "insufficient_evidence"]
|
||||
utterance_template_id: str | None = None
|
||||
learner_claimed_success: bool
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
attempt_payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
corrections: list[PracticeTeacherCorrectionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PracticeEpisodeItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
episode_submission_id: UUID
|
||||
episode_key: str
|
||||
session_id: UUID
|
||||
progress: Literal["practicing", "transfer_pending", "mastered"]
|
||||
mastery_allowed: bool
|
||||
mastery_blockers: list[str]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID]
|
||||
counterevidence: list[str]
|
||||
assessment_payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
attempts: list[PracticeAttemptItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PracticePrescriptionItem(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
prescription_record_id: UUID
|
||||
prescription_key: str
|
||||
session_id: UUID
|
||||
competency_id: str
|
||||
criterion_id: str
|
||||
observable_behavior: str
|
||||
activity_mode: Literal[
|
||||
"replay",
|
||||
"branch",
|
||||
"constrained_response",
|
||||
"voice_retry",
|
||||
"difficulty_ladder",
|
||||
]
|
||||
scenario_variant_id: str
|
||||
scenario_novelty: Literal["familiar", "unseen_transfer"]
|
||||
difficulty_level: int = Field(ge=1, le=5)
|
||||
prescription_payload: PracticePrescription
|
||||
created_at: datetime
|
||||
card_key: str
|
||||
coach_claim: str
|
||||
evidence_turn_ids: list[UUID]
|
||||
source_refs: list[str]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
counterevidence: list[str]
|
||||
|
||||
|
||||
class DeliberatePracticeReadModelResponse(BaseModel):
|
||||
learner_id: UUID
|
||||
clinical_claim_allowed: Literal[False]
|
||||
prescriptions: list[PracticePrescriptionItem]
|
||||
episodes: list[PracticeEpisodeItem]
|
||||
competency_graph: CompetencyGraph | None = None
|
||||
snapshot_id: UUID | None = None
|
||||
snapshot_no: int | None = Field(default=None, ge=1)
|
||||
next_practice: CurriculumDecision | None = None
|
||||
decision_id: UUID | None = None
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/practice/prescriptions",
|
||||
response_model=PracticePrescriptionSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_practice_prescriptions(
|
||||
session_id: UUID,
|
||||
body: PracticePrescriptionSubmissionRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> PracticePrescriptionSubmissionResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_prescription_submission(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
coaching_cards=body.coaching_cards,
|
||||
graph=body.competency_graph,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PracticePrescriptionSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/practice/{prescription_id}/attempts",
|
||||
response_model=PracticeAttemptSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_practice_attempt(
|
||||
prescription_id: str,
|
||||
body: PracticeAttemptSubmissionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_learner_attempt_submission(
|
||||
principal=principal,
|
||||
submission_id=body.submission_id,
|
||||
prescription_id=prescription_id,
|
||||
episode=body.episode,
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PracticeAttemptSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/practice/{prescription_id}/attempts/from-session/{practice_session_id}",
|
||||
response_model=PracticeAttemptSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def observe_completed_practice_session(
|
||||
prescription_id: str,
|
||||
practice_session_id: UUID,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_runtime_practice_session(
|
||||
principal=principal,
|
||||
prescription_id=prescription_id,
|
||||
practice_session_id=practice_session_id,
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PracticeAttemptSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/practice/attempts/{attempt_record_id}/correction",
|
||||
response_model=PracticeTeacherCorrectionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def correct_practice_attempt(
|
||||
attempt_record_id: UUID,
|
||||
body: PracticeTeacherCorrectionRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> PracticeTeacherCorrectionResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_teacher_correction(
|
||||
principal=principal,
|
||||
attempt_record_id=attempt_record_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PracticeTeacherCorrectionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/practice/learners/me",
|
||||
response_model=DeliberatePracticeReadModelResponse,
|
||||
)
|
||||
async def get_my_deliberate_practice(
|
||||
principal: LearnerPrincipal,
|
||||
) -> DeliberatePracticeReadModelResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return DeliberatePracticeReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/practice/learners/{learner_id}",
|
||||
response_model=DeliberatePracticeReadModelResponse,
|
||||
)
|
||||
async def get_learner_deliberate_practice(
|
||||
learner_id: UUID,
|
||||
principal: TeacherPrincipal,
|
||||
) -> DeliberatePracticeReadModelResponse:
|
||||
try:
|
||||
payload = await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal,
|
||||
learner_id=learner_id,
|
||||
)
|
||||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return DeliberatePracticeReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
210
apps/api/app/routes/measurements.py
Normal file
210
apps/api/app/routes/measurements.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Outcome & Alliance OS measurement routes.
|
||||
|
||||
The route surface keeps the reveal order explicit: a learner must lock a
|
||||
self-assessment before the client-agent and independent-observer jobs are
|
||||
scheduled. Scores are returned as separate goal/task/bond dimensions and are
|
||||
never collapsed into a synthetic total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..contracts.measurement import (
|
||||
AllianceCheckpoint,
|
||||
AllianceDimension,
|
||||
AllianceScores,
|
||||
MeasurementPerspective,
|
||||
MeasurementStatus,
|
||||
SourceKind,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import alliance_measurement
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["measurements"])
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class AlliancePulseCreateRequest(BaseModel):
|
||||
checkpoint: AllianceCheckpoint
|
||||
scores: AllianceScores
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(default=(), max_length=12)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class AlliancePulseAcceptedResponse(BaseModel):
|
||||
pulse_id: UUID
|
||||
status: Literal["awaiting_agents"] = "awaiting_agents"
|
||||
idempotent_replay: bool = False
|
||||
|
||||
|
||||
class AllianceEvidenceTurnResponse(BaseModel):
|
||||
turn_id: UUID
|
||||
seq: int
|
||||
speaker: str
|
||||
text: str
|
||||
|
||||
|
||||
class AllianceMeasurementResponse(BaseModel):
|
||||
measurement_id: UUID
|
||||
dimension: AllianceDimension
|
||||
perspective: MeasurementPerspective
|
||||
source_kind: SourceKind
|
||||
value: float | None = None
|
||||
confidence: float | None = None
|
||||
status: MeasurementStatus
|
||||
error_code: str | None = None
|
||||
rationale: str | None = None
|
||||
evidence: list[AllianceEvidenceTurnResponse] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlliancePulseResponse(BaseModel):
|
||||
pulse_id: UUID
|
||||
checkpoint: AllianceCheckpoint
|
||||
status: Literal["awaiting_agents", "ready", "degraded", "error"]
|
||||
learner_locked_at: datetime
|
||||
revealed_at: datetime | None = None
|
||||
error_code: str | None = None
|
||||
self_scores: AllianceScores
|
||||
measurements: list[AllianceMeasurementResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlliancePulseListResponse(BaseModel):
|
||||
items: list[AlliancePulseResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupervisorAllianceRatingRequest(BaseModel):
|
||||
scores: AllianceScores
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12)
|
||||
note: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
@field_validator("note")
|
||||
@classmethod
|
||||
def strip_note(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("note must not be blank")
|
||||
return stripped
|
||||
|
||||
|
||||
class SupervisorAllianceRatingResponse(BaseModel):
|
||||
status: Literal["recorded"] = "recorded"
|
||||
|
||||
|
||||
def _measurement_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/alliance-pulses",
|
||||
response_model=AlliancePulseAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def create_alliance_pulse(
|
||||
session_id: UUID,
|
||||
body: AlliancePulseCreateRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> AlliancePulseAcceptedResponse:
|
||||
try:
|
||||
result = await alliance_measurement.create_locked_pulse(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
checkpoint=body.checkpoint,
|
||||
scores=body.scores,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
)
|
||||
except (
|
||||
alliance_measurement.AlliancePulseNotFoundError,
|
||||
alliance_measurement.AlliancePulseConflictError,
|
||||
alliance_measurement.AlliancePulseStateError,
|
||||
) as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
|
||||
# The transaction above is committed before either independent agent can
|
||||
# run, so no model perspective can be revealed before learner lock-in.
|
||||
if not result.idempotent_replay:
|
||||
alliance_measurement.schedule_alliance_agents(result.pulse_id)
|
||||
return AlliancePulseAcceptedResponse(
|
||||
pulse_id=result.pulse_id,
|
||||
idempotent_replay=result.idempotent_replay,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/alliance-pulses",
|
||||
response_model=AlliancePulseListResponse,
|
||||
)
|
||||
async def get_alliance_pulses(
|
||||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> AlliancePulseListResponse:
|
||||
try:
|
||||
items = await alliance_measurement.list_alliance_pulses(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except alliance_measurement.AlliancePulseNotFoundError as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
return AlliancePulseListResponse.model_validate({"items": items})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/alliance-pulses/{pulse_id}/supervisor-rating",
|
||||
response_model=SupervisorAllianceRatingResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_supervisor_alliance_rating(
|
||||
session_id: UUID,
|
||||
pulse_id: UUID,
|
||||
body: SupervisorAllianceRatingRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> SupervisorAllianceRatingResponse:
|
||||
try:
|
||||
await alliance_measurement.add_supervisor_rating(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
pulse_id=pulse_id,
|
||||
scores=body.scores,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
note=body.note,
|
||||
)
|
||||
except (
|
||||
alliance_measurement.AlliancePulseNotFoundError,
|
||||
alliance_measurement.AlliancePulseConflictError,
|
||||
alliance_measurement.AlliancePulseStateError,
|
||||
) as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
return SupervisorAllianceRatingResponse()
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
585
apps/api/app/routes/multimodal_alliance.py
Normal file
585
apps/api/app/routes/multimodal_alliance.py
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
"""Typed HTTP boundary for G7 multimodal alliance ledgers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..contracts.multimodal_alliance import (
|
||||
AlignedVoiceTimeline,
|
||||
CalibratedAxisReadModel,
|
||||
FusionCalibration,
|
||||
ModalityAxisMeasurement,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import multimodal_alliance_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["multimodal-alliance"])
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Multimodal-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
|
||||
|
||||
|
||||
def _authenticate_internal(settings: Settings, presented_token: str | None) -> None:
|
||||
configured_token = settings.multimodal_alliance_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal multimodal ingestion is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
|
||||
|
||||
async def multimodal_internal_evaluator_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None,
|
||||
Header(alias=INTERNAL_TOKEN_HEADER),
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Authenticate before acquiring a connection or applying evaluator RLS."""
|
||||
|
||||
_authenticate_internal(settings, presented_token)
|
||||
async for conn in _evaluator_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
InternalDB = Annotated[
|
||||
asyncpg.Connection,
|
||||
Depends(multimodal_internal_evaluator_db),
|
||||
]
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
HumanPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.LEARNER, Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
RawAudioPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.LEARNER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class MultimodalConsentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
consent_status: Literal["granted", "withdrawn", "not_granted"]
|
||||
retain_audio: bool = False
|
||||
retain_derived_features: bool = False
|
||||
transcript_retained: Literal[True] = True
|
||||
retention_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
policy_version: str = Field(min_length=1, max_length=80)
|
||||
reason_code: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def preserve_consent_truth(self) -> "MultimodalConsentRequest":
|
||||
if self.consent_status == "granted":
|
||||
if not self.retain_derived_features or self.retention_days is None:
|
||||
raise ValueError(
|
||||
"granted consent requires derived retention and expiry"
|
||||
)
|
||||
elif (
|
||||
self.retain_audio
|
||||
or self.retain_derived_features
|
||||
or self.retention_days is not None
|
||||
):
|
||||
raise ValueError("ungranted consent cannot retain voice material")
|
||||
return self
|
||||
|
||||
|
||||
class MultimodalConsentResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
consent_snapshot_id: UUID
|
||||
consent_status: Literal["granted", "withdrawn", "not_granted"]
|
||||
deletion_request_id: UUID | None = None
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MultimodalWithdrawalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
policy_version: str = Field(min_length=1, max_length=80)
|
||||
reason_code: str = Field(default="learner_withdrawal", min_length=1, max_length=120)
|
||||
transcript_retained: Literal[True] = True
|
||||
|
||||
|
||||
class AudioAssetMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
audio_ref: str = Field(min_length=1, max_length=300)
|
||||
audio_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
media_type: Literal[
|
||||
"audio/wav", "audio/webm", "audio/ogg", "audio/mpeg", "audio/mp4"
|
||||
]
|
||||
byte_size: int = Field(gt=0, le=524_288_000)
|
||||
|
||||
|
||||
class MultimodalTimelineRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
timeline: AlignedVoiceTimeline
|
||||
audio_asset: AudioAssetMetadata | None = None
|
||||
|
||||
|
||||
class MultimodalTimelineResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
timeline_id: UUID
|
||||
audio_asset_id: UUID | None = None
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MeasurementProvenance(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=80)
|
||||
model_name: str = Field(min_length=1, max_length=160)
|
||||
prompt_version: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class MultimodalMeasurementFusionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
text_measurement: ModalityAxisMeasurement
|
||||
text_provenance: MeasurementProvenance
|
||||
voice_measurement: ModalityAxisMeasurement
|
||||
voice_provenance: MeasurementProvenance
|
||||
calibration: FusionCalibration
|
||||
|
||||
@model_validator(mode="after")
|
||||
def keep_modalities_independent(self) -> "MultimodalMeasurementFusionRequest":
|
||||
if self.text_measurement.modality != "text":
|
||||
raise ValueError("text_measurement must use text modality")
|
||||
if self.voice_measurement.modality != "voice":
|
||||
raise ValueError("voice_measurement must use voice modality")
|
||||
return self
|
||||
|
||||
|
||||
class MultimodalMeasurementFusionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
fusion_record_id: UUID
|
||||
result: CalibratedAxisReadModel
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MultimodalDeletionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
scopes: list[Literal["audio", "derived_features"]] = Field(
|
||||
min_length=1, max_length=2
|
||||
)
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def unique_scopes(cls, value: list[str]) -> list[str]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("deletion scopes must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class MultimodalDeletionRequestResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
deletion_request_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class DeletionTombstoneInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scope: Literal["audio", "derived_features"]
|
||||
target_ref_hash: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
deletion_proof: str = Field(min_length=1, max_length=300)
|
||||
deleted_at: datetime
|
||||
|
||||
|
||||
class MultimodalDeletionCompletionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
actor_uid: UUID | None = None
|
||||
actor_kind: Literal["retention_worker", "admin"]
|
||||
tombstones: list[DeletionTombstoneInput] = Field(min_length=1, max_length=2)
|
||||
|
||||
@field_validator("tombstones")
|
||||
@classmethod
|
||||
def unique_tombstone_scopes(
|
||||
cls, value: list[DeletionTombstoneInput]
|
||||
) -> list[DeletionTombstoneInput]:
|
||||
if len({item.scope for item in value}) != len(value):
|
||||
raise ValueError("tombstone scopes must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class MultimodalDeletionCompletionResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
deletion_request_id: UUID
|
||||
tombstone_ids: list[UUID] = Field(min_length=1)
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MultimodalRetentionSweepRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
limit: int = Field(default=100, ge=1, le=100)
|
||||
|
||||
|
||||
class MultimodalRetentionSweepItem(BaseModel):
|
||||
audio_asset_id: UUID
|
||||
submission_id: UUID
|
||||
deletion_request_id: UUID
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
class MultimodalRetentionSweepResponse(BaseModel):
|
||||
items: list[MultimodalRetentionSweepItem]
|
||||
|
||||
|
||||
class MultimodalSessionMetadataResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
session_id: UUID
|
||||
learner_id: UUID
|
||||
clinical_claim_allowed: Literal[False]
|
||||
consent_snapshots: list[dict[str, Any]]
|
||||
timelines: list[dict[str, Any]]
|
||||
word_timestamps: list[dict[str, Any]]
|
||||
voice_events: list[dict[str, Any]]
|
||||
measurements: list[dict[str, Any]]
|
||||
fusion_decisions: list[dict[str, Any]]
|
||||
deletion_requests: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RawAudioAssetResponse(BaseModel):
|
||||
"""Browser-safe raw-audio metadata; the private storage handle never crosses HTTP."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
audio_asset_id: UUID
|
||||
session_id: UUID
|
||||
media_type: str = Field(min_length=1, max_length=120)
|
||||
byte_size: int = Field(ge=0)
|
||||
duration_ms: int = Field(ge=0)
|
||||
retained_until: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RawAudioAccessResponse(BaseModel):
|
||||
items: list[RawAudioAssetResponse]
|
||||
|
||||
|
||||
def _raw_audio_storage_root(settings: Settings) -> Path:
|
||||
root = Path(settings.user_upload_dir)
|
||||
if not root.is_absolute():
|
||||
root = Path.cwd() / root
|
||||
return (root / "multimodal-audio").resolve()
|
||||
|
||||
|
||||
def _resolve_private_audio_ref(settings: Settings, audio_ref: str) -> Path:
|
||||
"""Resolve a private storage handle without exposing or escaping its root."""
|
||||
|
||||
parsed = urlsplit(audio_ref)
|
||||
if parsed.scheme != "private" or parsed.query or parsed.fragment:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="raw audio storage adapter unavailable",
|
||||
)
|
||||
relative = unquote(f"{parsed.netloc}{parsed.path}").replace("\\", "/").lstrip("/")
|
||||
if not relative:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="raw audio object not found",
|
||||
)
|
||||
root = _raw_audio_storage_root(settings)
|
||||
candidate = (root / relative).resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="raw audio object not found",
|
||||
) from exc
|
||||
if not candidate.is_file():
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="raw audio object not found",
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
multimodal_alliance_store.MultimodalConsentRequiredError,
|
||||
multimodal_alliance_store.MultimodalConsentWithdrawnError,
|
||||
),
|
||||
):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/multimodal-alliance/consent",
|
||||
response_model=MultimodalConsentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_multimodal_consent(
|
||||
session_id: UUID,
|
||||
body: MultimodalConsentRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> MultimodalConsentResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.append_consent_snapshot(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalConsentResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/multimodal-alliance/withdraw",
|
||||
response_model=MultimodalConsentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def withdraw_multimodal_consent(
|
||||
session_id: UUID,
|
||||
body: MultimodalWithdrawalRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> MultimodalConsentResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.append_consent_snapshot(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
consent_status="withdrawn",
|
||||
retain_audio=False,
|
||||
retain_derived_features=False,
|
||||
transcript_retained=body.transcript_retained,
|
||||
retention_days=None,
|
||||
policy_version=body.policy_version,
|
||||
reason_code=body.reason_code,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalConsentResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/multimodal-alliance/timelines",
|
||||
response_model=MultimodalTimelineResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_multimodal_timeline(
|
||||
session_id: UUID,
|
||||
body: MultimodalTimelineRequest,
|
||||
conn: InternalDB,
|
||||
) -> MultimodalTimelineResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.append_timeline(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
timeline=body.timeline,
|
||||
audio_asset=(
|
||||
body.audio_asset.model_dump() if body.audio_asset is not None else None
|
||||
),
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalTimelineResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/multimodal-alliance/measurements",
|
||||
response_model=MultimodalMeasurementFusionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_multimodal_measurement_fusion(
|
||||
session_id: UUID,
|
||||
body: MultimodalMeasurementFusionRequest,
|
||||
conn: InternalDB,
|
||||
) -> MultimodalMeasurementFusionResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.append_measurement_fusion(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
text=body.text_measurement,
|
||||
voice=body.voice_measurement,
|
||||
calibration=body.calibration,
|
||||
text_provenance=body.text_provenance.model_dump(),
|
||||
voice_provenance=body.voice_provenance.model_dump(),
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalMeasurementFusionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/multimodal-alliance/deletion-requests",
|
||||
response_model=MultimodalDeletionRequestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def request_multimodal_deletion(
|
||||
session_id: UUID,
|
||||
body: MultimodalDeletionRequest,
|
||||
principal: RawAudioPrincipal,
|
||||
) -> MultimodalDeletionRequestResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.append_deletion_request(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
scopes=body.scopes,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalDeletionRequestResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/multimodal-alliance/deletion-requests/{deletion_request_id}/complete",
|
||||
response_model=MultimodalDeletionCompletionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def complete_multimodal_deletion(
|
||||
deletion_request_id: UUID,
|
||||
body: MultimodalDeletionCompletionRequest,
|
||||
conn: InternalDB,
|
||||
) -> MultimodalDeletionCompletionResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.complete_deletion(
|
||||
conn=conn,
|
||||
deletion_request_id=deletion_request_id,
|
||||
submission_id=body.submission_id,
|
||||
tombstones=[item.model_dump() for item in body.tombstones],
|
||||
actor_uid=body.actor_uid,
|
||||
actor_kind=body.actor_kind,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalDeletionCompletionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/multimodal-alliance/retention/sweep",
|
||||
response_model=MultimodalRetentionSweepResponse,
|
||||
)
|
||||
async def sweep_multimodal_retention(
|
||||
body: MultimodalRetentionSweepRequest,
|
||||
conn: InternalDB,
|
||||
) -> MultimodalRetentionSweepResponse:
|
||||
try:
|
||||
items = await multimodal_alliance_store.request_expired_retention_deletions(
|
||||
conn=conn,
|
||||
as_of=datetime.now(UTC),
|
||||
limit=body.limit,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalRetentionSweepResponse.model_validate({"items": items})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/multimodal-alliance",
|
||||
response_model=MultimodalSessionMetadataResponse,
|
||||
)
|
||||
async def get_multimodal_session_metadata(
|
||||
session_id: UUID,
|
||||
principal: HumanPrincipal,
|
||||
) -> MultimodalSessionMetadataResponse:
|
||||
try:
|
||||
payload = await multimodal_alliance_store.read_session_metadata(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return MultimodalSessionMetadataResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/multimodal-alliance/raw-audio",
|
||||
response_model=RawAudioAccessResponse,
|
||||
)
|
||||
async def get_multimodal_raw_audio_access(
|
||||
session_id: UUID,
|
||||
principal: RawAudioPrincipal,
|
||||
) -> RawAudioAccessResponse:
|
||||
try:
|
||||
items = await multimodal_alliance_store.read_raw_audio_access(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return RawAudioAccessResponse(items=items)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/multimodal-alliance/raw-audio/{audio_asset_id}",
|
||||
response_class=FileResponse,
|
||||
)
|
||||
async def play_multimodal_raw_audio(
|
||||
session_id: UUID,
|
||||
audio_asset_id: UUID,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
principal: RawAudioPrincipal,
|
||||
) -> FileResponse:
|
||||
"""Stream a retained object through the authenticated API; never reveal its handle."""
|
||||
|
||||
try:
|
||||
asset = await multimodal_alliance_store.read_raw_audio_asset(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
audio_asset_id=audio_asset_id,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
audio_path = _resolve_private_audio_ref(settings, str(asset["audio_ref"]))
|
||||
return FileResponse(
|
||||
path=audio_path,
|
||||
media_type=str(asset["media_type"]),
|
||||
headers={
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Disposition": "inline",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
291
apps/api/app/routes/outcome_trajectories.py
Normal file
291
apps/api/app/routes/outcome_trajectories.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""HTTP boundary for G2 educational longitudinal outcome trajectories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..contracts.measurement import MeasurementPerspective, SourceKind
|
||||
from ..contracts.outcome_trajectory import (
|
||||
LongitudinalOutcomeAssessment,
|
||||
OutcomeAxis,
|
||||
RelationshipEventType,
|
||||
RelationshipMemoryProjection,
|
||||
SafetySignalReference,
|
||||
SyntheticExpectedDistribution,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import outcome_trajectory_store
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["outcome-trajectories"])
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class ExpectedArcLabelResponse(BaseModel):
|
||||
schema_version: Literal["vignette.synthetic-outcome-arc.v1"]
|
||||
arc_id: str
|
||||
title_ko: str
|
||||
data_classification: Literal["synthetic_educational"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
provenance_note: str
|
||||
session_count: Literal[5] = 5
|
||||
distributions: list[SyntheticExpectedDistribution] = Field(
|
||||
min_length=15, max_length=15
|
||||
)
|
||||
|
||||
|
||||
class OutcomeObservationResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
measurement_id: UUID | None = None
|
||||
session_id: UUID
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
axis: OutcomeAxis
|
||||
status: Literal["observed", "missing", "error"]
|
||||
value: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
raw_value: float | None = None
|
||||
scale_min: float | None = None
|
||||
scale_max: float | None = None
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
source_kind: SourceKind
|
||||
perspective: MeasurementPerspective
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
model_run_id: UUID | None = None
|
||||
evidence_refs: list[str] = Field(default_factory=list)
|
||||
missing_reason: str | None = None
|
||||
occurred_at: datetime | None = None
|
||||
|
||||
|
||||
class OutcomeTrajectoryResponse(BaseModel):
|
||||
session_id: UUID
|
||||
revision_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
supersedes_revision_id: UUID | None = None
|
||||
source_fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
recompute_reason: str
|
||||
computed_at: datetime
|
||||
notice_ko: str
|
||||
expected_arc: ExpectedArcLabelResponse
|
||||
assessment: LongitudinalOutcomeAssessment
|
||||
next_questions: list[str] = Field(default_factory=list)
|
||||
observations: list[OutcomeObservationResponse]
|
||||
safety_signals: list[SafetySignalReference] = Field(default_factory=list)
|
||||
relationship_memory: list[RelationshipMemoryProjection] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OutcomeTrajectoryRecomputeRequest(BaseModel):
|
||||
reason: str = Field(default="manual_recompute", min_length=1, max_length=300)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("reason must not be blank")
|
||||
return stripped
|
||||
|
||||
|
||||
class OutcomeAxisValues(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
distress_load: float = Field(ge=0.0, le=1.0)
|
||||
daily_functioning: float = Field(ge=0.0, le=1.0)
|
||||
learning_engagement: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class OutcomeObservationSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
scores: OutcomeAxisValues
|
||||
confidences: OutcomeAxisValues
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(default=(), max_length=12)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class OutcomeObservationSubmissionResponse(OutcomeTrajectoryResponse):
|
||||
submission_id: UUID
|
||||
submitted_measurement_ids: list[UUID] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
RelationshipView = Literal[
|
||||
"client", "counselor", "evaluator", "supervisor", "research"
|
||||
]
|
||||
|
||||
|
||||
class RelationshipMemoryCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: RelationshipEventType
|
||||
summaries: dict[RelationshipView, str] = Field(min_length=1, max_length=5)
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12)
|
||||
resolves_event_id: UUID | None = None
|
||||
|
||||
@field_validator("summaries")
|
||||
@classmethod
|
||||
def normalize_summaries(
|
||||
cls, value: dict[RelationshipView, str]
|
||||
) -> dict[RelationshipView, str]:
|
||||
normalized = {view: summary.strip() for view, summary in value.items()}
|
||||
if any(not summary for summary in normalized.values()):
|
||||
raise ValueError("relationship summaries must not be blank")
|
||||
return normalized
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_relationship_evidence(
|
||||
cls, value: tuple[UUID, ...]
|
||||
) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_explicit_repair_target(self) -> "RelationshipMemoryCreateRequest":
|
||||
if self.event_type == "repair_confirmed" and self.resolves_event_id is None:
|
||||
raise ValueError("repair_confirmed requires resolves_event_id")
|
||||
if self.event_type != "repair_confirmed" and self.resolves_event_id is not None:
|
||||
raise ValueError("only repair_confirmed can resolve a relationship event")
|
||||
return self
|
||||
|
||||
|
||||
class RelationshipMemoryCreateResponse(BaseModel):
|
||||
memory_event_id: UUID
|
||||
status: Literal["recorded"] = "recorded"
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/outcome-trajectory",
|
||||
response_model=OutcomeTrajectoryResponse,
|
||||
)
|
||||
async def get_outcome_trajectory(
|
||||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeTrajectoryResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/outcome-trajectory/recompute",
|
||||
response_model=OutcomeTrajectoryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def recompute_outcome_trajectory(
|
||||
session_id: UUID,
|
||||
body: OutcomeTrajectoryRecomputeRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
force_recompute=True,
|
||||
recompute_reason=body.reason,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeTrajectoryResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/outcome-observations",
|
||||
response_model=OutcomeObservationSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_outcome_observations(
|
||||
session_id: UUID,
|
||||
body: OutcomeObservationSubmissionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> OutcomeObservationSubmissionResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.submit_outcome_observations(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
scores=body.scores.model_dump(),
|
||||
confidences=body.confidences.model_dump(),
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeObservationSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/relationship-memory-events",
|
||||
response_model=RelationshipMemoryCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_relationship_memory_event(
|
||||
session_id: UUID,
|
||||
body: RelationshipMemoryCreateRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> RelationshipMemoryCreateResponse:
|
||||
try:
|
||||
memory_event_id = (
|
||||
await outcome_trajectory_store.append_relationship_memory_event(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
event_type=body.event_type,
|
||||
summaries=body.summaries,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
resolves_event_id=body.resolves_event_id,
|
||||
)
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return RelationshipMemoryCreateResponse(memory_event_id=memory_event_id)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -169,7 +169,7 @@ async def _record_persona_raw_source_artifact(
|
|||
f"{doc_uri}.raw",
|
||||
content_hash,
|
||||
license_class,
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
summary,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
457
apps/api/app/routes/rupture_repairs.py
Normal file
457
apps/api/app/routes/rupture_repairs.py
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
"""Typed HTTP boundary for G3 rupture/repair ledgers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..contracts.rupture_repair import RuptureLifecycleState, RuptureType
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, CurrentPrincipal, db_for_ai_view
|
||||
from ..services import rupture_repair_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["rupture-repairs"])
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Rupture-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
|
||||
|
||||
|
||||
async def rupture_internal_evaluator_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None,
|
||||
Header(alias=INTERNAL_TOKEN_HEADER),
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Authenticate before acquiring any evaluator-view DB connection."""
|
||||
|
||||
configured_token = settings.rupture_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal rupture ingestion is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
|
||||
async for conn in _evaluator_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
EvaluatorDB = Annotated[
|
||||
asyncpg.Connection,
|
||||
Depends(rupture_internal_evaluator_db),
|
||||
]
|
||||
|
||||
|
||||
class RuptureObservationResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
observation_id: UUID
|
||||
episode_id: UUID
|
||||
sequence_no: int = Field(ge=1)
|
||||
event_kind: Literal[
|
||||
"rupture.detected",
|
||||
"rupture.recognized",
|
||||
"rupture.missed",
|
||||
"repair.attempted",
|
||||
"repair.partial",
|
||||
"repair.resolved",
|
||||
"repair.missed",
|
||||
"human.corrected",
|
||||
]
|
||||
from_state: RuptureLifecycleState | None = None
|
||||
to_state: RuptureLifecycleState
|
||||
rupture_type: RuptureType
|
||||
source_kind: Literal["model_inferred", "observed_runtime", "human_rated"]
|
||||
perspective: Literal[
|
||||
"independent_observer", "runtime_observation", "supervisor_human"
|
||||
]
|
||||
ai_view: Literal["evaluator", "supervisor"]
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(min_length=1)
|
||||
counterevidence: list[str] = Field(default_factory=list)
|
||||
model_run_id: UUID | None = None
|
||||
supersedes_observation_id: UUID | None = None
|
||||
correction_reason: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RuptureReconciliationResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
revision_id: UUID
|
||||
episode_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
supersedes_revision_id: UUID | None = None
|
||||
fast_warning_observation_id: UUID
|
||||
deep_observation_id: UUID | None = None
|
||||
fast_warning_id: str
|
||||
provisional_status: Literal["missed", "partial"]
|
||||
deep_status: Literal[
|
||||
"missed",
|
||||
"partial",
|
||||
"resolved",
|
||||
"not_applicable",
|
||||
"insufficient_evidence",
|
||||
]
|
||||
disposition: Literal[
|
||||
"confirmed", "superseded_resolved", "superseded_partial", "dismissed"
|
||||
]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list)
|
||||
counterevidence: list[str] = Field(default_factory=list)
|
||||
model_run_id: UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RuptureSafetyReferenceResponse(BaseModel):
|
||||
episode_id: UUID
|
||||
safety_event_id: int
|
||||
turn_id: UUID | None = None
|
||||
ko_risk_level: int | None = None
|
||||
escalated: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RuptureEpisodeResponse(BaseModel):
|
||||
episode_id: UUID
|
||||
session_id: UUID
|
||||
case_id: UUID
|
||||
learner_id: UUID
|
||||
episode_key: str
|
||||
created_at: datetime
|
||||
rupture_type: RuptureType | None = None
|
||||
current_status: Literal[
|
||||
"onset",
|
||||
"recognized",
|
||||
"repair_attempted",
|
||||
"missed",
|
||||
"partial",
|
||||
"resolved",
|
||||
"not_applicable",
|
||||
"insufficient_evidence",
|
||||
] | None = None
|
||||
status_source: Literal[
|
||||
"lifecycle_event", "deep_reconciliation", "human_correction"
|
||||
]
|
||||
observations: list[RuptureObservationResponse] = Field(default_factory=list)
|
||||
reconciliation_revisions: list[RuptureReconciliationResponse] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
safety_references: list[RuptureSafetyReferenceResponse] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class RuptureRepairReadModelResponse(BaseModel):
|
||||
session_id: UUID
|
||||
requested_view: Literal["counselor", "supervisor"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
episodes: list[RuptureEpisodeResponse]
|
||||
|
||||
|
||||
class InternalRuptureObservationRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
episode_key: str = Field(min_length=1, max_length=180)
|
||||
idempotency_key: UUID
|
||||
event_kind: Literal[
|
||||
"rupture.detected",
|
||||
"rupture.recognized",
|
||||
"rupture.missed",
|
||||
"repair.attempted",
|
||||
"repair.partial",
|
||||
"repair.resolved",
|
||||
"repair.missed",
|
||||
]
|
||||
from_state: RuptureLifecycleState | None = None
|
||||
to_state: RuptureLifecycleState
|
||||
rupture_type: RuptureType
|
||||
source_kind: Literal["model_inferred", "observed_runtime"]
|
||||
perspective: Literal["independent_observer", "runtime_observation"]
|
||||
ai_view: Literal["evaluator"]
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(min_length=1)
|
||||
counterevidence: list[str] = Field(default_factory=list)
|
||||
model_run_id: UUID | None = None
|
||||
safety_event_ids: list[int] = Field(default_factory=list)
|
||||
visible_to: list[
|
||||
Literal["counselor", "evaluator", "supervisor", "research"]
|
||||
] = Field(
|
||||
default_factory=lambda: [
|
||||
"counselor",
|
||||
"evaluator",
|
||||
"supervisor",
|
||||
"research",
|
||||
],
|
||||
min_length=1,
|
||||
)
|
||||
|
||||
@field_validator("episode_key")
|
||||
@classmethod
|
||||
def strip_episode_key(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("episode_key must not be blank")
|
||||
return stripped
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_provenance_pair(self) -> "InternalRuptureObservationRequest":
|
||||
if self.source_kind == "model_inferred":
|
||||
if self.perspective != "independent_observer" or self.model_run_id is None:
|
||||
raise ValueError(
|
||||
"model_inferred requires independent_observer and model_run_id"
|
||||
)
|
||||
elif self.perspective != "runtime_observation":
|
||||
raise ValueError(
|
||||
"observed_runtime requires runtime_observation perspective"
|
||||
)
|
||||
if len(set(self.evidence_turn_ids)) != len(self.evidence_turn_ids):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
if len(set(self.safety_event_ids)) != len(self.safety_event_ids):
|
||||
raise ValueError("safety_event_ids must be unique")
|
||||
if len(set(self.visible_to)) != len(self.visible_to):
|
||||
raise ValueError("visible_to must be unique")
|
||||
if "evaluator" not in self.visible_to:
|
||||
raise ValueError("visible_to must include evaluator")
|
||||
return self
|
||||
|
||||
|
||||
class InternalRuptureObservationResponse(BaseModel):
|
||||
episode_id: UUID
|
||||
observation_id: UUID
|
||||
|
||||
|
||||
class InternalReconciliationRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
idempotency_key: UUID
|
||||
fast_warning_observation_id: UUID
|
||||
deep_observation_id: UUID | None = None
|
||||
fast_warning_id: str = Field(min_length=1, max_length=180)
|
||||
provisional_status: Literal["missed", "partial"]
|
||||
deep_status: Literal[
|
||||
"missed",
|
||||
"partial",
|
||||
"resolved",
|
||||
"not_applicable",
|
||||
"insufficient_evidence",
|
||||
]
|
||||
disposition: Literal[
|
||||
"confirmed", "superseded_resolved", "superseded_partial", "dismissed"
|
||||
]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(default_factory=list)
|
||||
counterevidence: list[str] = Field(default_factory=list)
|
||||
model_run_id: UUID
|
||||
ai_view: Literal["evaluator"]
|
||||
visible_to: list[
|
||||
Literal["counselor", "evaluator", "supervisor", "research"]
|
||||
] = Field(
|
||||
default_factory=lambda: [
|
||||
"counselor",
|
||||
"evaluator",
|
||||
"supervisor",
|
||||
"research",
|
||||
],
|
||||
min_length=1,
|
||||
)
|
||||
|
||||
@field_validator("fast_warning_id")
|
||||
@classmethod
|
||||
def strip_warning_id(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("fast_warning_id must not be blank")
|
||||
return stripped
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_disposition(self) -> "InternalReconciliationRequest":
|
||||
valid = (
|
||||
(self.disposition == "confirmed" and self.deep_status == self.provisional_status)
|
||||
or (self.disposition == "superseded_resolved" and self.deep_status == "resolved")
|
||||
or (self.disposition == "superseded_partial" and self.deep_status == "partial")
|
||||
or (self.disposition == "dismissed" and self.deep_status == "not_applicable")
|
||||
)
|
||||
if not valid:
|
||||
raise ValueError("reconciliation disposition does not match deep_status")
|
||||
if len(set(self.evidence_turn_ids)) != len(self.evidence_turn_ids):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
if len(set(self.visible_to)) != len(self.visible_to):
|
||||
raise ValueError("visible_to must be unique")
|
||||
if "evaluator" not in self.visible_to:
|
||||
raise ValueError("visible_to must include evaluator")
|
||||
return self
|
||||
|
||||
|
||||
class InternalReconciliationResponse(BaseModel):
|
||||
episode_id: UUID
|
||||
revision_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
|
||||
|
||||
class HumanRuptureCorrectionRequest(BaseModel):
|
||||
idempotency_key: UUID
|
||||
supersedes_observation_id: UUID
|
||||
rupture_type: RuptureType
|
||||
corrected_status: Literal["missed", "partial", "resolved"]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
evidence_turn_ids: list[UUID] = Field(min_length=1)
|
||||
counterevidence: list[str] = Field(default_factory=list)
|
||||
correction_reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
@field_validator("correction_reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("correction_reason must not be blank")
|
||||
return stripped
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence(cls, value: list[UUID]) -> list[UUID]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class HumanRuptureCorrectionResponse(BaseModel):
|
||||
episode_id: UUID
|
||||
observation_id: UUID
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, rupture_repair_store.RuptureRepairNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, rupture_repair_store.RuptureRepairConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, rupture_repair_store.RuptureRepairStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/ruptures",
|
||||
response_model=RuptureRepairReadModelResponse,
|
||||
)
|
||||
async def get_rupture_repairs(
|
||||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> RuptureRepairReadModelResponse:
|
||||
try:
|
||||
payload = await rupture_repair_store.read_rupture_repairs(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except (
|
||||
rupture_repair_store.RuptureRepairNotFoundError,
|
||||
rupture_repair_store.RuptureRepairConflictError,
|
||||
rupture_repair_store.RuptureRepairStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return RuptureRepairReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/ruptures/observations",
|
||||
response_model=InternalRuptureObservationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_internal_rupture_observation(
|
||||
session_id: UUID,
|
||||
body: InternalRuptureObservationRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> InternalRuptureObservationResponse:
|
||||
try:
|
||||
payload = await rupture_repair_store.append_evaluator_observation(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except (
|
||||
rupture_repair_store.RuptureRepairNotFoundError,
|
||||
rupture_repair_store.RuptureRepairConflictError,
|
||||
rupture_repair_store.RuptureRepairStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return InternalRuptureObservationResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/sessions/{session_id}/ruptures/{episode_id}/reconciliations",
|
||||
response_model=InternalReconciliationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_internal_reconciliation(
|
||||
session_id: UUID,
|
||||
episode_id: UUID,
|
||||
body: InternalReconciliationRequest,
|
||||
conn: EvaluatorDB,
|
||||
) -> InternalReconciliationResponse:
|
||||
try:
|
||||
payload = await rupture_repair_store.append_reconciliation_revision(
|
||||
conn=conn,
|
||||
session_id=session_id,
|
||||
episode_id=episode_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except (
|
||||
rupture_repair_store.RuptureRepairNotFoundError,
|
||||
rupture_repair_store.RuptureRepairConflictError,
|
||||
rupture_repair_store.RuptureRepairStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return InternalReconciliationResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/ruptures/{episode_id}/corrections",
|
||||
response_model=HumanRuptureCorrectionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_human_rupture_correction(
|
||||
session_id: UUID,
|
||||
episode_id: UUID,
|
||||
body: HumanRuptureCorrectionRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> HumanRuptureCorrectionResponse:
|
||||
try:
|
||||
observation_id = await rupture_repair_store.append_human_correction(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
episode_id=episode_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
except (
|
||||
rupture_repair_store.RuptureRepairNotFoundError,
|
||||
rupture_repair_store.RuptureRepairConflictError,
|
||||
rupture_repair_store.RuptureRepairStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return HumanRuptureCorrectionResponse(
|
||||
episode_id=episode_id,
|
||||
observation_id=observation_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -36,7 +36,10 @@ from ..services import (
|
|||
notifications,
|
||||
orchestrator,
|
||||
rag,
|
||||
rupture_runtime,
|
||||
rupture_scenario_director,
|
||||
session_digest_worker,
|
||||
session_learning_producer,
|
||||
state_machine,
|
||||
)
|
||||
from ..session_read_model import (
|
||||
|
|
@ -79,6 +82,11 @@ _SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
|
|||
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
|
||||
_STREAM_TURN_EVALUATION_TASKS: set[asyncio.Task[None]] = set()
|
||||
|
||||
# Text, SSE, and voice all call this module function after both durable turn UUIDs
|
||||
# exist. Install once here (main imports sessions before voice) so no route can miss
|
||||
# the same-process evaluator background boundary.
|
||||
rupture_runtime.install_turn_finalize_hook(turn_runtime)
|
||||
|
||||
TheoryMode = Literal["humanistic", "cbt", "integrative"]
|
||||
EndStateValue = str | int | float | bool | None | dict[str, float]
|
||||
|
||||
|
|
@ -502,6 +510,12 @@ async def _prepare_turn_context(
|
|||
kb_cues = (
|
||||
_KB_CUES_CACHE.get(session_id) or []
|
||||
) # 비차단: warm 전이면 빈 단서(graceful)
|
||||
scenario_context = (
|
||||
await rupture_scenario_director.load_stored_scenario_context(
|
||||
session_id=session_id,
|
||||
case_id=sess.case_id,
|
||||
)
|
||||
)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
session_id=session_id,
|
||||
case_id=sess.case_id,
|
||||
|
|
@ -515,6 +529,7 @@ async def _prepare_turn_context(
|
|||
kb_behavior_cues=kb_cues,
|
||||
),
|
||||
theory_mode=sess.theory_mode,
|
||||
scenario_context=scenario_context,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
return ctx
|
||||
|
|
@ -816,6 +831,10 @@ async def _evaluate_and_persist_stream_turn(
|
|||
|
||||
result.evaluation = evaluation
|
||||
await turn_runtime.maybe_recharge_live_coach_credit(sess, ctx, result)
|
||||
rupture_runtime.schedule_session_scan(
|
||||
ctx.session_id,
|
||||
trigger="fast_evaluation_persisted",
|
||||
)
|
||||
|
||||
|
||||
def _observe_stream_turn_evaluation_task(task: asyncio.Task[None]) -> None:
|
||||
|
|
@ -915,6 +934,18 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
write.status,
|
||||
write.scope,
|
||||
)
|
||||
if saved and write.status == "ready":
|
||||
try:
|
||||
await session_learning_producer.produce_session_learning_artifacts(
|
||||
sess.session_id
|
||||
)
|
||||
except Exception:
|
||||
# 평가 원장은 이미 커밋됐다. 후속 학습 원장 장애가 ready 평가를
|
||||
# error로 덮어쓰거나 알림 생성을 막아서는 안 된다.
|
||||
logger.exception(
|
||||
"session learning artifacts failed after evaluation save: session_id=%s",
|
||||
sess.session_id,
|
||||
)
|
||||
if saved:
|
||||
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||
except asyncio.TimeoutError:
|
||||
|
|
@ -1828,6 +1859,7 @@ async def end_session(
|
|||
|
||||
await _end_persisted_session(sess, carry)
|
||||
invalidate_session_context_cache(session_id)
|
||||
rupture_runtime.schedule_session_scan(session_id, trigger="session_ended")
|
||||
if not was_ended:
|
||||
_schedule_session_evaluation(sess)
|
||||
|
||||
|
|
|
|||
483
apps/api/app/routes/supervision_research.py
Normal file
483
apps/api/app/routes/supervision_research.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"""Standalone typed HTTP boundary for G6 Supervision & Research OS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from .. import db
|
||||
from ..config import Settings, get_settings
|
||||
from ..contracts.supervision_research import (
|
||||
EvaluationVersionBatch,
|
||||
LedgerEvidencePointer,
|
||||
LearnerAttentionSignal,
|
||||
Phase3EvidenceArtifact,
|
||||
TeacherAiDisagreement,
|
||||
)
|
||||
from ..deps import HumanDB, Principal, Role, require_role
|
||||
from ..services import supervision_research_producer, supervision_research_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["supervision-research"])
|
||||
INTERNAL_TOKEN_HEADER = "X-Vignette-Supervision-Research-Token"
|
||||
MIN_INTERNAL_TOKEN_LENGTH = 32
|
||||
|
||||
|
||||
async def _supervisor_db_provider() -> AsyncIterator[asyncpg.Connection]:
|
||||
async with db.acquire(ai_view="supervisor", ai_context=True) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
async def _research_db_provider() -> AsyncIterator[asyncpg.Connection]:
|
||||
async with db.acquire(ai_view="research", ai_context=True) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def _authenticate_internal(settings: Settings, presented_token: str | None) -> None:
|
||||
configured_token = settings.supervision_research_internal_token.get_secret_value()
|
||||
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal supervision research ingestion is unavailable",
|
||||
)
|
||||
if presented_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="internal authentication required",
|
||||
)
|
||||
if not secrets.compare_digest(presented_token, configured_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="internal authentication failed",
|
||||
)
|
||||
|
||||
|
||||
async def supervision_research_internal_supervisor_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None, Header(alias=INTERNAL_TOKEN_HEADER)
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Authenticate before acquiring a supervisor-view connection."""
|
||||
|
||||
_authenticate_internal(settings, presented_token)
|
||||
async for conn in _supervisor_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
async def supervision_research_internal_research_db(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
presented_token: Annotated[
|
||||
str | None, Header(alias=INTERNAL_TOKEN_HEADER)
|
||||
] = None,
|
||||
) -> AsyncIterator[asyncpg.Connection]:
|
||||
"""Authenticate before acquiring a research-view connection."""
|
||||
|
||||
_authenticate_internal(settings, presented_token)
|
||||
async for conn in _research_db_provider():
|
||||
yield conn
|
||||
|
||||
|
||||
SupervisorDB = Annotated[
|
||||
asyncpg.Connection, Depends(supervision_research_internal_supervisor_db)
|
||||
]
|
||||
ResearchDB = Annotated[
|
||||
asyncpg.Connection, Depends(supervision_research_internal_research_db)
|
||||
]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))
|
||||
]
|
||||
|
||||
|
||||
class LearnerRefMapping(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
learner_ref: str = Field(pattern=r"^learner-[a-z0-9-]+$")
|
||||
learner_id: UUID
|
||||
|
||||
|
||||
class AttentionSnapshotRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
snapshot_id: UUID
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
signals: list[LearnerAttentionSignal] = Field(min_length=1, max_length=1000)
|
||||
learners: list[LearnerRefMapping] = Field(min_length=1, max_length=1000)
|
||||
|
||||
@field_validator("learners")
|
||||
@classmethod
|
||||
def unique_learner_mapping(
|
||||
cls, value: list[LearnerRefMapping]
|
||||
) -> list[LearnerRefMapping]:
|
||||
refs = [item.learner_ref for item in value]
|
||||
ids = [item.learner_id for item in value]
|
||||
if len(refs) != len(set(refs)) or len(ids) != len(set(ids)):
|
||||
raise ValueError("attention learner mappings must be one-to-one")
|
||||
return value
|
||||
|
||||
|
||||
class AttentionSnapshotResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
snapshot_id: UUID
|
||||
item_count: int = Field(ge=1)
|
||||
idempotent_replay: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class ScopedEvidence(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
learner_id: UUID
|
||||
pointer: LedgerEvidencePointer
|
||||
|
||||
|
||||
class CurriculumGapRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
gap_snapshot_id: UUID
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
|
||||
gap_kind: Literal[
|
||||
"coverage", "growth_stagnation", "rupture_repair", "transfer", "calibration"
|
||||
]
|
||||
status: Literal["observed", "monitoring", "insufficient_evidence"]
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
affected_learner_count: int = Field(ge=0)
|
||||
evidence: list[ScopedEvidence] = Field(default_factory=list, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def preserve_insufficient_state(self) -> "CurriculumGapRequest":
|
||||
if self.status == "insufficient_evidence":
|
||||
if self.evidence or self.uncertainty != 1.0:
|
||||
raise ValueError("insufficient curriculum gap must remain evidence-free")
|
||||
elif not self.evidence:
|
||||
raise ValueError("classified curriculum gap requires evidence")
|
||||
return self
|
||||
|
||||
|
||||
class CurriculumGapResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
gap_snapshot_id: UUID
|
||||
idempotent_replay: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class TeacherDisagreementRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
disagreement_record_id: UUID
|
||||
dataset_row_id: UUID
|
||||
audit_event_id: UUID
|
||||
learner_id: UUID
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
disagreement: TeacherAiDisagreement
|
||||
|
||||
|
||||
class TeacherDisagreementResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
disagreement_record_id: UUID
|
||||
dataset_row_hash: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
idempotent_replay: bool
|
||||
raw_transcript_included: Literal[False] = False
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class EvaluationEvidenceMapping(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
evidence_event_id: str = Field(min_length=1, max_length=180)
|
||||
learner_id: UUID
|
||||
pointer: LedgerEvidencePointer
|
||||
|
||||
@model_validator(mode="after")
|
||||
def event_ids_match(self) -> "EvaluationEvidenceMapping":
|
||||
if self.evidence_event_id != self.pointer.event_id:
|
||||
raise ValueError("evaluation evidence event id must match pointer")
|
||||
return self
|
||||
|
||||
|
||||
class EvaluationComparisonRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
drift_report_id: UUID
|
||||
baseline_submission_id: UUID
|
||||
baseline_batch_record_id: UUID
|
||||
candidate_submission_id: UUID
|
||||
candidate_batch_record_id: UUID
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
baseline: EvaluationVersionBatch
|
||||
candidate: EvaluationVersionBatch
|
||||
evidence: list[EvaluationEvidenceMapping] = Field(min_length=1, max_length=5000)
|
||||
|
||||
@field_validator("evidence")
|
||||
@classmethod
|
||||
def unique_evaluation_evidence(
|
||||
cls, value: list[EvaluationEvidenceMapping]
|
||||
) -> list[EvaluationEvidenceMapping]:
|
||||
keys = [item.evidence_event_id for item in value]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("evaluation evidence mappings must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class EvaluationComparisonResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
drift_report_id: UUID
|
||||
status: Literal["stable", "drift_flagged", "insufficient_evidence"]
|
||||
matched_count: int = Field(ge=0)
|
||||
idempotent_replay: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class ManifestSourceMapping(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
domain: Literal["alliance", "rupture", "transfer", "calibration"]
|
||||
learner_id: UUID
|
||||
pointer: LedgerEvidencePointer
|
||||
|
||||
|
||||
class Phase3ManifestRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
submission_id: UUID
|
||||
manifest_id: UUID
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
artifacts: list[Phase3EvidenceArtifact] = Field(min_length=4, max_length=4)
|
||||
sources: list[ManifestSourceMapping] = Field(min_length=4, max_length=4)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def complete_source_domains(self) -> "Phase3ManifestRequest":
|
||||
source_domains = [item.domain for item in self.sources]
|
||||
artifact_domains = [item.domain for item in self.artifacts]
|
||||
if len(set(source_domains)) != 4 or set(source_domains) != set(artifact_domains):
|
||||
raise ValueError("manifest sources must map all four unique domains")
|
||||
return self
|
||||
|
||||
|
||||
class Phase3ManifestResponse(BaseModel):
|
||||
submission_id: UUID
|
||||
manifest_id: UUID
|
||||
artifact_count: Literal[4]
|
||||
idempotent_replay: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
class DerivedCycleRequest(BaseModel):
|
||||
"""호출자는 범위만 고르고, 신호·격차·manifest는 원장에서 파생한다."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
cohort_id: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class DerivedCycleResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
cohort_id: str
|
||||
derived_signal_count: int = Field(ge=0)
|
||||
attention_snapshot: dict[str, object] | None = None
|
||||
curriculum_gaps: list[dict[str, object]]
|
||||
phase3_manifest: dict[str, object] | None = None
|
||||
raw_transcript_included: Literal[False] = False
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
|
||||
|
||||
def _raise_store_error(exc: Exception) -> None:
|
||||
if isinstance(exc, supervision_research_store.SupervisionResearchConflictError):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, supervision_research_store.SupervisionResearchNotFoundError):
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/supervision-research/derive-cycle",
|
||||
response_model=DerivedCycleResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def derive_supervision_cycle(
|
||||
request: DerivedCycleRequest,
|
||||
conn: SupervisorDB,
|
||||
) -> DerivedCycleResponse:
|
||||
try:
|
||||
result = await supervision_research_producer.produce_supervision_cycle(
|
||||
conn,
|
||||
cohort_id=request.cohort_id,
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return DerivedCycleResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/supervision-research/attention-snapshots",
|
||||
response_model=AttentionSnapshotResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_attention_snapshot(
|
||||
request: AttentionSnapshotRequest, conn: SupervisorDB
|
||||
) -> AttentionSnapshotResponse:
|
||||
try:
|
||||
result = await supervision_research_store.append_attention_snapshot(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
snapshot_id=request.snapshot_id,
|
||||
cohort_id=request.cohort_id,
|
||||
signals=request.signals,
|
||||
learner_ids_by_ref={item.learner_ref: item.learner_id for item in request.learners},
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return AttentionSnapshotResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/supervision-research/curriculum-gaps",
|
||||
response_model=CurriculumGapResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_curriculum_gap(
|
||||
request: CurriculumGapRequest, conn: SupervisorDB
|
||||
) -> CurriculumGapResponse:
|
||||
try:
|
||||
result = await supervision_research_store.append_curriculum_gap(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
gap_snapshot_id=request.gap_snapshot_id,
|
||||
cohort_id=request.cohort_id,
|
||||
competency_id=request.competency_id,
|
||||
gap_kind=request.gap_kind,
|
||||
status=request.status,
|
||||
uncertainty=request.uncertainty,
|
||||
affected_learner_count=request.affected_learner_count,
|
||||
evidence=[(item.learner_id, item.pointer) for item in request.evidence],
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return CurriculumGapResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/supervision-research/teacher-disagreements",
|
||||
response_model=TeacherDisagreementResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_teacher_disagreement(
|
||||
request: TeacherDisagreementRequest,
|
||||
principal: TeacherPrincipal,
|
||||
conn: HumanDB,
|
||||
) -> TeacherDisagreementResponse:
|
||||
try:
|
||||
result = await supervision_research_store.append_teacher_disagreement(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
disagreement_record_id=request.disagreement_record_id,
|
||||
dataset_row_id=request.dataset_row_id,
|
||||
audit_event_id=request.audit_event_id,
|
||||
learner_id=request.learner_id,
|
||||
cohort_id=request.cohort_id,
|
||||
actor_uid=UUID(principal.user_id),
|
||||
disagreement=request.disagreement,
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return TeacherDisagreementResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/supervision-research/evaluation-comparisons",
|
||||
response_model=EvaluationComparisonResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_evaluation_comparison(
|
||||
request: EvaluationComparisonRequest, conn: ResearchDB
|
||||
) -> EvaluationComparisonResponse:
|
||||
pointer_map = {item.evidence_event_id: item.pointer for item in request.evidence}
|
||||
learner_map = {item.evidence_event_id: item.learner_id for item in request.evidence}
|
||||
try:
|
||||
result = await supervision_research_store.append_evaluation_comparison(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
drift_report_id=request.drift_report_id,
|
||||
baseline_submission_id=request.baseline_submission_id,
|
||||
baseline_batch_record_id=request.baseline_batch_record_id,
|
||||
candidate_submission_id=request.candidate_submission_id,
|
||||
candidate_batch_record_id=request.candidate_batch_record_id,
|
||||
cohort_id=request.cohort_id,
|
||||
baseline=request.baseline,
|
||||
candidate=request.candidate,
|
||||
pointers_by_event_id=pointer_map,
|
||||
learner_ids_by_event_id=learner_map,
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return EvaluationComparisonResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/supervision-research/phase3-manifests",
|
||||
response_model=Phase3ManifestResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_phase3_manifest(
|
||||
request: Phase3ManifestRequest, conn: ResearchDB
|
||||
) -> Phase3ManifestResponse:
|
||||
try:
|
||||
result = await supervision_research_store.append_phase3_manifest(
|
||||
conn,
|
||||
submission_id=request.submission_id,
|
||||
manifest_id=request.manifest_id,
|
||||
cohort_id=request.cohort_id,
|
||||
artifacts=request.artifacts,
|
||||
source_by_domain={
|
||||
item.domain: (item.learner_id, item.pointer) for item in request.sources
|
||||
},
|
||||
)
|
||||
except supervision_research_store.SupervisionResearchError as exc:
|
||||
_raise_store_error(exc)
|
||||
return Phase3ManifestResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("/internal/supervision-research/supervisor-view")
|
||||
async def read_internal_supervisor_view(conn: SupervisorDB) -> dict[str, object]:
|
||||
return await supervision_research_store.read_supervision_view(conn)
|
||||
|
||||
|
||||
@router.get("/internal/supervision-research/research-view")
|
||||
async def read_internal_research_view(conn: ResearchDB) -> dict[str, object]:
|
||||
return await supervision_research_store.read_research_view(conn)
|
||||
|
||||
|
||||
@router.get("/supervision-research/supervision-view")
|
||||
async def read_human_supervision_view(
|
||||
conn: HumanDB, _principal: TeacherPrincipal
|
||||
) -> dict[str, object]:
|
||||
return await supervision_research_store.read_supervision_view(conn)
|
||||
|
||||
|
||||
@router.get("/supervision-research/research-view")
|
||||
async def read_human_research_view(
|
||||
conn: HumanDB, _principal: TeacherPrincipal
|
||||
) -> dict[str, object]:
|
||||
return await supervision_research_store.read_research_view(conn)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTERNAL_TOKEN_HEADER",
|
||||
"router",
|
||||
"supervision_research_internal_research_db",
|
||||
"supervision_research_internal_supervisor_db",
|
||||
]
|
||||
|
|
@ -13,11 +13,14 @@ cleanly instead of crashing.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from typing import Optional
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
|
@ -36,9 +39,29 @@ from ..persona_repository import (
|
|||
get_session_voice_map,
|
||||
)
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services import evaluator, orchestrator, state_machine
|
||||
from ..services import (
|
||||
evaluator,
|
||||
multimodal_alliance,
|
||||
multimodal_alliance_store,
|
||||
orchestrator,
|
||||
rupture_scenario_director,
|
||||
state_machine,
|
||||
)
|
||||
from ..services import voice as voice_svc
|
||||
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
|
||||
from ..services.voice import (
|
||||
StreamingTranscriptEvent,
|
||||
TranscriptResult,
|
||||
VoicePreset,
|
||||
VoiceUnavailable,
|
||||
resolve_voice,
|
||||
voice_service,
|
||||
)
|
||||
from ..services.voice_runtime import (
|
||||
VOICE_AUDIO_BUFFER_MAX_BYTES,
|
||||
VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
||||
VOICE_UVICORN_WS_MAX_QUEUE,
|
||||
voice_runtime_metrics,
|
||||
)
|
||||
from ..store import InProcSession, TurnRecord, store
|
||||
|
||||
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||
|
|
@ -92,7 +115,9 @@ WS_CLOSE_BAD_REQUEST = 1008
|
|||
WS_CLOSE_UNAUTHORIZED = 1008
|
||||
|
||||
# Per-utterance audio cap to avoid unbounded memory growth.
|
||||
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
||||
_MAX_AUDIO_BYTES = VOICE_AUDIO_BUFFER_MAX_BYTES
|
||||
_STREAMING_EVENT_QUEUE_MAX_ITEMS = VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS
|
||||
_STREAMING_CONSENT_RECHECK_SECONDS = 1.0
|
||||
_PROVIDER_EVENT_MAX_ITEMS = 12
|
||||
_PROVIDER_EVENT_MAX_STRING = 80
|
||||
_PROVIDER_EVENT_ALLOWED_KEYS = {
|
||||
|
|
@ -103,6 +128,7 @@ _PROVIDER_EVENT_ALLOWED_KEYS = {
|
|||
"label",
|
||||
"source",
|
||||
"provider",
|
||||
"model",
|
||||
"start_ms",
|
||||
"end_ms",
|
||||
"duration_ms",
|
||||
|
|
@ -130,6 +156,7 @@ _PROVIDER_EVENT_TAXONOMY = {
|
|||
"speech_start": ("speech_start", "speech_activity"),
|
||||
"speech_end": ("speech_end", "speech_activity"),
|
||||
"speech_final": ("speech_final", "speech_activity"),
|
||||
"stt_word": ("stt_word", "timing"),
|
||||
"silence": ("silence", "timing"),
|
||||
"pause": ("silence", "timing"),
|
||||
"long_pause": ("silence", "timing"),
|
||||
|
|
@ -154,6 +181,15 @@ def _is_turn_persistence_unavailable(exc: Exception) -> bool:
|
|||
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
|
||||
|
||||
|
||||
def _append_audio_chunk_with_cap(buffer: bytearray, chunk: bytes) -> bool:
|
||||
"""Append only when the route-owned buffer remains within its hard cap."""
|
||||
|
||||
if len(chunk) > _MAX_AUDIO_BYTES - len(buffer):
|
||||
return False
|
||||
buffer.extend(chunk)
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def voice_health() -> JSONResponse:
|
||||
"""Return voice service readiness."""
|
||||
|
|
@ -166,13 +202,20 @@ async def voice_health() -> JSONResponse:
|
|||
"available": available,
|
||||
"stt_available": stt_available,
|
||||
"tts_available": tts_available,
|
||||
"stt_model": voice_svc.STT_MODEL,
|
||||
"stt_provider": voice_service.stt_provider(),
|
||||
"stt_model": voice_service.stt_model(),
|
||||
"stt_batch_fallback_available": voice_service.batch_stt_available(),
|
||||
"tts_model": (
|
||||
voice_svc.HIGGS_TTS_MODEL
|
||||
if tts_provider == "higgs"
|
||||
else voice_svc.TTS_MODEL
|
||||
),
|
||||
"tts_provider": tts_provider,
|
||||
"limits": {
|
||||
"max_utterance_audio_bytes": _MAX_AUDIO_BYTES,
|
||||
"streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
||||
"uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE,
|
||||
},
|
||||
"reason": (
|
||||
None
|
||||
if available
|
||||
|
|
@ -313,7 +356,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
if not voice_service.is_available():
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "degraded", "reason": "OPENAI_API_KEY is not configured"},
|
||||
{"type": "degraded", "reason": "voice STT/TTS is not configured"},
|
||||
)
|
||||
await _safe_close(websocket, WS_CLOSE_DEGRADED)
|
||||
return
|
||||
|
|
@ -325,12 +368,23 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
"session_id": session_id,
|
||||
"voice": voice_preset.openai_voice,
|
||||
"preset": voice_preset.preset,
|
||||
"stt_provider": voice_service.stt_provider(),
|
||||
"stt_model": voice_service.stt_model(),
|
||||
"stt_batch_fallback_available": voice_service.batch_stt_available(),
|
||||
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
|
||||
"tts_model": voice_service.tts_model_for_voice(voice_preset),
|
||||
"limits": {
|
||||
"max_utterance_audio_bytes": _MAX_AUDIO_BYTES,
|
||||
"streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
||||
"uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE,
|
||||
},
|
||||
"state": "idle",
|
||||
**bind_meta,
|
||||
},
|
||||
)
|
||||
|
||||
runtime_connection_id = voice_runtime_metrics.websocket_opened()
|
||||
|
||||
audio_buf = bytearray()
|
||||
receiving = False
|
||||
audio_started_at: float | None = None
|
||||
|
|
@ -339,6 +393,13 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
audio_sample_rate: int | None = None
|
||||
audio_channels: int | None = None
|
||||
audio_sample_width: int | None = None
|
||||
streaming_session: voice_svc.DeepgramStreamingSession | None = None
|
||||
streaming_consent_checked_at: float | None = None
|
||||
streaming_events: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(
|
||||
maxsize=_STREAMING_EVENT_QUEUE_MAX_ITEMS
|
||||
)
|
||||
discard_audio_until_end = False
|
||||
last_stream_transcript: tuple[str, bool] | None = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -349,16 +410,28 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
|
||||
# Binary frames are audio chunks.
|
||||
if msg.get("bytes") is not None:
|
||||
if discard_audio_until_end:
|
||||
continue
|
||||
if not receiving:
|
||||
# Be tolerant when audio arrives before audio_start.
|
||||
receiving = True
|
||||
audio_started_at = time.monotonic()
|
||||
audio_buf.clear()
|
||||
voice_runtime_metrics.audio_buffer_cleared(
|
||||
runtime_connection_id
|
||||
)
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "listening"}
|
||||
)
|
||||
audio_buf.extend(msg["bytes"])
|
||||
if len(audio_buf) > _MAX_AUDIO_BYTES:
|
||||
chunk = msg["bytes"]
|
||||
if not _append_audio_chunk_with_cap(audio_buf, chunk):
|
||||
voice_runtime_metrics.audio_overflow_rejected(
|
||||
runtime_connection_id
|
||||
)
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
|
|
@ -366,8 +439,79 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
"detail": "audio too large; please send a shorter utterance",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "idle"}
|
||||
)
|
||||
audio_buf.clear()
|
||||
voice_runtime_metrics.audio_buffer_cleared(
|
||||
runtime_connection_id
|
||||
)
|
||||
receiving = False
|
||||
discard_audio_until_end = True
|
||||
continue
|
||||
voice_runtime_metrics.audio_chunk_received(
|
||||
runtime_connection_id,
|
||||
current_buffer_bytes=len(audio_buf),
|
||||
chunk_bytes=len(chunk),
|
||||
)
|
||||
if streaming_session is not None:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
streaming_consent_checked_at is None
|
||||
or now - streaming_consent_checked_at
|
||||
>= _STREAMING_CONSENT_RECHECK_SECONDS
|
||||
):
|
||||
if not await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
):
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
discard_audio_until_end = True
|
||||
receiving = False
|
||||
continue
|
||||
streaming_consent_checked_at = now
|
||||
try:
|
||||
await streaming_session.send_audio(chunk)
|
||||
await asyncio.sleep(0)
|
||||
drained = await _drain_streaming_transcripts(
|
||||
websocket, streaming_events
|
||||
)
|
||||
voice_runtime_metrics.streaming_queue_observed(
|
||||
runtime_connection_id,
|
||||
queue_items=streaming_events.qsize(),
|
||||
)
|
||||
if drained is not None:
|
||||
last_stream_transcript = drained
|
||||
except Exception:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
if voice_service.batch_stt_available():
|
||||
voice_runtime_metrics.provider_fallback()
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "degraded",
|
||||
"reason": "streaming STT unavailable; using batch fallback",
|
||||
},
|
||||
)
|
||||
else:
|
||||
discard_audio_until_end = True
|
||||
receiving = False
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "streaming_stt_unavailable",
|
||||
"detail": "streaming STT failed and no batch fallback is configured",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "idle"}
|
||||
)
|
||||
continue
|
||||
|
||||
# Text frames are JSON controls.
|
||||
|
|
@ -384,16 +528,94 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
|
||||
ctype = ctrl.get("type")
|
||||
if ctype == "audio_start":
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
receiving = True
|
||||
discard_audio_until_end = False
|
||||
last_stream_transcript = None
|
||||
while not streaming_events.empty():
|
||||
streaming_events.get_nowait()
|
||||
voice_runtime_metrics.streaming_queue_observed(
|
||||
runtime_connection_id,
|
||||
queue_items=0,
|
||||
)
|
||||
audio_started_at = time.monotonic()
|
||||
audio_format = _safe_str(ctrl.get("format"))
|
||||
audio_sample_rate = _safe_int(ctrl.get("sample_rate"))
|
||||
audio_channels = _safe_int(ctrl.get("channels"))
|
||||
audio_sample_width = _safe_int(ctrl.get("sample_width"))
|
||||
audio_buf.clear()
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "listening"}
|
||||
)
|
||||
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
||||
if voice_service.can_stream_audio(
|
||||
fmt=audio_format,
|
||||
sample_rate=audio_sample_rate,
|
||||
channels=audio_channels,
|
||||
sample_width=audio_sample_width,
|
||||
):
|
||||
if not await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
):
|
||||
receiving = False
|
||||
discard_audio_until_end = True
|
||||
else:
|
||||
async def queue_streaming_event(
|
||||
event: StreamingTranscriptEvent,
|
||||
) -> None:
|
||||
queue_was_full = streaming_events.full()
|
||||
queue_wait_started = time.perf_counter()
|
||||
await streaming_events.put(event)
|
||||
voice_runtime_metrics.streaming_queue_observed(
|
||||
runtime_connection_id,
|
||||
queue_items=streaming_events.qsize(),
|
||||
saturated=queue_was_full,
|
||||
wait_seconds=(
|
||||
time.perf_counter() - queue_wait_started
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
streaming_session = (
|
||||
await voice_service.open_streaming_transcription(
|
||||
fmt=audio_format,
|
||||
sample_rate=audio_sample_rate,
|
||||
channels=audio_channels,
|
||||
sample_width=audio_sample_width,
|
||||
on_event=queue_streaming_event,
|
||||
)
|
||||
)
|
||||
streaming_consent_checked_at = time.monotonic()
|
||||
except Exception:
|
||||
if voice_service.batch_stt_available():
|
||||
voice_runtime_metrics.provider_fallback()
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "degraded",
|
||||
"reason": "streaming STT unavailable; using batch fallback",
|
||||
},
|
||||
)
|
||||
else:
|
||||
receiving = False
|
||||
discard_audio_until_end = True
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "streaming_stt_unavailable",
|
||||
"detail": "streaming STT is unavailable and no batch fallback is configured",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "idle"}
|
||||
)
|
||||
if not discard_audio_until_end:
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "listening"}
|
||||
)
|
||||
|
||||
elif ctype == "audio_end":
|
||||
receiving = False
|
||||
|
|
@ -408,28 +630,101 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
0, int((audio_started_at - last_audio_end_at) * 1000)
|
||||
)
|
||||
end_format = _safe_str(ctrl.get("format")) or audio_format
|
||||
await _handle_utterance(
|
||||
websocket,
|
||||
VoiceSessionContext(session_id, principal, voice_preset),
|
||||
VoiceAudioInput(
|
||||
audio=bytes(audio_buf),
|
||||
fmt=end_format,
|
||||
sample_rate=_safe_int(ctrl.get("sample_rate"))
|
||||
or audio_sample_rate,
|
||||
channels=_safe_int(ctrl.get("channels")) or audio_channels,
|
||||
sample_width=_safe_int(ctrl.get("sample_width"))
|
||||
or audio_sample_width,
|
||||
audio_started_at=audio_started_at,
|
||||
audio_ended_at=audio_ended_at,
|
||||
prosody=VoiceProsody(
|
||||
silence_ms=silence_ms,
|
||||
barge_in=_safe_bool(ctrl.get("barge_in")),
|
||||
provider_events=_safe_provider_events(
|
||||
ctrl.get("provider_events")
|
||||
),
|
||||
context = VoiceSessionContext(session_id, principal, voice_preset)
|
||||
utterance = VoiceAudioInput(
|
||||
audio=bytes(audio_buf),
|
||||
fmt=end_format,
|
||||
sample_rate=_safe_int(ctrl.get("sample_rate"))
|
||||
or audio_sample_rate,
|
||||
channels=_safe_int(ctrl.get("channels")) or audio_channels,
|
||||
sample_width=_safe_int(ctrl.get("sample_width"))
|
||||
or audio_sample_width,
|
||||
audio_started_at=audio_started_at,
|
||||
audio_ended_at=audio_ended_at,
|
||||
prosody=VoiceProsody(
|
||||
silence_ms=silence_ms,
|
||||
barge_in=_safe_bool(ctrl.get("barge_in")),
|
||||
provider_events=_safe_provider_events(
|
||||
ctrl.get("provider_events")
|
||||
),
|
||||
),
|
||||
)
|
||||
if discard_audio_until_end:
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
elif streaming_session is not None:
|
||||
if not await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
):
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
else:
|
||||
try:
|
||||
streaming_result, drained = (
|
||||
await _finish_streaming_transcription(
|
||||
websocket,
|
||||
streaming_session,
|
||||
streaming_events,
|
||||
)
|
||||
)
|
||||
voice_runtime_metrics.streaming_queue_observed(
|
||||
runtime_connection_id,
|
||||
queue_items=streaming_events.qsize(),
|
||||
)
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
except Exception:
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
if voice_service.batch_stt_available():
|
||||
voice_runtime_metrics.provider_fallback()
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "degraded",
|
||||
"reason": "streaming STT unavailable; using batch fallback",
|
||||
},
|
||||
)
|
||||
await _handle_utterance(websocket, context, utterance)
|
||||
else:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "streaming_stt_unavailable",
|
||||
"detail": "streaming STT finalization failed and no batch fallback is configured",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "state", "state": "idle"}
|
||||
)
|
||||
else:
|
||||
if drained is not None:
|
||||
last_stream_transcript = drained
|
||||
if await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
):
|
||||
await _handle_streaming_utterance(
|
||||
websocket,
|
||||
context,
|
||||
utterance,
|
||||
streaming_result,
|
||||
transcript_already_sent=(
|
||||
last_stream_transcript
|
||||
== (streaming_result.text, True)
|
||||
),
|
||||
)
|
||||
else:
|
||||
await _handle_utterance(websocket, context, utterance)
|
||||
last_audio_end_at = audio_ended_at
|
||||
audio_started_at = None
|
||||
audio_format = None
|
||||
|
|
@ -437,11 +732,19 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
audio_channels = None
|
||||
audio_sample_width = None
|
||||
audio_buf.clear()
|
||||
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
||||
discard_audio_until_end = False
|
||||
last_stream_transcript = None
|
||||
|
||||
elif ctype == "text_turn":
|
||||
# Text-only path for accessibility and deterministic tests.
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
receiving = False
|
||||
audio_buf.clear()
|
||||
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
||||
learner_text = (ctrl.get("text") or "").strip()
|
||||
if learner_text:
|
||||
await _run_turn_and_speak(
|
||||
|
|
@ -451,8 +754,13 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
)
|
||||
|
||||
elif ctype == "stt_result":
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
receiving = False
|
||||
audio_buf.clear()
|
||||
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
||||
stt_received_at = time.monotonic()
|
||||
await _handle_stt_result_control(
|
||||
websocket,
|
||||
|
|
@ -475,11 +783,16 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
await _safe_send_json(websocket, {"type": "pong"})
|
||||
|
||||
elif ctype == "close":
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
streaming_session = None
|
||||
streaming_consent_checked_at = None
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
voice_runtime_metrics.websocket_error()
|
||||
if _is_turn_persistence_unavailable(e):
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
|
|
@ -495,9 +808,61 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
websocket, {"type": "error", "detail": f"voice ws error: {e}"}
|
||||
)
|
||||
finally:
|
||||
if streaming_session is not None:
|
||||
await streaming_session.abort()
|
||||
voice_runtime_metrics.websocket_closed(runtime_connection_id)
|
||||
await _safe_close(websocket)
|
||||
|
||||
|
||||
async def _multimodal_voice_processing_allowed(
|
||||
websocket: WebSocket,
|
||||
*,
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
) -> bool:
|
||||
"""Stop before STT/derived processing when G7 consent is not active."""
|
||||
|
||||
try:
|
||||
await multimodal_alliance_store.assert_voice_processing_allowed(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_consent_withdrawn",
|
||||
"detail": str(exc),
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return False
|
||||
except multimodal_alliance_store.MultimodalConsentRequiredError as exc:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_consent_required",
|
||||
"detail": str(exc),
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return False
|
||||
except (multimodal_alliance_store.MultimodalAllianceError, RuntimeError):
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_consent_unavailable",
|
||||
"detail": "multimodal consent state is unavailable; voice processing is blocked",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_stt_result_control(
|
||||
websocket: WebSocket,
|
||||
*,
|
||||
|
|
@ -509,6 +874,12 @@ async def _handle_stt_result_control(
|
|||
audio_ended_at: float | None = None,
|
||||
last_audio_end_at: float | None = None,
|
||||
) -> None:
|
||||
if not await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
):
|
||||
return
|
||||
learner_text = str(ctrl.get("text") or "").strip()
|
||||
transcript_final = _safe_bool(ctrl.get("final"))
|
||||
silence_ms = _safe_int(ctrl.get("silence_ms"))
|
||||
|
|
@ -576,6 +947,13 @@ async def _handle_utterance(
|
|||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
if not await _multimodal_voice_processing_allowed(
|
||||
websocket,
|
||||
session_id=context.session_id,
|
||||
principal=context.principal,
|
||||
):
|
||||
return
|
||||
|
||||
# STT begins after the learner stops speaking.
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
||||
upload_audio, upload_fmt = _normalize_audio_upload(
|
||||
|
|
@ -641,6 +1019,286 @@ async def _handle_utterance(
|
|||
)
|
||||
|
||||
|
||||
async def _drain_streaming_transcripts(
|
||||
websocket: WebSocket,
|
||||
events: asyncio.Queue[StreamingTranscriptEvent],
|
||||
) -> tuple[str, bool] | None:
|
||||
"""Relay provider-neutral streaming updates without concurrent ASGI sends."""
|
||||
|
||||
last: tuple[str, bool] | None = None
|
||||
while True:
|
||||
try:
|
||||
event = events.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
return last
|
||||
await _relay_streaming_transcript(websocket, event)
|
||||
last = (event.text, event.speech_final)
|
||||
|
||||
|
||||
async def _finish_streaming_transcription(
|
||||
websocket: WebSocket,
|
||||
session: voice_svc.DeepgramStreamingSession,
|
||||
events: asyncio.Queue[StreamingTranscriptEvent],
|
||||
) -> tuple[TranscriptResult, tuple[str, bool] | None]:
|
||||
"""Finalize while draining the bounded event queue to avoid producer deadlock."""
|
||||
|
||||
finish_task = asyncio.create_task(session.finish())
|
||||
last: tuple[str, bool] | None = None
|
||||
try:
|
||||
while not finish_task.done():
|
||||
event_task = asyncio.create_task(events.get())
|
||||
done, _ = await asyncio.wait(
|
||||
{finish_task, event_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if event_task in done:
|
||||
event = event_task.result()
|
||||
await _relay_streaming_transcript(websocket, event)
|
||||
last = (event.text, event.speech_final)
|
||||
else:
|
||||
event_task.cancel()
|
||||
try:
|
||||
await event_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
result = await finish_task
|
||||
drained = await _drain_streaming_transcripts(websocket, events)
|
||||
return result, drained or last
|
||||
except Exception:
|
||||
if not finish_task.done():
|
||||
finish_task.cancel()
|
||||
raise
|
||||
|
||||
|
||||
async def _relay_streaming_transcript(
|
||||
websocket: WebSocket, event: StreamingTranscriptEvent
|
||||
) -> None:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": event.text,
|
||||
# Deepgram is_final seals one segment; speech_final seals the
|
||||
# learner utterance. The browser's `final` contract means the
|
||||
# latter so it never stops capture at an intermediate segment.
|
||||
"final": event.speech_final,
|
||||
"speech_final": event.speech_final,
|
||||
"speaker": "counselor",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _handle_streaming_utterance(
|
||||
websocket: WebSocket,
|
||||
context: VoiceSessionContext,
|
||||
utterance: VoiceAudioInput,
|
||||
stt: TranscriptResult,
|
||||
*,
|
||||
transcript_already_sent: bool,
|
||||
) -> None:
|
||||
"""Persist and run one finalized provider-streamed learner utterance."""
|
||||
|
||||
learner_text = stt.text.strip()
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
||||
if not transcript_already_sent:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": learner_text,
|
||||
"final": True,
|
||||
"speech_final": True,
|
||||
"speaker": "counselor",
|
||||
},
|
||||
)
|
||||
if not learner_text:
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
audio_ref = _voice_audio_ref(utterance.audio, utterance.fmt)
|
||||
duration_s = stt.duration or _elapsed_seconds(
|
||||
utterance.audio_started_at, utterance.audio_ended_at
|
||||
)
|
||||
if stt.words:
|
||||
duration_s = max(duration_s or 0.0, max(word.end for word in stt.words))
|
||||
speech_rate = _estimate_speech_rate(learner_text, duration_s)
|
||||
provider_events = _merge_provider_events(
|
||||
[
|
||||
{
|
||||
"type": "stt_metadata",
|
||||
"provider": "deepgram",
|
||||
"model": stt.model,
|
||||
"source": "streaming_stt",
|
||||
"is_final": True,
|
||||
}
|
||||
],
|
||||
utterance.prosody.provider_events,
|
||||
stt.provider_events,
|
||||
)
|
||||
try:
|
||||
await _persist_streaming_timeline(
|
||||
context=context,
|
||||
utterance=utterance,
|
||||
stt=stt,
|
||||
audio_ref=audio_ref,
|
||||
duration_s=duration_s,
|
||||
)
|
||||
except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_consent_withdrawn",
|
||||
"detail": str(exc),
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
except multimodal_alliance_store.MultimodalConsentRequiredError as exc:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_consent_required",
|
||||
"detail": str(exc),
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
except multimodal_alliance_store.MultimodalAllianceError:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"code": "multimodal_timeline_unavailable",
|
||||
"detail": "multimodal timeline persistence is unavailable; voice turn is blocked",
|
||||
},
|
||||
)
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
await _run_turn_and_speak(
|
||||
websocket,
|
||||
context,
|
||||
VoiceTurnInput(
|
||||
learner_text=learner_text,
|
||||
prosody=VoiceProsody(
|
||||
audio_ref=audio_ref,
|
||||
duration_s=duration_s,
|
||||
silence_ms=utterance.prosody.silence_ms,
|
||||
speech_rate=speech_rate,
|
||||
barge_in=utterance.prosody.barge_in,
|
||||
provider_events=provider_events,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _persist_streaming_timeline(
|
||||
*,
|
||||
context: VoiceSessionContext,
|
||||
utterance: VoiceAudioInput,
|
||||
stt: TranscriptResult,
|
||||
audio_ref: str,
|
||||
duration_s: float | None,
|
||||
) -> dict[str, object] | None:
|
||||
audio_sha256 = hashlib.sha256(utterance.audio).hexdigest()
|
||||
submission_id = uuid5(
|
||||
NAMESPACE_URL,
|
||||
f"vignette:g7:streaming-stt:{context.session_id}:{audio_sha256}:{stt.model}",
|
||||
)
|
||||
duration_ms = max(
|
||||
1,
|
||||
round((duration_s or 0.0) * 1000),
|
||||
*(round(word.end * 1000) for word in stt.words),
|
||||
)
|
||||
words = []
|
||||
for index, word in enumerate(sorted(stt.words, key=lambda item: item.start)):
|
||||
start_ms = max(0, min(duration_ms - 1, round(word.start * 1000)))
|
||||
end_ms = max(start_ms + 1, min(duration_ms, round(word.end * 1000)))
|
||||
words.append(
|
||||
{
|
||||
"word_index": index,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
"speaker": "learner",
|
||||
# Common counselling words are dictionary-attackable when stored
|
||||
# as plain SHA-256. Bind the pseudonym to this deployment and
|
||||
# submission so the timeline remains useful without creating a
|
||||
# reusable transcript fingerprint.
|
||||
"token_hash": hmac.new(
|
||||
settings.session_secret.encode("utf-8"),
|
||||
(
|
||||
f"{context.session_id}:{submission_id}:"
|
||||
f"{word.word.casefold()}"
|
||||
).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest(),
|
||||
}
|
||||
)
|
||||
|
||||
events = []
|
||||
for index, event in enumerate(_safe_provider_events(stt.provider_events)):
|
||||
provider_type = str(event.get("event_type") or "")
|
||||
event_type = _g7_event_type(provider_type)
|
||||
if event_type is None:
|
||||
continue
|
||||
start_ms = max(0, _safe_int(event.get("start_ms")) or 0)
|
||||
end_ms = _safe_int(event.get("end_ms"))
|
||||
if end_ms is None:
|
||||
end_ms = start_ms + max(0, _safe_int(event.get("duration_ms")) or 0)
|
||||
start_ms = min(start_ms, duration_ms - 1)
|
||||
end_ms = min(duration_ms, max(start_ms + 1, end_ms))
|
||||
confidence = _safe_float(event.get("confidence"))
|
||||
uncertainty = 0.5 if confidence is None else max(0.0, min(1.0, 1.0 - confidence))
|
||||
events.append(
|
||||
{
|
||||
"event_id": f"oas-g7-event-{submission_id.hex}-{index}",
|
||||
"event_type": event_type,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
"actor": "learner",
|
||||
"observed_feature": f"provider observed {provider_type}",
|
||||
"uncertainty": uncertainty,
|
||||
"source": "stt_word_timestamps",
|
||||
}
|
||||
)
|
||||
|
||||
timeline = multimodal_alliance.align_voice_timeline(
|
||||
audio_duration_ms=duration_ms,
|
||||
words=words,
|
||||
events=events,
|
||||
)
|
||||
_, media_type = _audio_meta(utterance.fmt)
|
||||
return await multimodal_alliance_store.append_runtime_timeline(
|
||||
session_id=context.session_id,
|
||||
submission_id=submission_id,
|
||||
timeline=timeline,
|
||||
audio_asset={
|
||||
"audio_ref": audio_ref,
|
||||
"audio_sha256": audio_sha256,
|
||||
"media_type": media_type,
|
||||
"byte_size": len(utterance.audio),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _g7_event_type(provider_type: str) -> str | None:
|
||||
if provider_type in {"speech_final", "speech_end", "voice_activity", "speech_rate"}:
|
||||
return "pace"
|
||||
if provider_type in {"barge_in", "interrupt", "interruption"}:
|
||||
return "interruption"
|
||||
if provider_type == "overlap":
|
||||
return "overlap"
|
||||
if provider_type in {"silence", "pause", "long_pause"}:
|
||||
return "silence"
|
||||
if provider_type in {"background_noise", "noise"}:
|
||||
return "audio_quality"
|
||||
if provider_type in {"sigh", "cry", "laugh", "breath", "pitch", "intonation", "prosody"}:
|
||||
return "prosody"
|
||||
return None
|
||||
|
||||
|
||||
async def _run_turn_and_speak(
|
||||
websocket: WebSocket,
|
||||
context: VoiceSessionContext,
|
||||
|
|
@ -670,21 +1328,10 @@ async def _run_turn_and_speak(
|
|||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
recall = await session_routes.ensure_recall_context(sess)
|
||||
kb_cues = session_routes.cached_kb_cues(context.session_id)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
ctx = await _prepare_voice_turn_context(
|
||||
session_id=context.session_id,
|
||||
case_id=sess.case_id,
|
||||
card=sess.persona,
|
||||
state=sess.state,
|
||||
sess=sess,
|
||||
learner_text=learner_text,
|
||||
memory=orchestrator.TurnMemory(
|
||||
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
|
||||
|
||||
|
|
@ -782,6 +1429,41 @@ async def _run_turn_and_speak(
|
|||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
|
||||
|
||||
async def _prepare_voice_turn_context(
|
||||
*,
|
||||
session_id: str,
|
||||
sess: InProcSession,
|
||||
learner_text: str,
|
||||
) -> orchestrator.TurnContext:
|
||||
"""Build voice turn context with the same fail-closed G3 ledger projection."""
|
||||
|
||||
from . import sessions as session_routes
|
||||
|
||||
recall = await session_routes.ensure_recall_context(sess)
|
||||
kb_cues = session_routes.cached_kb_cues(session_id)
|
||||
scenario_context = (
|
||||
await rupture_scenario_director.load_stored_scenario_context(
|
||||
session_id=session_id,
|
||||
case_id=sess.case_id,
|
||||
)
|
||||
)
|
||||
return orchestrator.prepare_turn(
|
||||
session_id=session_id,
|
||||
case_id=sess.case_id,
|
||||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text=learner_text,
|
||||
memory=orchestrator.TurnMemory(
|
||||
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,
|
||||
scenario_context=scenario_context,
|
||||
)
|
||||
|
||||
|
||||
async def _load_voice_session(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
|
|
@ -1113,6 +1795,15 @@ def _safe_int(value: object) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _safe_float(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_str(value: object) -> str | None:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue