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