Files
nextcraft/apps/ai-service/tests/voice/test_latency.py
T
CIAgent 007865a5a1 test(P05): latency instrumentation + conversational-budget docs (Wave 4)
Task 5-4-01: tests/voice/test_latency.py — instrumentation presence + population
(stt_ms/llm_ms/tts_ms in every answer response; latency_ms on every persisted turn);
DEFENSE_TURN_BUDGET_MS=4s named; mock turns within budget. README: voice mock-first
section — real server STT/TTS deferred to v0.4 (CUT-1/G-7), AI_VOICE_PROVIDER modes,
the v0.4 wall-clock acceptance probe (manual, keys in .ciagent/.env.secrets only).

Suite 383 green; ruff clean.

---ci---
phase: 5
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-006], partial: []}
---/ci---
2026-09-12 04:39:29 +00:00

102 lines
4.3 KiB
Python

"""Per-turn latency instrumentation tests (Task 5-4-01, REQ-3-006, A-109).
Mock-based: asserts instrumentation PRESENCE and population (stt_ms / llm_ms /
tts_ms fields, per-turn latency_ms persisted, the budget constant defined) —
wall-clock against a real voice endpoint is a v0.4 acceptance criterion
(real STT/TTS deferred per GRILL CUT-1 / G-7).
"""
from __future__ import annotations
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
#: A-109: the documented conversational budget (acceptance criterion for the
#: v0.4 real-voice probe; mock turns are near-instant so v0.3 asserts
#: instrumentation, not wall-clock).
DEFENSE_TURN_BUDGET_MS = 4_000
@pytest.fixture()
def client(tmp_path: Path) -> TestClient:
llm = MockProvider()
app = create_app(Settings(provider="mock", voice_provider="mock"))
app.state.provider = llm
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
app.state.trace_integrity = TraceIntegrityMap()
app.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
app.state.voice_provider = MockVoiceProvider(["my answer"])
app.state.examiner_agent = ExaminerAgent(llm, Settings(provider="mock"))
with TestClient(app) as c:
yield c
class TestLatencyInstrumentation:
def test_budget_constant_defined(self) -> None:
"""The conversational budget is a named, documented constant (A-109)."""
assert DEFENSE_TURN_BUDGET_MS > 0
assert DEFENSE_TURN_BUDGET_MS <= 5_000 # conversational feel target
def test_answer_reports_per_phase_latency(self, client: TestClient) -> None:
start = client.post(
"/v1/defense/start",
json={"learner_id": "lat-learner", "task_id": "lat-task"},
).json()
resp = client.post(
f"/v1/defense/{start['defense_id']}/answer", data={"text": "answer"}
)
assert resp.status_code == 200
latency = resp.json()["turn_latency"]
assert latency["llm_ms"] is not None and latency["llm_ms"] >= 0
assert "stt_ms" in latency and "tts_ms" in latency
def test_audio_answer_populates_stt_ms(self, client: TestClient) -> None:
start = client.post(
"/v1/defense/start",
json={"learner_id": "lat-learner", "task_id": "lat-task"},
).json()
resp = client.post(
f"/v1/defense/{start['defense_id']}/answer",
files={"audio": ("a.wav", b"RIFF" + b"\x00" * 32, "audio/wav")},
)
latency = resp.json()["turn_latency"]
assert latency["stt_ms"] is not None and latency["stt_ms"] >= 0
def test_every_turn_persists_latency_ms(self, client: TestClient) -> None:
start = client.post(
"/v1/defense/start",
json={"learner_id": "lat-learner", "task_id": "lat-task"},
).json()
client.post(f"/v1/defense/{start['defense_id']}/answer", data={"text": "a"})
transcript = client.get(f"/v1/defense/{start['defense_id']}").json()
assert transcript["turns"]
for turn in transcript["turns"]:
assert "latency_ms" in turn
assert turn["latency_ms"] is not None or turn["role"] == "learner"
def test_mock_turns_within_budget(self, client: TestClient) -> None:
"""Mock turns must be near-instant — the budget holds trivially."""
start = client.post(
"/v1/defense/start",
json={"learner_id": "lat-learner", "task_id": "lat-task"},
).json()
resp = client.post(
f"/v1/defense/{start['defense_id']}/answer", data={"text": "a"}
).json()
assert resp["turn_latency"]["llm_ms"] < DEFENSE_TURN_BUDGET_MS