22d4fa212c
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---
337 lines
12 KiB
Python
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 {},
|
|
}
|