Files
sales-trainer/backend/tests/test_persona_reply_contract.py
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

223 lines
8.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_prompt_omits_hidden_internal_reason_and_unsafe_transcript_roles():
stub = StubLLM([json.dumps({"reply": "ค่ะ"}, ensure_ascii=False)])
Simulator(stub).persona_reply(
persona=_persona(),
sales_kit={"productName": "สินค้า"},
messages=[
{"role": "seller", "text": "ขอถามปัญหาหลักหน่อยครับ"},
{"role": "assistant", "text": "hidden assistant transcript"},
{"role": "system", "text": "hidden system prompt"},
{"role": "system", "text": "⏳ public scene note"},
],
internal={
"turns": 2,
"misses": 1,
"score": 40,
"last_reason": "hidden judge reasoning",
"provider_path": "/private/provider/path",
},
)
prompt = "\n".join(message["content"] for message in stub.calls[0])
assert "hidden judge reasoning" not in prompt
assert "/private/provider/path" not in prompt
assert "hidden assistant transcript" not in prompt
assert "hidden system prompt" not in prompt
assert "⏳ public scene note" in prompt
def test_malformed_persona_and_transcript_are_bounded_before_prompting():
stub = StubLLM([json.dumps({"reply": "รับทราบค่ะ", "decision": "none", "mood": 0})])
reply, meta = Simulator(stub).persona_reply(
persona=_persona(channel={"internal": "secret"}, tolerance="not-a-number"),
sales_kit={"productName": "สินค้า"},
messages=[None, "malformed", {"role": "seller", "text": "ขอรายละเอียดเพิ่มครับ"}],
internal={"score": {"hidden": "state"}},
)
assert reply == "รับทราบค่ะ"
assert meta == {"decision": "none", "mood": 0}
prompt = "\n".join(message["content"] for message in stub.calls[0])
assert "internal" not in prompt
assert "not-a-number" not in prompt
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