"""Tests for debrief generation + guardrail filter (TASK-05-01, TASK-05-02, TASK-05-03).""" from __future__ import annotations import asyncio from typing import Any import pytest from server.debrief import generate_debrief from server.guardrails.customer_service import CustomerServiceGuardrail from server.scenarios.loader import load from server.services.base import GuardrailContext class _FakeLLM: """A fake LLMProvider that returns a canned debrief (no real API call).""" debrief_model = "deepseek-v4-flash:cloud" roleplay_model = "gemma4:cloud" def __init__(self, response: str) -> None: self._response = response async def chat_full(self, messages, *, model=None, no_think=False): return self._response, {"output_tokens": 50, "model": model or self.debrief_model} def _load_scenario(): return load("customer_service_refund_ca_v01") def test_debrief_references_learner_turns_and_branch(): """TASK-05-01: a scripted escalate session produces a debrief referencing the learner's turns + the escalates_unresolved focus.""" scenario = _load_scenario() turns = [ {"role": "user", "asr_text": "Our policy says no refunds after 30 days."}, {"role": "assistant", "tts_text": "But I just want my money back!"}, {"role": "user", "asr_text": "I can't help you, that's the policy."}, ] llm = _FakeLLM( "- What you did well: you stayed calm.\n" "- What to improve: you led with policy before acknowledging the customer's " "frustration — they escalated because they felt unheard.\n" "- Next step: acknowledge emotion first, then explain policy." ) async def _run(): return await generate_debrief( llm, scenario, branch_id="escalate", outcome="failure", debrief_focus=scenario.branch_by_id("escalate").debrief_focus, learner_turns=turns, ) text, usage = asyncio.run(_run()) assert "policy" in text.lower() or "frustration" in text.lower() assert usage["model"] == "deepseek-v4-flash:cloud" def test_debrief_uses_no_think_mode(): """REQ-LLM-02: the debrief uses deepseek-v4-flash:cloud no_think (D-020).""" scenario = _load_scenario() llm = _FakeLLM("debrief text") captured: dict[str, Any] = {} async def _chat_full(messages, *, model=None, no_think=False): captured["model"] = model captured["no_think"] = no_think return "debrief", {"output_tokens": 1} llm.chat_full = _chat_full # type: ignore async def _run(): return await generate_debrief( llm, scenario, "accept_resolution", "success", "focus", [{"role": "user", "asr_text": "hi"}], ) asyncio.run(_run()) assert captured["model"] == "deepseek-v4-flash:cloud" assert captured["no_think"] is True def test_debrief_guardrail_blocks_legal_action(): """TASK-05-02: a debrief containing 'tell the customer to sue' is filtered.""" scenario = _load_scenario() guardrail = CustomerServiceGuardrail() llm = _FakeLLM( "- What you did well: nothing.\n" "- What to improve: you should tell the customer to sue them.\n" "- Next step: recommend legal action." ) async def _run(): return await generate_debrief( llm, scenario, "escalate", "failure", "focus", [{"role": "user", "asr_text": "policy"}], guardrail=guardrail, ) text, _ = asyncio.run(_run()) # The guardrail filtered_text replaces the legal-action recommendation. assert "Focus your coaching" in text or "legal action" not in text.lower() or "learner" in text.lower() def test_debrief_normal_coaching_passes_guardrail(): """TASK-05-02: a normal coaching debrief passes the guardrail filter.""" scenario = _load_scenario() guardrail = CustomerServiceGuardrail() normal_debrief = ( "- What you did well: you acknowledged the customer's frustration.\n" "- What to improve: offer a concrete resolution sooner.\n" "- Next step: practice the empathy-first opening." ) llm = _FakeLLM(normal_debrief) async def _run(): return await generate_debrief( llm, scenario, "accept_resolution", "success", "focus", [{"role": "user", "asr_text": "I'm sorry, I can offer a refund."}], guardrail=guardrail, ) text, _ = asyncio.run(_run()) assert text == normal_debrief # unchanged — guardrail allowed it def test_debrief_voice_via_ttsprovider(): """TASK-05-03: the debrief is synthesized via the TTSProvider interface (D-006). This is a structural test: the debrief text is passed to TTSProvider.synthesize, reusing the same voice as the role-play (no separate TTS path). """ from server.services.base import TTSProvider, TTSResult class _FakeTTS(TTSProvider): name = "fake" synthesized: list[str] = [] @property def voice_id(self) -> str: return "fake-voice" async def synthesize(self, text): self.synthesized.append(text) yield b"\x00\x00" async def synthesize_all(self, text): self.synthesized.append(text) return b"\x00\x00", TTSResult(chars=len(text), voice_id=self.voice_id) tts = _FakeTTS() debrief_text = "You did well. Improve your empathy. Next: practice." async def _run(): return await tts.synthesize_all(debrief_text) audio, result = asyncio.run(_run()) assert tts.synthesized == [debrief_text] # same TTS path as role-play assert result.voice_id == "fake-voice" # D-006: one voice assert len(audio) > 0