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)
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"
@@ -291,16 +258,6 @@ def send_message(gid: str, pid: str):
internal.setdefault("turns", 0)
internal["turns"] = internal.get("turns", 0) + 1
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
# 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.
s["sessions"].update(session["id"], messages=messages, internal=internal)
# 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)
# Evaluate this turn via the (judge) LLM: how the persona feels + whether it has decided.
# This is context-based (NOT fixed keywords), so e.g. "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays
# pending until the customer truly commits to (or abandons) the decision.
turn_eval = sim.evaluate_turn(
persona=persona, messages=messages, internal=internal
)
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"):
outcome = "won" if decision == "buy" else "lost"
debrief = _build_abbrev_debrief(outcome, persona, internal, slocale)