Files
sales-trainer/backend/tests/test_persona_reply_contract.py

177 lines
6.1 KiB
Python

"""Persona reply contract tests: visible text must stay natural and bounded."""
from __future__ import annotations
import json
import pytest
from app.llm import LLMError
from app.services.simulator import MAX_PERSONA_REPLY_CHARS, Simulator
class StubLLM:
def __init__(self, responses: list[str]):
self.responses = list(responses)
self.calls: list[list[dict[str, str]]] = []
def complete_conversation(self, messages, **kwargs):
self.calls.append(messages)
if not self.responses:
raise AssertionError("stub response exhausted")
return self.responses.pop(0)
def _persona(**overrides):
persona = {
"name": "คุณนิด",
"tier": "B",
"initiation_mode": "customer",
"channel": "line",
"profession": "เจ้าของร้าน",
"age_group": "ผู้ใหญ่",
"background": "ทำร้านเล็ก",
"personality": "ระวังตัว",
"lifestyle": "ยุ่ง",
"income": "กลาง",
"budget": "จำกัด",
"decision_timeline": "เดือนนี้",
"pains": [{"name": "ยอดขาย", "description": "ยอดขายผันผวน"}],
"negotiation_levers": ["ราคา"],
"goal": "อยากลดความเสี่ยง",
"tolerance": 2,
"recontact": True,
"special": "wrong_text",
}
persona.update(overrides)
return persona
def _reply(stub: StubLLM):
return Simulator(stub).persona_reply(
persona=_persona(),
sales_kit={"productName": "สินค้า"},
messages=[{"role": "seller", "text": "ขอถามปัญหาหลักหน่อยครับ"}],
internal={"turns": 1, "misses": 0, "score": 50},
scenario="social",
)
def test_fenced_json_exposes_only_reply_and_keeps_metadata_internal():
stub = StubLLM([
"```json\n" + json.dumps(
{"reply": "ขอคิดดูก่อนนะคะ", "decision": "buy", "mood": 2, "secret": "hidden"},
ensure_ascii=False,
) + "\n```"
])
reply, meta = _reply(stub)
assert reply == "ขอคิดดูก่อนนะคะ"
assert "decision" not in reply
assert "mood" not in reply
assert meta == {"decision": "buy", "mood": 2}
def test_plain_natural_text_is_a_safe_fallback():
stub = StubLLM(["ลูกค้ายังไม่แน่ใจ ขอถามเรื่องราคาเพิ่มได้ไหมคะ"])
reply, meta = _reply(stub)
assert reply == "ลูกค้ายังไม่แน่ใจ ขอถามเรื่องราคาเพิ่มได้ไหมคะ"
assert meta == {"decision": "none", "mood": 0}
def test_missing_reply_gets_one_bounded_retry():
stub = StubLLM([
json.dumps({"decision": "none", "mood": 0}),
json.dumps({"reply": "ได้ค่ะ เล่ารายละเอียดเพิ่มได้เลย", "decision": "none", "mood": 1}),
])
reply, meta = _reply(stub)
assert reply == "ได้ค่ะ เล่ารายละเอียดเพิ่มได้เลย"
assert meta == {"decision": "none", "mood": 1}
assert len(stub.calls) == 2
assert "reply" in stub.calls[1][-1]["content"].lower()
def test_blank_reply_after_retry_fails_closed():
stub = StubLLM([json.dumps({"reply": ""}), json.dumps({"reply": " "})])
with pytest.raises(LLMError, match="persona reply"):
_reply(stub)
assert len(stub.calls) == 2
def test_reply_is_bounded_without_losing_thai_unicode():
text = "" * (MAX_PERSONA_REPLY_CHARS + 100)
stub = StubLLM([json.dumps({"reply": text}, ensure_ascii=False)])
reply, _ = _reply(stub)
assert len(reply) == MAX_PERSONA_REPLY_CHARS
assert reply == "" * MAX_PERSONA_REPLY_CHARS
def test_prompt_contains_persona_behavior_contract():
stub = StubLLM([json.dumps({"reply": "ค่ะ"}, ensure_ascii=False)])
_reply(stub)
system = stub.calls[0][0]["content"]
assert "tier" in system.lower()
assert "recontact" in system.lower()
assert "wrong_text" in system
assert "tolerance" in system.lower()
def test_partial_protocol_without_braces_never_leaks_into_bubble():
# Provider appended contract fields as plain text (no braces) — the exact
# raw leakage seen in production. Must NOT be rendered verbatim; it must
# be rejected so the bounded retry (or fail-closed) path is used.
stub = StubLLM([
'"สวัสดีค่ะ สนใจสอบถามค่ะ" "decision": "none", "mood": 0',
json.dumps({"reply": "สวัสดีค่ะ สนใจสอบถามค่ะ", "decision": "none", "mood": 0}, ensure_ascii=False),
])
reply, meta = _reply(stub)
assert reply == "สวัสดีค่ะ สนใจสอบถามค่ะ"
assert "decision" not in reply
assert "mood" not in reply
assert len(stub.calls) == 2
def test_malformed_brace_payload_after_retry_fails_closed():
stub = StubLLM([
'{"reply". "สวัสดีค่ะ" "\'decision": "none", "mood"\' 0}',
'{"reply". "ขอลองก่อนนะคะ" "\'decision": "walk"\'}',
])
with pytest.raises(LLMError, match="persona reply"):
_reply(stub)
assert len(stub.calls) == 2
def test_quoted_sentence_unwraps_quotes():
stub = StubLLM(['"สวัสดีค่ะ พอดีสนใจสินค้าค่ะ"'])
reply, _ = _reply(stub)
assert reply == "สวัสดีค่ะ พอดีสนใจสินค้าค่ะ"
assert '"' not in reply
def test_natural_thai_with_colon_is_not_stripped_as_protocol_noise():
# A legit spoken reply containing a colon (e.g. a time) must NOT be treated
# as trailing protocol noise — the heuristic must not over-strip real text.
msg = "เจอกันพรุ่งนี้ 09:00 นะครับ"
stub = StubLLM([msg])
reply, _ = _reply(stub)
assert reply == msg
assert "09:00" in reply