"""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). Otherwise it is LOST (or abandoned if the user ended early). Scoring (0-100): painResolution + trust + objectionHandling are the only factors. 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], ) -> str: pains_txt = self._describe_pains(persona.get("pains", [])) 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"), 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), ) 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), }) msgs.extend(messages[-30:]) # context window 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]], ) -> 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:] ) user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}" 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