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

@@ -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(