refactor(chat): decide buy/walk by per-turn LLM judge (not fixed keywords)

Removed the fixed-value text detector. persona_reply no longer forces JSON meta; instead a
per-turn evaluate_turn() calls the judge LLM after every customer reply to read the persona's
current mood + whether it has decided (buy/walk/pending) + score_delta + reason. send_message
consumes that context-based decision to (a) end the chat as won/lost and (b) move the score.

This is what the user asked: the system evaluates EVERY turn and decides at the moment it's
truly committed — not keyword matching (so 'ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?' stays pending).
Mock updated: judge returns buy on first send (keeps E2E deterministic). 11/11 suites pass.
This commit is contained in:
Macky
2026-08-09 13:19:15 +07:00
parent 94e75238a3
commit c92400b195
4 changed files with 102 additions and 68 deletions

View File

@@ -27,39 +27,6 @@ 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"
@@ -291,16 +258,6 @@ def send_message(gid: str, pid: str):
internal.setdefault("turns", 0) internal.setdefault("turns", 0)
internal["turns"] = internal.get("turns", 0) + 1 internal["turns"] = internal.get("turns", 0) + 1
internal["signals"] = internal.get("signals", []) internal["signals"] = internal.get("signals", [])
try:
mood = int(meta.get("mood", 0))
except (TypeError, ValueError):
mood = 0
# A clearly annoyed customer is a "miss" against the seller.
if mood <= -1:
internal["misses"] = internal.get("misses", 0) + 1
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "annoy"})
elif mood >= 1:
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "warm"})
# Re-contact persona behavior: after enough info is exchanged (turn 2), the customer # Re-contact persona behavior: after enough info is exchanged (turn 2), the customer
# goes quiet, a time-lapse system note is shown, and the customer re-engages warmer. # goes quiet, a time-lapse system note is shown, and the customer re-engages warmer.
@@ -316,13 +273,31 @@ def send_message(gid: str, pid: str):
# Save the time-lapse note immediately so the UI shows it even if send ends here. # Save the time-lapse note immediately so the UI shows it even if send ends here.
s["sessions"].update(session["id"], messages=messages, internal=internal) s["sessions"].update(session["id"], messages=messages, internal=internal)
# Decision by the persona ends the session (one-shot lock). # Evaluate this turn via the (judge) LLM: how the persona feels + whether it has decided.
decision = meta.get("decision") # This is context-based (NOT fixed keywords), so e.g. "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays
# Real LLMs usually DON'T emit a structured meta.decision — they just say it in the # pending until the customer truly commits to (or abandons) the decision.
# customer's reply. Fall back to a lightweight text detector so "ซื้อไม่ไหว"/"no thanks" turn_eval = sim.evaluate_turn(
# actually ENDS the chat (otherwise it never reports win/loss). persona=persona, messages=messages, internal=internal
if decision not in ("buy", "walk"): )
decision = _detect_customer_decision(reply, locale=slocale) decision = turn_eval.get("decision", "pending")
try:
mood = int(turn_eval.get("mood", 0))
except (TypeError, ValueError):
mood = 0
# Apply the judge's score delta to internal score trend.
try:
sd = int(turn_eval.get("score_delta", 0))
except (TypeError, ValueError):
sd = 0
internal["score"] = max(0, min(100, int(internal.get("score", 50)) + sd))
internal["last_reason"] = turn_eval.get("reason", "")
# Track mood trend for debrief.
if mood <= -1:
internal["misses"] = internal.get("misses", 0) + 1
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "annoy"})
elif mood >= 1:
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "warm"})
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

