- 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.
556 lines
25 KiB
Python
556 lines
25 KiB
Python
"""Sales chat simulator: the trainee's chat engine against one persona.
|
|
|
|
Reuses the persona card + sales kit + chat history + internal state. A separate
|
|
judge-LLM decides outcome (won/lost) + scoring + coaching. Hidden/latent data is
|
|
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
|
|
|
|
MAX_PERSONA_REPLY_CHARS = 4000
|
|
|
|
|
|
def _safe_roleplay_internal(value: object) -> dict[str, Any]:
|
|
"""Keep judge-only state out of the customer role-play prompt."""
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
safe: dict[str, Any] = {}
|
|
for key in ("turns", "misses", "score"):
|
|
item = value.get(key)
|
|
if isinstance(item, int) and not isinstance(item, bool):
|
|
safe[key] = item
|
|
recontact_done = value.get("recontact_done")
|
|
if isinstance(recontact_done, bool):
|
|
safe["recontact_done"] = recontact_done
|
|
signals = value.get("signals")
|
|
if isinstance(signals, list):
|
|
safe_signals = []
|
|
for signal in signals[-20:]:
|
|
if not isinstance(signal, dict):
|
|
continue
|
|
turn = signal.get("turn")
|
|
mood = signal.get("mood")
|
|
signal_type = signal.get("type")
|
|
if (
|
|
isinstance(turn, int)
|
|
and not isinstance(turn, bool)
|
|
and isinstance(mood, int)
|
|
and not isinstance(mood, bool)
|
|
and signal_type in {"annoy", "warm"}
|
|
):
|
|
safe_signals.append({"turn": turn, "mood": mood, "type": signal_type})
|
|
safe["signals"] = safe_signals
|
|
return safe
|
|
|
|
|
|
def _safe_prompt_text(value: object, default: str = "", limit: int = 4000) -> str:
|
|
if isinstance(value, str):
|
|
return value[:limit]
|
|
if isinstance(value, (int, float, bool)):
|
|
return str(value)[:limit]
|
|
return default
|
|
|
|
|
|
def _safe_persona(value: object) -> dict[str, Any]:
|
|
"""Normalize persisted persona values before prompt or arithmetic use."""
|
|
raw = value if isinstance(value, dict) else {}
|
|
safe = dict(raw)
|
|
for key in (
|
|
"name", "profession", "age_group", "location", "product_context",
|
|
"background", "income", "lifestyle", "personality", "communication_style",
|
|
"budget", "decision_timeline", "goal", "opener", "special", "notes",
|
|
):
|
|
safe[key] = _safe_prompt_text(raw.get(key), "")
|
|
channel = raw.get("channel")
|
|
safe["channel"] = channel if isinstance(channel, str) and channel in {"facebook", "line"} else "social"
|
|
initiation_mode = raw.get("initiation_mode")
|
|
safe["initiation_mode"] = (
|
|
initiation_mode
|
|
if isinstance(initiation_mode, str) and initiation_mode in {"customer", "seller"}
|
|
else "customer"
|
|
)
|
|
tier = raw.get("tier")
|
|
safe["tier"] = tier if isinstance(tier, str) and tier in {"A", "B", "C"} else "B"
|
|
safe["recontact"] = raw.get("recontact") is True
|
|
try:
|
|
tolerance = int(raw.get("tolerance", 3) or 3)
|
|
except (TypeError, ValueError, OverflowError):
|
|
tolerance = 3
|
|
safe["tolerance"] = max(1, min(10, tolerance))
|
|
for key in ("pains", "negotiation_levers", "objections"):
|
|
safe[key] = raw.get(key) if isinstance(raw.get(key), list) else []
|
|
return safe
|
|
|
|
|
|
def _safe_transcript(value: object) -> list[dict[str, str]]:
|
|
"""Drop malformed transcript rows before they reach the simulator."""
|
|
if not isinstance(value, list):
|
|
return []
|
|
safe: list[dict[str, str]] = []
|
|
for item in value[-100:]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
role = item.get("role")
|
|
text = item.get("text")
|
|
if role not in {"system", "seller", "customer"} or not isinstance(text, str):
|
|
continue
|
|
safe.append({"role": role, "text": text[:4000]})
|
|
return safe
|
|
|
|
|
|
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
|
|
Stay perfectly in character at ALL times. Use {tone}.
|
|
|
|
CONTEXT ABOUT YOU (USE THIS — it is your truth, but DO NOT reveal latent details unless asked
|
|
naturally and it makes sense for a real customer to reveal them):
|
|
- Profession: {profession} | Age: {age_group} | Channel: {channel}
|
|
- Background: {background}
|
|
- Personality: {personality}
|
|
- Lifestyle: {lifestyle} | Income: {income}
|
|
- Budget: {budget} | Decision timeline: {decision_timeline}
|
|
- Your pains (some may be product-solvable, some NOT): {pains}
|
|
- Your negotiation levers: {levers}
|
|
- Your goal/mood: {goal}
|
|
- Tier: {tier} (A = ready, B = researching/unsure, C = resistant but has a real pain)
|
|
- Your tolerance (TOLERANCE): you walk away after about {tolerance} irritating/off-point/pushy
|
|
answers. If the seller is repeatedly wrong, ignores your need, or is pushy, you feel fed up.
|
|
- Recontact lead: {recontact}. If true, you have prior context with this seller and may be warmer
|
|
after the time-lapse note, but still require the real pain to be handled.
|
|
Special flag: {special}.
|
|
Initiation mode: {init_mode}. {special_instr}
|
|
|
|
BEHAVIOR RULES:
|
|
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate.
|
|
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops.
|
|
Past your tolerance, you SAY so plainly and end the chat (e.g. "Never mind, forget it" / "I'll
|
|
think about it elsewhere" / "ok, bye").
|
|
3. When the seller genuinely resolves your real pain AND you accept the price, you DECIDE and SAY
|
|
plainly you'll take it (e.g. "ok, let's go with it" / "fine, send me the order").
|
|
4. You reveal pains only when the seller asks good questions or builds trust.
|
|
5. Respond in natural, in-character style.
|
|
6. Stay in character; never mention this is a simulation. When you decide (buy OR walk away), say it
|
|
naturally in-dialogue; do not narrate as meta.
|
|
|
|
Reply ONLY with a JSON object:
|
|
{{"reply": "<your message>", "decision": "none" | "buy" | "walk", "mood": -2..2}}
|
|
- "decision": set "buy" ONLY when you clearly decided to purchase; "walk" ONLY when you clearly
|
|
decided NOT to purchase and are ending the chat; otherwise "none".
|
|
- "mood": -2 (very annoyed) .. +2 (very receptive), current feel about the seller.
|
|
Only output that JSON.
|
|
"""
|
|
|
|
JUDGE_SYSTEM = """You are the JUDGE of a sales-training chat. Decide the outcome and score it.
|
|
|
|
A sale is CLOSED only if BOTH:
|
|
1. The seller resolved the customer's real pain(s) (the conditions that matter to this persona),
|
|
AND
|
|
2. The customer verbally accepts the offer/price (in the final exchange).
|
|
|
|
REALISM RULES:
|
|
- A good response can WIN even in a hard scenario (e.g. customer who 'texted wrong', 'changed their
|
|
mind', or has been silent). If the seller re-engages gently, re-qualifies the real need, and closes,
|
|
it's a WIN. Do NOT auto-fail on special cases — always reward genuinely skillful recovery.
|
|
- LOST reflects the persona TYPICALLY losing (real-world >90% of such leads do not convert), but the
|
|
trainee's skill evaluation must remain fair: a strong close beats a weak one, always.
|
|
- If the seller was pushy, rude, ignored the need, or mis-diagnosed the pain, mark LOST even if the
|
|
price was acceptable.
|
|
|
|
Scoring (0-100): painResolution + trust + objectionHandling + quality of the close.
|
|
Return JSON:
|
|
{
|
|
"outcome": "won" | "lost",
|
|
"score": 0-100,
|
|
"pain": "the persona's key pain",
|
|
"why": "brief reason for won/lost",
|
|
"failurePoints": ["what went wrong, or []"],
|
|
"coaching": ["for each weak point, a concrete 'you should have said/asked this instead']",
|
|
"painProgress": {"painName": 0-100}
|
|
}
|
|
"""
|
|
|
|
|
|
class Simulator:
|
|
def __init__(self, llm: LLMClient, judge_llm: LLMClient | None = None) -> None:
|
|
self.llm = llm
|
|
self.judge_llm = judge_llm or llm
|
|
|
|
# ── persona reply ──────────────────────────────────────────────────
|
|
def persona_reply(
|
|
self,
|
|
*,
|
|
persona: dict[str, Any],
|
|
sales_kit: dict[str, Any],
|
|
messages: list[dict[str, str]],
|
|
internal: dict[str, Any],
|
|
scenario: str = "social",
|
|
scenario_adapt: str = "",
|
|
) -> tuple[str, dict[str, Any]]:
|
|
"""Return (reply_text, meta) where meta includes decision/mood from the persona."""
|
|
persona = _safe_persona(persona)
|
|
messages = _safe_transcript(messages)
|
|
pains_txt = self._describe_pains(persona.get("pains", []))
|
|
adapt = scenario_adapt or {
|
|
"social": "Chat style: short, casual, quick social-messaging replies.",
|
|
"f2f_call": "Style: natural, conversational like a live face-to-face or phone talk.",
|
|
}.get(scenario, "")
|
|
# NOTE: 'recontact' is a MID-CHAT behavior, not baked into the opening — the trainee
|
|
# chats with this customer normally first. At the right turn (see chat_routes) a
|
|
# time-lapse system note is inserted and only THEN does the customer re-engage warmer.
|
|
tolerance = int(persona.get("tolerance", 3) or 3)
|
|
system = CHAT_SYSTEM.format(
|
|
name=persona.get("name", "Customer"),
|
|
tone=persona.get("communication_style", "natural, casual"),
|
|
profession=persona.get("profession", "customer"),
|
|
age_group=persona.get("age_group", "adult"),
|
|
channel=persona.get("channel", "social") + (f" ({scenario})" if scenario else ""),
|
|
background=persona.get("background", ""),
|
|
personality=persona.get("personality", ""),
|
|
lifestyle=persona.get("lifestyle", ""),
|
|
income=persona.get("income", ""),
|
|
budget=persona.get("budget", ""),
|
|
decision_timeline=persona.get("decision_timeline", ""),
|
|
pains=pains_txt,
|
|
levers=self._describe_levers(persona.get("negotiation_levers", [])),
|
|
goal=persona.get("goal", ""),
|
|
tier=persona.get("tier", "B"),
|
|
tolerance=tolerance,
|
|
recontact="yes" if persona.get("recontact") else "no",
|
|
special=persona.get("special", ""),
|
|
init_mode="you contacted the seller first (customer-initiated)"
|
|
if persona.get("initiation_mode") == "customer"
|
|
else "the seller opened the sale to you (you are a lead)",
|
|
special_instr=self._special_instr(persona) + "\n" + adapt,
|
|
)
|
|
msgs = [{"role": "system", "content": system}]
|
|
msgs.append({
|
|
"role": "system",
|
|
"content": "Internal state (for your role-play only): "
|
|
+ json.dumps(_safe_roleplay_internal(internal), ensure_ascii=False),
|
|
})
|
|
for m in messages[-30:]:
|
|
role = m.get("role")
|
|
text = m.get("text")
|
|
if not isinstance(text, str):
|
|
continue
|
|
if role == "system":
|
|
if text.startswith("⏳ "):
|
|
msgs.append({"role": "system", "content": f"[scene note from transcript]: {text[:4000]}"})
|
|
elif role in {"seller", "customer"}:
|
|
msgs.append({"role": role, "content": text[:4000]})
|
|
for attempt in range(2):
|
|
if attempt:
|
|
msgs.append({
|
|
"role": "system",
|
|
"content": "Contract correction: output one JSON object with a non-empty string field named reply. Do not include analysis or extra prose.",
|
|
})
|
|
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
|
parsed = self._parse_persona_reply(resp)
|
|
if parsed is not None:
|
|
return parsed
|
|
raise LLMError("persona reply was empty or missing reply field")
|
|
|
|
def evaluate_turn(self, *, persona, messages, internal=None) -> dict[str, Any]:
|
|
"""Per-turn state evaluation: how the persona feels + whether it has decided.
|
|
|
|
Uses the SAME judge LLM (structured JSON) on every round so the win/loss decision is
|
|
derived from the conversation context — NOT from fixed keywords. Returns
|
|
{mood, decision(buy|walk|pending), score_delta, reason}.
|
|
"""
|
|
persona = _safe_persona(persona)
|
|
messages = _safe_transcript(messages)
|
|
internal = _safe_roleplay_internal(internal)
|
|
transcript = "\n".join(
|
|
f"{m.get('role')}: {m.get('text')}" for m in messages[-30:]
|
|
)
|
|
persona_summary = json.dumps({
|
|
"name": persona.get("name", "?"),
|
|
"pains": persona.get("pains", []),
|
|
"budget": persona.get("budget", ""),
|
|
"tolerance": persona.get("tolerance", 3),
|
|
"negotiation_levers": persona.get("negotiation_levers", []),
|
|
"special": persona.get("special", ""),
|
|
"recontact": persona.get("recontact", False),
|
|
"goal": persona.get("goal", ""),
|
|
"decision_timeline": persona.get("decision_timeline", ""),
|
|
}, ensure_ascii=False)
|
|
state_note = ""
|
|
if internal:
|
|
state_note = (
|
|
f"\n\nINTERNAL (hidden, judging only): turns={internal.get('turns', 0)}, "
|
|
f"misses={internal.get('misses', 0)}, score={internal.get('score', 50)}"
|
|
)
|
|
sys = (
|
|
"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 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}."
|
|
)
|
|
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
|
|
try:
|
|
result = self.judge_llm.complete_json(
|
|
sys, user_prompt, temperature=0.2, max_tokens=800
|
|
)
|
|
except LLMError:
|
|
# judge unavailable — fall back to pending (don't crash, don't misuse keywords)
|
|
return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
|
|
if not isinstance(result, dict):
|
|
return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
|
|
result.setdefault("mood", 0)
|
|
result.setdefault("decision", "pending")
|
|
result.setdefault("score_delta", 0)
|
|
result.setdefault("reason", "")
|
|
if result.get("decision") not in ("buy", "walk", "try", "pending"):
|
|
result["decision"] = "pending"
|
|
return result
|
|
|
|
# ── judge ──────────────────────────────────────────────────────────
|
|
def judge(
|
|
self,
|
|
*,
|
|
persona: dict[str, Any],
|
|
messages: list[dict[str, str]],
|
|
internal: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
persona = _safe_persona(persona)
|
|
messages = _safe_transcript(messages)
|
|
internal = _safe_roleplay_internal(internal)
|
|
persona_summary = json.dumps({
|
|
"name": persona.get("name"),
|
|
"pains": persona.get("pains", []),
|
|
"budget": persona.get("budget"),
|
|
"negotiation_levers": persona.get("negotiation_levers"),
|
|
"special": persona.get("special"),
|
|
}, ensure_ascii=False)
|
|
transcript = "\n".join(
|
|
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
|
|
)
|
|
state_note = ""
|
|
if internal:
|
|
try:
|
|
state_note = (
|
|
"\n\nINTERNAL (hidden, for judging only): "
|
|
f"score_trend={internal.get('score', 50)}"
|
|
)
|
|
except Exception:
|
|
state_note = ""
|
|
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
|
|
try:
|
|
result = self.judge_llm.complete_json(
|
|
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
|
|
)
|
|
except LLMError as exc:
|
|
raise
|
|
if not isinstance(result, dict):
|
|
raise LLMError("judge returned an invalid response")
|
|
result.setdefault("outcome", "lost")
|
|
result.setdefault("score", 0)
|
|
result.setdefault("pain", "")
|
|
result.setdefault("why", "")
|
|
result.setdefault("failurePoints", [])
|
|
result.setdefault("coaching", [])
|
|
result.setdefault("painProgress", {})
|
|
return result
|
|
|
|
def public_debrief(
|
|
self,
|
|
*,
|
|
messages: list[dict[str, str]],
|
|
outcome: str,
|
|
score: int,
|
|
) -> dict[str, Any]:
|
|
"""Generate trainee coaching without sending hidden persona data.
|
|
|
|
The final judge receives latent persona context and its prose is therefore
|
|
never safe to expose. This second, public-only pass sees only the seller's
|
|
own messages plus the already-computed outcome/score, so it cannot repeat
|
|
hidden pains, budget, opener, or other persona fields.
|
|
"""
|
|
messages = _safe_transcript(messages)
|
|
seller_transcript = "\n".join(
|
|
str(message.get("text", ""))
|
|
for message in messages[-40:]
|
|
if isinstance(message, dict)
|
|
and message.get("role") == "seller"
|
|
and isinstance(message.get("text"), str)
|
|
)
|
|
system = (
|
|
"You are a sales communication coach. Produce feedback only from the seller's "
|
|
"observable messages below. Do not infer, mention, quote, or invent any customer "
|
|
"profile, pain, budget, personality, opener, objection, negotiation lever, or "
|
|
"other hidden context. Do not reveal customer information. Return only valid JSON: "
|
|
'{"why": "short observable explanation", "failurePoints": ["seller behavior"], '
|
|
'"coaching": ["specific communication improvement"]}. '
|
|
"If there is not enough seller text, return empty strings/lists."
|
|
)
|
|
user_prompt = (
|
|
f"OUTCOME: {outcome}\nSCORE: {score}\n"
|
|
f"SELLER MESSAGES ONLY:\n{seller_transcript}"
|
|
)
|
|
result = self.judge_llm.complete_json(
|
|
system,
|
|
user_prompt,
|
|
temperature=0.2,
|
|
max_tokens=1200,
|
|
)
|
|
return result if isinstance(result, dict) else {}
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────
|
|
def _describe_pains(self, pains: list[Any]) -> str:
|
|
if not pains:
|
|
return "(you have some personal frustrations, but the seller must find out)"
|
|
out = []
|
|
for p in pains:
|
|
if isinstance(p, dict):
|
|
out.append(
|
|
f"{p.get('name','pain')} (fit={p.get('fit','?')}): {p.get('description','')} "
|
|
f"root={p.get('rootCause','')}"
|
|
)
|
|
else:
|
|
out.append(str(p))
|
|
return "; ".join(out)
|
|
|
|
def _describe_levers(self, levers: object) -> str:
|
|
if not isinstance(levers, list):
|
|
return "price, delivery time"
|
|
values = []
|
|
for lever in levers[:20]:
|
|
if isinstance(lever, str) and lever.strip():
|
|
values.append(lever.strip())
|
|
elif isinstance(lever, dict):
|
|
label = lever.get("name") or lever.get("label") or lever.get("description")
|
|
if isinstance(label, str) and label.strip():
|
|
values.append(label.strip())
|
|
return ", ".join(values) or "price, delivery time"
|
|
|
|
def _parse_persona_reply(self, text: object) -> tuple[str, dict[str, Any]] | None:
|
|
"""Parse provider output without leaking protocol fields to the bubble.
|
|
|
|
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. 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
|
|
raw = text.strip()
|
|
if not raw:
|
|
return None
|
|
|
|
payload: Any = None
|
|
candidates = [raw]
|
|
extracted = self._extract_json(raw)
|
|
if extracted != raw:
|
|
candidates.append(extracted)
|
|
for candidate in candidates:
|
|
try:
|
|
payload = json.loads(candidate)
|
|
break
|
|
except (TypeError, json.JSONDecodeError):
|
|
continue
|
|
|
|
if isinstance(payload, dict):
|
|
reply = payload.get("reply")
|
|
if not isinstance(reply, str) or not reply.strip():
|
|
return None
|
|
decision = payload.get("decision")
|
|
if decision not in ("none", "buy", "walk"):
|
|
decision = "none"
|
|
try:
|
|
mood = max(-2, min(2, int(payload.get("mood", 0))))
|
|
except (TypeError, ValueError):
|
|
mood = 0
|
|
return reply.strip()[:MAX_PERSONA_REPLY_CHARS], {
|
|
"decision": decision,
|
|
"mood": mood,
|
|
}
|
|
|
|
# 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
|
|
# 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 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 ""
|
|
|
|
def _extract_json(self, text: str) -> str:
|
|
text = text.strip()
|
|
start = text.find("{")
|
|
end = text.rfind("}")
|
|
if start != -1 and end != -1 and end > start:
|
|
return text[start : end + 1]
|
|
return text
|