"""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 from typing import Any from ..llm import LLMClient, LLMError 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} - 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. 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 chat drags on many turns (or turns > ~12) without the seller reaching the pain or closing, treat it as LOST due to failing to convert / the opportunity cooling (mirrors real leads going cold). - 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 + efficiency (fewer turns, higher). 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=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time", goal=persona.get("goal", ""), tolerance=tolerance, 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", "")}) try: resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400) except LLMError as exc: raise # Return the customer's reply as plain text (no forced JSON) — a natural sentence is # the persona's message. Mood/decision are evaluated separately by evaluate_turn(). reply = resp.strip() if resp and resp.strip() else "(ลูกค้ายังไม่ตอบ)" return reply, {"decision": "none", "mood": 0} 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, 'walk' if clearly " "refused/walking away (cannot afford / no interest), 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": ""} result.setdefault("mood", 0) result.setdefault("decision", "pending") result.setdefault("score_delta", 0) result.setdefault("reason", "") if result.get("decision") not in ("buy", "walk", "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"turns={internal.get('turns', 0)}, 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 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 _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." ) 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