@@ -143,19 +143,63 @@ class Simulator:
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400) resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
except LLMError as exc: except LLMError as exc:
raise raise
# extract {reply, decision, mood} # Return the customer's reply as plain text (no forced JSON) — a natural sentence is
meta: dict[str, Any] = {"decision": "none", "mood": 0} # the persona's message. Mood/decision are evaluated separately by evaluate_turn().
reply = resp.strip() if resp and resp.strip() else "(ลูกค้ายังไม่ตอบ)"
return reply, {"decision": "none", "mood": 0}
def evaluate_turn(self, *, persona, messages, internal=None) -> dict[str, Any]:
"""Per-turn state evaluation: how the persona feels + whether it has decided.
Uses the SAME judge LLM (structured JSON) on every round so the win/loss decision is
derived from the conversation context — NOT from fixed keywords. Returns
{mood, decision(buy|walk|pending), score_delta, reason}.
"""
transcript = "\n".join(
f"{m.get('role')}: {m.get('text')}" for m in messages[-30:]
)
persona_summary = json.dumps({
"name": persona.get("name", "?"),
"pains": persona.get("pains", []),
"budget": persona.get("budget", ""),
"tolerance": persona.get("tolerance", 3),
"negotiation_levers": persona.get("negotiation_levers", []),
"special": persona.get("special", ""),
"recontact": persona.get("recontact", False),
"goal": persona.get("goal", ""),
"decision_timeline": persona.get("decision_timeline", ""),
}, ensure_ascii=False)
state_note = ""
if internal:
state_note = (
f"\n\nINTERNAL (hidden, judging only): turns={internal.get('turns', 0)}, "
f"misses={internal.get('misses', 0)}, score={internal.get('score', 50)}"
)
sys = (
"You are a neutral sales-coaching judge. Read the TRANSCRIPT and decide, as the "
"customer persona, how it CURRENTLY feels and whether it has made a decision.\n"
"- mood: -2..+2 (very negative .. very positive toward purchase)\n"
"- decision: 'buy' if the customer has clearly decided to buy, 'walk' if clearly "
"refused/walking away (cannot afford / no interest), else 'pending' (still deciding)\n"
"- score_delta: -15..+15 (direction of the sale after this turn)\n"
"- reason: one short Thai/English sentence matching the transcript language.\n"
"Only output valid JSON: {mood, decision, score_delta, reason}."
)
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
try: try:
data = json.loads(self._extract_json(resp)) result = self.judge_llm.complete_json(
reply = (data.get("reply") or data.get("response") or str(resp)).strip() sys, user_prompt, temperature=0.2, max_tokens=800
meta["decision"] = data.get("decision", "none") )
try: except LLMError:
meta["mood"] = int(float(data.get("mood", 0))) # judge unavailable — fall back to pending (don't crash, don't misuse keywords)
except (TypeError, ValueError): return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
meta["mood"] = 0 result.setdefault("mood", 0)
except Exception: result.setdefault("decision", "pending")
reply = resp.strip() result.setdefault("score_delta", 0)
return reply, meta result.setdefault("reason", "")
if result.get("decision") not in ("buy", "walk", "pending"):
result["decision"] = "pending"
return result
# ── judge ────────────────────────────────────────────────────────── # ── judge ──────────────────────────────────────────────────────────
def judge( def judge(

View File

@@ -104,6 +104,10 @@ class MockLLM:
"coaching": [], "coaching": [],
"painProgress": {"slow checkout": 100}, "painProgress": {"slow checkout": 100},
} }
if "neutral sales-coaching judge" in sp:
# Per-turn state evaluation: mock decides to buy on the first seller message
# (keeps E2E deterministic: first send auto-finishes as won), else pending.
return {"mood": 1, "decision": "buy", "score_delta": 5, "reason": "mock buy"}
return {} return {}
def complete_conversation(self, messages, **kw) -> str: def complete_conversation(self, messages, **kw) -> str:

View File

@@ -40,11 +40,22 @@ 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 assert rr.status_code == 200 and rr.get_json()["session"]["id"] == sid1
print("[ok] /chat/resume returns active session") print("[ok] /chat/resume returns active session")
# 4. decision detection: unit-test the text detector # 4. decision comes from the LLM judge (evaluate_turn), NOT a fixed-text list.
from app.api.chat_routes import _detect_customer_decision # The mock judge returns {mood, decision: buy, ...} for the eval prompt.
assert _detect_customer_decision("ผมซื้อไม่ไหวแล้วครับ ขอตัวก่อน") == "walk" from app.services.simulator import Simulator
assert _detect_customer_decision("สวัสดีครับ ผมสนใจสินค้าครับ") is None sim2 = Simulator(MockLLM())
assert _detect_customer_decision("ok ผมเอาครับ รับเลย") == "buy" res = sim2.evaluate_turn(
print("[ok] _detect_customer_decision: walk/buy/None cases correct") persona={"name": "สมชาย", "pains": [], "tolerance": 2},
messages=[
{"role": "customer", "text": "สวัสดีครับ"},
{"role": "seller", "text": "สวัสดีครับ มีอะไรช่วยได้ไหม"},
{"role": "customer", "text": "แพงเกินไป ผมซื้อไม่ไหวแล้วครับ ขอตัวก่อน"},
],
)
print("[ok] evaluate_turn decision:", res.get("decision"), "| mood:", res.get("mood"))
assert res.get("decision") in ("buy", "walk", "pending"), res
# The decision is produced by the LLM judge object structure (has the fields we consume in send)
assert isinstance(res, dict) and "mood" in res and "score_delta" in res and "reason" in res
print("[ok] evaluate_turn returns mood/decision/score_delta/reason (context-based, not fixed text)")
print("ALL RESUME+DECISION TESTS PASSED") print("ALL RESUME+DECISION TESTS PASSED")