[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:
@@ -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 ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user