Files
sales-trainer/backend/app/services/persona_generator.py

182 lines
9.0 KiB
Python

"""Persona generator: builds 15 personas (5 per tier) from a Sales Kit + scenario."""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient
from .persona_prompts import PERSONA_SYSTEM
from .store import PERSONA_CHANNELS, validate_persona_traits
TIERS = ["A", "B", "C"]
PER_TIER = 5 # 5 per tier = 15 total
TARGET = 15 # total personas the system generates (no "add more" button needed)
class PersonaGenerator:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def generate(
self,
*,
sales_kit: dict[str, Any],
language: str = "en",
channel: str = "facebook",
) -> list[dict[str, Any]]:
if channel not in PERSONA_CHANNELS:
raise ValueError("persona channel must be facebook or line")
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
lang_name = "Thai" if language == "th" else "English"
scenario = (sales_kit.get("scenarioFrame") or "").strip() or "a general product sale"
user_prompt = (
f"Platform/channel preference: {channel}\n"
f"Language: {lang_name} (all persona text in {lang_name})\n"
f"Sales Kit:\n{kit_json}\n\n"
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
)
last_error: ValueError | None = None
for attempt in range(1, 4):
prompt = user_prompt + (
""
if attempt == 1
else "\n\n(Note: the previous output failed the strict persona schema. Output exactly 15 valid personas: 5 A, 5 B, 5 C, unique ids, valid Facebook/LINE channel, valid customer/seller initiation, and one tier-C wrong_text persona. No extra prose.)"
)
result = self.llm.complete_json(
PERSONA_SYSTEM, prompt, temperature=0.8, max_tokens=14000
)
try:
payload = result.get("personas") if isinstance(result, dict) else None
return self._normalize_personas(payload, channel)
except ValueError as exc:
last_error = exc
raise last_error or ValueError("persona generator returned invalid personas")
def _normalize_personas(self, personas: object, channel: str) -> list[dict[str, Any]]:
if not isinstance(personas, list):
raise ValueError("persona generator returned invalid persona list")
if len(personas) != TARGET:
raise ValueError("persona generator must return exactly 15 personas")
raw_ids: set[str] = set()
normalized: list[dict[str, Any]] = []
counts = {tier: 0 for tier in TIERS}
for raw in personas:
if not isinstance(raw, dict):
raise ValueError("persona generator returned invalid persona object")
source_id = raw.get("id")
if source_id is not None:
if not isinstance(source_id, str) or not source_id.strip():
raise ValueError("persona id is invalid")
if source_id in raw_ids:
raise ValueError("duplicate persona id")
raw_ids.add(source_id)
p = dict(raw)
if not isinstance(p.get("name"), str) or not p["name"].strip():
raise ValueError("persona name is invalid")
tier = p.get("tier", p.get("intent_tier"))
if tier not in TIERS:
raise ValueError("persona tier is invalid")
if p.get("channel") not in PERSONA_CHANNELS:
raise ValueError("persona channel must be facebook or line")
if p.get("initiation_mode") not in ("customer", "seller"):
raise ValueError("persona initiation_mode must be customer or seller")
for field in ("pains", "objections", "negotiation_levers"):
if not isinstance(p.get(field), list):
raise ValueError(f"persona {field} is invalid")
for field, low, high in (("difficulty", 1, 5), ("tolerance", 1, 5)):
value = p.get(field, 1 if field == "difficulty" else 3)
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
raise ValueError(f"persona {field} is invalid")
p[field] = value
if "recontact" in p and not isinstance(p["recontact"], bool):
raise ValueError("persona recontact is invalid")
p.setdefault("recontact", False)
p.setdefault("special", "")
counts[tier] += 1
normalized.append(p)
if any(counts[tier] != PER_TIER for tier in TIERS):
raise ValueError("persona tiers must contain exactly 5 per tier")
if not any(p.get("tier") == "C" and p.get("special") == "wrong_text" for p in normalized):
raise ValueError("tier C requires a wrong_text persona")
# Canonical ids are assigned only after all provider data is valid.
for index, p in enumerate(normalized, start=1):
p["id"] = f"persona-{index:02d}"
validate_persona_traits(p)
return normalized
def generate_variant(
self,
source: dict[str, Any],
sales_kit: dict[str, Any] | None = None,
language: str = "en",
) -> dict[str, Any]:
"""Create ONE new persona that is a fresh incarnation of a source persona.
The variant LOCKS the source's core traits — pain points, objections, negotiation
levers, tolerance (temper), and any special/recontact behavior — so it practices the
SAME selling challenge, but gets a NEW identity (name, profession, age, location,
background, personality, income, opener) so it isn't an identical copy.
Because the seller already knows how this customer 'plays', we vary the new identity
so the trainee still has to re-read and re-adjust rather than memorizing exact answers.
"""
kit_note = (
f"Sales Kit\\n{json.dumps(sales_kit, ensure_ascii=False)[:6000]}"
if sales_kit
else ""
)
src = json.dumps(
{
"pains": source.get("pains", []),
"objections": source.get("objections", []),
"negotiation_levers": source.get("negotiation_levers", []),
"tolerance": source.get("tolerance", 3),
"special": source.get("special", ""),
"recontact": source.get("recontact", False),
"goal": source.get("goal", ""),
"decision_timeline": source.get("decision_timeline", ""),
"budget": source.get("budget", ""),
"difficulty": source.get("difficulty", 1),
"tier": source.get("tier", "B"),
"product_context": source.get("product_context", ""),
},
ensure_ascii=False,
)
lang_name = "Thai" if language == "th" else "English"
prompt = (
f"Create ONE new, realistic customer persona that is a fresh incarnation of an existing one.\n"
f"Language: {lang_name} (all text in {lang_name})\n"
f"{kit_note}\n"
f"LOCK (keep exactly these — they drive the training): pains[], objections[], "
f"negotiation_levers[], tolerance, special, recontact, goal, decision_timeline, "
f"budget, difficulty, tier, product_context.\n"
f"VARY (make DIFFERENT so it's not a copy): name, profession, age_group, location, "
f"background, income, lifestyle, personality, communication_style, opener, and any "
f"surface small-talk. Keep it consistent with the locked traits (a customer with the "
f"same pain would believably have a different name/job/life).\n"
f"Output exactly one JSON object for the persona.\n"
)
result = self.llm.complete_json(
PERSONA_SYSTEM, prompt, temperature=0.9, max_tokens=3000
)
variant = result if isinstance(result, dict) else {}
# Accept either a single persona object or a {"personas": [...]} container.
if isinstance(variant.get("personas"), list) and variant["personas"]:
variant = variant["personas"][0]
if not isinstance(variant, dict) or not variant.get("name"):
raise ValueError("variant generator returned no persona")
# Lock the core traits regardless of what the LLM chose to change.
for locked in ("pains", "objections", "negotiation_levers", "tolerance",
"special", "recontact", "goal", "decision_timeline", "budget",
"difficulty", "tier", "product_context"):
if locked in source:
variant[locked] = source.get(locked)
variant.setdefault("initiation_mode", source.get("initiation_mode", "customer"))
variant.setdefault("channel", source.get("channel", "facebook"))
variant.setdefault("pains", source.get("pains", []))
validate_persona_traits(variant)
return variant