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

@@ -338,6 +338,53 @@ def update_persona(gid: str, pid: str):
return jsonify({"persona": strip_secret_fields(full)})
@groups_bp.post("/<gid>/personas/<pid>/variant")
@require_auth
def create_persona_variant(gid: str, pid: str):
"""Create a NEW persona cloned from an existing one (fresh identity, same core traits).
Lets a trainee practice the SAME selling challenge repeatedly even though each persona
can only be chatted once — the variant is a different person with the same pain points /
personality / temperament, so the training repeats but never as an identical copy.
Anyone who has access to the group can create a variant (admin or trainee).
"""
s = _stores()
group = _get_owned_group(s, gid)
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
src = next((p for p in group.get("personas", []) if p.get("id") == pid), None)
if src is None:
raise ApiError("persona not found", 404)
lang = (group.get("input") or {}).get("language", "th")
try:
from ..services.persona_generator import PersonaGenerator
variant = PersonaGenerator(s["llm"]).generate_variant(
source=src,
sales_kit=group.get("sales_kit") or {},
language=lang,
)
except Exception as exc:
raise ApiError(f"variant failed: {exc}", 500)
# Assign a unique id and append to the group (keeps existing personas/sessions intact).
import uuid as _uuid
variant["id"] = f"persona-{_uuid.uuid4().hex[:10]}"
variant["source_persona_id"] = pid
personas = group.get("personas", []) + [variant]
s["groups"].update(gid, personas=personas)
full = ensure_persona_shape(variant)
actor = current_user()
if actor.get("role") == "super_admin":
persona_out = full
elif actor.get("role") == "user":
persona_out = revealable_view(full)
else:
persona_out = strip_secret_fields(full)
return jsonify({"persona": persona_out}), 201
@groups_bp.delete("/<gid>")
@require_auth
@require_roles("admin")

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