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) 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"): def _scenarios(locale: str = "th"):
"""Scenario presets, localized. Returns {id: {label, init, adapt}}.""" """Scenario presets, localized. Returns {id: {label, init, adapt}}."""
t = locale != "en" t = locale != "en"
@@ -153,6 +186,18 @@ def start_session(gid: str, pid: str):
if locale not in ("en", "th"): if locale not in ("en", "th"):
locale = "th" locale = "th"
scenario_meta, init_mode = _scenario_config(scenario, persona, locale) 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 # One-shot: reject if already finished this persona
try: try:
session = s["sessions"].create( 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 by the persona ends the session (one-shot lock).
decision = meta.get("decision") 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"): if decision in ("buy", "walk"):
outcome = "won" if decision == "buy" else "lost" outcome = "won" if decision == "buy" else "lost"
debrief = _build_abbrev_debrief(outcome, persona, internal, slocale) debrief = _build_abbrev_debrief(outcome, persona, internal, slocale)

View File

@@ -0,0 +1,50 @@
"""Test: chat RESUME (no re-pick scenario) + decision detection for real-LLM text."""
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)
sys.path.insert(0, BACKEND + "/scripts")
from mock_llm import MockLLM
app = create_app(); app.extensions["llm"] = MockLLM()
C = app.test_client()
def tok(u,p): return C.post("/api/auth/login", json={"username":u,"password":p}).get_json()["token"]
AT = tok("admin","1234"); AH={"Authorization":f"Bearer {AT}"}
C.post("/api/auth/setup", headers=AH, json={"username":"admin","email":"a@b.co","password":"newpass","accepted_terms":True})
AT = tok("admin","newpass"); AH={"Authorization":f"Bearer {AT}"}
C.post("/api/groups", headers=AH, json={"product":"POS CRM","segment":"SME","language":"th"})
gid = C.get("/api/groups", headers=AH).get_json()["groups"][0]["id"]
C.post(f"/api/groups/{gid}/analyze", headers=AH)
pid = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"][0]["id"]
# 1. start chat
r1 = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=AH, json={"scenario":"social","locale":"th"})
assert r1.status_code == 200, r1.get_json()
sid1 = r1.get_json()["session"]["id"]
# 2. call start AGAIN (as if re-entering) -> should RESUME the same session, not create new
r2 = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=AH, json={"scenario":"f2f_call","locale":"th"})
assert r2.status_code == 200, r2.get_json()
sid2 = r2.get_json()["session"]["id"]
assert sid1 == sid2, f"resume must return same session (got {sid2}, want {sid1})"
assert r2.get_json()["scenario"] == "social", "resume keeps ORIGINAL scenario (not re-pick)"
print("[ok] resume returns same active session + keeps original scenario")
# 3. resume endpoint also returns the active session
rr = C.get(f"/api/chat/{gid}/personas/{pid}/chat/resume", headers=AH)
assert rr.status_code == 200 and rr.get_json()["session"]["id"] == sid1
print("[ok] /chat/resume returns active session")
# 4. decision detection: unit-test the text detector
from app.api.chat_routes import _detect_customer_decision
assert _detect_customer_decision("ผมซื้อไม่ไหวแล้วครับ ขอตัวก่อน") == "walk"
assert _detect_customer_decision("สวัสดีครับ ผมสนใจสินค้าครับ") is None
assert _detect_customer_decision("ok ผมเอาครับ รับเลย") == "buy"
print("[ok] _detect_customer_decision: walk/buy/None cases correct")
print("ALL RESUME+DECISION TESTS PASSED")