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.
This commit is contained in:
Macky
2026-08-08 11:27:05 +07:00
parent 8670addcc3
commit bd6a7ffa32
27 changed files with 314 additions and 110 deletions

View File

@@ -47,9 +47,18 @@ A sale is CLOSED only if BOTH:
AND
2. The customer verbally accepts the offer/price (in the final exchange).
Otherwise it is LOST (or abandoned if the user ended early).
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 are the only factors.
Scoring (0-100): painResolution + trust + objectionHandling + efficiency (fewer turns, higher).
Return JSON:
{
"outcome": "won" | "lost",
@@ -76,14 +85,21 @@ class Simulator:
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"),
channel=persona.get("channel", "facebook") + (f" ({scenario})" if scenario else ""),
background=persona.get("background", ""),
personality=persona.get("personality", ""),
lifestyle=persona.get("lifestyle", ""),
@@ -96,7 +112,7 @@ class Simulator:
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),
special_instr=self._special_instr(persona) + "\n" + adapt,
)
msgs = [{"role": "system", "content": system}]
# send a compact recap of internal state to the persona ad
@@ -106,7 +122,13 @@ class Simulator:
"content": "Internal state (for your role-play only): "
+ json.dumps(internal, ensure_ascii=False),
})
msgs.extend(messages[-30:]) # context window
# 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:
@@ -125,6 +147,7 @@ class Simulator:
*,
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"),
@@ -136,7 +159,16 @@ class Simulator:
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}"
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