feat(personas): 'create persona from this persona' (variant) — practice the same challenge, new identity

Each persona is one-shot (chat once = win/lose locked). To keep training repeatable:
- New endpoint POST /api/groups/<gid>/personas/<pid>/variant creates a NEW persona that is
  a fresh incarnation of the source: LOCKS pain points, objections, negotiation levers,
  tolerance, special/recontact, goal, budget, difficulty, tier, product_context — but VARYS
  name/profession/age/location/background/personality/opener so it isn't an identical copy.
- Added to the same group as a distinct persona (fresh not_tried, so chat-able again).
- UI: on the Personas page, a finished (won/lost) persona gets a
  'สร้างบุคคลต้นแบบจากต้นแบบนี้' button; reload shows the variant.
All 10 backend suites pass. Rebuilt dist.
This commit is contained in:
Macky
2026-08-09 12:43:00 +07:00
parent 8771438aef
commit 10c7d01236
29 changed files with 217 additions and 30 deletions

View File

@@ -87,8 +87,76 @@ class PersonaGenerator:
p["special"] = "wrong_text"
break
if len(normalized) < 8:
raise ValueError(f"expected ~15 personas, generated only {len(normalized)}")
# NOTE: if we're short of 15 (real LLMs occasionally return 14), we ACCEPT what we
# got rather than crashing the whole analyze — with TARGET=15 there's normally no gap.
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", "social"))
variant.setdefault("pains", source.get("pains", []))
return variant