Files
sales-trainer/backend/app/services/store.py
Macky aa2eb8dd37 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.
2026-08-08 14:34:34 +07:00

77 lines
3.0 KiB
Python

"""Persona data model + shape normalization.
A persona has a canonical schema. Fields are split into:
- revealable: shown to trainees up front (what a real seller could plausibly know)
- latent: hidden until the conversation ends (pain, income, personality, budget,
negotiation levers, hidden opener, etc.)
Every persona also carries an `intent_tier` (A/B/C), an `initiation_mode`
(customer/seller), a `channel` (facebook/line), a set of `pains` with resolution
conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text).
"""
from __future__ import annotations
from typing import Any
DEFAULT_TIERS = ["A", "B", "C"]
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
"""Fill defaults so a persona dict is always structurally complete."""
pid = p.get("id") or p.get("name", "persona")
base = {
"id": pid,
"name": p.get("name", ""),
"tier": p.get("tier", p.get("intent_tier", "B")),
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
"channel": p.get("channel", "facebook"), # facebook | line
# revealable
"profession": p.get("profession", ""),
"age_group": p.get("age_group", ""),
"location": p.get("location", ""),
"product_context": p.get("product_context", ""),
# latent (hidden until end)
"background": p.get("background", ""),
"income": p.get("income", ""),
"lifestyle": p.get("lifestyle", ""),
"personality": p.get("personality", ""),
"communication_style": p.get("communication_style", ""),
"budget": p.get("budget", ""),
"decision_timeline": p.get("decision_timeline", ""),
"goal": p.get("goal", ""),
"objections": p.get("objections", []),
"pains": p.get("pains", []),
"negotiation_levers": p.get("negotiation_levers", []),
"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
if base["tier"] not in DEFAULT_TIERS:
base["tier"] = "B"
if base["initiation_mode"] not in ("customer", "seller"):
base["initiation_mode"] = "customer"
if base["channel"] not in ("facebook", "line"):
base["channel"] = "facebook"
return base
def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
"""Return ONLY the fields a trainee may see before/while chatting."""
return {
"id": p.get("id"),
"name": p.get("name"),
"tier": p.get("tier"),
"channel": p.get("channel"),
"initiation_mode": p.get("initiation_mode"),
"profession": p.get("profession"),
"age_group": p.get("age_group"),
"location": p.get("location"),
"product_context": p.get("product_context"),
}
def full_view(p: dict[str, Any]) -> dict[str, Any]:
return ensure_persona_shape(p)