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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user