Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)
- Auth/roles (no self-reg), admin user provision, JWT - Analyze: sales kit + initial pain-fit from form/upload - Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special - Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching - Trainee loop: win/lose board, weak-areas, user-generated personas - Admin analytics; EN+TH Vue SPA served by Flask - Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF - Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
186
backend/app/services/simulator.py
Normal file
186
backend/app/services/simulator.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""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).
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user