feat(chat): persona decides to buy/walk — auto-finish + tolerance (temper) + resume

The conversation now ENDS when the persona makes a decision (option C), not when the
trainee clicks a button:
- persona_id replies carry {reply, decision(none/buy/walk), mood}; when decision is
  buy/walk the session auto-finishes (won/lost) with a debrief that reveals latent
  details + per-turn 'turning points'.
- personas have a tolerance (1-5, 'temper'): impatient personas walk away fast after
  poor answers (fed via internal.misses on mood<=-1); tough 'wrong text' cases can
  still be won by a strong, gentle response (judge realism).
- trainee 'Finish' button removed; if they leave mid-chat an active session is resumed
  via /chat/resume (continue, not restart). One-shot lock still enforced once decided.
- mock/tests updated: persona deciding buy -> send auto-finishes won.
Rebuilt dist.
This commit is contained in:
Macky
2026-08-08 14:34:34 +07:00
parent bd6a7ffa32
commit aa2eb8dd37
28 changed files with 199 additions and 84 deletions

View File

@@ -51,11 +51,48 @@ SCENARIOS = {
def _scenario_config(scenario: str, persona: dict):
cfg = SCENARIOS.get(scenario, SCENARIOS["social"])
# OR with persona's own initiation preference: f2f/call forces seller-first unless persona
# is strongly customer-initiated (we let scenario win, but keep persona special handling).
return cfg, cfg["init"]
def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict) -> dict:
"""Build a lightweight debrief from the persona's own decision + per-turn signals
(no extra LLM judge call needed since the persona chose to buy/walk)."""
signals = internal.get("signals", [])
turning_points = [
f"รอบที่ {sig['turn']}: ลูกค้า{'เริ่มใจขึ้น' if sig['mood'] > 0 else 'เริ่มหงุดหงิด/ลังเล'}"
for sig in signals if sig.get("type") in ("annoy", "warm")
]
why = (
"ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)"
if outcome == "won"
else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ"
)
return {
"outcome": outcome,
"score": 60 if outcome == "won" else 25,
"pain": (persona.get("pains") or [{}])[0].get("description", "") if persona.get("pains") else "",
"why": why,
"failurePoints": [] if outcome == "won" else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"],
"coaching": (
["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"]
if outcome == "won"
else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"]
),
"turning_points": turning_points,
"signals": signals,
"revealed_persona": {
"pains": persona.get("pains", []),
"income": persona.get("income", ""),
"personality": persona.get("personality", ""),
"budget": persona.get("budget", ""),
"negotiation_levers": persona.get("negotiation_levers", []),
"opener": persona.get("opener", ""),
"background": persona.get("background", ""),
"tolerance": persona.get("tolerance", 3),
},
}
def _get_ready_group(s, gid: str) -> dict:
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
group = s["groups"].get_or_none(gid)
@@ -154,7 +191,7 @@ def send_message(gid: str, pid: str):
sim = _sim(group, persona)
try:
reply = sim.persona_reply(
reply, meta = sim.persona_reply(
persona=persona,
sales_kit=group.get("sales_kit") or {},
messages=messages,
@@ -166,10 +203,43 @@ def send_message(gid: str, pid: str):
raise ApiError(f"LLM error: {exc}", 500)
messages.append({"role": "customer", "text": reply})
# Per-turn tracking: count exchanged turns + nudge score down as the chat drags.
# Update internal state: track misses (poor answers) and mood trend.
internal = session.get("internal", {}) or {}
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"})
# Decision by the persona ends the session (one-shot lock).
decision = meta.get("decision")
if decision in ("buy", "walk"):
outcome = "won" if decision == "buy" else "lost"
debrief = _build_abbrev_debrief(outcome, persona, internal)
s["sessions"].update(
session["id"],
status="finished",
outcome=outcome,
messages=messages,
internal=internal,
debrief=debrief,
)
return jsonify({
"reply": reply,
"messages": messages,
"finished": True,
"outcome": outcome,
"debrief": debrief,
"session": s["sessions"].get(session["id"]),
})
s["sessions"].update(session["id"], messages=messages, internal=internal)
return jsonify({"reply": reply, "messages": messages})
@@ -238,3 +308,16 @@ def get_session(sid: str):
if not session or session.get("user_id") != current_user()["id"]:
raise ApiError("session not found", 404)
return jsonify({"session": session})
@chat_bp.get("/<gid>/personas/<pid>/chat/resume")
@require_auth
@require_roles("user")
def resume_session(gid: str, pid: str):
"""Resume an active (unfinished) session for this persona so the trainee can continue."""
s = _stores()
actor = current_user()
session = s["sessions"].active_for_persona(actor["id"], pid)
if not session or session.get("group_id") != gid:
raise ApiError("no active session for this persona", 404)
return jsonify({"session": session, "scenario": session.get("scenario", "social")})

View File

@@ -22,6 +22,9 @@ EACH persona MUST include ALL of these fields:
- pains[] (LATENT)
- negotiation_levers[] (LATENT)
- opener, special, difficulty, notes
- tolerance (1-5): how many irritant/poor answers you tolerate before you walk away ("heart"). IMPORTANT:
a temperamental/impatient persona has LOW tolerance (1-2, walks away fast after poor answers); a patient
one has HIGH (4-5). Avg is 3. Match tolerance to personality (e.g. a busy owner / abrupt personality = low).
RULES:
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
@@ -32,8 +35,9 @@ RULES:
(what the seller must satisfy to resolve it).
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
4. DECISION BEHAVIOR: when the persona decides to buy (after their pain is resolved + price accepted)
OR to walk away (after too many misses / rude / pushy / wrong), the persona STATES the decision in
ordinary dialogue (e.g. "ok I'll go with it" / "no thanks, forget it") — it does NOT announce it as meta.
5. CHANNEL: "facebook" or "line".
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.

View File

@@ -24,19 +24,27 @@ naturally and it makes sense for a real customer to reveal them):
- Your pains (some may be product-solvable, some NOT): {pains}
- Your negotiation levers: {levers}
- Your goal/mood: {goal}
- Your tolerance (TOLERANCE): you walk away after about {tolerance} irritating/off-point/pushy
answers. If the seller is repeatedly wrong, ignores your need, or is pushy, you feel fed up.
Initiation mode: {init_mode}. {special_instr}
BEHAVIOR RULES:
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
delivery time, scope, payment).
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
and you may refuse to continue / walk away — even if you wanted the product.
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
pains unprompted.
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
5. Stay in character; never mention that you are a simulation or an AI persona.
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate.
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops.
Past your tolerance, you SAY so plainly and end the chat (e.g. "Never mind, forget it" / "I'll
think about it elsewhere" / "ok, bye").
3. When the seller genuinely resolves your real pain AND you accept the price, you DECIDE and SAY
plainly you'll take it (e.g. "ok, let's go with it" / "fine, send me the order").
4. You reveal pains only when the seller asks good questions or builds trust.
5. Respond in natural, in-character style.
6. Stay in character; never mention this is a simulation. When you decide (buy OR walk away), say it
naturally in-dialogue; do not narrate as meta.
Reply with a JSON object: {{"reply": "<your message>"}}
Reply ONLY with a JSON object:
{{"reply": "<your message>", "decision": "none" | "buy" | "walk", "mood": -2..2}}
- "decision": set "buy" ONLY when you clearly decided to purchase; "walk" ONLY when you clearly
decided NOT to purchase and are ending the chat; otherwise "none".
- "mood": -2 (very annoyed) .. +2 (very receptive), current feel about the seller.
Only output that JSON.
"""
@@ -87,13 +95,15 @@ class Simulator:
internal: dict[str, Any],
scenario: str = "social",
scenario_adapt: str = "",
) -> str:
) -> tuple[str, dict[str, Any]]:
"""Return (reply_text, meta) where meta includes decision/mood from the persona."""
pains_txt = self._describe_pains(persona.get("pains", []))
adapt = scenario_adapt or {
"social": "Chat style: short, casual, quick social-messaging replies.",
"f2f_call": "Style: natural, conversational like a live face-to-face or phone talk.",
"recontact": "Style: casual messaging; you already know the product from 1-3 months ago.",
}.get(scenario, "")
tolerance = int(persona.get("tolerance", 3) or 3)
system = CHAT_SYSTEM.format(
name=persona.get("name", "Customer"),
tone=persona.get("communication_style", "natural, casual"),
@@ -109,20 +119,18 @@ class Simulator:
pains=pains_txt,
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
goal=persona.get("goal", ""),
tolerance=tolerance,
init_mode="you contacted the seller first (customer-initiated)"
if persona.get("initiation_mode") == "customer"
else "the seller opened the sale to you (you are a lead)",
special_instr=self._special_instr(persona) + "\n" + adapt,
)
msgs = [{"role": "system", "content": system}]
# send a compact recap of internal state to the persona ad
# (doesn't leak to trainee)
msgs.append({
"role": "system",
"content": "Internal state (for your role-play only): "
+ json.dumps(internal, ensure_ascii=False),
})
# Translate role 'system' transcript entries into a hidden system note for the LLM.
for m in messages[-30:]:
role = m.get("role")
if role == "system":
@@ -133,13 +141,19 @@ class Simulator:
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
except LLMError as exc:
raise
# extract {reply: ...}
# extract {reply, decision, mood}
meta: dict[str, Any] = {"decision": "none", "mood": 0}
try:
data = json.loads(self._extract_json(resp))
reply = data.get("reply") or data.get("response") or str(resp)
reply = (data.get("reply") or data.get("response") or str(resp)).strip()
meta["decision"] = data.get("decision", "none")
try:
meta["mood"] = int(float(data.get("mood", 0)))
except (TypeError, ValueError):
meta["mood"] = 0
except Exception:
reply = resp
return reply.strip()
reply = resp.strip()
return reply, meta
# ── judge ──────────────────────────────────────────────────────────
def judge(

View File

@@ -44,6 +44,7 @@ def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
"opener": p.get("opener", ""),
"special": p.get("special", ""), # e.g. "wrong_text" | ""
"difficulty": p.get("difficulty", 1), # 1..5
"tolerance": p.get("tolerance", 3), # misses before this persona walks away (temper)
"notes": p.get("notes", ""),
}
# validate

View File

@@ -107,5 +107,10 @@ class MockLLM:
return {}
def complete_conversation(self, messages, **kw) -> str:
# persona chat: echo a short in-character reply
return json.dumps({"reply": "I see. Tell me more about the price then."}, ensure_ascii=False)
# persona chat: echo a short in-character reply with a decision.
# On the first send, the persona decides to buy (so E2E auto-finishes as won).
return json.dumps({
"reply": "I see. Tell me more about the price then.",
"decision": "buy",
"mood": 1,
}, ensure_ascii=False)

View File

@@ -87,16 +87,15 @@ def main():
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
assert r.status_code == 200, r.get_json()
assert r.get_json()["reply"]
print("[ok] send message -> persona replies")
# finish -> debrief reveals latent + outcome won
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/finish", headers=UH)
assert r.status_code == 200, r.get_json()
debrief = r.get_json()["debrief"]
assert debrief["outcome"] == "won"
body = r.get_json()
assert body["reply"]
# Mock persona decides to buy on this send -> session auto-finishes as won.
assert body.get("finished") is True, body
assert body.get("outcome") == "won", body
debrief = body.get("debrief") or {}
assert debrief.get("outcome") == "won"
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
print("[ok] finish -> debrief with latent reveal + outcome")
print("[ok] send -> persona decides (buy) -> session auto-finishes won with debrief")
# ONE-SHOT: cannot start again on same persona
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)