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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -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,

View file

@ -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),
)

View 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"]

View 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",
]

View 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"]

View 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"]

View 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"]

View 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"]

View file

@ -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,
)

View 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"]

View file

@ -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)

View 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",
]

View file

@ -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()