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