177 lines
6.1 KiB
Python
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
|