Files
nextcraft/apps/ai-service/ai_service/api/defense.py
T
CIAgent 22d4fa212c fix(P05): verifier P0s — browser descriptor served, 409 post-finish, 422 empty audio, verdict persisted
Four gaps found by independent verifier probing of the defense endpoints
(all Must-Have-relevant, all trivially fixed):

1. Browser-mode descriptor was dead code: BROWSER_FALLBACK_DESCRIPTOR
   existed but start always returned mode='mock' even with
   AI_VOICE_PROVIDER=browser (Must-Have #6 violated). start now derives
   the descriptor from settings.voice_provider (D-030).
2. answer after finish returned 200 and appended turns to a sealed
   transcript — the store explicitly assigns sequencing to the endpoints
   (defense_store.py: 'turns after finalize are a sequencing bug for the
   endpoints to prevent, task 5-3-01'); the endpoints didn't. Now 409.
3. Zero-byte audio upload crashed the mock provider (MockVoiceFailure ->
   500); a real provider would 500 the same way. Empty upload is a client
   error: 422 before any provider call (provider contract unchanged).
4. Verdict was NOT persisted (Must-Have #1 'verdict + transcript
   persisted'): finish persisted only signals; GET after finish could not
   re-serve the verdict. The verdict now nests in integrity_signals
   (JSON-object dict per the DefenseStore.finalize contract).

3 regression tests added (empty-audio 422, post-finish 409, verdict
retrievable from GET; browser-descriptor test). Suite 386 green; ruff clean.

---ci---
phase: 5
milestone: v0.3
status: verify
requirements:
  covered: [REQ-3-006]
  partial: []
lessons:
  - A descriptor that exists but is never served is indistinguishable from
    dead code until you probe the configured mode end-to-end (factory tests
    proved selection, not service).
  - Store contracts that 'assign' sequencing to callers need an endpoint
    test for the forbidden transition, or the assignment is decorative.
---/ci---
2026-09-12 04:49:46 +00:00

337 lines
12 KiB
Python

"""Oral-defense endpoints (Task 5-3-01, REQ-3-006, A-109).
Full defense loop over HTTP with mock-first voice (D-030) and the seventh
Examiner agent (SSE question streaming happens through the chat pipeline;
these endpoints are the session orchestration + transcript persistence):
POST /v1/defense/start {learner_id, task_id}
POST /v1/defense/{id}/answer {text} | multipart audio (STT)
GET /v1/defense/{id}/audio/{turn_id} TTS bytes (streaming)
POST /v1/defense/{id}/finish verdict + integrity signals
GET /v1/defense/{id} transcript + signals
Integrity signals (A-109) are computed server-side from turn metadata:
long pauses = learner turns whose latency_ms exceeds PAUSE_THRESHOLD_MS.
The defense does NOT gate on trace completeness (the grader does, G-4);
an incomplete trace is surfaced as `trace_complete: false` so the UI can
disclose it before the learner defends.
"""
from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ..agents.examiner import ExaminerAgent
from ..grading.features import TraceDigest, compute_digest
from ..llm.types import Message
from ..voice.base import VoiceDescriptor
from ..voice.browser import BROWSER_FALLBACK_DESCRIPTOR
from ..voice.defense_store import DefenseRecord, DefenseStore, DefenseTurn
from .deps import (
get_examiner,
get_settings,
get_trace_store,
get_variant_store,
get_voice_provider,
get_voice_store,
)
if TYPE_CHECKING: # pragma: no cover
pass
router = APIRouter(prefix="/v1/defense", tags=["defense"])
#: A-109: learner turns slower than this are flagged as long pauses (ms).
PAUSE_THRESHOLD_MS = 15_000
_ROLE_EXAMINER = "examiner"
_ROLE_LEARNER = "learner"
class StartRequest(BaseModel):
learner_id: str = Field(min_length=1)
task_id: str = Field(min_length=1)
class StartResponse(BaseModel):
defense_id: str
voice_descriptor: dict
trace_complete: bool
first_question: str
class AnswerResponse(BaseModel):
question: str
turn_latency: dict[str, int | None]
class FinishResponse(BaseModel):
verdict: dict
integrity_signals: dict
async def _digest_for_task(
trace_store, learner_id: str, task_id: str
) -> tuple[TraceDigest | None, bool]:
"""Digest of the learner's trace for this task + completeness flag."""
if not trace_store.list_tasks(learner_id) or task_id not in trace_store.list_tasks(
learner_id
):
return None, True # no trace at all is "complete" for defense purposes
trace = trace_store.get_trace(learner_id, task_id)
gaps = trace_store.gaps(learner_id, task_id)
return (compute_digest(trace) if trace else None), (len(gaps) == 0)
def _voice_descriptor(settings) -> VoiceDescriptor:
"""The capability descriptor for the configured voice mode (D-030).
Must-Have #6: browser mode returns BROWSER_FALLBACK_DESCRIPTOR so the
web client selects native SpeechRecognition/speechSynthesis; mock mode
returns the mock descriptor. (A v0.4 server provider would return
mode="server" — the protocol seam.)
"""
if (settings.voice_provider or "mock").strip().lower() == "browser":
return BROWSER_FALLBACK_DESCRIPTOR
return VoiceDescriptor(
mode="mock", sr_available=True, tts_available=True, hint=""
)
@router.post("/start", response_model=StartResponse)
async def start_defense(
body: StartRequest,
examiner: ExaminerAgent = Depends(get_examiner),
voice_store: DefenseStore = Depends(get_voice_store),
voice_provider=Depends(get_voice_provider),
trace_store=Depends(get_trace_store),
variant_store=Depends(get_variant_store),
settings=Depends(get_settings),
) -> StartResponse:
record = voice_store.start(
DefenseRecord(
id=f"dfn-{int(time.time() * 1000):x}-{body.learner_id[:8]}",
learner_id=body.learner_id,
task_id=body.task_id,
status="in_progress",
created_at=datetime.now(UTC),
)
)
digest, trace_complete = await _digest_for_task(trace_store, body.learner_id, body.task_id)
variant = variant_store.get_by_task(body.task_id)
statement = variant.statement if variant is not None else None
started = time.perf_counter()
question = await examiner.next_question(
history=[], trace_digest=digest, variant_statement=statement
)
llm_ms = int((time.perf_counter() - started) * 1000)
voice_store.append_turn(
record.id,
DefenseTurn(
defense_id=record.id,
seq=0,
role=_ROLE_EXAMINER,
text=question,
ts=datetime.now(UTC),
latency_ms=llm_ms,
created_at=datetime.now(UTC),
),
)
descriptor = getattr(voice_provider, "descriptor", None) or _voice_descriptor(
settings
)
return StartResponse(
defense_id=record.id,
voice_descriptor=descriptor.model_dump(),
trace_complete=trace_complete,
first_question=question,
)
@router.post("/{defense_id}/answer", response_model=AnswerResponse)
async def answer_defense(
defense_id: str,
text: str | None = Form(default=None),
audio: UploadFile | None = File(default=None),
voice_store: DefenseStore = Depends(get_voice_store),
voice_provider=Depends(get_voice_provider),
examiner: ExaminerAgent = Depends(get_examiner),
trace_store=Depends(get_trace_store),
variant_store=Depends(get_variant_store),
) -> AnswerResponse:
record = voice_store.get(defense_id)
if record is None:
raise HTTPException(status_code=404, detail=f"no defense {defense_id!r}")
if record.status == "finished":
# The store owns the finished transition but does NOT police turn
# sequencing (defense_store.py: "turns after finalize are a sequencing
# bug for the endpoints to prevent") — this is the endpoint half of
# that contract: a sealed transcript is append-only-no-more.
raise HTTPException(
status_code=409, detail="defense is finished; start a new defense"
)
if text is None and audio is None:
raise HTTPException(status_code=422, detail="provide {text} or audio")
# STT (typed fallback bypasses the voice provider entirely).
stt_ms: int | None = None
if audio is not None:
stt_started = time.perf_counter()
raw = await audio.read()
if not raw:
# Empty upload is a client error (422), not a provider crash
# (500): validate before the provider call so every provider —
# mock today, the v0.4 real one — sees the same contract.
raise HTTPException(status_code=422, detail="audio upload is empty")
fmt = (audio.content_type or "audio/wav").split("/")[-1]
segment = await voice_provider.transcribe(raw, fmt)
stt_ms = int((time.perf_counter() - stt_started) * 1000)
text = segment.text
turns = record.turns if hasattr(record, "turns") else []
history = [
Message(role="assistant" if t.role == _ROLE_EXAMINER else "user", content=t.text)
for t in turns
]
next_seq = len(turns)
voice_store.append_turn(
defense_id,
DefenseTurn(
defense_id=defense_id,
seq=next_seq,
role=_ROLE_LEARNER,
text=text or "",
ts=datetime.now(UTC),
latency_ms=stt_ms,
created_at=datetime.now(UTC),
),
)
digest, _ = await _digest_for_task(trace_store, record.learner_id, record.task_id)
variant = variant_store.get_by_task(record.task_id)
llm_started = time.perf_counter()
question = await examiner.next_question(
history=history + [Message(role="user", content=text or "")],
trace_digest=digest,
variant_statement=variant.statement if variant is not None else None,
)
llm_ms = int((time.perf_counter() - llm_started) * 1000)
voice_store.append_turn(
defense_id,
DefenseTurn(
defense_id=defense_id,
seq=next_seq + 1,
role=_ROLE_EXAMINER,
text=question,
ts=datetime.now(UTC),
latency_ms=llm_ms,
created_at=datetime.now(UTC),
),
)
return AnswerResponse(
question=question,
turn_latency={"stt_ms": stt_ms, "llm_ms": llm_ms, "tts_ms": None},
)
@router.get("/{defense_id}/audio/{turn_id}")
async def defense_audio(
defense_id: str,
turn_id: int,
voice_store: DefenseStore = Depends(get_voice_store),
voice_provider=Depends(get_voice_provider),
):
record = voice_store.get(defense_id)
if record is None:
raise HTTPException(status_code=404, detail=f"no defense {defense_id!r}")
turn = next((t for t in record.turns if t.seq == turn_id), None)
if turn is None or turn.role != _ROLE_EXAMINER:
raise HTTPException(status_code=404, detail=f"no examiner turn {turn_id!r}")
async def stream():
async for chunk in voice_provider.synthesize(turn.text):
yield chunk
return StreamingResponse(stream(), media_type="audio/wav")
@router.post("/{defense_id}/finish", response_model=FinishResponse)
async def finish_defense(
defense_id: str,
voice_store: DefenseStore = Depends(get_voice_store),
examiner: ExaminerAgent = Depends(get_examiner),
trace_store=Depends(get_trace_store),
variant_store=Depends(get_variant_store),
) -> FinishResponse:
record = voice_store.get(defense_id)
if record is None:
raise HTTPException(status_code=404, detail=f"no defense {defense_id!r}")
turns = record.turns if hasattr(record, "turns") else []
history = [
Message(role="assistant" if t.role == _ROLE_EXAMINER else "user", content=t.text)
for t in turns
]
digest, _ = await _digest_for_task(trace_store, record.learner_id, record.task_id)
variant = variant_store.get_by_task(record.task_id)
verdict = await examiner.final_verdict(
history=history,
trace_digest=digest,
variant_statement=variant.statement if variant is not None else None,
)
signals: dict = {
"long_pauses": [
{"turn": t.seq, "latency_ms": t.latency_ms}
for t in turns
if t.role == _ROLE_LEARNER and (t.latency_ms or 0) > PAUSE_THRESHOLD_MS
],
"pause_threshold_ms": PAUSE_THRESHOLD_MS,
# Must-Have #1: "verdict + transcript persisted" — the verdict is
# stored INSIDE integrity_signals so GET /{id} after finish can
# re-serve it (the finish response alone would lose it). Signals
# are a JSON object dict (DefenseStore.finalize contract), so the
# verdict nests under the "verdict" key alongside the A-109
# markers the Proctor/Mentor feeds read.
"verdict": verdict.model_dump(),
}
voice_store.finalize(defense_id, signals)
return FinishResponse(verdict=verdict.model_dump(), integrity_signals=signals)
@router.get("/{defense_id}")
async def get_defense(
defense_id: str,
voice_store: DefenseStore = Depends(get_voice_store),
):
record = voice_store.get(defense_id)
if record is None:
raise HTTPException(status_code=404, detail=f"no defense {defense_id!r}")
return {
"defense_id": record.id,
"learner_id": record.learner_id,
"task_id": record.task_id,
"status": record.status,
"turns": [
{
"seq": t.seq,
"role": t.role,
"text": t.text,
"ts": t.ts,
"latency_ms": t.latency_ms,
}
for t in record.turns
],
"integrity_signals": record.integrity_signals or {},
}