"""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 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, ) 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 {}, }