"""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} Initiation mode: {init_mode}. {special_instr} BEHAVIOR RULES: 1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies, delivery time, scope, payment). 2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops and you may refuse to continue / walk away — even if you wanted the product. 3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your pains unprompted. 4. Respond in natural, in-character chat style ({channel} style, casual for LINE). 5. Stay in character; never mention that you are a simulation or an AI persona. Reply with a JSON object: {{"reply": ""}} 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 = "", ) -> str: 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.", "recontact": "Style: casual messaging; you already know the product from 1-3 months ago.", }.get(scenario, "") 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", ""), 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}] # send a compact recap of internal state to the persona ad # (doesn't leak to trainee) msgs.append({ "role": "system", "content": "Internal state (for your role-play only): " + json.dumps(internal, ensure_ascii=False), }) # Translate role 'system' transcript entries into a hidden system note for the LLM. 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: ...} try: data = json.loads(self._extract_json(resp)) reply = data.get("reply") or data.get("response") or str(resp) except Exception: reply = resp return reply.strip() # ── 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