- 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.
223 lines
8.1 KiB
Python
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
|