Files
Macky fd2cae9e62 refactor(scenario): only 2 scenarios (social / face-to-face); 're-contact' becomes a persona trait
- Scenario picker now has only social + f2f_call (removed recontact) in backend
  _scenarios, start_session validation, and Chat.vue picker.
- 'ลูกค้ากลับมาติดต่อ' is no longer a scenario: it's now a PERSONA trait. Persona prompt
  generates ~1-in-4 personas with recontact=true (asked before, now returns warmer/ready);
  store shape gets recontact field; simulator injects a re-contact note into the persona's
  system prompt so it plays as a returning customer naturally.
- test_scenario updated: unknown scenario defaults to social; recontact shown as a
  generated persona trait.
All 9 backend suites pass. Rebuilt dist.
2026-08-09 11:21:34 +07:00

75 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", "social"), # internal tone hint (scenario overrides)
# 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" | ""
"recontact": bool(p.get("recontact")), # returned after researching earlier (pre-qualified, warm)
"difficulty": p.get("difficulty", 1), # 1..5
"tolerance": p.get("tolerance", 3), # misses before this persona walks away (temper)
"notes": p.get("notes", ""),
}
# validation
if base["tier"] not in DEFAULT_TIERS:
base["tier"] = "B"
if base["initiation_mode"] not in ("customer", "seller"):
base["initiation_mode"] = "customer"
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"),
"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)