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---
235 lines
9.8 KiB
Python
235 lines
9.8 KiB
Python
"""Defense endpoint tests (Task 5-3-01, REQ-3-006) — mock voice + mock LLM."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ai_service.agents.examiner import ExaminerAgent
|
|
from ai_service.config import Settings
|
|
from ai_service.grading.store import SQLiteGradeStore
|
|
from ai_service.llm.mock import MockProvider
|
|
from ai_service.main import create_app
|
|
from ai_service.telemetry.ingest import TraceIntegrityMap
|
|
from ai_service.telemetry.store import SQLiteTraceStore
|
|
from ai_service.variants.store import SQLiteVariantStore
|
|
from ai_service.voice.defense_store import SQLiteDefenseStore
|
|
from ai_service.voice.mock import MockVoiceProvider
|
|
|
|
VERDICT = {
|
|
"verdict": "developing",
|
|
"understanding": "Explains the build clearly.",
|
|
"process_justification": "Justifies choices.",
|
|
"communication": "Clear and specific.",
|
|
"strengths": ["Grounded answers in the digest."],
|
|
"gaps": ["Did not address the edge cases."],
|
|
}
|
|
|
|
|
|
class ScriptedLLM(MockProvider):
|
|
"""Question-mode calls get a question; verdict-mode calls get D-020 JSON.
|
|
|
|
Discriminator: the verdict prompt contains "final verdict JSON" — the
|
|
question prompt says "next question".
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
|
|
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
|
|
all_text = "\n".join(m.content for m in messages)
|
|
if "final verdict JSON" in all_text:
|
|
return json.dumps(VERDICT)
|
|
return "Why did you structure the fix that way?"
|
|
|
|
|
|
@pytest.fixture()
|
|
def app(tmp_path: Path):
|
|
application = create_app(Settings(provider="mock", voice_provider="mock"))
|
|
llm = ScriptedLLM()
|
|
application.state.provider = llm
|
|
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
|
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
|
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
|
application.state.trace_integrity = TraceIntegrityMap()
|
|
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
|
application.state.voice_provider = MockVoiceProvider(
|
|
["the fix was in the retry loop"]
|
|
)
|
|
settings = Settings(provider="mock", voice_provider="mock")
|
|
application.state.examiner_agent = ExaminerAgent(llm, settings)
|
|
return application
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(app) -> TestClient:
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def _start(client: TestClient) -> dict:
|
|
resp = client.post(
|
|
"/v1/defense/start", json={"learner_id": "defense-learner", "task_id": "defense-task"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
class TestStart:
|
|
def test_start_returns_first_question_and_descriptor(self, client) -> None:
|
|
body = _start(client)
|
|
assert body["first_question"]
|
|
assert body["defense_id"]
|
|
assert body["voice_descriptor"]["mode"] == "mock"
|
|
assert body["trace_complete"] is True
|
|
stored = client.get(f"/v1/defense/{body['defense_id']}")
|
|
assert stored.status_code == 200
|
|
turns = stored.json()["turns"]
|
|
assert turns and turns[0]["role"] == "examiner"
|
|
|
|
def test_start_with_unknown_trace_is_complete_flag(self, client) -> None:
|
|
body = _start(client)
|
|
assert body["trace_complete"] is True
|
|
|
|
|
|
class TestBrowserFallback:
|
|
def test_browser_mode_serves_browser_descriptor(self, tmp_path: Path) -> None:
|
|
"""Must-Have #6: AI_VOICE_PROVIDER=browser → start returns the
|
|
browser-native SR/TTS fallback descriptor (D-030), not 'mock'."""
|
|
application = create_app(Settings(provider="mock", voice_provider="browser"))
|
|
application.state.provider = ScriptedLLM()
|
|
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
|
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
|
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
|
application.state.trace_integrity = TraceIntegrityMap()
|
|
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
|
application.state.voice_provider = MockVoiceProvider(["answer"])
|
|
llm = ScriptedLLM()
|
|
application.state.examiner_agent = ExaminerAgent(
|
|
llm, Settings(provider="mock", voice_provider="browser")
|
|
)
|
|
with TestClient(application) as c:
|
|
body = _start(c)
|
|
assert body["voice_descriptor"]["mode"] == "browser"
|
|
assert body["voice_descriptor"]["sr_available"]
|
|
assert "SpeechRecognition" in body["voice_descriptor"]["hint"]
|
|
|
|
|
|
class TestAnswer:
|
|
def test_typed_answer_yields_followup_with_latency(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
resp = client.post(
|
|
f"/v1/defense/{defense_id}/answer", data={"text": "I fixed the loop."}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["question"]
|
|
assert body["turn_latency"]["llm_ms"] is not None
|
|
|
|
def test_audio_answer_transcribed_and_recorded(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
wav_bytes = b"RIFF" + b"\x00" * 64
|
|
resp = client.post(
|
|
f"/v1/defense/{defense_id}/answer",
|
|
files={"audio": ("answer.wav", wav_bytes, "audio/wav")},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
stored = client.get(f"/v1/defense/{defense_id}").json()
|
|
learner_turns = [t for t in stored["turns"] if t["role"] == "learner"]
|
|
assert learner_turns, "learner turn missing after audio answer"
|
|
assert learner_turns[0]["text"] == "the fix was in the retry loop"
|
|
|
|
def test_empty_audio_is_422_not_500(self, client) -> None:
|
|
"""Zero-byte upload must 422 before the provider call (a real
|
|
provider would raise the same way the mock does — validate first)."""
|
|
defense_id = _start(client)["defense_id"]
|
|
resp = client.post(
|
|
f"/v1/defense/{defense_id}/answer",
|
|
files={"audio": ("answer.wav", b"", "audio/wav")},
|
|
)
|
|
assert resp.status_code == 422, resp.text
|
|
stored = client.get(f"/v1/defense/{defense_id}").json()
|
|
assert len(stored["turns"]) == 1 # nothing appended
|
|
|
|
def test_answer_after_finish_is_409(self, client) -> None:
|
|
"""A sealed transcript is append-only-no-more: the endpoints own
|
|
turn-vs-finalize sequencing (defense_store contract)."""
|
|
defense_id = _start(client)["defense_id"]
|
|
client.post(f"/v1/defense/{defense_id}/answer", data={"text": "a"})
|
|
assert client.post(f"/v1/defense/{defense_id}/finish").status_code == 200
|
|
resp = client.post(f"/v1/defense/{defense_id}/answer", data={"text": "late"})
|
|
assert resp.status_code == 409, resp.text
|
|
stored = client.get(f"/v1/defense/{defense_id}").json()
|
|
assert len(stored["turns"]) == 3 # ex, lrn, ex — no post-finish turns
|
|
|
|
def test_neither_text_nor_audio_422(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
resp = client.post(f"/v1/defense/{defense_id}/answer")
|
|
assert resp.status_code == 422
|
|
|
|
def test_unknown_defense_404(self, client) -> None:
|
|
resp = client.post("/v1/defense/dfn-nope/answer", data={"text": "hi"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestAudioEndpoint:
|
|
def test_examiner_turn_streams_wav(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
resp = client.get(f"/v1/defense/{defense_id}/audio/0")
|
|
assert resp.status_code == 200
|
|
assert resp.content
|
|
assert resp.headers["content-type"].startswith("audio/")
|
|
|
|
def test_unknown_turn_404(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
assert client.get(f"/v1/defense/{defense_id}/audio/42").status_code == 404
|
|
|
|
|
|
class TestFinishAndGet:
|
|
def test_full_loop_verdict_and_signals(self, client) -> None:
|
|
defense_id = _start(client)["defense_id"]
|
|
client.post(f"/v1/defense/{defense_id}/answer", data={"text": "answer one"})
|
|
finish = client.post(f"/v1/defense/{defense_id}/finish")
|
|
assert finish.status_code == 200, finish.text
|
|
body = finish.json()
|
|
assert body["verdict"]["verdict"] == "developing"
|
|
assert body["integrity_signals"]["pause_threshold_ms"]
|
|
stored = client.get(f"/v1/defense/{defense_id}").json()
|
|
assert stored["status"] == "finished"
|
|
assert stored["integrity_signals"]
|
|
# Must-Have #1: "verdict + transcript persisted" — the verdict must
|
|
# be retrievable from GET after finish, not only in the finish body.
|
|
assert stored["integrity_signals"]["verdict"]["verdict"] == "developing"
|
|
|
|
def test_long_pause_flagged(self, client, app) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
from ai_service.voice.defense_store import DefenseTurn
|
|
|
|
defense_id = _start(client)["defense_id"]
|
|
# inject a slow learner turn directly (simulated latency)
|
|
store = app.state.defense_store
|
|
store.append_turn(
|
|
defense_id,
|
|
DefenseTurn(
|
|
defense_id=defense_id,
|
|
seq=99,
|
|
role="learner",
|
|
text="slow reply",
|
|
ts=datetime.now(UTC),
|
|
latency_ms=30_000,
|
|
created_at=datetime.now(UTC),
|
|
),
|
|
)
|
|
finish = client.post(f"/v1/defense/{defense_id}/finish")
|
|
assert finish.status_code == 200
|
|
signals = finish.json()["integrity_signals"]
|
|
assert any(p["turn"] == 99 for p in signals["long_pauses"])
|
|
|
|
def test_unknown_defense_404_on_all(self, client) -> None:
|
|
assert client.post("/v1/defense/dfn-nope/finish").status_code == 404
|
|
assert client.get("/v1/defense/dfn-nope").status_code == 404
|