[verified] fix live-QA UX: no JSON leak in chat, greeting openers (wrong_text cools off post-first-reply), auto-close+summarize on buy/walk/try, remove Finish button

This commit is contained in:
Macky
2026-08-18 09:42:05 +07:00
parent 2c40fc7502
commit ffb7cdda31
11 changed files with 379 additions and 46 deletions

View File

@@ -7,6 +7,7 @@ never exposed mid-chat. Initiation is per-persona (customer or seller).
from __future__ import annotations
import json
import re
from typing import Any
from ..llm import LLMClient, LLMError
@@ -189,8 +190,10 @@ class Simulator:
"You are a neutral sales-coaching judge. Read the TRANSCRIPT and decide, as the "
"customer persona, how it CURRENTLY feels and whether it has made a decision.\n"
"- mood: -2..+2 (very negative .. very positive toward purchase)\n"
"- decision: 'buy' if the customer has clearly decided to buy, 'walk' if clearly "
"refused/walking away (cannot afford / no interest), else 'pending' (still deciding)\n"
"- decision: 'buy' if the customer has clearly decided to buy now, 'walk' if clearly "
"refused/walking away (cannot afford / no interest), 'try' if the customer has decided to "
"trial/test the product or service first and will come back later (not a straight refusal, "
"not a confirmed purchase), else 'pending' (still deciding)\n"
"- score_delta: -15..+15 (direction of the sale after this turn)\n"
"- reason: one short Thai/English sentence matching the transcript language.\n"
"Only output valid JSON: {mood, decision, score_delta, reason}."
@@ -209,7 +212,7 @@ class Simulator:
result.setdefault("decision", "pending")
result.setdefault("score_delta", 0)
result.setdefault("reason", "")
if result.get("decision") not in ("buy", "walk", "pending"):
if result.get("decision") not in ("buy", "walk", "try", "pending"):
result["decision"] = "pending"
return result
@@ -292,7 +295,9 @@ class Simulator:
The preferred contract is a JSON object. A plain natural-language
response remains a safe compatibility fallback for providers that ignore
JSON mode. Objects without a usable ``reply`` are retryable; they are
never rendered verbatim.
never rendered verbatim. A malformed/partial protocol blob (even one
missing its opening brace) is NEVER surfaced — it is rejected and
triggers a bounded retry instead of leaking raw JSON into the chat.
"""
if not isinstance(text, str):
return None
@@ -328,18 +333,72 @@ class Simulator:
"mood": mood,
}
# A plain sentence is safe; protocol-looking malformed JSON is not.
if raw.startswith(("{", "[", "```")):
# Reject anything that still looks like a protocol envelope or partial
# JSON (e.g. a fenced block, a leading brace/bracket, or text carrying
# the contract's reserved keys). These must never render in the bubble.
if self._looks_like_protocol(raw, extracted):
return None
return raw[:MAX_PERSONA_REPLY_CHARS], {"decision": "none", "mood": 0}
# A leading quote-wrapped reply with trailing key:value noise can reach
# here; drop the trailing noise so only the spoken sentence is kept.
cleaned = self._strip_trailing_protocol_noise(raw)
if not cleaned:
return None
return cleaned[:MAX_PERSONA_REPLY_CHARS], {"decision": "none", "mood": 0}
def _looks_like_protocol(self, raw: str, extracted: str) -> bool:
"""True when the provider text still carries a JSON/protocol signature.
Covers the exact leakage observed in the wild: a valid-looking reply
glued to `"decision": ...` / `"mood": ...` keys with or without braces.
"""
if raw.startswith(("{", "[", "```")):
return True
# Reserved contract keys mark incomplete protocol output.
lowered = raw.lower()
if '"reply"' in raw or '"decision"' in lowered or '"mood"' in lowered:
return True
if "'reply'" in raw or "'decision'" in lowered or "'mood'" in lowered:
return True
# A colon immediately after a quoted fragment implies key:value noise.
if ":" in raw and (extracted.startswith('"') or "{" in raw or "}" in raw):
return True
return False
def _strip_trailing_protocol_noise(self, raw: str) -> str:
"""Remove a trailing `"key": value` fragment from a spoken reply.
Providers sometimes append contract fields as plain text after the
sentence. If they do, keep only the leading natural-language portion.
Also unwraps a single leading/trailing quote pair so a bare quoted
sentence (`"สวัสดีค่ะ"`) returns clean text instead of quotes.
"""
# Find the first occurrence of a JSON-ish key fragment: `"word"` or `'word'` followed by ':'.
match = re.search(r'''["'][A-Za-z_]+["']\s*:''', raw)
if match:
head = raw[: match.start()].strip().rstrip('"').strip()
# Recurse to drop any earlier key-fragments too (rare chained noise).
if head and head != raw:
sub = self._strip_trailing_protocol_noise(head)
return sub or head
return head
cleaned = raw.strip()
# Unwrap a matching leading+trailing quote pair (then retry once).
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in ('"', "'"):
inner = cleaned[1:-1].strip()
if inner:
return inner
return cleaned
return cleaned.rstrip('"').strip()
def _special_instr(self, persona: dict[str, Any]) -> str:
if persona.get("special") == "wrong_text":
return (
"SPECIAL: You opened as if ready to buy, but the moment the seller replies you act "
"disinterested and try to end the chat (e.g. 'never mind, forget it'). Deep down your "
"pain is still real. A seller who gently re-engages without pushing may earn a second "
"chance; a pushy seller drives you away for good."
"SPECIAL: You messaged this seller normally (your opener was a genuine greeting/interest "
"message — you have not complained and you have NOT said 'wrong chat' or 'never mind' yet). "
"ONLY your very first reply to the seller's opening is allowed to cool off: sound "
"disinterested and try to end the chat (e.g. 'oh, sorry, I think I messaged the wrong "
"person' / 'never mind, forget it'). Deep down your pain is still real. A seller who gently "
"re-engages without pushing may earn a second chance; a pushy seller drives you away for good."
)
return ""