diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 7b4b3bc..4b7efb5 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -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) diff --git a/backend/scripts/test_resume_decision.py b/backend/scripts/test_resume_decision.py new file mode 100644 index 0000000..caa5323 --- /dev/null +++ b/backend/scripts/test_resume_decision.py @@ -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")