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