12b2300f6f
---ci--- phase: 7 milestone: v0.3 status: review lessons: - P0 CORS: allow_methods lacked PUT while the build surface writes files with PUT — every cross-origin Save failed preflight; pinned with tests/api/test_cors.py - P0 ingest leak: queue-overflow flood path returned without the disconnect sentinel, parking the drainer forever (one leaked task-set per flooded trace); sentinel now always enqueued, real-server regression test added - P1 perf: flood cap counted rows via len(get_trace(...)) — O(trace) per append, O(n²) per session; TraceStore.count() (COUNT(*)) added and wired - P0 security: file routes followed exec-planted symlinks out of the workspace bind; _resolve_in_workspace refuses escapes (422), read/write now 404 on unknown sandboxes (was 500) - P1 security: WS ingest accepted any browser Origin (CORS middleware does not cover WS); localhost dev origins + no-Origin (capture agent) allowed, others 1008 - P1 correctness: use-sandbox-session leaked a created sandbox on any mid-start failure (per-learner cap 1 → all retries 429 forever); failed starts now destroy what they created - P2 testing: reconnect-flush test killed mid-burst (nondeterministic under load, reproduced on pre-change code); now waits for server-side observation of the pre-kill burst — the underlying one-line replay-margin/ACK gap is documented for v0.4 - maintainability: grading-store/templates/grading.ts docstrings claimed grading is variant-blind (stale pre-P4 text) — updated; ARCHITECTURE.md referenced nonexistent voice/openai_audio.py; dead if TYPE_CHECKING: pass blocks removed ---/ci---
333 lines
12 KiB
Python
333 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 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 {},
|
|
}
|