"""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 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": "", "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.""" 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(internal, ensure_ascii=False), }) for m in messages[-30:]: role = m.get("role") if role == "system": msgs.append({"role": "system", "content": f"[scene note from transcript]: {m.get('text')}"}) else: msgs.append({"role": role, "content": m.get("text", "")}) 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}. """ 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_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): result = {} 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 # ── 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