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:
Macky
2026-08-08 15:06:48 +07:00
parent aa2eb8dd37
commit 15c60ae400
40 changed files with 302 additions and 107 deletions

View File

@@ -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",