"""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