Files
sales-trainer/backend/app/services/simulator.py
Macky bd6a7ffa32 feat(chat): scenario-based training — choice of channel/Situation + realistic per-turn evaluation
Backend:
- Channel/initiation now driven by a SCENARIO chosen at chat start, not baked into the
  persona: social (customer opens), f2f_call (seller must open, proactive), recontact
  (customer re-contacts after 1-3 months).
- /chat/start accepts {scenario}; session stores scenario + internal{turns,score}.
- persona_reply takes scenario + adapts tone; system-role transcript entries are fed to
  the persona as hidden scene notes.
- JUDGE updated for realism: good response can WIN even in hard/tough-text scenarios;
  long/no-close chats (turns >~12) lose; pushy/ignoring-need loses. Efficiency rewarded.

Frontend:
- Scenario picker before chat (choose Social / Face-to-face-call / Re-contact).
- Chat thread renders role=system as a centered time-lapse/scene note.
- Choose-scenario i18n (EN+TH).
Rebuilt dist.
2026-08-08 11:27:05 +07:00

219 lines
9.7 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}
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": "<your message>"}}
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