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:
@@ -27,6 +27,35 @@ def _sim(group, persona):
|
||||
return Simulator(llm)
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"social": {
|
||||
"label": "Social Media",
|
||||
"init": "customer",
|
||||
"system_preamble": "💬 ช่องทาง ข้อความโซเชียล — ลูกค้าทักมาหาคุณก่อน (โทนสั้น ทักๆ ตามสไตล์แชท)",
|
||||
"adapt": "You are chatting on a social-messaging app (LINE-style). Keep replies SHORT, casual, and quick — one line to a few lines. The customer opened the chat.",
|
||||
},
|
||||
"f2f_call": {
|
||||
"label": "พบหน้า / โทรศัพท์",
|
||||
"init": "seller",
|
||||
"system_preamble": "📞 สถานการณ์ พบหน้าหรือโทรศัพท์ — คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (lead)",
|
||||
"adapt": "This is a face-to-face or phone sales situation. You must sound natural and conversational like a live talk. The seller will open — you are the lead they are reaching out to.",
|
||||
},
|
||||
"recontact": {
|
||||
"label": "ลูกค้ากลับมาติดต่อ (เคยได้ข้อมูล 1-3 เดือน)",
|
||||
"init": "customer",
|
||||
"system_preamble": "⏳ 1-3 เดือนผ่านไป... ลูกค้าคนนี้เคยได้รับข้อมูลสินค้าไปแล้ว ตอนนี้กลับมาติดต่อคุณอีกครั้ง (พร้อมตัดสินใจมากขึ้น)",
|
||||
"adapt": "This customer researched you 1-3 months ago. They have already read about the product and are now more ready to decide. They re-contacted you on a messaging channel. Keep replies natural and fairly concise.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _scenario_config(scenario: str, persona: dict):
|
||||
cfg = SCENARIOS.get(scenario, SCENARIOS["social"])
|
||||
# OR with persona's own initiation preference: f2f/call forces seller-first unless persona
|
||||
# is strongly customer-initiated (we let scenario win, but keep persona special handling).
|
||||
return cfg, cfg["init"]
|
||||
|
||||
|
||||
def _get_ready_group(s, gid: str) -> dict:
|
||||
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
||||
group = s["groups"].get_or_none(gid)
|
||||
@@ -53,6 +82,12 @@ def start_session(gid: str, pid: str):
|
||||
if not persona:
|
||||
raise ApiError("persona not found", 404)
|
||||
actor = current_user()
|
||||
# Scenario chosen by the trainee at chat start (not baked into the persona).
|
||||
body = request.get_json(silent=True) or {}
|
||||
scenario = (body.get("scenario") or "social").strip().lower()
|
||||
if scenario not in ("social", "f2f_call", "recontact"):
|
||||
scenario = "social"
|
||||
scenario_meta, init_mode = _scenario_config(scenario, persona)
|
||||
# One-shot: reject if already finished this persona
|
||||
try:
|
||||
session = s["sessions"].create(
|
||||
@@ -60,27 +95,35 @@ def start_session(gid: str, pid: str):
|
||||
persona_name=persona.get("name", "?"),
|
||||
persona_meta={
|
||||
"tier": persona.get("tier"),
|
||||
"initiation_mode": persona.get("initiation_mode"),
|
||||
"initiation_mode": init_mode,
|
||||
"channel": persona.get("channel"),
|
||||
"scenario": scenario,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ApiError(str(exc), 400)
|
||||
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
scenario=scenario,
|
||||
internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []},
|
||||
)
|
||||
sim = _sim(group, persona)
|
||||
# Seller-initiated: give the trainee an opening task (no persona message yet).
|
||||
init_mode = persona.get("initiation_mode", "customer")
|
||||
# Seed messages: system note(s) then customer opener for customer-first scenarios.
|
||||
seeded = list(session.get("messages", []))
|
||||
if scenario_meta.get("system_preamble"):
|
||||
seeded.append({"role": "system", "text": scenario_meta["system_preamble"]})
|
||||
if init_mode == "customer":
|
||||
# Customer opens: inject the persona's opener as the first message.
|
||||
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
||||
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
|
||||
else:
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
task="The customer did NOT message first. You must open the sale — start the "
|
||||
"conversation with this lead (e.g. introduce yourself and engage with interest).",
|
||||
)
|
||||
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
|
||||
seeded.append({"role": "customer", "text": opener})
|
||||
s["sessions"].update(session["id"], messages=seeded)
|
||||
sess = s["sessions"].get(session["id"])
|
||||
return jsonify({
|
||||
"session": sess,
|
||||
"initiation_mode": init_mode,
|
||||
"scenario": scenario,
|
||||
"scenario_meta": scenario_meta,
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
||||
@@ -106,6 +149,8 @@ def send_message(gid: str, pid: str):
|
||||
raise ApiError("session context missing", 404)
|
||||
messages = list(session.get("messages", []))
|
||||
messages.append({"role": "seller", "text": text})
|
||||
scenario = session.get("scenario", "social") or "social"
|
||||
adapt = SCENARIOS.get(scenario, SCENARIOS["social"]).get("adapt", "")
|
||||
|
||||
sim = _sim(group, persona)
|
||||
try:
|
||||
@@ -114,12 +159,19 @@ def send_message(gid: str, pid: str):
|
||||
sales_kit=group.get("sales_kit") or {},
|
||||
messages=messages,
|
||||
internal=session.get("internal", {}),
|
||||
scenario=scenario,
|
||||
scenario_adapt=adapt,
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
messages.append({"role": "customer", "text": reply})
|
||||
|
||||
s["sessions"].update(session["id"], messages=messages)
|
||||
# Per-turn tracking: count exchanged turns + nudge score down as the chat drags.
|
||||
internal = session.get("internal", {}) or {}
|
||||
internal.setdefault("turns", 0)
|
||||
internal["turns"] = internal.get("turns", 0) + 1
|
||||
|
||||
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||
return jsonify({"reply": reply, "messages": messages})
|
||||
|
||||
|
||||
@@ -139,7 +191,9 @@ def finish_session(gid: str, pid: str):
|
||||
sim = _sim(group, persona)
|
||||
messages = session.get("messages", [])
|
||||
try:
|
||||
verdict = sim.judge(persona=persona, messages=messages)
|
||||
verdict = sim.judge(
|
||||
persona=persona, messages=messages, internal=session.get("internal", {})
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,8 @@ def main():
|
||||
assert r.status_code == 200, r.get_json()
|
||||
session = r.get_json()["session"]
|
||||
assert session["status"] == "active"
|
||||
assert len(session["messages"]) >= 1 and session["messages"][0]["role"] == "customer", "customer should open"
|
||||
assert len(session["messages"]) >= 1
|
||||
assert any(m["role"] == "customer" for m in session["messages"]), "customer should open"
|
||||
print("[ok] customer-initiated session starts with customer opener")
|
||||
|
||||
# send messages
|
||||
@@ -102,14 +103,15 @@ def main():
|
||||
assert r.status_code == 400, r.get_json()
|
||||
print("[ok] one-shot enforced (cannot re-chat same persona)")
|
||||
|
||||
# seller-initiated persona -> session starts WITHOUT opener (task for seller)
|
||||
# seller-facing persona + f2f_call scenario -> session starts WITHOUT opener (seller must open)
|
||||
sel = next(p for p in personas if p["initiation_mode"] == "seller")
|
||||
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start", headers=UH)
|
||||
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start",
|
||||
json={"scenario": "f2f_call"}, headers=UH)
|
||||
assert r.status_code == 200, r.get_json()
|
||||
s2 = r.get_json()["session"]
|
||||
assert "task" in r.get_json() or "initiation_mode" in r.get_json()
|
||||
assert s2["messages"] == [] , "seller-initiated should not have a customer opener"
|
||||
print("[ok] seller-initiated session (no customer opener, seller must open)")
|
||||
assert "initiation_mode" in r.get_json()
|
||||
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f_call: no customer opener (seller must open)"
|
||||
print("[ok] seller-first (f2f/call) session — no customer opener, seller must open")
|
||||
|
||||
# board
|
||||
r = client.get("/api/me/board", headers=UH)
|
||||
|
||||
79
backend/scripts/test_scenario.py
Normal file
79
backend/scripts/test_scenario.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Test: scenario-based chat start (scenario determines who opens)."""
|
||||
import os, sys, tempfile, warnings
|
||||
from pathlib import Path
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
BACKEND = str(Path(__file__).resolve().parents[1])
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
from app.factory import create_app
|
||||
from app.config import Config
|
||||
|
||||
td = tempfile.mkdtemp()
|
||||
Config.DATA_DIR = Path(td)
|
||||
|
||||
from app import factory
|
||||
|
||||
def make_app():
|
||||
app = create_app()
|
||||
# mock LLM so analyze works deterministically
|
||||
sys.path.insert(0, BACKEND + "/scripts")
|
||||
from mock_llm import MockLLM
|
||||
app.extensions["llm"] = MockLLM()
|
||||
return app
|
||||
|
||||
app = make_app()
|
||||
C = app.test_client()
|
||||
|
||||
def login(u, p):
|
||||
return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"]
|
||||
|
||||
AT = login("admin", "1234")
|
||||
AH = {"Authorization": f"Bearer {AT}"}
|
||||
C.post("/api/auth/setup", headers=AH, json={"username": "admin", "email": "a@b.co", "password": "newpass"}).get_json()
|
||||
# re-login with new password
|
||||
AT = login("admin", "newpass")
|
||||
AH = {"Authorization": f"Bearer {AT}"}
|
||||
|
||||
# create group + analyze
|
||||
r = C.post("/api/groups", headers=AH, json={"product": "Inbound CRM", "segment": "SME", "channel": "line", "language": "th"})
|
||||
gid = r.get_json()["group"]["id"]
|
||||
C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||
personas = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||
# build a ready group accepted by trainees
|
||||
C.post(f"/api/groups/{gid}/reanalyze", headers=AH)
|
||||
|
||||
# create a trainee user in same org
|
||||
C.post("/api/admin/users", headers=AH, json={"username": "t1", "name": "T", "password": "pppp", "role": "user"})
|
||||
T = login("t1", "pppp")
|
||||
TH = {"Authorization": f"Bearer {T}"}
|
||||
|
||||
pid = personas[0]["id"]
|
||||
|
||||
# social -> customer opens
|
||||
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario": "social"})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
s = r.get_json()["session"]
|
||||
assert any(m["role"] == "customer" for m in s["messages"]), "social should have customer opener"
|
||||
print("[ok] social -> customer opens")
|
||||
|
||||
# f2f_call -> seller must open (no customer opener)
|
||||
# use a different persona (one-shot per persona)
|
||||
pid2 = personas[1]["id"]
|
||||
r = C.post(f"/api/chat/{gid}/personas/{pid2}/chat/start", headers=TH, json={"scenario": "f2f_call"})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
s2 = r.get_json()["session"]
|
||||
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f should NOT have customer opener"
|
||||
assert any(m["role"] == "system" for m in s2["messages"]), "f2f should have a scenario system note"
|
||||
print("[ok] f2f_call -> seller must open (system note present)")
|
||||
|
||||
# recontact -> system preamble about time-lapse + customer opens
|
||||
pid3 = personas[2]["id"]
|
||||
r = C.post(f"/api/chat/{gid}/personas/{pid3}/chat/start", headers=TH, json={"scenario": "recontact"})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
s3 = r.get_json()["session"]
|
||||
assert any(m["role"] == "customer" for m in s3["messages"]), "recontact should open w/ customer"
|
||||
assert any(m["role"] == "system" and "เดือน" in m["text"] for m in s3["messages"]), "recontact system note (months)"
|
||||
print("[ok] recontact -> time-lapse system note + customer opens")
|
||||
|
||||
print("ALL SCENARIO TESTS PASSED")
|
||||
Reference in New Issue
Block a user