feat(i18n + export + guide): locale-aware scenarios/debrief; CSV export; how-to-use page
Backend:
- SCENARIOS localized (th/en): scenario preamble + persona tone adaptation follow the
trainee's locale; start accepts {locale} and stores it on the session; send/auto-finish
use it so debrief text (why/coaching/turning points) is in the active language.
- /api/analytics/export returns per-trainee finished-session CSV (admin).
Frontend:
- Scenario picker labels localize by i18n.locale.
- New /guide page (non-IT how-to), linked from Training.
- Analytics: date-filter bar uses line icon + 'Download CSV' button (fetch w/ auth).
Rebuilt dist.
This commit is contained in:
@@ -27,57 +27,92 @@ 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 _scenarios(locale: str = "th"):
|
||||
"""Scenario presets, localized. Returns {id: {label, init, adapt}}."""
|
||||
t = locale != "en"
|
||||
return {
|
||||
"social": {
|
||||
"label": "Social Media" if not t else "Social Media (แชท)",
|
||||
"init": "customer",
|
||||
"preamble": "💬 Social messaging — the customer messaged you first." if not t else "💬 ช่องทางข้อความโซเชียล — ลูกค้าทักมาหาคุณก่อน (โทนสั้น ทักๆ)",
|
||||
"adapt": (
|
||||
"Chat style: short, casual, quick social-messaging replies. The customer opened."
|
||||
if not t
|
||||
else "ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชทโซเชียล"
|
||||
),
|
||||
},
|
||||
"f2f_call": {
|
||||
"label": "Face-to-face / Phone" if not t else "พบหน้า / โทรศัพท์",
|
||||
"init": "seller",
|
||||
"preamble": "📞 Face-to-face / phone — you must proactively open with this lead." if not t else "📞 สถานการณ์ พบหน้าหรือโทรศัพท์ — คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด",
|
||||
"adapt": (
|
||||
"Natural, conversational like a live face-to-face or phone sales talk. The seller opens."
|
||||
if not t
|
||||
else "คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน) โทนเหมือนคุยสด"
|
||||
),
|
||||
},
|
||||
"recontact": {
|
||||
"label": "Re-contact (1-3 months later)" if not t else "ลูกค้ากลับมาติดต่อ (1-3 เดือน)",
|
||||
"init": "customer",
|
||||
"preamble": "⏳ 1-3 months passed... this customer previously got your info, now re-contacts, more ready to decide." if not t else "⏳ 1-3 เดือนผ่านไป... ลูกค้าคนนี้เคยได้รับข้อมูลสินค้าไปแล้ว ตอนนี้กลับมาติดต่อคุณอีกครั้ง (พร้อมตัดสินใจมากขึ้น)",
|
||||
"adapt": (
|
||||
"You researched this seller 1-3 months ago and now re-contact, more ready to decide."
|
||||
if not t
|
||||
else "เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _scenario_config(scenario: str, persona: dict):
|
||||
cfg = SCENARIOS.get(scenario, SCENARIOS["social"])
|
||||
def _scenario_config(scenario: str, persona: dict, locale: str = "th"):
|
||||
cfg = _scenarios(locale).get(scenario, _scenarios(locale)["social"])
|
||||
return cfg, cfg["init"]
|
||||
|
||||
|
||||
def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict) -> dict:
|
||||
"""Build a lightweight debrief from the persona's own decision + per-turn signals
|
||||
(no extra LLM judge call needed since the persona chose to buy/walk)."""
|
||||
def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict, locale: str = "th") -> dict:
|
||||
"""Build a lightweight debrief from the persona's own decision + per-turn signals."""
|
||||
signals = internal.get("signals", [])
|
||||
turning_points = [
|
||||
f"รอบที่ {sig['turn']}: ลูกค้า{'เริ่มใจขึ้น' if sig['mood'] > 0 else 'เริ่มหงุดหงิด/ลังเล'}"
|
||||
for sig in signals if sig.get("type") in ("annoy", "warm")
|
||||
]
|
||||
en = locale == "en"
|
||||
turning_points = []
|
||||
for sig in signals:
|
||||
if sig.get("type") in ("annoy", "warm"):
|
||||
turn = sig.get("turn", "?")
|
||||
if sig.get("mood", 0) > 0:
|
||||
turning_points.append(
|
||||
f"Turn {turn}: customer warmed up" if en else f"รอบที่ {turn}: ลูกค้าเริ่มใจขึ้น"
|
||||
)
|
||||
else:
|
||||
turning_points.append(
|
||||
f"Turn {turn}: customer annoyed/hesitant" if en else f"รอบที่ {turn}: ลูกค้าเริ่มหงุดหงิด/ลังเล"
|
||||
)
|
||||
why = (
|
||||
"ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)"
|
||||
("Customer decided to buy (pain resolved + offer accepted)" if en else "ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)")
|
||||
if outcome == "won"
|
||||
else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ"
|
||||
else (
|
||||
"Customer decided not to buy — value not enough, or responses missed the need"
|
||||
if en
|
||||
else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ"
|
||||
)
|
||||
)
|
||||
coaching = (
|
||||
["Great job — the customer closed with you"] if en and outcome == "won"
|
||||
else ["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"] if outcome == "won"
|
||||
else (
|
||||
turning_points + ["Ask deeper about the need", "Handle objections more directly"]
|
||||
if en
|
||||
else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"]
|
||||
)
|
||||
)
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"score": 60 if outcome == "won" else 25,
|
||||
"pain": (persona.get("pains") or [{}])[0].get("description", "") if persona.get("pains") else "",
|
||||
"why": why,
|
||||
"failurePoints": [] if outcome == "won" else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"],
|
||||
"coaching": (
|
||||
["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"]
|
||||
if outcome == "won"
|
||||
else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"]
|
||||
"failurePoints": [] if outcome == "won" else (
|
||||
["Failed to close the sale", "Customer walked away before deciding"]
|
||||
if en else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"]
|
||||
),
|
||||
"coaching": coaching,
|
||||
"turning_points": turning_points,
|
||||
"signals": signals,
|
||||
"revealed_persona": {
|
||||
@@ -124,7 +159,10 @@ def start_session(gid: str, pid: str):
|
||||
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)
|
||||
locale = (body.get("locale") or "th").strip().lower()
|
||||
if locale not in ("en", "th"):
|
||||
locale = "th"
|
||||
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
|
||||
# One-shot: reject if already finished this persona
|
||||
try:
|
||||
session = s["sessions"].create(
|
||||
@@ -135,6 +173,7 @@ def start_session(gid: str, pid: str):
|
||||
"initiation_mode": init_mode,
|
||||
"channel": persona.get("channel"),
|
||||
"scenario": scenario,
|
||||
"locale": locale,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -143,13 +182,14 @@ def start_session(gid: str, pid: str):
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
scenario=scenario,
|
||||
locale=locale,
|
||||
internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []},
|
||||
)
|
||||
sim = _sim(group, persona)
|
||||
# Seed messages: system note(s) then customer opener for customer-first scenarios.
|
||||
# Seed messages: localized scenario preamble, 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 scenario_meta.get("preamble"):
|
||||
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
|
||||
if init_mode == "customer":
|
||||
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
||||
seeded.append({"role": "customer", "text": opener})
|
||||
@@ -187,7 +227,8 @@ def send_message(gid: str, pid: str):
|
||||
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", "")
|
||||
slocale = session.get("locale", "th") or "th"
|
||||
adapt = _scenarios(slocale).get(scenario, _scenarios(slocale)["social"]).get("adapt", "")
|
||||
|
||||
sim = _sim(group, persona)
|
||||
try:
|
||||
@@ -223,7 +264,7 @@ def send_message(gid: str, pid: str):
|
||||
decision = meta.get("decision")
|
||||
if decision in ("buy", "walk"):
|
||||
outcome = "won" if decision == "buy" else "lost"
|
||||
debrief = _build_abbrev_debrief(outcome, persona, internal)
|
||||
debrief = _build_abbrev_debrief(outcome, persona, internal, slocale)
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
status="finished",
|
||||
|
||||
Reference in New Issue
Block a user