"""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, "") # Personality trait: a "recontact" customer asked about this before and is only now # coming back, already warmer / more ready to buy. Reference it in chat naturally. if persona.get("recontact"): adapt += ( "\nYou contacted/interacted with this seller BEFORE (earlier contact) and are now " "coming back — you already know the product basics, so you're warmer and more ready " "to decide. Mention this naturally (e.g. 'I asked about this a while back')." ) 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", "facebook") + (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 # extract {reply, decision, mood} meta: dict[str, Any] = {"decision": "none", "mood": 0} try: data = json.loads(self._extract_json(resp)) reply = (data.get("reply") or data.get("response") or str(resp)).strip() meta["decision"] = data.get("decision", "none") try: meta["mood"] = int(float(data.get("mood", 0))) except (TypeError, ValueError): meta["mood"] = 0 except Exception: reply = resp.strip() return reply, meta # ── 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