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

View 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")

View File

@@ -1,4 +1,4 @@
import{c as k,_ as h,p as b,a as r,b as e,l as m,u as o,m as l,t as n,i as u,w as p,v,H as U,g as f,F as V,x as C,y as w,r as y,o as i,C as M}from"./index-yErsUbD6.js";import{U as z}from"./users-BK4yWB6N.js";/**
import{c as k,_ as h,p as b,a as r,b as e,l as m,u as o,m as l,t as n,i as u,w as p,v,H as U,g as f,F as V,x as C,y as w,r as y,o as i,C as M}from"./index-C--0e2U-.js";import{U as z}from"./users-d_q-MTMu.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as x,_ as L,p as M,a as p,b as s,l as i,u as a,m as c,t as e,i as n,q as w,w as f,v as b,s as U,g as N,F as R,x as j,y as A,z as B,r as m,h as E,o as y}from"./index-yErsUbD6.js";import{U as P}from"./users-BK4yWB6N.js";import{P as S}from"./plus-Y1qXSgej.js";/**
import{c as x,_ as L,p as M,a as p,b as s,l as i,u as a,m as c,t as e,i as n,q as w,w as f,v as b,s as U,g as N,F as R,x as j,y as A,z as B,r as m,h as E,o as y}from"./index-C--0e2U-.js";import{U as P}from"./users-d_q-MTMu.js";import{P as S}from"./plus-DQOnWKR_.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as P,_ as G,p as K,y as S,a as l,l as k,q as D,u as a,b as s,t,i as n,F as j,x as T,m as g,g as m,C as L,w as R,v as $,d as q,k as H,r as u,B as J,E as O,z as U,o}from"./index-yErsUbD6.js";import{T as Y}from"./target-DmESUw9c.js";import{A as Q}from"./arrow-left-hWxF5h5m.js";/**
import{c as P,_ as G,p as K,y as S,a as l,l as k,q as D,u as a,b as s,t,i as n,F as j,x as T,m as g,g as m,C as L,w as R,v as $,d as q,k as H,r as u,B as J,E as O,z as U,o}from"./index-C--0e2U-.js";import{T as Y}from"./target-dGrJgR_B.js";import{A as Q}from"./arrow-left-C-Wn16Dy.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as v,_ as M,a as k,l as u,q as z,b as e,u as l,m as o,t as r,i,w as g,v as y,H as V,g as C,z as A,r as m,y as x,j as B,o as w}from"./index-yErsUbD6.js";import{A as q}from"./arrow-left-hWxF5h5m.js";/**
import{c as v,_ as M,a as k,l as u,q as z,b as e,u as l,m as o,t as r,i,w as g,v as y,H as V,g as C,z as A,r as m,y as x,j as B,o as w}from"./index-C--0e2U-.js";import{A as q}from"./arrow-left-C-Wn16Dy.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as q,_ as E,I as F,o as u,a as d,b as t,t as a,w as o,v as i,H as G,u as f,h as M,g as A,J as I,r as g,p as D,l as k,q as T,m as C,i as B,G as J,F as j,x as z,D as H,k as R,y as N,z as K,C as O}from"./index-yErsUbD6.js";import{U as Q}from"./users-BK4yWB6N.js";/**
import{c as q,_ as E,I as F,o as u,a as d,b as t,t as a,w as o,v as i,H as G,u as f,h as M,g as A,J as I,r as g,p as D,l as k,q as T,m as C,i as B,G as J,F as j,x as z,D as H,k as R,y as N,z as K,C as O}from"./index-C--0e2U-.js";import{U as Q}from"./users-d_q-MTMu.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as e,a as o,b as l,l as n,u as i,m as s,t as r,i as a,G as d,o as u}from"./index-yErsUbD6.js";import{B as g}from"./book-open-Cmnj20ue.js";import{L as p}from"./layout-dashboard-DplDGQ9R.js";import{T as y}from"./target-DmESUw9c.js";/**
import{c as e,a as o,b as l,l as n,u as i,m as s,t as r,i as a,G as d,o as u}from"./index-C--0e2U-.js";import{B as g}from"./book-open-BDMN_yKI.js";import{L as p}from"./layout-dashboard-qMD4csbw.js";import{T as y}from"./target-dGrJgR_B.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as k,_ as x,a as y,b as e,t as l,u as a,i as n,w as h,v as M,d as w,e as B,f as _,g as E,r,h as f,j as S,k as V,o as s}from"./index-yErsUbD6.js";/**
import{c as k,_ as x,a as y,b as e,t as l,u as a,i as n,w as h,v as M,d as w,e as B,f as _,g as E,r,h as f,j as S,k as V,o as s}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1 +1 @@
import{_ as f,p as x,y as v,a as r,b as t,l as b,u as e,m as k,t as s,i as a,g as _,w as B,A as N,F as D,x as S,r as m,B as c,o as i,C as T}from"./index-yErsUbD6.js";import{L as V}from"./layout-dashboard-DplDGQ9R.js";const C={style:{margin:"0"}},L={key:0,class:"row stat-row"},M={class:"card stat"},z={class:"card stat won"},F={class:"card stat lost"},j={class:"card stat"},A={key:1,class:"card"},E={key:2,class:"card empty-state"},G={class:"card",style:{"margin-top":"20px"}},I={style:{"margin-top":"0"}},q={__name:"MyBoard",setup(H){const o=m([]),u=m([]),p=m(!0),y=c(()=>o.value.length),g=c(()=>o.value.filter(n=>n.my_outcome==="won").length),h=c(()=>o.value.filter(n=>n.my_outcome==="lost").length),w=c(()=>o.value.filter(n=>n.my_outcome==="not_tried").length);return x(async()=>{try{o.value=(await v.myBoard()).board||[],u.value=(await v.mySessions()).sessions||[]}finally{p.value=!1}}),(n,l)=>(i(),r("div",null,[t("div",null,[t("h2",C,[b(e(V),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),k(" "+s(e(a).t("myDashboard")),1)]),l[0]||(l[0]=t("div",{class:"muted",style:{"margin-top":"4px","margin-bottom":"16px"}},"สรุปผลการฝึกของตัวคุณเอง — ดูว่าปิดการขายได้กี่ครั้ง ยังฝึกกับใครบ้าง",-1))]),p.value?_("",!0):(i(),r("div",L,[t("div",M,[t("span",null,s(e(a).t("total")),1),t("strong",null,s(y.value),1)]),t("div",z,[t("span",null,s(e(a).t("won")),1),t("strong",null,s(g.value),1)]),t("div",F,[t("span",null,s(e(a).t("lost")),1),t("strong",null,s(h.value),1)]),t("div",j,[t("span",null,s(e(a).t("notTried")),1),t("strong",null,s(w.value),1)])])),p.value?(i(),r("div",A,[...l[1]||(l[1]=[t("div",{class:"skeleton",style:{height:"50px"}},null,-1)])])):o.value.length===0?(i(),r("div",E,[...l[2]||(l[2]=[t("strong",null,"No practice yet",-1),t("span",null,"Go to Training and try closing a sale with a persona.",-1)])])):_("",!0),B(t("div",G,[t("h3",I,s(e(a).t("mySessions")),1),(i(!0),r(D,null,S(u.value,d=>(i(),r("div",{key:d.id,style:{display:"flex","justify-content":"space-between",padding:"8px 0","border-bottom":"1px solid var(--border)"}},[t("span",null,s(d.persona_name),1),t("span",{class:T(["badge",d.outcome])},s(d.outcome==="won"?e(a).t("won"):e(a).t("lost")),3)]))),128))],512),[[N,u.value.length]])]))}},O=f(q,[["__scopeId","data-v-6767b6b8"]]);export{O as default};
import{_ as f,p as x,y as v,a as r,b as t,l as b,u as e,m as k,t as s,i as a,g as _,w as B,A as N,F as D,x as S,r as m,B as c,o as i,C as T}from"./index-C--0e2U-.js";import{L as V}from"./layout-dashboard-qMD4csbw.js";const C={style:{margin:"0"}},L={key:0,class:"row stat-row"},M={class:"card stat"},z={class:"card stat won"},F={class:"card stat lost"},j={class:"card stat"},A={key:1,class:"card"},E={key:2,class:"card empty-state"},G={class:"card",style:{"margin-top":"20px"}},I={style:{"margin-top":"0"}},q={__name:"MyBoard",setup(H){const o=m([]),u=m([]),p=m(!0),y=c(()=>o.value.length),g=c(()=>o.value.filter(n=>n.my_outcome==="won").length),h=c(()=>o.value.filter(n=>n.my_outcome==="lost").length),w=c(()=>o.value.filter(n=>n.my_outcome==="not_tried").length);return x(async()=>{try{o.value=(await v.myBoard()).board||[],u.value=(await v.mySessions()).sessions||[]}finally{p.value=!1}}),(n,l)=>(i(),r("div",null,[t("div",null,[t("h2",C,[b(e(V),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),k(" "+s(e(a).t("myDashboard")),1)]),l[0]||(l[0]=t("div",{class:"muted",style:{"margin-top":"4px","margin-bottom":"16px"}},"สรุปผลการฝึกของตัวคุณเอง — ดูว่าปิดการขายได้กี่ครั้ง ยังฝึกกับใครบ้าง",-1))]),p.value?_("",!0):(i(),r("div",L,[t("div",M,[t("span",null,s(e(a).t("total")),1),t("strong",null,s(y.value),1)]),t("div",z,[t("span",null,s(e(a).t("won")),1),t("strong",null,s(g.value),1)]),t("div",F,[t("span",null,s(e(a).t("lost")),1),t("strong",null,s(h.value),1)]),t("div",j,[t("span",null,s(e(a).t("notTried")),1),t("strong",null,s(w.value),1)])])),p.value?(i(),r("div",A,[...l[1]||(l[1]=[t("div",{class:"skeleton",style:{height:"50px"}},null,-1)])])):o.value.length===0?(i(),r("div",E,[...l[2]||(l[2]=[t("strong",null,"No practice yet",-1),t("span",null,"Go to Training and try closing a sale with a persona.",-1)])])):_("",!0),B(t("div",G,[t("h3",I,s(e(a).t("mySessions")),1),(i(!0),r(D,null,S(u.value,d=>(i(),r("div",{key:d.id,style:{display:"flex","justify-content":"space-between",padding:"8px 0","border-bottom":"1px solid var(--border)"}},[t("span",null,s(d.persona_name),1),t("span",{class:T(["badge",d.outcome])},s(d.outcome==="won"?e(a).t("won"):e(a).t("lost")),3)]))),128))],512),[[N,u.value.length]])]))}},O=f(q,[["__scopeId","data-v-6767b6b8"]]);export{O as default};

View File

@@ -0,0 +1 @@
import{_ as T,p as L,a as r,l as c,q as y,b as t,t as s,u as a,i as n,h as p,m as d,g as w,F as _,x as h,k as N,y as x,z as S,r as A,o as l,S as F,C,f as B}from"./index-C--0e2U-.js";import{T as j}from"./target-dGrJgR_B.js";import{A as q}from"./arrow-left-C-Wn16Dy.js";const D={class:"row",style:{"align-items":"center"}},E={style:{margin:"0"}},I={class:"muted",style:{"margin-left":"auto"}},M={key:0,class:"card guide"},R={key:1,class:"row",style:{margin:"12px 0",gap:"10px"}},G={class:"primary"},H={class:"grid"},J={class:"row",style:{"justify-content":"space-between"}},K={class:"diff"},O={class:"muted"},Q={class:"muted"},U={class:"muted"},W={class:"muted",style:{"margin-top":"6px"}},X={class:"primary",style:{width:"100%"}},Y={class:"primary",style:{width:"100%"}},Z={class:"muted",style:{"margin-top":"auto","font-size":"12px"}},tt=["onClick","disabled"],st={__name:"Personas",setup(et){const u=N().params.gid,k=A([]),z=A(!0);async function b(){try{k.value=(await x.listPersonas(u)).personas}finally{z.value=!1}}function P(o){return k.value.filter(i=>i.tier===o)}function V(o){return n.t(o==="A"?"tierA":o==="B"?"tierB":"tierC")}function v(o){return o==="won"?n.t("won"):o==="lost"?n.t("lost"):o==="not_tried"?n.t("notTried"):o||"-"}async function $(o){o._busy=!0;try{await x.createPersonaVariant(u,o.id),await b()}catch(i){alert(i.message)}finally{o._busy=!1}}return L(b),(o,i)=>{const m=S("router-link");return l(),r("div",null,[c(m,{to:"/training",class:"btn-back"},{default:y(()=>[c(a(q),{size:16,"stroke-width":2}),d(" "+s(a(n).t("training")),1)]),_:1}),t("div",D,[t("h2",E,s(a(n).t("personas")),1),t("span",I,s(a(n).t("selectPersona")),1)]),a(p).isAdmin?w("",!0):(l(),r("div",M,[t("strong",null,[c(a(j),{size:17,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),i[0]||(i[0]=d(" วิธีฝึก",-1))]),i[1]||(i[1]=t("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[t("li",null,"เลือกลูกค้าจำลอง (บุคคลต้นแบบ) คนหนึ่งที่อยากฝึกด้วย"),t("li",null,"ระดับ A ง่ายสุด → ระดับ C ยากสุด (ดูจากดาว ★ ความยาก)"),t("li",null,[d("กด "),t("strong",null,"แชท"),d(" → เลือกสถานการณ์ (โซเชียล / พบหน้า-โทร) → เริ่มคุยกับลูกค้า")]),t("li",null,"ลูกค้าจะตัดสินใจเองว่าซื้อหรือไม่ซื้อ (ฝึกได้คนละครั้งเท่านั้น)")],-1))])),a(p).isAdmin?(l(),r("div",R,[c(m,{to:`/admin/groups/${a(u)}/edit`},{default:y(()=>[t("button",G,[c(a(F),{size:18,"stroke-width":1.8}),d(" "+s(a(n).t("managePersonas")),1)])]),_:1},8,["to"]),i[2]||(i[2]=t("span",{class:"muted"},"Admin: จัดการรายละเอียดบุคคลต้นแบบได้ที่นี่",-1))])):w("",!0),(l(),r(_,null,h(["A","B","C"],f=>t("div",{key:f,style:{margin:"20px 0"}},[t("h4",null,s(V(f)),1),t("div",H,[(l(!0),r(_,null,h(P(f),e=>(l(),r("div",{key:e.id,class:"card pcard lift"},[t("div",J,[t("strong",null,s(e.name),1),t("span",{class:C(["badge",e.my_outcome])},s(v(e.my_outcome)),3)]),t("div",K,[(l(),r(_,null,h(5,g=>t("span",{key:g,class:C(["star",{on:g<=(e.difficulty||1)}])},"★",2)),64)),t("span",O,s(a(n).t("difficulty"))+" "+s(e.difficulty||1)+"/5",1)]),t("div",Q,[d(s(e.profession)+" · "+s(e.age_group)+" · "+s(e.location),1),i[3]||(i[3]=t("br",null,null,-1)),t("span",U,s(a(n).t("difficulty"))+" "+s(e.difficulty||1)+"/5",1)]),t("div",W,s(e.product_context),1),a(p).isAdmin?(l(),B(m,{key:0,to:`/groups/${a(u)}/chat/${e.id}`,style:{"margin-top":"auto"}},{default:y(()=>[t("button",X,s(a(n).t("chat")),1)]),_:1},8,["to"])):(l(),r(_,{key:1},[e.my_outcome==="not_tried"?(l(),B(m,{key:0,to:`/groups/${a(u)}/chat/${e.id}`,style:{"margin-top":"auto"}},{default:y(()=>[t("button",Y,s(a(n).t("chat")),1)]),_:1},8,["to"])):(l(),r(_,{key:1},[t("div",Z,"✓ "+s(a(n).t("trained"))+" ("+s(v(e.my_outcome))+")",1),t("button",{class:"soft",style:{width:"100%","margin-top":"8px"},onClick:g=>$(e),disabled:e._busy},s(e._busy?"กำลังสร้าง…":"สร้างบุคคลต้นแบบจากต้นแบบนี้"),9,tt)],64))],64))]))),128))])])),64))])}}},lt=T(st,[["__scopeId","data-v-20adbc50"]]);export{lt as default};

View File

@@ -1 +0,0 @@
import{_ as T,p as $,a as l,l as u,q as _,b as t,t as s,u as e,i as n,h as g,m as d,g as x,F as y,x as p,k as L,y as N,z as V,r as w,o as a,S,C as b,f as A}from"./index-yErsUbD6.js";import{T as F}from"./target-DmESUw9c.js";import{A as j}from"./arrow-left-hWxF5h5m.js";const q={class:"row",style:{"align-items":"center"}},D={style:{margin:"0"}},E={class:"muted",style:{"margin-left":"auto"}},I={key:0,class:"card guide"},M={key:1,class:"row",style:{margin:"12px 0",gap:"10px"}},R={class:"primary"},G={class:"grid"},H={class:"row",style:{"justify-content":"space-between"}},J={class:"diff"},K={class:"muted"},O={class:"muted"},Q={class:"muted"},U={class:"muted",style:{"margin-top":"6px"}},W={class:"primary",style:{width:"100%"}},X={class:"primary",style:{width:"100%"}},Y={key:1,class:"muted",style:{"margin-top":"auto","font-size":"12px"}},Z={__name:"Personas",setup(tt){const c=L().params.gid,h=w([]),C=w(!0);async function B(){try{h.value=(await N.listPersonas(c)).personas}finally{C.value=!1}}function z(r){return h.value.filter(i=>i.tier===r)}function P(r){return n.t(r==="A"?"tierA":r==="B"?"tierB":"tierC")}function k(r){return r==="won"?n.t("won"):r==="lost"?n.t("lost"):n.t("notTried")}return $(B),(r,i)=>{const m=V("router-link");return a(),l("div",null,[u(m,{to:"/training",class:"btn-back"},{default:_(()=>[u(e(j),{size:16,"stroke-width":2}),d(" "+s(e(n).t("training")),1)]),_:1}),t("div",q,[t("h2",D,s(e(n).t("personas")),1),t("span",E,s(e(n).t("selectPersona")),1)]),e(g).isAdmin?x("",!0):(a(),l("div",I,[t("strong",null,[u(e(F),{size:17,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),i[0]||(i[0]=d(" วิธีฝึก",-1))]),i[1]||(i[1]=t("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[t("li",null,"เลือกลูกค้าจำลอง (บุคคลต้นแบบ) คนหนึ่งที่อยากฝึกด้วย"),t("li",null,"ระดับ A ง่ายสุด → ระดับ C ยากสุด (ดูจากดาว ★ ความยาก)"),t("li",null,[d("กด "),t("strong",null,"แชท"),d(" → เลือกสถานการณ์ (โซเชียล / พบหน้า-โทร) → เริ่มคุยกับลูกค้า")]),t("li",null,"ลูกค้าจะตัดสินใจเองว่าซื้อหรือไม่ซื้อ (ฝึกได้คนละครั้งเท่านั้น)")],-1))])),e(g).isAdmin?(a(),l("div",M,[u(m,{to:`/admin/groups/${e(c)}/edit`},{default:_(()=>[t("button",R,[u(e(S),{size:18,"stroke-width":1.8}),d(" "+s(e(n).t("managePersonas")),1)])]),_:1},8,["to"]),i[2]||(i[2]=t("span",{class:"muted"},"Admin: จัดการรายละเอียดบุคคลต้นแบบได้ที่นี่",-1))])):x("",!0),(a(),l(y,null,p(["A","B","C"],f=>t("div",{key:f,style:{margin:"20px 0"}},[t("h4",null,s(P(f)),1),t("div",G,[(a(!0),l(y,null,p(z(f),o=>(a(),l("div",{key:o.id,class:"card pcard lift"},[t("div",H,[t("strong",null,s(o.name),1),t("span",{class:b(["badge",o.my_outcome])},s(k(o.my_outcome)),3)]),t("div",J,[(a(),l(y,null,p(5,v=>t("span",{key:v,class:b(["star",{on:v<=(o.difficulty||1)}])},"★",2)),64)),t("span",K,s(e(n).t("difficulty"))+" "+s(o.difficulty||1)+"/5",1)]),t("div",O,[d(s(o.profession)+" · "+s(o.age_group)+" · "+s(o.location),1),i[3]||(i[3]=t("br",null,null,-1)),t("span",Q,s(e(n).t("difficulty"))+" "+s(o.difficulty||1)+"/5",1)]),t("div",U,s(o.product_context),1),e(g).isAdmin?(a(),A(m,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:_(()=>[t("button",W,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),l(y,{key:1},[o.my_outcome==="not_tried"?(a(),A(m,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:_(()=>[t("button",X,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),l("div",Y,"✓ "+s(e(n).t("trained"))+" ("+s(k(o.my_outcome))+")",1))],64))]))),128))])])),64))])}}},at=T(Z,[["__scopeId","data-v-ec851b01"]]);export{at as default};

View File

@@ -0,0 +1 @@
.grid[data-v-20adbc50]{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}.pcard[data-v-20adbc50]{display:flex;flex-direction:column;min-height:190px}.diff[data-v-20adbc50]{margin:8px 0}.star[data-v-20adbc50]{color:#d8dbe3}.star.on[data-v-20adbc50]{color:#f59e0b}.badge.won[data-v-20adbc50]{background:#dcfce7;color:#166534}.badge.lost[data-v-20adbc50]{background:#fee2e2;color:#991b1b}.badge.not_tried[data-v-20adbc50]{background:#eef2ff;color:#4338ca}.guide[data-v-20adbc50]{background:#eef2ff;border-color:#c7d2fe;margin:12px 0 16px}

View File

@@ -1 +0,0 @@
.grid[data-v-ec851b01]{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}.pcard[data-v-ec851b01]{display:flex;flex-direction:column;min-height:190px}.diff[data-v-ec851b01]{margin:8px 0}.star[data-v-ec851b01]{color:#d8dbe3}.star.on[data-v-ec851b01]{color:#f59e0b}.badge.won[data-v-ec851b01]{background:#dcfce7;color:#166534}.badge.lost[data-v-ec851b01]{background:#fee2e2;color:#991b1b}.badge.not_tried[data-v-ec851b01]{background:#eef2ff;color:#4338ca}.guide[data-v-ec851b01]{background:#eef2ff;border-color:#c7d2fe;margin:12px 0 16px}

View File

@@ -1,4 +1,4 @@
import{c as U,_ as T,r as c,h as u,a as h,b as e,l as k,u as a,S as $,m as v,t as o,i as s,w as b,v as x,g as V,C as S,y as M,o as g}from"./index-yErsUbD6.js";import{L as D}from"./lock-CCBV2bwv.js";/**
import{c as U,_ as T,r as c,h as u,a as h,b as e,l as k,u as a,S as $,m as v,t as o,i as s,w as b,v as x,g as V,C as S,y as M,o as g}from"./index-C--0e2U-.js";import{L as D}from"./lock-CdhbyMuc.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1 +1 @@
import{_ as U,a as v,b as e,l as K,u as s,m,t as a,i as l,h as _,w,v as b,d as h,n as N,g as T,r,j as B,o as f}from"./index-yErsUbD6.js";import{L as C}from"./lock-CCBV2bwv.js";const M={class:"setup-wrap"},D={class:"card setup-card"},L={class:"muted"},P={class:"terms"},j={key:0,class:"error",role:"alert"},z=["disabled"],E={key:0,class:"spinner"},I={key:1},V="/legal",R={__name:"Setup",setup(q){const S=B(),i=r(""),o=r(""),p=r(""),y=r(!1),u=r(""),d=r(!1);async function c(){if(u.value="",o.value.length<4){u.value=l.t("passwordTooShort");return}if(o.value!==p.value){u.value=l.t("passwordMismatch");return}d.value=!0;try{await _.finishSetup(i.value.trim(),o.value,!0),S.push("/")}catch(k){u.value=k.message}finally{d.value=!1}}return(k,t)=>{var g,x;return f(),v("div",M,[e("div",D,[e("h1",null,[K(s(C),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),m(" "+a(s(l).t("setupTitle")),1)]),e("p",L,[m(a(s(l).t("setupSubtitle"))+" ",1),e("strong",null,a(((g=s(_).user)==null?void 0:g.name)||((x=s(_).user)==null?void 0:x.username)),1)]),e("label",null,a(s(l).t("email")),1),w(e("input",{"onUpdate:modelValue":t[0]||(t[0]=n=>i.value=n),type:"email",autocomplete:"email",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,i.value]]),e("label",null,a(s(l).t("newPassword")),1),w(e("input",{"onUpdate:modelValue":t[1]||(t[1]=n=>o.value=n),type:"password",autocomplete:"new-password",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,o.value]]),e("label",null,a(s(l).t("confirmPassword")),1),w(e("input",{"onUpdate:modelValue":t[2]||(t[2]=n=>p.value=n),type:"password",autocomplete:"new-password",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,p.value]]),e("label",P,[w(e("input",{"onUpdate:modelValue":t[3]||(t[3]=n=>y.value=n),type:"checkbox"},null,512),[[N,y.value]]),e("span",null,[t[4]||(t[4]=m("ฉันยอมรับ ",-1)),e("a",{href:V,target:"_blank",rel:"noopener"},"ข้อกำหนดการใช้งาน"),t[5]||(t[5]=m(" และ ",-1)),e("a",{href:V,target:"_blank",rel:"noopener"},"นโยบายความเป็นส่วนตัว")])]),u.value?(f(),v("div",j,a(u.value),1)):T("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:d.value||!i.value||!o.value||o.value!==p.value||!y.value,onClick:c},[d.value?(f(),v("span",E)):(f(),v("span",I,a(s(l).t("save")),1))],8,z)])])}}},G=U(R,[["__scopeId","data-v-a3748c0a"]]);export{G as default};
import{_ as U,a as v,b as e,l as K,u as s,m,t as a,i as l,h as _,w,v as b,d as h,n as N,g as T,r,j as B,o as f}from"./index-C--0e2U-.js";import{L as C}from"./lock-CdhbyMuc.js";const M={class:"setup-wrap"},D={class:"card setup-card"},L={class:"muted"},P={class:"terms"},j={key:0,class:"error",role:"alert"},z=["disabled"],E={key:0,class:"spinner"},I={key:1},V="/legal",R={__name:"Setup",setup(q){const S=B(),i=r(""),o=r(""),p=r(""),y=r(!1),u=r(""),d=r(!1);async function c(){if(u.value="",o.value.length<4){u.value=l.t("passwordTooShort");return}if(o.value!==p.value){u.value=l.t("passwordMismatch");return}d.value=!0;try{await _.finishSetup(i.value.trim(),o.value,!0),S.push("/")}catch(k){u.value=k.message}finally{d.value=!1}}return(k,t)=>{var g,x;return f(),v("div",M,[e("div",D,[e("h1",null,[K(s(C),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),m(" "+a(s(l).t("setupTitle")),1)]),e("p",L,[m(a(s(l).t("setupSubtitle"))+" ",1),e("strong",null,a(((g=s(_).user)==null?void 0:g.name)||((x=s(_).user)==null?void 0:x.username)),1)]),e("label",null,a(s(l).t("email")),1),w(e("input",{"onUpdate:modelValue":t[0]||(t[0]=n=>i.value=n),type:"email",autocomplete:"email",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,i.value]]),e("label",null,a(s(l).t("newPassword")),1),w(e("input",{"onUpdate:modelValue":t[1]||(t[1]=n=>o.value=n),type:"password",autocomplete:"new-password",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,o.value]]),e("label",null,a(s(l).t("confirmPassword")),1),w(e("input",{"onUpdate:modelValue":t[2]||(t[2]=n=>p.value=n),type:"password",autocomplete:"new-password",class:"wide",onKeyup:h(c,["enter"])},null,544),[[b,p.value]]),e("label",P,[w(e("input",{"onUpdate:modelValue":t[3]||(t[3]=n=>y.value=n),type:"checkbox"},null,512),[[N,y.value]]),e("span",null,[t[4]||(t[4]=m("ฉันยอมรับ ",-1)),e("a",{href:V,target:"_blank",rel:"noopener"},"ข้อกำหนดการใช้งาน"),t[5]||(t[5]=m(" และ ",-1)),e("a",{href:V,target:"_blank",rel:"noopener"},"นโยบายความเป็นส่วนตัว")])]),u.value?(f(),v("div",j,a(u.value),1)):T("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:d.value||!i.value||!o.value||o.value!==p.value||!y.value,onClick:c},[d.value?(f(),v("span",E)):(f(),v("span",I,a(s(l).t("save")),1))],8,z)])])}}},G=U(R,[["__scopeId","data-v-a3748c0a"]]);export{G as default};

View File

@@ -1,4 +1,4 @@
import{c as x,_ as b,p as C,y as f,h as l,a as d,b as s,l as c,u as t,m,t as o,i as n,q as _,f as A,g as h,F as z,x as M,r as v,z as T,o as r,C as B,D as N}from"./index-yErsUbD6.js";import{T as P}from"./target-DmESUw9c.js";import{B as V}from"./book-open-Cmnj20ue.js";import{P as $}from"./plus-Y1qXSgej.js";/**
import{c as x,_ as b,p as C,y as f,h as l,a as d,b as s,l as c,u as t,m,t as o,i as n,q as _,f as A,g as h,F as z,x as M,r as v,z as T,o as r,C as B,D as N}from"./index-C--0e2U-.js";import{T as P}from"./target-dGrJgR_B.js";import{B as V}from"./book-open-BDMN_yKI.js";import{P as $}from"./plus-DQOnWKR_.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as e}from"./index-yErsUbD6.js";/**
import{c as e}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as a}from"./index-yErsUbD6.js";/**
import{c as a}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{c as t}from"./index-yErsUbD6.js";/**
import{c as t}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as e}from"./index-yErsUbD6.js";/**
import{c as e}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as e}from"./index-yErsUbD6.js";/**
import{c as e}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c}from"./index-yErsUbD6.js";/**
import{c}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -1,4 +1,4 @@
import{c as e}from"./index-yErsUbD6.js";/**
import{c as e}from"./index-C--0e2U-.js";/**
* @license lucide-vue-next v1.0.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sales Trainer</title>
<script type="module" crossorigin src="/assets/index-yErsUbD6.js"></script>
<script type="module" crossorigin src="/assets/index-C--0e2U-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9WjPRES.css">
</head>
<body>

View File

@@ -47,6 +47,7 @@ export const api = {
analyzeGroup: (id, opts = {}) => request('POST', `/api/groups/${id}/analyze${opts.append ? '?append=true' : ''}`),
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
createPersonaVariant: (gid, pid) => request('POST', `/api/groups/${gid}/personas/${pid}/variant`),
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
chatStart: (gid, pid, scenario, locale) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, { scenario, locale }),
chatResume: (gid, pid) => request('GET', `/api/chat/${gid}/personas/${pid}/chat/resume`),

View File

@@ -48,7 +48,12 @@
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
<div v-else class="muted" style="margin-top:auto;font-size:12px"> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
<template v-else>
<div class="muted" style="margin-top:auto;font-size:12px"> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
{{ p._busy ? 'กำลังสร้าง…' : 'สร้างบุคคลต้นแบบจากต้นแบบนี้' }}
</button>
</template>
</template>
</div>
</div>
@@ -76,7 +81,19 @@ async function load() {
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
function outcomeLabel(o) {
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : i18n.t('notTried')
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : o === 'not_tried' ? i18n.t('notTried') : o || '-'
}
async function makeVariant(p) {
p._busy = true
try {
await api.createPersonaVariant(gid, p.id)
await load() // reload so the new variant shows up in the list
} catch (e) {
alert(e.message)
} finally {
p._busy = false
}
}
onMounted(load)
</script>