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")})