fix(chat): resume unfinished session without re-picking scenario; detect win/loss from real-LLM text

- start_session now RESUMES an existing ACTIVE session for the persona instead of creating
  a new one / forcing re-pick of the scenario (keep original scenario+messages).
- send_message falls back to a text-based decision detector (_detect_customer_decision)
  because real LLMs rarely emit structured meta.decision — so a customer who says
  'ซื้อไม่ไหว'/'no thanks' now actually ENDS the chat as lost (was stuck active forever).
  Fragments handled in TH + EN; buy + walk.
All 11 backend suites pass. Rebuilt dist.
This commit is contained in:
Macky
2026-08-09 13:12:08 +07:00
parent 10c7d01236
commit 94e75238a3
2 changed files with 100 additions and 0 deletions

View File

@@ -27,6 +27,39 @@ def _sim(group, persona):
return Simulator(llm)
WALK_FRAGMENTS = [
"no thanks", "not interested", "forget it", "never mind", "no longer", "out of budget",
"too expensive", "can't afford", "cannot afford", "ซื้อไม่ไหว", "ไม่ซื้อ", "ไม่เอาแล้ว",
"ไม่สนใจ", "พอแค่นี้", "ไม่ไหว", "แพงไป", "ลืมไปเถอะ", "ตัดสินใจไม่ซื้อ", "ขอไม่ซื้อ",
"ยังไม่ซื้อ", "ไว้ก่อน", "ไปก่อนนะ", "ขอบคุณแต่ไม่เอา", "ไม่เอาครับ", "ไม่เอาค่ะ", "ไม่เอา",
]
BUY_FRAGMENTS = [
"i'll take it", "i'll go with it", "i'll buy", "deal,", "let's do it", "count me in",
"how do i pay", "where do i sign", "ok i'll buy", "ซื้อเลย", "ตกลงซื้อ", "เอาครับ", "เอาค่ะ",
"ซื้อครับ", "ซื้อค่ะ", "ตัดสินใจซื้อ", "เอาเลย", "ตกลง", "ซื้อ", "รับไว้", "โอเคค่ะ รับ", "โอเคครับ รับ",
]
def _detect_customer_decision(text: str, locale: str = "th") -> str | None:
"""Detect whether the customer clearly walked away or decided to buy, from their message.
Returns 'walk' | 'buy' | None. Used as a fallback because real LLMs rarely emit a
structured meta.decision — they just state it in the conversation.
"""
if not text:
return None
low = text.lower()
# Walk-away signals can appear anywhere; be conservative to avoid false ends on
# phrases like "I can't afford that, what's the discount?" (question) vs "too expensive, no."
for frag in WALK_FRAGMENTS:
if frag in low:
return "walk"
for frag in BUY_FRAGMENTS:
if frag in low:
return "buy"
return None
def _scenarios(locale: str = "th"):
"""Scenario presets, localized. Returns {id: {label, init, adapt}}."""
t = locale != "en"
@@ -153,6 +186,18 @@ def start_session(gid: str, pid: str):
if locale not in ("en", "th"):
locale = "th"
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
# RESUME: if this user already has an ACTIVE (unfinished) session for this persona, keep
# chatting it — do NOT create a new one and do NOT force re-picking the scenario.
existing = s["sessions"].active_for_persona(actor["id"], pid)
if existing and existing.get("group_id") == gid:
return jsonify({
"session": existing,
"initiation_mode": existing.get("persona_meta", {}).get("initiation_mode") or "customer",
"scenario": existing.get("scenario", scenario),
"scenario_meta": _scenario_config(existing.get("scenario", scenario), persona, locale),
})
# One-shot: reject if already finished this persona
try:
session = s["sessions"].create(
@@ -273,6 +318,11 @@ def send_message(gid: str, pid: str):
# Decision by the persona ends the session (one-shot lock).
decision = meta.get("decision")
# Real LLMs usually DON'T emit a structured meta.decision — they just say it in the
# customer's reply. Fall back to a lightweight text detector so "ซื้อไม่ไหว"/"no thanks"
# actually ENDS the chat (otherwise it never reports win/loss).
if decision not in ("buy", "walk"):
decision = _detect_customer_decision(reply, locale=slocale)
if decision in ("buy", "walk"):
outcome = "won" if decision == "buy" else "lost"
debrief = _build_abbrev_debrief(outcome, persona, internal, slocale)