fbd6602814
---ci--- phase: 0 milestone: v0.1 status: complete ---/ci---
164 lines
5.6 KiB
Python
164 lines
5.6 KiB
Python
"""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 |