Files
sales-trainer/backend/app/services/simulator.py
Macky c92400b195 refactor(chat): decide buy/walk by per-turn LLM judge (not fixed keywords)
Removed the fixed-value text detector. persona_reply no longer forces JSON meta; instead a
per-turn evaluate_turn() calls the judge LLM after every customer reply to read the persona's
current mood + whether it has decided (buy/walk/pending) + score_delta + reason. send_message
consumes that context-based decision to (a) end the chat as won/lost and (b) move the score.

This is what the user asked: the system evaluates EVERY turn and decides at the moment it's
truly committed — not keyword matching (so 'ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?' stays pending).
Mock updated: judge returns buy on first send (keeps E2E deterministic). 11/11 suites pass.
2026-08-09 13:19:15 +07:00

279 lines
14 KiB
Python

"""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": "<your message>", "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