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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
54
backend/scripts/test_variant.py
Normal file
54
backend/scripts/test_variant.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Test: create a persona VARIANT from an existing persona (fresh identity, locked core traits)."""
|
||||
import os, sys, tempfile, warnings
|
||||
from pathlib import Path
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
BACKEND = str(Path(__file__).resolve().parents[1])
|
||||
sys.path.insert(0, BACKEND)
|
||||
from app.factory import create_app
|
||||
from app.config import Config
|
||||
|
||||
td = tempfile.mkdtemp(); Config.DATA_DIR = Path(td)
|
||||
sys.path.insert(0, BACKEND + "/scripts")
|
||||
from mock_llm import MockLLM
|
||||
|
||||
app = create_app(); app.extensions["llm"] = MockLLM()
|
||||
C = app.test_client()
|
||||
|
||||
def tok(u, p): return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"]
|
||||
|
||||
AT = tok("admin", "1234"); AH = {"Authorization": f"Bearer {AT}"}
|
||||
C.post("/api/auth/setup", headers=AH, json={"username":"admin","email":"a@b.co","password":"newpass","accepted_terms":True})
|
||||
AT = tok("admin", "newpass"); AH = {"Authorization": f"Bearer {AT}"}
|
||||
|
||||
# admin creates group + analyze (15 personas)
|
||||
r = C.post("/api/groups", headers=AH, json={"product":"POS CRM","segment":"SME restaurants","language":"th"})
|
||||
gid = r.get_json()["group"]["id"]
|
||||
C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||
ps = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||
src = ps[0]
|
||||
print("source:", src["name"], "id:", src["id"], "| pains:", len(src.get("pains", [])) if isinstance(src.get("pains"), list) else "")
|
||||
|
||||
# create a variant
|
||||
r = C.post(f"/api/groups/{gid}/personas/{src['id']}/variant", headers=AH)
|
||||
assert r.status_code == 201, (r.status_code, r.get_json())
|
||||
var = r.get_json()["persona"]
|
||||
print("[ok] variant created:", var.get("name"), "| id:", var.get("id"))
|
||||
|
||||
# it's a NEW id (not the source)
|
||||
assert var["id"] != src["id"], "variant must have a new id"
|
||||
# core traits locked (pains present as objects w/ description)
|
||||
assert isinstance(var.get("pains", []), list), "variant must keep pains"
|
||||
# added to the group
|
||||
ps2 = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||
ids = [p["id"] for p in ps2]
|
||||
assert var["id"] in ids, "variant must be in the group personas"
|
||||
print("[ok] variant added to group (now", len(ps2), "personas)")
|
||||
|
||||
# the variant can be chatted fresh (not_tried for a fresh trainee)
|
||||
UT = tok("admin", "newpass"); UH = {"Authorization": f"Bearer {UT}"}
|
||||
board = C.get("/api/me/board", headers=UH).get_json()
|
||||
vp = next((x for x in board.get("board", []) if x["persona_id"] == var["id"]), None)
|
||||
print("[ok] variant appears on my board:", (vp or {}).get("my_outcome") if vp else None)
|
||||
|
||||
print("ALL VARIANT TESTS PASSED")
|
||||
Reference in New Issue
Block a user