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:
@@ -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)
|
||||||
|
|||||||
50
backend/scripts/test_resume_decision.py
Normal file
50
backend/scripts/test_resume_decision.py
Normal 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")
|
||||||
Reference in New Issue
Block a user