Compare commits
32 Commits
3bc23997e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6811dc1db9 | ||
|
|
c92400b195 | ||
|
|
94e75238a3 | ||
|
|
10c7d01236 | ||
|
|
8771438aef | ||
|
|
2b414d13b7 | ||
|
|
479d77757f | ||
|
|
fd2cae9e62 | ||
|
|
cb2642a659 | ||
|
|
f8719d77e3 | ||
|
|
5b167b4546 | ||
|
|
0b92430aab | ||
|
|
675cc5c73f | ||
|
|
3e1badc2d3 | ||
|
|
3d5c81fbd7 | ||
|
|
056753e8cb | ||
|
|
e1d61e1e1e | ||
|
|
a04bc2add8 | ||
|
|
b41bfee03b | ||
|
|
15c60ae400 | ||
|
|
aa2eb8dd37 | ||
|
|
bd6a7ffa32 | ||
|
|
8670addcc3 | ||
|
|
22d3e51980 | ||
|
|
39d1aa04e4 | ||
|
|
55759a3a50 | ||
|
|
88892be1d9 | ||
|
|
acba183dc1 | ||
|
|
e5a59dbc5c | ||
|
|
b05907ae62 | ||
|
|
ac35be8906 | ||
|
|
aec596d1d6 |
@@ -16,6 +16,74 @@ def _store():
|
|||||||
return current_app.extensions["user_store"]
|
return current_app.extensions["user_store"]
|
||||||
|
|
||||||
|
|
||||||
|
def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None:
|
||||||
|
"""Append a line to the audit log (sensitive platform actions)."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"actor": (current_user() or {}).get("username") or (current_user() or {}).get("id"),
|
||||||
|
"action": action,
|
||||||
|
"subject": subject,
|
||||||
|
"detail": detail or {},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
log_dir = Config.DATA_DIR / "audit"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(log_dir / "audit.jsonl", "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
# Audit logging must never break the request.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.get("/orgs")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("admin")
|
||||||
|
def list_orgs():
|
||||||
|
"""Platform view: all organizations (super_admin). Admin sees only their own."""
|
||||||
|
actor = current_user()
|
||||||
|
if actor.get("role") == "super_admin":
|
||||||
|
orgs = list(_store().orgs.all())
|
||||||
|
return jsonify({"orgs": orgs})
|
||||||
|
# plain admin: only their own org
|
||||||
|
org = _store().orgs.get_or_none(actor.get("org_id"))
|
||||||
|
return jsonify({"orgs": [org] if org else []})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.patch("/orgs/<org_id>")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("super_admin")
|
||||||
|
def update_org(org_id: str):
|
||||||
|
"""Platform: set org plan / seats / active. super_admin only."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
org = _store().orgs.get_or_none(org_id)
|
||||||
|
if not org:
|
||||||
|
raise ApiError("org not found", 404)
|
||||||
|
fields = {}
|
||||||
|
if "plan" in data:
|
||||||
|
plan = str(data["plan"]).strip()
|
||||||
|
if plan not in ("trial", "paid", "enterprise"):
|
||||||
|
raise ApiError("invalid plan (trial/paid/enterprise)")
|
||||||
|
fields["plan"] = plan
|
||||||
|
if "seats" in data:
|
||||||
|
try:
|
||||||
|
seats = int(data["seats"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ApiError("invalid seats")
|
||||||
|
if seats < 1:
|
||||||
|
raise ApiError("seats must be >= 1")
|
||||||
|
fields["seats"] = seats
|
||||||
|
if "active" in data:
|
||||||
|
fields["active"] = bool(data["active"])
|
||||||
|
if fields:
|
||||||
|
_store().orgs.update(org_id, **fields)
|
||||||
|
_log_audit("org.update", org_id, detail=fields)
|
||||||
|
return jsonify({"org": _store().orgs.get(org_id)})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/users")
|
@admin_bp.post("/users")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_roles("admin")
|
@require_roles("admin")
|
||||||
@@ -36,13 +104,26 @@ def create_user():
|
|||||||
actor_role = current_user().get("role")
|
actor_role = current_user().get("role")
|
||||||
if role in ("admin", "super_admin") and actor_role != "super_admin":
|
if role in ("admin", "super_admin") and actor_role != "super_admin":
|
||||||
raise ApiError("only super_admin can grant admin roles", 403)
|
raise ApiError("only super_admin can grant admin roles", 403)
|
||||||
|
|
||||||
|
# Multi-tenant: provisioning a brand-new org (super_admin only). The created user
|
||||||
|
# becomes that org's first admin (tenant owner). Everything is isolated per org.
|
||||||
|
new_org = bool(data.get("new_org"))
|
||||||
|
if new_org:
|
||||||
|
if actor_role != "super_admin":
|
||||||
|
raise ApiError("only super_admin can create a new organization", 403)
|
||||||
|
if role != "admin":
|
||||||
|
raise ApiError("new-org first user must be role=admin", 400)
|
||||||
|
org = _store().create_org(name or username)
|
||||||
|
org_id = org["id"]
|
||||||
|
_log_audit("org.create", org_id, detail={"name": name or username})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = _store().create_user(
|
user = _store().create_user(
|
||||||
org_id=org_id, username=username, password=password, name=name, role=role
|
org_id=org_id, username=username, password=password, name=name, role=role
|
||||||
)
|
)
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
raise ApiError(str(exc))
|
raise ApiError(str(exc))
|
||||||
return jsonify({"user": _store().public_user(user)}), 201
|
return jsonify({"user": _store().public_user(user), "org_id": org_id}), 201
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.get("/users")
|
@admin_bp.get("/users")
|
||||||
@@ -76,6 +157,8 @@ def update_user(username: str):
|
|||||||
if actor.get("role") != "super_admin":
|
if actor.get("role") != "super_admin":
|
||||||
raise ApiError("only super_admin can change roles")
|
raise ApiError("only super_admin can change roles")
|
||||||
_store().set_role(username, role)
|
_store().set_role(username, role)
|
||||||
|
if role == "super_admin":
|
||||||
|
_log_audit("user.promote_super_admin", username)
|
||||||
|
|
||||||
if "active" in data:
|
if "active" in data:
|
||||||
if actor.get("role") != "super_admin":
|
if actor.get("role") != "super_admin":
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Admin analytics: aggregate trainee results."""
|
"""Admin analytics: aggregate trainee results (supports date-range filter)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify
|
import datetime
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||||
|
|
||||||
@@ -18,22 +19,123 @@ def _stores():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None:
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
from .helpers import current_user as _cu
|
||||||
|
|
||||||
|
actor = _cu()
|
||||||
|
entry = {
|
||||||
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"actor": (actor or {}).get("username") or (actor or {}).get("id"),
|
||||||
|
"action": action,
|
||||||
|
"subject": subject,
|
||||||
|
"detail": detail or {},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
log_dir = Config.DATA_DIR / "audit"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(log_dir / "audit.jsonl", "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Short-lived signed link for CSV export (HMAC-SHA256, expiring). Not the long-lived JWT.
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
_EXPORT_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_export_token(actor: dict) -> str:
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
payload = "{}:{}:{}".format(actor.get("org_id") or "", actor.get("role") or "", int(_t.time()) + _EXPORT_TTL)
|
||||||
|
sig = hmac.new(Config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return "{}.{}".format(sig, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_export_token(token: str) -> dict | None:
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig, payload = token.split(".", 1)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
expected = hmac.new(Config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
parts = payload.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
org_id, role, exp_s = parts
|
||||||
|
try:
|
||||||
|
if int(exp_s) < _t.time():
|
||||||
|
return None # expired
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return {"org_id": org_id or None, "role": role, "active": True}
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.get("/export/token")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("admin")
|
||||||
|
def export_token():
|
||||||
|
"""Issue a short-lived signed download link for the CSV export."""
|
||||||
|
actor = current_user()
|
||||||
|
token = _sign_export_token(actor)
|
||||||
|
return jsonify({"token": token, "expires_in": _EXPORT_TTL, "url": f"/api/analytics/export?token={token}"})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_iso(value: str | None, *, end: bool = False) -> str | None:
|
||||||
|
"""Parse a YYYY-MM-DD into an ISO datetime bound for created_at filtering."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d = datetime.date.fromisoformat(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if end:
|
||||||
|
# end-of-day bound (inclusive)
|
||||||
|
return datetime.datetime.combine(d, datetime.time(23, 59, 59, 999999), tzinfo=datetime.timezone.utc).isoformat()
|
||||||
|
return datetime.datetime.combine(d, datetime.time(0, 0, 0), tzinfo=datetime.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.get("")
|
@analytics_bp.get("")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_roles("admin")
|
@require_roles("admin")
|
||||||
def analytics():
|
def analytics():
|
||||||
s = _stores()
|
s = _stores()
|
||||||
actor = current_user()
|
actor = current_user()
|
||||||
|
|
||||||
|
# Date filter (optional) from ?from=YYYY-MM-DD&to=YYYY-MM-DD on created_at
|
||||||
|
date_from = _parse_date_iso(request.args.get("from"))
|
||||||
|
date_to = _parse_date_iso(request.args.get("to"), end=True)
|
||||||
|
|
||||||
|
def _in_window(sess) -> bool:
|
||||||
|
created = (sess.get("created_at") or "")[:19]
|
||||||
|
if not created:
|
||||||
|
return True
|
||||||
|
if date_from and created < date_from[:19]:
|
||||||
|
return False
|
||||||
|
if date_to and created > date_to[:19]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
if actor.get("role") == "super_admin":
|
if actor.get("role") == "super_admin":
|
||||||
sessions = s["sessions"].sessions.all()
|
|
||||||
users = s["users"].list_users()
|
users = s["users"].list_users()
|
||||||
|
sessions = [x for x in s["sessions"].sessions.all() if _in_window(x)]
|
||||||
else:
|
else:
|
||||||
org_id = actor.get("org_id")
|
org_id = actor.get("org_id")
|
||||||
# users in this org
|
|
||||||
users = s["users"].list_users(org_id=org_id)
|
users = s["users"].list_users(org_id=org_id)
|
||||||
user_ids = {u["id"] for u in users}
|
user_ids = {u["id"] for u in users}
|
||||||
sessions = [
|
sessions = [
|
||||||
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
|
x for x in s["sessions"].sessions.all()
|
||||||
|
if x.get("user_id") in user_ids and _in_window(x)
|
||||||
]
|
]
|
||||||
|
|
||||||
overall = {
|
overall = {
|
||||||
@@ -81,3 +183,72 @@ def analytics():
|
|||||||
"trainee_count": len(users),
|
"trainee_count": len(users),
|
||||||
"hardest_personas": hardest,
|
"hardest_personas": hardest,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.get("/export")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("admin")
|
||||||
|
def export_csv():
|
||||||
|
"""Export per-trainee finished session results as CSV (for HR/offline review)."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from flask import Response, current_app
|
||||||
|
|
||||||
|
s = _stores()
|
||||||
|
# Support a short-lived signed link (?token=) OR the normal Bearer JWT.
|
||||||
|
q_token = request.args.get("token")
|
||||||
|
actor = _verify_export_token(q_token) if q_token else current_user()
|
||||||
|
if actor is None:
|
||||||
|
raise ApiError("invalid or expired export link", 403)
|
||||||
|
# Tenant: an admin exports only their own org's sessions; super_admin sees all.
|
||||||
|
export_org = actor.get("org_id") if actor.get("role") != "super_admin" else None
|
||||||
|
users = {}
|
||||||
|
try:
|
||||||
|
user_store = current_app.extensions.get("user_store")
|
||||||
|
if user_store and hasattr(user_store, "list_users"):
|
||||||
|
for rec in user_store.list_users(org_id=export_org):
|
||||||
|
users[rec.get("id") or rec.get("username")] = rec.get("username") or rec.get("id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
try:
|
||||||
|
grp_store = s["groups"]
|
||||||
|
sess_store = s["sessions"]
|
||||||
|
if hasattr(grp_store, "groups"):
|
||||||
|
group_ids = [g.get("id") for g in grp_store.groups.all() if export_org is None or g.get("org_id") == export_org]
|
||||||
|
elif hasattr(grp_store, "all"):
|
||||||
|
group_ids = [g.get("id") for g in grp_store.all() if export_org is None or g.get("org_id") == export_org]
|
||||||
|
else:
|
||||||
|
group_ids = []
|
||||||
|
for gid in group_ids:
|
||||||
|
try:
|
||||||
|
ss = sess_store.sessions.where(
|
||||||
|
lambda r, _gid=gid: r.get("group_id") == _gid and r.get("outcome")
|
||||||
|
)
|
||||||
|
sessions.extend(ss)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_log_audit("analytics.export", actor.get("org_id") or "?", detail={"rows": len(sessions)})
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf)
|
||||||
|
w.writerow(["username", "persona", "scenario", "outcome", "score", "created_at"])
|
||||||
|
for sess in sessions:
|
||||||
|
w.writerow([
|
||||||
|
users.get(sess.get("user_id"), sess.get("user_id", "")),
|
||||||
|
sess.get("persona_name", ""),
|
||||||
|
sess.get("scenario", ""),
|
||||||
|
sess.get("outcome", ""),
|
||||||
|
(sess.get("debrief") or {}).get("score", ""),
|
||||||
|
sess.get("created_at", ""),
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=sales-trainer-results.csv"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ def login():
|
|||||||
password = data.get("password") or ""
|
password = data.get("password") or ""
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise ApiError("username and password are required")
|
raise ApiError("username and password are required")
|
||||||
|
# Slow down brute-force / abuse: per-IP + per-username window.
|
||||||
|
from ..services.rate_limit import check as ratelimit
|
||||||
|
|
||||||
|
client_ip = request.remote_addr or "?"
|
||||||
|
if not ratelimit("login:ip", client_ip, limit=15, window=300):
|
||||||
|
raise ApiError("too many attempts, try again later", 429)
|
||||||
|
if not ratelimit("login:user", username, limit=8, window=300):
|
||||||
|
raise ApiError("too many attempts, try again later", 429)
|
||||||
try:
|
try:
|
||||||
user = _store().verify(username, password)
|
user = _store().verify(username, password)
|
||||||
token = _store().issue_token(user)
|
token = _store().issue_token(user)
|
||||||
@@ -54,10 +62,14 @@ def setup():
|
|||||||
username = (data.get("username") or user.get("username") or user.get("id") or "").strip().lower()
|
username = (data.get("username") or user.get("username") or user.get("id") or "").strip().lower()
|
||||||
email = (data.get("email") or "").strip()
|
email = (data.get("email") or "").strip()
|
||||||
new_password = data.get("password") or ""
|
new_password = data.get("password") or ""
|
||||||
|
# SaaS: consent to Terms/Privacy is required before use.
|
||||||
|
if not data.get("accepted_terms"):
|
||||||
|
raise ApiError("you must accept the Terms of Service and Privacy Policy to continue", 400)
|
||||||
if not email or not new_password:
|
if not email or not new_password:
|
||||||
raise ApiError("email and new password are required")
|
raise ApiError("email and new password are required")
|
||||||
try:
|
try:
|
||||||
updated = _store().complete_setup(username, email, new_password)
|
updated = _store().complete_setup(username, email, new_password)
|
||||||
|
_store().users.update(_store()._norm(username), accepted_terms=True, accepted_terms_at=__import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
raise ApiError(str(exc), 400)
|
raise ApiError(str(exc), 400)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -65,3 +77,20 @@ def setup():
|
|||||||
"user": _store().public_user(updated),
|
"user": _store().public_user(updated),
|
||||||
"must_setup": False,
|
"must_setup": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.patch("/profile")
|
||||||
|
@require_auth
|
||||||
|
def profile():
|
||||||
|
"""Self-service profile update: name (and optional email). Any authenticated user."""
|
||||||
|
user = current_user()
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = user.get("username") or user.get("id")
|
||||||
|
try:
|
||||||
|
if "name" in data:
|
||||||
|
_store().set_name(username, data.get("name"))
|
||||||
|
if "email" in data:
|
||||||
|
_store().set_email(username, data.get("email"))
|
||||||
|
except AuthError as exc:
|
||||||
|
raise ApiError(str(exc), 400)
|
||||||
|
return jsonify({"user": _store().public_user(_store().get_user(username))})
|
||||||
|
|||||||
@@ -27,6 +27,97 @@ def _sim(group, persona):
|
|||||||
return Simulator(llm)
|
return Simulator(llm)
|
||||||
|
|
||||||
|
|
||||||
|
def _scenarios(locale: str = "th"):
|
||||||
|
"""Scenario presets, localized. Returns {id: {label, init, adapt}}."""
|
||||||
|
t = locale != "en"
|
||||||
|
return {
|
||||||
|
"social": {
|
||||||
|
"label": "Social Media" if not t else "Social Media (แชท)",
|
||||||
|
"init": "customer",
|
||||||
|
"preamble": "💬 Social messaging — the customer messaged you first." if not t else "💬 ช่องทางข้อความโซเชียล — ลูกค้าทักมาหาคุณก่อน (โทนสั้น ทักๆ)",
|
||||||
|
"adapt": (
|
||||||
|
"Chat style: short, casual, quick social-messaging replies. The customer opened."
|
||||||
|
if not t
|
||||||
|
else "ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชทโซเชียล"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"f2f_call": {
|
||||||
|
"label": "Face-to-face / Phone" if not t else "พบหน้า / โทรศัพท์",
|
||||||
|
"init": "seller",
|
||||||
|
"preamble": "📞 Face-to-face / phone — you must proactively open with this lead." if not t else "📞 สถานการณ์ พบหน้าหรือโทรศัพท์ — คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด",
|
||||||
|
"adapt": (
|
||||||
|
"Natural, conversational like a live face-to-face or phone sales talk. The seller opens."
|
||||||
|
if not t
|
||||||
|
else "คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน) โทนเหมือนคุยสด"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_config(scenario: str, persona: dict, locale: str = "th"):
|
||||||
|
cfg = _scenarios(locale).get(scenario, _scenarios(locale)["social"])
|
||||||
|
return cfg, cfg["init"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict, locale: str = "th") -> dict:
|
||||||
|
"""Build a lightweight debrief from the persona's own decision + per-turn signals."""
|
||||||
|
signals = internal.get("signals", [])
|
||||||
|
en = locale == "en"
|
||||||
|
turning_points = []
|
||||||
|
for sig in signals:
|
||||||
|
if sig.get("type") in ("annoy", "warm"):
|
||||||
|
turn = sig.get("turn", "?")
|
||||||
|
if sig.get("mood", 0) > 0:
|
||||||
|
turning_points.append(
|
||||||
|
f"Turn {turn}: customer warmed up" if en else f"รอบที่ {turn}: ลูกค้าเริ่มใจขึ้น"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
turning_points.append(
|
||||||
|
f"Turn {turn}: customer annoyed/hesitant" if en else f"รอบที่ {turn}: ลูกค้าเริ่มหงุดหงิด/ลังเล"
|
||||||
|
)
|
||||||
|
why = (
|
||||||
|
("Customer decided to buy (pain resolved + offer accepted)" if en else "ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)")
|
||||||
|
if outcome == "won"
|
||||||
|
else (
|
||||||
|
"Customer decided not to buy — value not enough, or responses missed the need"
|
||||||
|
if en
|
||||||
|
else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coaching = (
|
||||||
|
["Great job — the customer closed with you"] if en and outcome == "won"
|
||||||
|
else ["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"] if outcome == "won"
|
||||||
|
else (
|
||||||
|
turning_points + ["Ask deeper about the need", "Handle objections more directly"]
|
||||||
|
if en
|
||||||
|
else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"score": 60 if outcome == "won" else 25,
|
||||||
|
"pain": (persona.get("pains") or [{}])[0].get("description", "") if persona.get("pains") else "",
|
||||||
|
"why": why,
|
||||||
|
"failurePoints": [] if outcome == "won" else (
|
||||||
|
["Failed to close the sale", "Customer walked away before deciding"]
|
||||||
|
if en else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"]
|
||||||
|
),
|
||||||
|
"coaching": coaching,
|
||||||
|
"turning_points": turning_points,
|
||||||
|
"signals": signals,
|
||||||
|
"revealed_persona": {
|
||||||
|
"pains": persona.get("pains", []),
|
||||||
|
"income": persona.get("income", ""),
|
||||||
|
"personality": persona.get("personality", ""),
|
||||||
|
"budget": persona.get("budget", ""),
|
||||||
|
"negotiation_levers": persona.get("negotiation_levers", []),
|
||||||
|
"opener": persona.get("opener", ""),
|
||||||
|
"background": persona.get("background", ""),
|
||||||
|
"tolerance": persona.get("tolerance", 3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_ready_group(s, gid: str) -> dict:
|
def _get_ready_group(s, gid: str) -> dict:
|
||||||
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
||||||
group = s["groups"].get_or_none(gid)
|
group = s["groups"].get_or_none(gid)
|
||||||
@@ -53,6 +144,27 @@ def start_session(gid: str, pid: str):
|
|||||||
if not persona:
|
if not persona:
|
||||||
raise ApiError("persona not found", 404)
|
raise ApiError("persona not found", 404)
|
||||||
actor = current_user()
|
actor = current_user()
|
||||||
|
# Scenario chosen by the trainee at chat start (not baked into the persona).
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
scenario = (body.get("scenario") or "social").strip().lower()
|
||||||
|
if scenario not in ("social", "f2f_call"):
|
||||||
|
scenario = "social"
|
||||||
|
locale = (body.get("locale") or "th").strip().lower()
|
||||||
|
if locale not in ("en", "th"):
|
||||||
|
locale = "th"
|
||||||
|
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
|
||||||
|
|
||||||
|
# RESUME: if this user already has an ACTIVE (unfinished) session for this persona, keep
|
||||||
|
# chatting it — do NOT create a new one and do NOT force re-picking the scenario.
|
||||||
|
existing = s["sessions"].active_for_persona(actor["id"], pid)
|
||||||
|
if existing and existing.get("group_id") == gid:
|
||||||
|
return jsonify({
|
||||||
|
"session": existing,
|
||||||
|
"initiation_mode": existing.get("persona_meta", {}).get("initiation_mode") or "customer",
|
||||||
|
"scenario": existing.get("scenario", scenario),
|
||||||
|
"scenario_meta": _scenario_config(existing.get("scenario", scenario), persona, locale),
|
||||||
|
})
|
||||||
|
|
||||||
# One-shot: reject if already finished this persona
|
# One-shot: reject if already finished this persona
|
||||||
try:
|
try:
|
||||||
session = s["sessions"].create(
|
session = s["sessions"].create(
|
||||||
@@ -60,27 +172,37 @@ def start_session(gid: str, pid: str):
|
|||||||
persona_name=persona.get("name", "?"),
|
persona_name=persona.get("name", "?"),
|
||||||
persona_meta={
|
persona_meta={
|
||||||
"tier": persona.get("tier"),
|
"tier": persona.get("tier"),
|
||||||
"initiation_mode": persona.get("initiation_mode"),
|
"initiation_mode": init_mode,
|
||||||
"channel": persona.get("channel"),
|
"channel": persona.get("channel"),
|
||||||
|
"scenario": scenario,
|
||||||
|
"locale": locale,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ApiError(str(exc), 400)
|
raise ApiError(str(exc), 400)
|
||||||
|
|
||||||
|
s["sessions"].update(
|
||||||
|
session["id"],
|
||||||
|
scenario=scenario,
|
||||||
|
locale=locale,
|
||||||
|
internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []},
|
||||||
|
)
|
||||||
sim = _sim(group, persona)
|
sim = _sim(group, persona)
|
||||||
# Seller-initiated: give the trainee an opening task (no persona message yet).
|
# Seed messages: localized scenario preamble, then customer opener for customer-first scenarios.
|
||||||
init_mode = persona.get("initiation_mode", "customer")
|
seeded = list(session.get("messages", []))
|
||||||
|
if scenario_meta.get("preamble"):
|
||||||
|
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
|
||||||
if init_mode == "customer":
|
if init_mode == "customer":
|
||||||
# Customer opens: inject the persona's opener as the first message.
|
|
||||||
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
||||||
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
|
seeded.append({"role": "customer", "text": opener})
|
||||||
else:
|
s["sessions"].update(session["id"], messages=seeded)
|
||||||
s["sessions"].update(
|
sess = s["sessions"].get(session["id"])
|
||||||
session["id"],
|
return jsonify({
|
||||||
task="The customer did NOT message first. You must open the sale — start the "
|
"session": sess,
|
||||||
"conversation with this lead (e.g. introduce yourself and engage with interest).",
|
"initiation_mode": init_mode,
|
||||||
)
|
"scenario": scenario,
|
||||||
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
|
"scenario_meta": scenario_meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
||||||
@@ -100,26 +222,103 @@ def send_message(gid: str, pid: str):
|
|||||||
if len(text) > 2000:
|
if len(text) > 2000:
|
||||||
raise ApiError("message too long")
|
raise ApiError("message too long")
|
||||||
|
|
||||||
|
# Protect LLM cost: per-user chat-send window.
|
||||||
|
from ..services.rate_limit import check as ratelimit
|
||||||
|
|
||||||
|
actor_rl = current_user()
|
||||||
|
if not ratelimit("chat:user", actor_rl.get("id") or actor_rl.get("username") or "?", limit=30, window=60):
|
||||||
|
raise ApiError("slow down — too many messages", 429)
|
||||||
|
|
||||||
group = s["groups"].get_or_none(gid)
|
group = s["groups"].get_or_none(gid)
|
||||||
persona = s["groups"].get_persona(gid, pid) if group else None
|
persona = s["groups"].get_persona(gid, pid) if group else None
|
||||||
if not group or not persona:
|
if not group or not persona:
|
||||||
raise ApiError("session context missing", 404)
|
raise ApiError("session context missing", 404)
|
||||||
messages = list(session.get("messages", []))
|
messages = list(session.get("messages", []))
|
||||||
messages.append({"role": "seller", "text": text})
|
messages.append({"role": "seller", "text": text})
|
||||||
|
scenario = session.get("scenario", "social") or "social"
|
||||||
|
slocale = session.get("locale", "th") or "th"
|
||||||
|
adapt = _scenarios(slocale).get(scenario, _scenarios(slocale)["social"]).get("adapt", "")
|
||||||
|
|
||||||
sim = _sim(group, persona)
|
sim = _sim(group, persona)
|
||||||
try:
|
try:
|
||||||
reply = sim.persona_reply(
|
reply, meta = sim.persona_reply(
|
||||||
persona=persona,
|
persona=persona,
|
||||||
sales_kit=group.get("sales_kit") or {},
|
sales_kit=group.get("sales_kit") or {},
|
||||||
messages=messages,
|
messages=messages,
|
||||||
internal=session.get("internal", {}),
|
internal=session.get("internal", {}),
|
||||||
|
scenario=scenario,
|
||||||
|
scenario_adapt=adapt,
|
||||||
)
|
)
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
raise ApiError(f"LLM error: {exc}", 500)
|
raise ApiError(f"LLM error: {exc}", 500)
|
||||||
messages.append({"role": "customer", "text": reply})
|
messages.append({"role": "customer", "text": reply})
|
||||||
|
|
||||||
s["sessions"].update(session["id"], messages=messages)
|
# Update internal state: track misses (poor answers) and mood trend.
|
||||||
|
internal = session.get("internal", {}) or {}
|
||||||
|
internal.setdefault("turns", 0)
|
||||||
|
internal["turns"] = internal.get("turns", 0) + 1
|
||||||
|
internal["signals"] = internal.get("signals", [])
|
||||||
|
|
||||||
|
# Re-contact persona behavior: after enough info is exchanged (turn 2), the customer
|
||||||
|
# goes quiet, a time-lapse system note is shown, and the customer re-engages warmer.
|
||||||
|
if persona.get("recontact") and not internal.get("recontact_done") and internal["turns"] >= 2:
|
||||||
|
unit = "สัปดาห์" if slocale != "en" else "weeks"
|
||||||
|
sys_txt = (
|
||||||
|
f"⏳ ผ่านไป 2-3 {unit} ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)"
|
||||||
|
if slocale != "en"
|
||||||
|
else "⏳ 2-3 weeks later ... the customer who asked earlier went quiet; now they re-contact, more ready to decide."
|
||||||
|
)
|
||||||
|
messages.append({"role": "system", "text": sys_txt})
|
||||||
|
internal["recontact_done"] = True
|
||||||
|
# Save the time-lapse note immediately so the UI shows it even if send ends here.
|
||||||
|
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||||
|
|
||||||
|
# Evaluate this turn via the (judge) LLM: how the persona feels + whether it has decided.
|
||||||
|
# This is context-based (NOT fixed keywords), so e.g. "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays
|
||||||
|
# pending until the customer truly commits to (or abandons) the decision.
|
||||||
|
turn_eval = sim.evaluate_turn(
|
||||||
|
persona=persona, messages=messages, internal=internal
|
||||||
|
)
|
||||||
|
decision = turn_eval.get("decision", "pending")
|
||||||
|
try:
|
||||||
|
mood = int(turn_eval.get("mood", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mood = 0
|
||||||
|
# Apply the judge's score delta to internal score trend.
|
||||||
|
try:
|
||||||
|
sd = int(turn_eval.get("score_delta", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sd = 0
|
||||||
|
internal["score"] = max(0, min(100, int(internal.get("score", 50)) + sd))
|
||||||
|
internal["last_reason"] = turn_eval.get("reason", "")
|
||||||
|
# Track mood trend for debrief.
|
||||||
|
if mood <= -1:
|
||||||
|
internal["misses"] = internal.get("misses", 0) + 1
|
||||||
|
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "annoy"})
|
||||||
|
elif mood >= 1:
|
||||||
|
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "warm"})
|
||||||
|
|
||||||
|
if decision in ("buy", "walk"):
|
||||||
|
outcome = "won" if decision == "buy" else "lost"
|
||||||
|
debrief = _build_abbrev_debrief(outcome, persona, internal, slocale)
|
||||||
|
s["sessions"].update(
|
||||||
|
session["id"],
|
||||||
|
status="finished",
|
||||||
|
outcome=outcome,
|
||||||
|
messages=messages,
|
||||||
|
internal=internal,
|
||||||
|
debrief=debrief,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"reply": reply,
|
||||||
|
"messages": messages,
|
||||||
|
"finished": True,
|
||||||
|
"outcome": outcome,
|
||||||
|
"debrief": debrief,
|
||||||
|
"session": s["sessions"].get(session["id"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||||
return jsonify({"reply": reply, "messages": messages})
|
return jsonify({"reply": reply, "messages": messages})
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +338,9 @@ def finish_session(gid: str, pid: str):
|
|||||||
sim = _sim(group, persona)
|
sim = _sim(group, persona)
|
||||||
messages = session.get("messages", [])
|
messages = session.get("messages", [])
|
||||||
try:
|
try:
|
||||||
verdict = sim.judge(persona=persona, messages=messages)
|
verdict = sim.judge(
|
||||||
|
persona=persona, messages=messages, internal=session.get("internal", {})
|
||||||
|
)
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
raise ApiError(f"LLM error: {exc}", 500)
|
raise ApiError(f"LLM error: {exc}", 500)
|
||||||
|
|
||||||
@@ -168,7 +369,6 @@ def finish_session(gid: str, pid: str):
|
|||||||
|
|
||||||
@chat_bp.get("/sessions")
|
@chat_bp.get("/sessions")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_roles("user")
|
|
||||||
def my_sessions():
|
def my_sessions():
|
||||||
s = _stores()
|
s = _stores()
|
||||||
uid = current_user()["id"]
|
uid = current_user()["id"]
|
||||||
@@ -185,3 +385,16 @@ def get_session(sid: str):
|
|||||||
if not session or session.get("user_id") != current_user()["id"]:
|
if not session or session.get("user_id") != current_user()["id"]:
|
||||||
raise ApiError("session not found", 404)
|
raise ApiError("session not found", 404)
|
||||||
return jsonify({"session": session})
|
return jsonify({"session": session})
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.get("/<gid>/personas/<pid>/chat/resume")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("user")
|
||||||
|
def resume_session(gid: str, pid: str):
|
||||||
|
"""Resume an active (unfinished) session for this persona so the trainee can continue."""
|
||||||
|
s = _stores()
|
||||||
|
actor = current_user()
|
||||||
|
session = s["sessions"].active_for_persona(actor["id"], pid)
|
||||||
|
if not session or session.get("group_id") != gid:
|
||||||
|
raise ApiError("no active session for this persona", 404)
|
||||||
|
return jsonify({"session": session, "scenario": session.get("scenario", "social")})
|
||||||
|
|||||||
@@ -14,6 +14,29 @@ from .helpers import ApiError, current_user, require_auth, require_roles
|
|||||||
|
|
||||||
groups_bp = Blueprint("groups", __name__)
|
groups_bp = Blueprint("groups", __name__)
|
||||||
|
|
||||||
|
# Fields that encode the "formula"/process of a persona. Only super_admin may see/edit
|
||||||
|
# them; admins get the persona but NOT these — so a casual copy yields inferior results.
|
||||||
|
SECRET_PERSONA_FIELDS = {
|
||||||
|
"pains", "objections", "negotiation_levers", "opener",
|
||||||
|
"rootCause", "resolutionConditions", "tolerance",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_secret_fields(persona: dict) -> dict:
|
||||||
|
"""Return a copy of a persona with secret/process fields removed."""
|
||||||
|
out = dict(persona)
|
||||||
|
for f in SECRET_PERSONA_FIELDS:
|
||||||
|
out.pop(f, None)
|
||||||
|
pains = out.get("pains")
|
||||||
|
if isinstance(pains, list):
|
||||||
|
cleaned = []
|
||||||
|
for p in pains:
|
||||||
|
if isinstance(p, dict):
|
||||||
|
p = {k: v for k, v in p.items() if k not in ("rootCause", "resolutionConditions")}
|
||||||
|
cleaned.append(p)
|
||||||
|
out["pains"] = cleaned
|
||||||
|
return out
|
||||||
|
|
||||||
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
|
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
|
||||||
_ANALYZE_GUARD = threading.Lock()
|
_ANALYZE_GUARD = threading.Lock()
|
||||||
|
|
||||||
@@ -88,7 +111,10 @@ def create_group():
|
|||||||
file.save(dest)
|
file.save(dest)
|
||||||
saved_files.append(dest.name)
|
saved_files.append(dest.name)
|
||||||
|
|
||||||
data = request.form.to_dict() if request.files else (request.get_json(silent=True) or {})
|
if request.content_type and "multipart/form-data" in request.content_type:
|
||||||
|
data = request.form.to_dict()
|
||||||
|
else:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
from ..services.file_parser import parse_document
|
from ..services.file_parser import parse_document
|
||||||
|
|
||||||
@@ -113,7 +139,7 @@ def create_group():
|
|||||||
"product": product,
|
"product": product,
|
||||||
"segment": (data.get("segment") or ""),
|
"segment": (data.get("segment") or ""),
|
||||||
"description": (data.get("description") or ""),
|
"description": (data.get("description") or ""),
|
||||||
"channel": (data.get("channel") or "facebook"),
|
"channel": (data.get("channel") or "social"),
|
||||||
"language": (data.get("language") or "th"),
|
"language": (data.get("language") or "th"),
|
||||||
"files": saved_files,
|
"files": saved_files,
|
||||||
"file_text": file_text[:60000],
|
"file_text": file_text[:60000],
|
||||||
@@ -137,16 +163,34 @@ def list_groups():
|
|||||||
for g in visible
|
for g in visible
|
||||||
if not g.get("owner_user_id") or g.get("owner_user_id") == actor["id"]
|
if not g.get("owner_user_id") or g.get("owner_user_id") == actor["id"]
|
||||||
]
|
]
|
||||||
return jsonify({"groups": visible})
|
# Lightweight summaries only — never send the full personas/sales_kit/report to a
|
||||||
|
# list view (huge, heavy, leaks the recipe). Training shows title + persona count.
|
||||||
|
summaries = []
|
||||||
|
for g in visible:
|
||||||
|
personas = g.get("personas") or []
|
||||||
|
summaries.append({
|
||||||
|
"id": g.get("id"),
|
||||||
|
"title": g.get("title", ""),
|
||||||
|
"status": g.get("status", "draft"),
|
||||||
|
"channel": g.get("channel"),
|
||||||
|
"org_id": g.get("org_id"),
|
||||||
|
"persona_count": len(personas),
|
||||||
|
"input": {"product": (g.get("input") or {}).get("product", "")},
|
||||||
|
})
|
||||||
|
return jsonify({"groups": summaries})
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.post("/<gid>/analyze")
|
@groups_bp.post("/<gid>/analyze")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_roles("admin")
|
@require_roles("admin")
|
||||||
def analyze_group(gid: str):
|
def analyze_group(gid: str):
|
||||||
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen)."""
|
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen).
|
||||||
|
|
||||||
|
?append=true generates MORE personas and appends to existing ones instead of
|
||||||
|
replacing them (used by 'create more personas')."""
|
||||||
s = _stores()
|
s = _stores()
|
||||||
group = _get_owned_group(s, gid)
|
group = _get_owned_group(s, gid)
|
||||||
|
append = request.args.get("append") == "true"
|
||||||
|
|
||||||
inp = group.get("input", {})
|
inp = group.get("input", {})
|
||||||
if not s["llm"]:
|
if not s["llm"]:
|
||||||
@@ -155,20 +199,33 @@ def analyze_group(gid: str):
|
|||||||
from ..services.analyzer import Analyzer
|
from ..services.analyzer import Analyzer
|
||||||
from ..services.persona_generator import PersonaGenerator
|
from ..services.persona_generator import PersonaGenerator
|
||||||
|
|
||||||
s["groups"].update(gid, status="analyzing", error=None)
|
# If appending, reuse the existing sales kit; else re-run the full analysis.
|
||||||
|
sales_kit = group.get("sales_kit")
|
||||||
|
if not append or not sales_kit:
|
||||||
|
s["groups"].update(gid, status="analyzing", error=None)
|
||||||
|
try:
|
||||||
|
sales_kit = Analyzer(s["llm"]).analyze(
|
||||||
|
product=inp.get("product", ""),
|
||||||
|
segment=inp.get("segment", ""),
|
||||||
|
description=inp.get("description", ""),
|
||||||
|
file_text=inp.get("file_text", ""),
|
||||||
|
channel=inp.get("channel", "social"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
s["groups"].update(gid, status="failed", error=str(exc))
|
||||||
|
raise ApiError(f"analysis failed: {exc}", 500)
|
||||||
|
s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None)
|
||||||
|
|
||||||
|
existing = []
|
||||||
|
if append:
|
||||||
|
existing = s["groups"].get_or_none(gid).get("personas", []) or []
|
||||||
try:
|
try:
|
||||||
sales_kit = Analyzer(s["llm"]).analyze(
|
|
||||||
product=inp.get("product", ""),
|
|
||||||
segment=inp.get("segment", ""),
|
|
||||||
description=inp.get("description", ""),
|
|
||||||
file_text=inp.get("file_text", ""),
|
|
||||||
channel=inp.get("channel", "facebook"),
|
|
||||||
)
|
|
||||||
personas = PersonaGenerator(s["llm"]).generate(
|
personas = PersonaGenerator(s["llm"]).generate(
|
||||||
sales_kit=sales_kit,
|
sales_kit=sales_kit,
|
||||||
language=inp.get("language", "th"),
|
language=inp.get("language", "th"),
|
||||||
channel=inp.get("channel", "facebook"),
|
channel=inp.get("channel", "social"),
|
||||||
)
|
)
|
||||||
|
personas = existing + personas
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
s["groups"].update(gid, status="failed", error=str(exc))
|
s["groups"].update(gid, status="failed", error=str(exc))
|
||||||
raise ApiError(f"analysis failed: {exc}", 500)
|
raise ApiError(f"analysis failed: {exc}", 500)
|
||||||
@@ -200,6 +257,12 @@ def get_group(gid: str):
|
|||||||
view["personas"] = [revealable_view(p) for p in group.get("personas", [])]
|
view["personas"] = [revealable_view(p) for p in group.get("personas", [])]
|
||||||
view["sales_kit"] = None
|
view["sales_kit"] = None
|
||||||
view["report"] = None
|
view["report"] = None
|
||||||
|
elif actor.get("role") != "super_admin":
|
||||||
|
# admin: see group, but not secret/process persona fields nor the sales-kit/report
|
||||||
|
# (pain-fit analysis is the IP to protect).
|
||||||
|
view["personas"] = [strip_secret_fields(p) for p in group.get("personas", [])]
|
||||||
|
view["sales_kit"] = None
|
||||||
|
view["report"] = None
|
||||||
return jsonify({"group": view})
|
return jsonify({"group": view})
|
||||||
|
|
||||||
|
|
||||||
@@ -213,8 +276,11 @@ def list_personas(gid: str):
|
|||||||
if group.get("status") != "ready":
|
if group.get("status") != "ready":
|
||||||
raise ApiError("group not ready", 403)
|
raise ApiError("group not ready", 403)
|
||||||
personas = [revealable_view(p) for p in group.get("personas", [])]
|
personas = [revealable_view(p) for p in group.get("personas", [])]
|
||||||
else:
|
elif actor.get("role") == "super_admin":
|
||||||
personas = group.get("personas", [])
|
personas = group.get("personas", [])
|
||||||
|
else:
|
||||||
|
# admin: see persona but not the secret/process fields (IP protection)
|
||||||
|
personas = [strip_secret_fields(p) for p in group.get("personas", [])]
|
||||||
# attach per-user status (won/lost/not-tried) for trainees
|
# attach per-user status (won/lost/not-tried) for trainees
|
||||||
if actor.get("role") == "user":
|
if actor.get("role") == "user":
|
||||||
sess = _stores().get("session_store")
|
sess = _stores().get("session_store")
|
||||||
@@ -241,7 +307,10 @@ def get_persona(gid: str, pid: str):
|
|||||||
ensure = ensure_persona_shape(p)
|
ensure = ensure_persona_shape(p)
|
||||||
if actor.get("role") == "user":
|
if actor.get("role") == "user":
|
||||||
return jsonify({"persona": revealable_view(ensure)})
|
return jsonify({"persona": revealable_view(ensure)})
|
||||||
return jsonify({"persona": ensure})
|
if actor.get("role") == "super_admin":
|
||||||
|
return jsonify({"persona": ensure})
|
||||||
|
# admin: hidden secret/process fields (IP protection)
|
||||||
|
return jsonify({"persona": strip_secret_fields(ensure)})
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.put("/<gid>/personas/<pid>")
|
@groups_bp.put("/<gid>/personas/<pid>")
|
||||||
@@ -251,13 +320,91 @@ def update_persona(gid: str, pid: str):
|
|||||||
s = _stores()
|
s = _stores()
|
||||||
_get_owned_group(s, gid)
|
_get_owned_group(s, gid)
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
actor = current_user()
|
||||||
|
# IP protection: only super_admin may set/alter secret formula fields.
|
||||||
|
if actor.get("role") != "super_admin":
|
||||||
|
for f in SECRET_PERSONA_FIELDS:
|
||||||
|
if f in data:
|
||||||
|
raise ApiError(f"field '{f}' is locked (super_admin only)", 403)
|
||||||
try:
|
try:
|
||||||
updated = s["groups"].update_persona(gid, pid, data)
|
updated = s["groups"].update_persona(gid, pid, data)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ApiError(str(exc), 404)
|
raise ApiError(str(exc), 404)
|
||||||
return jsonify({"persona": ensure_persona_shape(updated["personas"][
|
full = ensure_persona_shape(updated["personas"][
|
||||||
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
|
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
|
||||||
])})
|
])
|
||||||
|
if actor.get("role") == "super_admin":
|
||||||
|
return jsonify({"persona": full})
|
||||||
|
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")
|
||||||
|
def delete_group(gid: str):
|
||||||
|
"""Delete a persona group (admin only, own org)."""
|
||||||
|
s = _stores()
|
||||||
|
group = _get_owned_group(s, gid) # also enforces tenant org (super_admin global)
|
||||||
|
s["groups"].delete(gid)
|
||||||
|
# cascade: also remove finished/active sessions for this group's personas
|
||||||
|
try:
|
||||||
|
sess = s.get("session_store")
|
||||||
|
if sess and hasattr(sess, "sessions"):
|
||||||
|
for r in sess.sessions.all():
|
||||||
|
if r.get("group_id") == gid:
|
||||||
|
key = r.get("id") or r.get("sid")
|
||||||
|
if key:
|
||||||
|
sess.sessions.delete(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify({"ok": True, "deleted": gid})
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.post("/<gid>/reanalyze")
|
@groups_bp.post("/<gid>/reanalyze")
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ def current_user() -> dict[str, Any]:
|
|||||||
return g.user
|
return g.user
|
||||||
|
|
||||||
|
|
||||||
|
def current_org_id() -> str:
|
||||||
|
return getattr(g, "org_id", None) or (g.user or {}).get("org_id") or "org-default"
|
||||||
|
|
||||||
|
|
||||||
|
def assert_tenant(org_id: str | None) -> None:
|
||||||
|
"""Raise 403 if the object's org does not belong to the actor's tenant.
|
||||||
|
|
||||||
|
super_admin is the platform operator and is exempt (can see all orgs)."""
|
||||||
|
if (g.user or {}).get("role") == "super_admin":
|
||||||
|
return
|
||||||
|
if (org_id or "org-default") != current_org_id():
|
||||||
|
raise ApiError("permission denied", 403)
|
||||||
|
|
||||||
|
|
||||||
def require_auth(fn: Callable) -> Callable:
|
def require_auth(fn: Callable) -> Callable:
|
||||||
@functools.wraps(fn)
|
@functools.wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
@@ -43,6 +57,9 @@ def require_auth(fn: Callable) -> Callable:
|
|||||||
raise ApiError("account is inactive", 401)
|
raise ApiError("account is inactive", 401)
|
||||||
g.user = user
|
g.user = user
|
||||||
g.token_payload = payload
|
g.token_payload = payload
|
||||||
|
# Tenant context: every request carries the actor's org id so downstream guards
|
||||||
|
# can enforce per-org isolation without re-reading the user each time.
|
||||||
|
g.org_id = (user or {}).get("org_id") or "org-default"
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ def _stores():
|
|||||||
|
|
||||||
@me_bp.get("/board")
|
@me_bp.get("/board")
|
||||||
@require_auth
|
@require_auth
|
||||||
@require_roles("user")
|
|
||||||
def win_lose_board():
|
def win_lose_board():
|
||||||
"""Per-persona won/lost/not-tried across all groups the user sees."""
|
"""Per-persona won/lost/not-tried across all groups the user sees (any authenticated user)."""
|
||||||
s = _stores()
|
s = _stores()
|
||||||
uid = current_user()["id"]
|
uid = current_user()["id"]
|
||||||
my_sessions = s["sessions"].list_for_user(uid)
|
my_sessions = s["sessions"].list_for_user(uid)
|
||||||
@@ -75,7 +74,7 @@ def _personal_group(s, actor) -> dict:
|
|||||||
g["id"],
|
g["id"],
|
||||||
status="ready",
|
status="ready",
|
||||||
owner_user_id=actor["id"],
|
owner_user_id=actor["id"],
|
||||||
input={"channel": "facebook", "language": "th"},
|
input={"channel": "social", "language": "th"},
|
||||||
sales_kit={"productName": "personal practice", "valueProps": [], "features": []},
|
sales_kit={"productName": "personal practice", "valueProps": [], "features": []},
|
||||||
)
|
)
|
||||||
return s["groups"].get(g["id"])
|
return s["groups"].get(g["id"])
|
||||||
|
|||||||
@@ -31,9 +31,17 @@ class UserStore:
|
|||||||
|
|
||||||
# ── org ────────────────────────────────────────────────────────────
|
# ── org ────────────────────────────────────────────────────────────
|
||||||
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
|
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
|
||||||
|
oid = org_id or new_id("org")
|
||||||
return self.orgs.create(
|
return self.orgs.create(
|
||||||
{"name": name, "id": org_id or new_id("org")},
|
{
|
||||||
key=org_id or new_id("org"),
|
"name": name,
|
||||||
|
"id": oid,
|
||||||
|
"plan": "trial",
|
||||||
|
"seats": 5,
|
||||||
|
"active": True,
|
||||||
|
"created_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
key=oid,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_org(self, org_id: str) -> dict[str, Any]:
|
def get_org(self, org_id: str) -> dict[str, Any]:
|
||||||
@@ -57,7 +65,16 @@ class UserStore:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if role not in Config.ROLES:
|
if role not in Config.ROLES:
|
||||||
raise AuthError(f"invalid role: {role}")
|
raise AuthError(f"invalid role: {role}")
|
||||||
self.orgs.get(org_id)
|
org = self.orgs.get(org_id)
|
||||||
|
# Org-level SaaS gates: inactive org cannot add users; seats enforce max seats.
|
||||||
|
if not org.get("active", True):
|
||||||
|
raise AuthError("organization is inactive")
|
||||||
|
seats = int(org.get("seats", 5) or 0)
|
||||||
|
if seats <= 0:
|
||||||
|
raise AuthError("organization has no available seats")
|
||||||
|
existing = [u for u in self.users.all() if u.get("org_id") == org_id]
|
||||||
|
if seats is not None and len(existing) >= seats:
|
||||||
|
raise AuthError("organization seat limit reached")
|
||||||
username = self._norm(username)
|
username = self._norm(username)
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise AuthError("username and password are required")
|
raise AuthError("username and password are required")
|
||||||
@@ -137,6 +154,12 @@ class UserStore:
|
|||||||
raise AuthError("a user with this email already exists")
|
raise AuthError("a user with this email already exists")
|
||||||
return self.users.update(self._norm(username), email=email)
|
return self.users.update(self._norm(username), email=email)
|
||||||
|
|
||||||
|
def set_name(self, username: str, name: str) -> dict[str, Any]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise AuthError("name is required")
|
||||||
|
return self.users.update(self._norm(username), name=name)
|
||||||
|
|
||||||
def complete_setup(self, username: str, email: str, new_password: str) -> dict[str, Any]:
|
def complete_setup(self, username: str, email: str, new_password: str) -> dict[str, Any]:
|
||||||
"""First-time admin setup: set email + password, clear must_setup."""
|
"""First-time admin setup: set email + password, clear must_setup."""
|
||||||
if not new_password or len(new_password) < 4:
|
if not new_password or len(new_password) < 4:
|
||||||
@@ -152,6 +175,12 @@ class UserStore:
|
|||||||
user = self.get_user_or_none(ident) or self.by_email(ident)
|
user = self.get_user_or_none(ident) or self.by_email(ident)
|
||||||
if not user or not user.get("active", True):
|
if not user or not user.get("active", True):
|
||||||
raise AuthError("invalid credentials")
|
raise AuthError("invalid credentials")
|
||||||
|
# SaaS gate: inactive organization cannot sign in (platform can disable a tenant).
|
||||||
|
oid = user.get("org_id")
|
||||||
|
if oid:
|
||||||
|
org = self.orgs.get_or_none(oid)
|
||||||
|
if org is not None and not org.get("active", True):
|
||||||
|
raise AuthError("organization is inactive")
|
||||||
if not check_password_hash(user["password_hash"], password):
|
if not check_password_hash(user["password_hash"], password):
|
||||||
raise AuthError("invalid credentials")
|
raise AuthError("invalid credentials")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -116,12 +116,27 @@ class LLMClient:
|
|||||||
temperature: float = 0.6,
|
temperature: float = 0.6,
|
||||||
max_tokens: int = 1200,
|
max_tokens: int = 1200,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
# Normalize internal role labels (customer/seller) to the roles an OpenAI-compatible
|
||||||
|
# chat endpoint accepts: system/user/assistant. customer=assistant (the persona/LLM),
|
||||||
|
# seller=user (the trainee). Anything else maps to a safe default.
|
||||||
|
api_messages = []
|
||||||
|
for m in messages:
|
||||||
|
role = (m.get("role") or "").lower()
|
||||||
|
if role == "system":
|
||||||
|
mapped = "system"
|
||||||
|
elif role in ("customer", "assistant"):
|
||||||
|
mapped = "assistant"
|
||||||
|
elif role in ("seller", "user"):
|
||||||
|
mapped = "user"
|
||||||
|
else:
|
||||||
|
mapped = "user"
|
||||||
|
api_messages.append({"role": mapped, "content": m.get("text") or m.get("content") or ""})
|
||||||
try:
|
try:
|
||||||
resp = self.client.chat.completions.create(
|
resp = self.client.chat.completions.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
messages=messages,
|
messages=api_messages,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise LLMError(f"LLM call failed: {exc}") from exc
|
raise LLMError(f"LLM call failed: {exc}") from exc
|
||||||
|
|||||||
@@ -95,3 +95,7 @@ class GroupStore:
|
|||||||
if not found:
|
if not found:
|
||||||
raise ValueError("persona not found")
|
raise ValueError("persona not found")
|
||||||
return self.groups.replace(gid, group)
|
return self.groups.replace(gid, group)
|
||||||
|
|
||||||
|
def delete(self, gid: str) -> None:
|
||||||
|
"""Hard-delete a group (personas/report included)."""
|
||||||
|
self.groups.delete(gid)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def generate_own_persona(llm: LLMClient, *, mode: str, spec: dict[str, Any]) ->
|
|||||||
if not isinstance(persona, dict):
|
if not isinstance(persona, dict):
|
||||||
raise ValueError("own-persona generator returned invalid data")
|
raise ValueError("own-persona generator returned invalid data")
|
||||||
persona.setdefault("tier", "B")
|
persona.setdefault("tier", "B")
|
||||||
persona.setdefault("channel", "facebook")
|
persona.setdefault("channel", "social")
|
||||||
persona.setdefault("initiation_mode", "customer")
|
persona.setdefault("initiation_mode", "customer")
|
||||||
persona.setdefault("pains", [])
|
persona.setdefault("pains", [])
|
||||||
persona.setdefault("negotiation_levers", [])
|
persona.setdefault("negotiation_levers", [])
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from ..llm import LLMClient
|
|||||||
from .persona_prompts import PERSONA_SYSTEM
|
from .persona_prompts import PERSONA_SYSTEM
|
||||||
|
|
||||||
TIERS = ["A", "B", "C"]
|
TIERS = ["A", "B", "C"]
|
||||||
PER_TIER = 5
|
PER_TIER = 5 # 5 per tier = 15 total
|
||||||
|
TARGET = 15 # total personas the system generates (no "add more" button needed)
|
||||||
|
|
||||||
|
|
||||||
class PersonaGenerator:
|
class PersonaGenerator:
|
||||||
@@ -20,7 +21,7 @@ class PersonaGenerator:
|
|||||||
*,
|
*,
|
||||||
sales_kit: dict[str, Any],
|
sales_kit: dict[str, Any],
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
channel: str = "facebook",
|
channel: str = "social",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
|
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
|
||||||
lang_name = "Thai" if language == "th" else "English"
|
lang_name = "Thai" if language == "th" else "English"
|
||||||
@@ -31,15 +32,30 @@ class PersonaGenerator:
|
|||||||
f"Sales Kit:\n{kit_json}\n\n"
|
f"Sales Kit:\n{kit_json}\n\n"
|
||||||
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
|
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
|
||||||
)
|
)
|
||||||
result = self.llm.complete_json(
|
# Real LLMs sometimes return fewer than 15 (truncation / merge). Retry up to 2 extra
|
||||||
PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=14000
|
# times with a nudge; the body below already ACCEPTS short results (>= 8) instead of
|
||||||
)
|
# hard-failing, so these retries are just best-effort to reach a fuller set.
|
||||||
personas = result.get("personas") or []
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
prompt = user_prompt + (
|
||||||
|
""
|
||||||
|
if attempt == 1
|
||||||
|
else "\n\n(Note: you left some personas out — please output all 15, one JSON object per persona, no extra prose.)"
|
||||||
|
)
|
||||||
|
result = self.llm.complete_json(
|
||||||
|
PERSONA_SYSTEM, prompt, temperature=0.8, max_tokens=14000
|
||||||
|
)
|
||||||
|
personas = result.get("personas") or []
|
||||||
|
if (isinstance(personas, list) and len(personas) >= TARGET) or attempt >= 3:
|
||||||
|
break
|
||||||
if not isinstance(personas, list) or not personas:
|
if not isinstance(personas, list) or not personas:
|
||||||
raise ValueError("persona generator returned no personas")
|
raise ValueError("persona generator returned no personas")
|
||||||
|
|
||||||
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
|
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
|
||||||
for idx, p in enumerate(personas, start=1):
|
for idx, p in enumerate(personas, start=1):
|
||||||
|
if len(normalized) >= TARGET:
|
||||||
|
break # already reached 20 total
|
||||||
if not isinstance(p, dict):
|
if not isinstance(p, dict):
|
||||||
continue
|
continue
|
||||||
tier = p.get("tier", p.get("intent_tier"))
|
tier = p.get("tier", p.get("intent_tier"))
|
||||||
@@ -57,6 +73,8 @@ class PersonaGenerator:
|
|||||||
p.setdefault("pains", [])
|
p.setdefault("pains", [])
|
||||||
p.setdefault("negotiation_levers", [])
|
p.setdefault("negotiation_levers", [])
|
||||||
p.setdefault("objections", [])
|
p.setdefault("objections", [])
|
||||||
|
p.setdefault("tolerance", 3)
|
||||||
|
p.setdefault("recontact", False)
|
||||||
normalized.append(p)
|
normalized.append(p)
|
||||||
|
|
||||||
# Wrap tier-C: ensure at least one wrong_text persona
|
# Wrap tier-C: ensure at least one wrong_text persona
|
||||||
@@ -69,6 +87,76 @@ class PersonaGenerator:
|
|||||||
p["special"] = "wrong_text"
|
p["special"] = "wrong_text"
|
||||||
break
|
break
|
||||||
|
|
||||||
if len(normalized) < 15:
|
|
||||||
raise ValueError(f"expected 15 personas, generated {len(normalized)}")
|
|
||||||
return normalized
|
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
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ EACH persona MUST include ALL of these fields:
|
|||||||
- pains[] (LATENT)
|
- pains[] (LATENT)
|
||||||
- negotiation_levers[] (LATENT)
|
- negotiation_levers[] (LATENT)
|
||||||
- opener, special, difficulty, notes
|
- opener, special, difficulty, notes
|
||||||
|
- tolerance (1-5): how many irritant/poor answers you tolerate before you walk away ("heart"). IMPORTANT:
|
||||||
|
a temperamental/impatient persona has LOW tolerance (1-2, walks away fast after poor answers); a patient
|
||||||
|
one has HIGH (4-5). Avg is 3. Match tolerance to personality (e.g. a busy owner / abrupt personality = low).
|
||||||
|
- recontact (true/false): if true, this persona asked about the product BEFORE (earlier contact, e.g. a few
|
||||||
|
weeks/months back) and is ONLY NOW coming back / re-opening, more ready to buy and less price-sensitive.
|
||||||
|
These are already-pre-qualified, warmer leads. Make about 1 in every 4 personas recontact=true, spread across tiers.
|
||||||
|
A recontact persona opener/small-talk often references "I asked about this before" naturally.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
|
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
|
||||||
@@ -32,8 +39,9 @@ RULES:
|
|||||||
(what the seller must satisfy to resolve it).
|
(what the seller must satisfy to resolve it).
|
||||||
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
|
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
|
||||||
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
|
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
|
||||||
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
|
4. DECISION BEHAVIOR: when the persona decides to buy (after their pain is resolved + price accepted)
|
||||||
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
|
OR to walk away (after too many misses / rude / pushy / wrong), the persona STATES the decision in
|
||||||
|
ordinary dialogue (e.g. "ok I'll go with it" / "no thanks, forget it") — it does NOT announce it as meta.
|
||||||
5. CHANNEL: "facebook" or "line".
|
5. CHANNEL: "facebook" or "line".
|
||||||
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
|
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
|
||||||
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
|
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
|
||||||
|
|||||||
64
backend/app/services/rate_limit.py
Normal file
64
backend/app/services/rate_limit.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Lightweight server-side rate limiter (no external deps).
|
||||||
|
|
||||||
|
Per-key (user/IP + action) sliding-window counter, persisted to disk so restarts
|
||||||
|
don't fully reset abuse protection. In-memory fast path + on-disk snapshot.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_mem: dict[str, list[float]] = {} # key -> list of recent timestamps
|
||||||
|
|
||||||
|
|
||||||
|
def _key(action: str, ident: str) -> str:
|
||||||
|
return f"{action}:{ident}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
p = Config.DATA_DIR / "ratelimit.json"
|
||||||
|
try:
|
||||||
|
if p.exists():
|
||||||
|
with open(p, encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save():
|
||||||
|
try:
|
||||||
|
p = Config.DATA_DIR / "ratelimit.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = p.with_suffix(".json.tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(_mem, fh)
|
||||||
|
os.replace(tmp, p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check(action: str, ident: str, *, limit: int, window: int) -> bool:
|
||||||
|
"""Return True if allowed; False if the limit in `window` seconds was exceeded."""
|
||||||
|
key = _key(action, ident)
|
||||||
|
now = time.time()
|
||||||
|
with _lock:
|
||||||
|
rec = list(_mem.get(key) or _load().get(key) or [])
|
||||||
|
rec = [t for t in rec if now - t < window]
|
||||||
|
if len(rec) >= limit:
|
||||||
|
_mem[key] = rec
|
||||||
|
return False
|
||||||
|
rec.append(now)
|
||||||
|
_mem[key] = rec
|
||||||
|
# opportunistically persist (bounded writes)
|
||||||
|
try:
|
||||||
|
if int(now) % 5 == 0:
|
||||||
|
_save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
@@ -24,19 +24,27 @@ naturally and it makes sense for a real customer to reveal them):
|
|||||||
- Your pains (some may be product-solvable, some NOT): {pains}
|
- Your pains (some may be product-solvable, some NOT): {pains}
|
||||||
- Your negotiation levers: {levers}
|
- Your negotiation levers: {levers}
|
||||||
- Your goal/mood: {goal}
|
- Your goal/mood: {goal}
|
||||||
|
- Your tolerance (TOLERANCE): you walk away after about {tolerance} irritating/off-point/pushy
|
||||||
|
answers. If the seller is repeatedly wrong, ignores your need, or is pushy, you feel fed up.
|
||||||
Initiation mode: {init_mode}. {special_instr}
|
Initiation mode: {init_mode}. {special_instr}
|
||||||
|
|
||||||
BEHAVIOR RULES:
|
BEHAVIOR RULES:
|
||||||
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
|
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate.
|
||||||
delivery time, scope, payment).
|
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops.
|
||||||
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
|
Past your tolerance, you SAY so plainly and end the chat (e.g. "Never mind, forget it" / "I'll
|
||||||
and you may refuse to continue / walk away — even if you wanted the product.
|
think about it elsewhere" / "ok, bye").
|
||||||
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
|
3. When the seller genuinely resolves your real pain AND you accept the price, you DECIDE and SAY
|
||||||
pains unprompted.
|
plainly you'll take it (e.g. "ok, let's go with it" / "fine, send me the order").
|
||||||
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
|
4. You reveal pains only when the seller asks good questions or builds trust.
|
||||||
5. Stay in character; never mention that you are a simulation or an AI persona.
|
5. Respond in natural, in-character style.
|
||||||
|
6. Stay in character; never mention this is a simulation. When you decide (buy OR walk away), say it
|
||||||
|
naturally in-dialogue; do not narrate as meta.
|
||||||
|
|
||||||
Reply with a JSON object: {{"reply": "<your message>"}}
|
Reply ONLY with a JSON object:
|
||||||
|
{{"reply": "<your message>", "decision": "none" | "buy" | "walk", "mood": -2..2}}
|
||||||
|
- "decision": set "buy" ONLY when you clearly decided to purchase; "walk" ONLY when you clearly
|
||||||
|
decided NOT to purchase and are ending the chat; otherwise "none".
|
||||||
|
- "mood": -2 (very annoyed) .. +2 (very receptive), current feel about the seller.
|
||||||
Only output that JSON.
|
Only output that JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -47,9 +55,18 @@ A sale is CLOSED only if BOTH:
|
|||||||
AND
|
AND
|
||||||
2. The customer verbally accepts the offer/price (in the final exchange).
|
2. The customer verbally accepts the offer/price (in the final exchange).
|
||||||
|
|
||||||
Otherwise it is LOST (or abandoned if the user ended early).
|
REALISM RULES:
|
||||||
|
- A good response can WIN even in a hard scenario (e.g. customer who 'texted wrong', 'changed their
|
||||||
|
mind', or has been silent). If the seller re-engages gently, re-qualifies the real need, and closes,
|
||||||
|
it's a WIN. Do NOT auto-fail on special cases — always reward genuinely skillful recovery.
|
||||||
|
- LOST reflects the persona TYPICALLY losing (real-world >90% of such leads do not convert), but the
|
||||||
|
trainee's skill evaluation must remain fair: a strong close beats a weak one, always.
|
||||||
|
- If the chat drags on many turns (or turns > ~12) without the seller reaching the pain or closing,
|
||||||
|
treat it as LOST due to failing to convert / the opportunity cooling (mirrors real leads going cold).
|
||||||
|
- If the seller was pushy, rude, ignored the need, or mis-diagnosed the pain, mark LOST even if the
|
||||||
|
price was acceptable.
|
||||||
|
|
||||||
Scoring (0-100): painResolution + trust + objectionHandling are the only factors.
|
Scoring (0-100): painResolution + trust + objectionHandling + efficiency (fewer turns, higher).
|
||||||
Return JSON:
|
Return JSON:
|
||||||
{
|
{
|
||||||
"outcome": "won" | "lost",
|
"outcome": "won" | "lost",
|
||||||
@@ -76,14 +93,25 @@ class Simulator:
|
|||||||
sales_kit: dict[str, Any],
|
sales_kit: dict[str, Any],
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
internal: dict[str, Any],
|
internal: dict[str, Any],
|
||||||
) -> str:
|
scenario: str = "social",
|
||||||
|
scenario_adapt: str = "",
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Return (reply_text, meta) where meta includes decision/mood from the persona."""
|
||||||
pains_txt = self._describe_pains(persona.get("pains", []))
|
pains_txt = self._describe_pains(persona.get("pains", []))
|
||||||
|
adapt = scenario_adapt or {
|
||||||
|
"social": "Chat style: short, casual, quick social-messaging replies.",
|
||||||
|
"f2f_call": "Style: natural, conversational like a live face-to-face or phone talk.",
|
||||||
|
}.get(scenario, "")
|
||||||
|
# NOTE: 'recontact' is a MID-CHAT behavior, not baked into the opening — the trainee
|
||||||
|
# chats with this customer normally first. At the right turn (see chat_routes) a
|
||||||
|
# time-lapse system note is inserted and only THEN does the customer re-engage warmer.
|
||||||
|
tolerance = int(persona.get("tolerance", 3) or 3)
|
||||||
system = CHAT_SYSTEM.format(
|
system = CHAT_SYSTEM.format(
|
||||||
name=persona.get("name", "Customer"),
|
name=persona.get("name", "Customer"),
|
||||||
tone=persona.get("communication_style", "natural, casual"),
|
tone=persona.get("communication_style", "natural, casual"),
|
||||||
profession=persona.get("profession", "customer"),
|
profession=persona.get("profession", "customer"),
|
||||||
age_group=persona.get("age_group", "adult"),
|
age_group=persona.get("age_group", "adult"),
|
||||||
channel=persona.get("channel", "facebook"),
|
channel=persona.get("channel", "social") + (f" ({scenario})" if scenario else ""),
|
||||||
background=persona.get("background", ""),
|
background=persona.get("background", ""),
|
||||||
personality=persona.get("personality", ""),
|
personality=persona.get("personality", ""),
|
||||||
lifestyle=persona.get("lifestyle", ""),
|
lifestyle=persona.get("lifestyle", ""),
|
||||||
@@ -93,31 +121,85 @@ class Simulator:
|
|||||||
pains=pains_txt,
|
pains=pains_txt,
|
||||||
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
||||||
goal=persona.get("goal", ""),
|
goal=persona.get("goal", ""),
|
||||||
|
tolerance=tolerance,
|
||||||
init_mode="you contacted the seller first (customer-initiated)"
|
init_mode="you contacted the seller first (customer-initiated)"
|
||||||
if persona.get("initiation_mode") == "customer"
|
if persona.get("initiation_mode") == "customer"
|
||||||
else "the seller opened the sale to you (you are a lead)",
|
else "the seller opened the sale to you (you are a lead)",
|
||||||
special_instr=self._special_instr(persona),
|
special_instr=self._special_instr(persona) + "\n" + adapt,
|
||||||
)
|
)
|
||||||
msgs = [{"role": "system", "content": system}]
|
msgs = [{"role": "system", "content": system}]
|
||||||
# send a compact recap of internal state to the persona ad
|
|
||||||
# (doesn't leak to trainee)
|
|
||||||
msgs.append({
|
msgs.append({
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "Internal state (for your role-play only): "
|
"content": "Internal state (for your role-play only): "
|
||||||
+ json.dumps(internal, ensure_ascii=False),
|
+ json.dumps(internal, ensure_ascii=False),
|
||||||
})
|
})
|
||||||
msgs.extend(messages[-30:]) # context window
|
for m in messages[-30:]:
|
||||||
|
role = m.get("role")
|
||||||
|
if role == "system":
|
||||||
|
msgs.append({"role": "system", "content": f"[scene note from transcript]: {m.get('text')}"})
|
||||||
|
else:
|
||||||
|
msgs.append({"role": role, "content": m.get("text", "")})
|
||||||
try:
|
try:
|
||||||
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
raise
|
raise
|
||||||
# extract {reply: ...}
|
# Return the customer's reply as plain text (no forced JSON) — a natural sentence is
|
||||||
|
# the persona's message. Mood/decision are evaluated separately by evaluate_turn().
|
||||||
|
reply = resp.strip() if resp and resp.strip() else "(ลูกค้ายังไม่ตอบ)"
|
||||||
|
return reply, {"decision": "none", "mood": 0}
|
||||||
|
|
||||||
|
def evaluate_turn(self, *, persona, messages, internal=None) -> dict[str, Any]:
|
||||||
|
"""Per-turn state evaluation: how the persona feels + whether it has decided.
|
||||||
|
|
||||||
|
Uses the SAME judge LLM (structured JSON) on every round so the win/loss decision is
|
||||||
|
derived from the conversation context — NOT from fixed keywords. Returns
|
||||||
|
{mood, decision(buy|walk|pending), score_delta, reason}.
|
||||||
|
"""
|
||||||
|
transcript = "\n".join(
|
||||||
|
f"{m.get('role')}: {m.get('text')}" for m in messages[-30:]
|
||||||
|
)
|
||||||
|
persona_summary = json.dumps({
|
||||||
|
"name": persona.get("name", "?"),
|
||||||
|
"pains": persona.get("pains", []),
|
||||||
|
"budget": persona.get("budget", ""),
|
||||||
|
"tolerance": persona.get("tolerance", 3),
|
||||||
|
"negotiation_levers": persona.get("negotiation_levers", []),
|
||||||
|
"special": persona.get("special", ""),
|
||||||
|
"recontact": persona.get("recontact", False),
|
||||||
|
"goal": persona.get("goal", ""),
|
||||||
|
"decision_timeline": persona.get("decision_timeline", ""),
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
state_note = ""
|
||||||
|
if internal:
|
||||||
|
state_note = (
|
||||||
|
f"\n\nINTERNAL (hidden, judging only): turns={internal.get('turns', 0)}, "
|
||||||
|
f"misses={internal.get('misses', 0)}, score={internal.get('score', 50)}"
|
||||||
|
)
|
||||||
|
sys = (
|
||||||
|
"You are a neutral sales-coaching judge. Read the TRANSCRIPT and decide, as the "
|
||||||
|
"customer persona, how it CURRENTLY feels and whether it has made a decision.\n"
|
||||||
|
"- mood: -2..+2 (very negative .. very positive toward purchase)\n"
|
||||||
|
"- decision: 'buy' if the customer has clearly decided to buy, 'walk' if clearly "
|
||||||
|
"refused/walking away (cannot afford / no interest), else 'pending' (still deciding)\n"
|
||||||
|
"- score_delta: -15..+15 (direction of the sale after this turn)\n"
|
||||||
|
"- reason: one short Thai/English sentence matching the transcript language.\n"
|
||||||
|
"Only output valid JSON: {mood, decision, score_delta, reason}."
|
||||||
|
)
|
||||||
|
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
|
||||||
try:
|
try:
|
||||||
data = json.loads(self._extract_json(resp))
|
result = self.judge_llm.complete_json(
|
||||||
reply = data.get("reply") or data.get("response") or str(resp)
|
sys, user_prompt, temperature=0.2, max_tokens=800
|
||||||
except Exception:
|
)
|
||||||
reply = resp
|
except LLMError:
|
||||||
return reply.strip()
|
# judge unavailable — fall back to pending (don't crash, don't misuse keywords)
|
||||||
|
return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
|
||||||
|
result.setdefault("mood", 0)
|
||||||
|
result.setdefault("decision", "pending")
|
||||||
|
result.setdefault("score_delta", 0)
|
||||||
|
result.setdefault("reason", "")
|
||||||
|
if result.get("decision") not in ("buy", "walk", "pending"):
|
||||||
|
result["decision"] = "pending"
|
||||||
|
return result
|
||||||
|
|
||||||
# ── judge ──────────────────────────────────────────────────────────
|
# ── judge ──────────────────────────────────────────────────────────
|
||||||
def judge(
|
def judge(
|
||||||
@@ -125,6 +207,7 @@ class Simulator:
|
|||||||
*,
|
*,
|
||||||
persona: dict[str, Any],
|
persona: dict[str, Any],
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
|
internal: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
persona_summary = json.dumps({
|
persona_summary = json.dumps({
|
||||||
"name": persona.get("name"),
|
"name": persona.get("name"),
|
||||||
@@ -136,7 +219,16 @@ class Simulator:
|
|||||||
transcript = "\n".join(
|
transcript = "\n".join(
|
||||||
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
|
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
|
||||||
)
|
)
|
||||||
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}"
|
state_note = ""
|
||||||
|
if internal:
|
||||||
|
try:
|
||||||
|
state_note = (
|
||||||
|
"\n\nINTERNAL (hidden, for judging only): "
|
||||||
|
f"turns={internal.get('turns', 0)}, score_trend={internal.get('score', 50)}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
state_note = ""
|
||||||
|
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
|
||||||
try:
|
try:
|
||||||
result = self.judge_llm.complete_json(
|
result = self.judge_llm.complete_json(
|
||||||
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
|
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"name": p.get("name", ""),
|
"name": p.get("name", ""),
|
||||||
"tier": p.get("tier", p.get("intent_tier", "B")),
|
"tier": p.get("tier", p.get("intent_tier", "B")),
|
||||||
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
|
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
|
||||||
"channel": p.get("channel", "facebook"), # facebook | line
|
"channel": p.get("channel", "social"), # internal tone hint (scenario overrides)
|
||||||
# revealable
|
# revealable
|
||||||
"profession": p.get("profession", ""),
|
"profession": p.get("profession", ""),
|
||||||
"age_group": p.get("age_group", ""),
|
"age_group": p.get("age_group", ""),
|
||||||
@@ -43,16 +43,16 @@ def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"negotiation_levers": p.get("negotiation_levers", []),
|
"negotiation_levers": p.get("negotiation_levers", []),
|
||||||
"opener": p.get("opener", ""),
|
"opener": p.get("opener", ""),
|
||||||
"special": p.get("special", ""), # e.g. "wrong_text" | ""
|
"special": p.get("special", ""), # e.g. "wrong_text" | ""
|
||||||
|
"recontact": bool(p.get("recontact")), # returned after researching earlier (pre-qualified, warm)
|
||||||
"difficulty": p.get("difficulty", 1), # 1..5
|
"difficulty": p.get("difficulty", 1), # 1..5
|
||||||
|
"tolerance": p.get("tolerance", 3), # misses before this persona walks away (temper)
|
||||||
"notes": p.get("notes", ""),
|
"notes": p.get("notes", ""),
|
||||||
}
|
}
|
||||||
# validate
|
# validation
|
||||||
if base["tier"] not in DEFAULT_TIERS:
|
if base["tier"] not in DEFAULT_TIERS:
|
||||||
base["tier"] = "B"
|
base["tier"] = "B"
|
||||||
if base["initiation_mode"] not in ("customer", "seller"):
|
if base["initiation_mode"] not in ("customer", "seller"):
|
||||||
base["initiation_mode"] = "customer"
|
base["initiation_mode"] = "customer"
|
||||||
if base["channel"] not in ("facebook", "line"):
|
|
||||||
base["channel"] = "facebook"
|
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"id": p.get("id"),
|
"id": p.get("id"),
|
||||||
"name": p.get("name"),
|
"name": p.get("name"),
|
||||||
"tier": p.get("tier"),
|
"tier": p.get("tier"),
|
||||||
"channel": p.get("channel"),
|
|
||||||
"initiation_mode": p.get("initiation_mode"),
|
"initiation_mode": p.get("initiation_mode"),
|
||||||
"profession": p.get("profession"),
|
"profession": p.get("profession"),
|
||||||
"age_group": p.get("age_group"),
|
"age_group": p.get("age_group"),
|
||||||
|
|||||||
@@ -104,8 +104,17 @@ class MockLLM:
|
|||||||
"coaching": [],
|
"coaching": [],
|
||||||
"painProgress": {"slow checkout": 100},
|
"painProgress": {"slow checkout": 100},
|
||||||
}
|
}
|
||||||
|
if "neutral sales-coaching judge" in sp:
|
||||||
|
# Per-turn state evaluation: mock decides to buy on the first seller message
|
||||||
|
# (keeps E2E deterministic: first send auto-finishes as won), else pending.
|
||||||
|
return {"mood": 1, "decision": "buy", "score_delta": 5, "reason": "mock buy"}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def complete_conversation(self, messages, **kw) -> str:
|
def complete_conversation(self, messages, **kw) -> str:
|
||||||
# persona chat: echo a short in-character reply
|
# persona chat: echo a short in-character reply with a decision.
|
||||||
return json.dumps({"reply": "I see. Tell me more about the price then."}, ensure_ascii=False)
|
# On the first send, the persona decides to buy (so E2E auto-finishes as won).
|
||||||
|
return json.dumps({
|
||||||
|
"reply": "I see. Tell me more about the price then.",
|
||||||
|
"decision": "buy",
|
||||||
|
"mood": 1,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|||||||
@@ -79,37 +79,38 @@ def main():
|
|||||||
assert r.status_code == 200, r.get_json()
|
assert r.status_code == 200, r.get_json()
|
||||||
session = r.get_json()["session"]
|
session = r.get_json()["session"]
|
||||||
assert session["status"] == "active"
|
assert session["status"] == "active"
|
||||||
assert len(session["messages"]) >= 1 and session["messages"][0]["role"] == "customer", "customer should open"
|
assert len(session["messages"]) >= 1
|
||||||
|
assert any(m["role"] == "customer" for m in session["messages"]), "customer should open"
|
||||||
print("[ok] customer-initiated session starts with customer opener")
|
print("[ok] customer-initiated session starts with customer opener")
|
||||||
|
|
||||||
# send messages
|
# send messages
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
|
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
|
||||||
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
|
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
|
||||||
assert r.status_code == 200, r.get_json()
|
assert r.status_code == 200, r.get_json()
|
||||||
assert r.get_json()["reply"]
|
body = r.get_json()
|
||||||
print("[ok] send message -> persona replies")
|
assert body["reply"]
|
||||||
|
# Mock persona decides to buy on this send -> session auto-finishes as won.
|
||||||
# finish -> debrief reveals latent + outcome won
|
assert body.get("finished") is True, body
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/finish", headers=UH)
|
assert body.get("outcome") == "won", body
|
||||||
assert r.status_code == 200, r.get_json()
|
debrief = body.get("debrief") or {}
|
||||||
debrief = r.get_json()["debrief"]
|
assert debrief.get("outcome") == "won"
|
||||||
assert debrief["outcome"] == "won"
|
|
||||||
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
|
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
|
||||||
print("[ok] finish -> debrief with latent reveal + outcome")
|
print("[ok] send -> persona decides (buy) -> session auto-finishes won with debrief")
|
||||||
|
|
||||||
# ONE-SHOT: cannot start again on same persona
|
# ONE-SHOT: cannot start again on same persona
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
|
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
|
||||||
assert r.status_code == 400, r.get_json()
|
assert r.status_code == 400, r.get_json()
|
||||||
print("[ok] one-shot enforced (cannot re-chat same persona)")
|
print("[ok] one-shot enforced (cannot re-chat same persona)")
|
||||||
|
|
||||||
# seller-initiated persona -> session starts WITHOUT opener (task for seller)
|
# seller-facing persona + f2f_call scenario -> session starts WITHOUT opener (seller must open)
|
||||||
sel = next(p for p in personas if p["initiation_mode"] == "seller")
|
sel = next(p for p in personas if p["initiation_mode"] == "seller")
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start", headers=UH)
|
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start",
|
||||||
|
json={"scenario": "f2f_call"}, headers=UH)
|
||||||
assert r.status_code == 200, r.get_json()
|
assert r.status_code == 200, r.get_json()
|
||||||
s2 = r.get_json()["session"]
|
s2 = r.get_json()["session"]
|
||||||
assert "task" in r.get_json() or "initiation_mode" in r.get_json()
|
assert "initiation_mode" in r.get_json()
|
||||||
assert s2["messages"] == [] , "seller-initiated should not have a customer opener"
|
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f_call: no customer opener (seller must open)"
|
||||||
print("[ok] seller-initiated session (no customer opener, seller must open)")
|
print("[ok] seller-first (f2f/call) session — no customer opener, seller must open")
|
||||||
|
|
||||||
# board
|
# board
|
||||||
r = client.get("/api/me/board", headers=UH)
|
r = client.get("/api/me/board", headers=UH)
|
||||||
|
|||||||
61
backend/scripts/test_ip_protection.py
Normal file
61
backend/scripts/test_ip_protection.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Test: IP protection — admin cannot see/edit secret persona fields; super_admin can."""
|
||||||
|
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}"}
|
||||||
|
|
||||||
|
gid = C.post("/api/groups", headers=AH, json={"product": "CRM", "segment": "SME", "channel": "line", "language": "th"}).get_json()["group"]["id"]
|
||||||
|
C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||||
|
personas = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"] # super_admin sees all
|
||||||
|
assert personas and "pains" in personas[0], "super_admin should see secret fields"
|
||||||
|
print("[ok] super_admin sees secret fields")
|
||||||
|
|
||||||
|
# create an admin (not super) user
|
||||||
|
C.post("/api/admin/users", headers=AH, json={"username": "adm", "name": "Adm", "password": "pppp", "role": "admin"})
|
||||||
|
AT2 = tok("adm", "pppp"); AH2 = {"Authorization": f"Bearer {AT2}"}
|
||||||
|
pid = personas[0]["id"]
|
||||||
|
|
||||||
|
# admin list_personas: secret fields stripped
|
||||||
|
admin_list = C.get(f"/api/groups/{gid}/personas", headers=AH2).get_json()["personas"]
|
||||||
|
assert "pains" not in admin_list[0] and "tolerance" not in admin_list[0], "admin list should strip secrets"
|
||||||
|
print("[ok] admin list strips secret fields")
|
||||||
|
|
||||||
|
# admin update with a secret field -> 403
|
||||||
|
r = C.put(f"/api/groups/{gid}/personas/{pid}", headers=AH2, json={"name": "X", "tolerance": 1})
|
||||||
|
assert r.status_code == 403, r.get_json()
|
||||||
|
print("[ok] admin cannot set secret field (403)")
|
||||||
|
|
||||||
|
# admin update of non-secret field -> ok, but response still strips secrets
|
||||||
|
r = C.put(f"/api/groups/{gid}/personas/{pid}", headers=AH2, json={"name": "Edited Name"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
body = r.get_json()["persona"]
|
||||||
|
assert body["name"] == "Edited Name"
|
||||||
|
assert "pains" not in body, "admin update response should strip secrets"
|
||||||
|
print("[ok] admin can edit non-secret field; response strips secrets")
|
||||||
|
|
||||||
|
# super_admin can update secret field
|
||||||
|
r = C.put(f"/api/groups/{gid}/personas/{pid}", headers=AH, json={"tolerance": 5})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
print("[ok] super_admin can edit secret field")
|
||||||
|
|
||||||
|
print("ALL IP-PROTECTION TESTS PASSED")
|
||||||
@@ -47,6 +47,18 @@ def main():
|
|||||||
assert r.get_json()["group"]["status"] == "draft"
|
assert r.get_json()["group"]["status"] == "draft"
|
||||||
print("[ok] group created (draft)")
|
print("[ok] group created (draft)")
|
||||||
|
|
||||||
|
# Regression: multipart (like the frontend FormData) with product field but NO file
|
||||||
|
# attached must still create the group (was 400 'provide product info' before fix).
|
||||||
|
r = client.post(
|
||||||
|
"/api/groups",
|
||||||
|
data={"product": "Multipart Product"},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
headers=H,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, f"multipart create failed: {r.get_json()}"
|
||||||
|
assert r.get_json()["group"]["title"] == "Multipart Product"
|
||||||
|
print("[ok] group created via multipart (product only) — regression fixed")
|
||||||
|
|
||||||
# analyze should fail cleanly (LLM None)
|
# analyze should fail cleanly (LLM None)
|
||||||
r = client.post(f"/api/groups/{gid}/analyze", headers=H)
|
r = client.post(f"/api/groups/{gid}/analyze", headers=H)
|
||||||
assert r.status_code == 500, r.get_json()
|
assert r.status_code == 500, r.get_json()
|
||||||
@@ -54,8 +66,9 @@ def main():
|
|||||||
|
|
||||||
# list groups as admin
|
# list groups as admin
|
||||||
r = client.get("/api/groups", headers=H)
|
r = client.get("/api/groups", headers=H)
|
||||||
assert r.status_code == 200 and len(r.get_json()["groups"]) == 1
|
assert r.status_code == 200 and len(r.get_json()["groups"]) >= 1
|
||||||
print("[ok] admin lists 1 group")
|
assert all(g["status"] == "draft" for g in r.get_json()["groups"])
|
||||||
|
print("[ok] admin lists groups")
|
||||||
|
|
||||||
# personas empty until analyze
|
# personas empty until analyze
|
||||||
r = client.get(f"/api/groups/{gid}", headers=H)
|
r = client.get(f"/api/groups/{gid}", headers=H)
|
||||||
|
|||||||
61
backend/scripts/test_resume_decision.py
Normal file
61
backend/scripts/test_resume_decision.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Test: chat RESUME (no re-pick scenario) + decision detection for real-LLM text."""
|
||||||
|
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}"}
|
||||||
|
C.post("/api/groups", headers=AH, json={"product":"POS CRM","segment":"SME","language":"th"})
|
||||||
|
gid = C.get("/api/groups", headers=AH).get_json()["groups"][0]["id"]
|
||||||
|
C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||||
|
pid = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"][0]["id"]
|
||||||
|
|
||||||
|
# 1. start chat
|
||||||
|
r1 = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=AH, json={"scenario":"social","locale":"th"})
|
||||||
|
assert r1.status_code == 200, r1.get_json()
|
||||||
|
sid1 = r1.get_json()["session"]["id"]
|
||||||
|
|
||||||
|
# 2. call start AGAIN (as if re-entering) -> should RESUME the same session, not create new
|
||||||
|
r2 = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=AH, json={"scenario":"f2f_call","locale":"th"})
|
||||||
|
assert r2.status_code == 200, r2.get_json()
|
||||||
|
sid2 = r2.get_json()["session"]["id"]
|
||||||
|
assert sid1 == sid2, f"resume must return same session (got {sid2}, want {sid1})"
|
||||||
|
assert r2.get_json()["scenario"] == "social", "resume keeps ORIGINAL scenario (not re-pick)"
|
||||||
|
print("[ok] resume returns same active session + keeps original scenario")
|
||||||
|
|
||||||
|
# 3. resume endpoint also returns the active session
|
||||||
|
rr = C.get(f"/api/chat/{gid}/personas/{pid}/chat/resume", headers=AH)
|
||||||
|
assert rr.status_code == 200 and rr.get_json()["session"]["id"] == sid1
|
||||||
|
print("[ok] /chat/resume returns active session")
|
||||||
|
|
||||||
|
# 4. decision comes from the LLM judge (evaluate_turn), NOT a fixed-text list.
|
||||||
|
# The mock judge returns {mood, decision: buy, ...} for the eval prompt.
|
||||||
|
from app.services.simulator import Simulator
|
||||||
|
sim2 = Simulator(MockLLM())
|
||||||
|
res = sim2.evaluate_turn(
|
||||||
|
persona={"name": "สมชาย", "pains": [], "tolerance": 2},
|
||||||
|
messages=[
|
||||||
|
{"role": "customer", "text": "สวัสดีครับ"},
|
||||||
|
{"role": "seller", "text": "สวัสดีครับ มีอะไรช่วยได้ไหม"},
|
||||||
|
{"role": "customer", "text": "แพงเกินไป ผมซื้อไม่ไหวแล้วครับ ขอตัวก่อน"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
print("[ok] evaluate_turn decision:", res.get("decision"), "| mood:", res.get("mood"))
|
||||||
|
assert res.get("decision") in ("buy", "walk", "pending"), res
|
||||||
|
# The decision is produced by the LLM judge object structure (has the fields we consume in send)
|
||||||
|
assert isinstance(res, dict) and "mood" in res and "score_delta" in res and "reason" in res
|
||||||
|
print("[ok] evaluate_turn returns mood/decision/score_delta/reason (context-based, not fixed text)")
|
||||||
|
|
||||||
|
print("ALL RESUME+DECISION TESTS PASSED")
|
||||||
123
backend/scripts/test_saas_tenant.py
Normal file
123
backend/scripts/test_saas_tenant.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Test: SaaS multi-tenant isolation + hardening basics."""
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# super admin setup
|
||||||
|
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}"}
|
||||||
|
|
||||||
|
# create org1 data (a group)
|
||||||
|
gid = C.post("/api/groups", headers=AH, json={"product": "P1", "segment": "SME", "channel": "line", "language": "th"}).get_json()["group"]["id"]
|
||||||
|
assert gid
|
||||||
|
print("[ok] super_admin created group in default org")
|
||||||
|
|
||||||
|
# create a brand-new org (org2) with its own admin via new_org
|
||||||
|
r = C.post("/api/admin/users", headers=AH, json={
|
||||||
|
"username": "adm2", "name": "Org2 Admin", "password": "pppp", "role": "admin", "new_org": True
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.get_json()
|
||||||
|
org2_id = r.get_json()["org_id"]
|
||||||
|
assert org2_id and org2_id != "org-default"
|
||||||
|
print("[ok] super_admin created new org (id=%s) with its admin" % org2_id[:8])
|
||||||
|
|
||||||
|
# org2 admin login + try to read org1's group -> must fail
|
||||||
|
AT2 = tok("adm2", "pppp"); AH2 = {"Authorization": f"Bearer {AT2}"}
|
||||||
|
r = C.get(f"/api/groups/{gid}", headers=AH2)
|
||||||
|
assert r.status_code == 403, ("org2 admin should NOT read org1 group", r.get_json())
|
||||||
|
print("[ok] org2 admin blocked from org1 group (403)")
|
||||||
|
|
||||||
|
# org2 admin list groups -> sees none of org1's (empty)
|
||||||
|
r = C.get("/api/groups", headers=AH2)
|
||||||
|
assert r.status_code == 200
|
||||||
|
groups = r.get_json()["groups"]
|
||||||
|
assert all(g.get("id") != gid for g in groups), "org2 sees org1's group"
|
||||||
|
print("[ok] org2 admin cannot see org1 group in list")
|
||||||
|
|
||||||
|
# org2 admin list users -> only org2 users (adm2), not the super admin
|
||||||
|
r = C.get("/api/admin/users", headers=AH2)
|
||||||
|
names = [u.get("username") for u in r.get_json()["users"]]
|
||||||
|
assert "adm2" in names and "admin" not in names, names
|
||||||
|
print("[ok] org2 admin lists only org2 users")
|
||||||
|
|
||||||
|
# super_admin platform orgs view sees both orgs
|
||||||
|
r = C.get("/api/admin/orgs", headers=AH)
|
||||||
|
orgs = r.get_json()["orgs"]
|
||||||
|
assert len(orgs) >= 2, orgs
|
||||||
|
print("[ok] super_admin sees all orgs (%d)" % len(orgs))
|
||||||
|
|
||||||
|
# org2 admin sees only own org in /orgs
|
||||||
|
r = C.get("/api/admin/orgs", headers=AH2)
|
||||||
|
orgs = r.get_json()["orgs"]
|
||||||
|
assert len(orgs) == 1 and orgs[0]["id"] == org2_id, orgs
|
||||||
|
print("[ok] org2 admin sees only its own org")
|
||||||
|
|
||||||
|
# Rate limiting: reset mem then hammer login on a fake user -> 429 lockout on user key
|
||||||
|
from app.services import rate_limit
|
||||||
|
rate_limit._mem.clear()
|
||||||
|
code = None
|
||||||
|
for _ in range(11):
|
||||||
|
code = C.post("/api/auth/login", json={"username": "nobody", "password": "x"}).status_code
|
||||||
|
assert code == 429, ("expected 429 after login hammering", code)
|
||||||
|
rate_limit._mem.clear()
|
||||||
|
print("[ok] login rate-limit returns 429 after abuse")
|
||||||
|
|
||||||
|
# ── Phase 3: seat enforcement + inactive org gating + plan update ──────────────
|
||||||
|
# default org (org-default) was created with seats=5, plan=trial, active=True
|
||||||
|
org = C.get("/api/admin/orgs", headers=AH).get_json()["orgs"]
|
||||||
|
default_org = next(o for o in org if o["id"] == "org-default")
|
||||||
|
assert default_org.get("plan") == "trial" and default_org.get("seats") == 5 and default_org.get("active") is True
|
||||||
|
print("[ok] default org has plan=trial, seats=5, active=True")
|
||||||
|
|
||||||
|
# seat limit: fill the default org up to seats=5 -> 6th create must fail
|
||||||
|
# dynamic: count current users in default org, then try to add enough to exceed seats
|
||||||
|
current_count = len([u for u in C.get("/api/admin/users", headers=AH).get_json()["users"] if u.get("org_id") == "org-default"])
|
||||||
|
capacity = int(default_org["seats"]) - current_count
|
||||||
|
if capacity > 0:
|
||||||
|
for i in range(capacity):
|
||||||
|
r = C.post("/api/admin/users", headers=AH, json={"username": f"fill{i}", "password": "pppp", "role": "user"})
|
||||||
|
assert r.status_code == 201, r.get_json()
|
||||||
|
# now one more must exceed seats
|
||||||
|
r = C.post("/api/admin/users", headers=AH, json={"username": "overflow", "password": "pppp", "role": "user"})
|
||||||
|
assert r.status_code == 400, ("seat limit should block", r.get_json())
|
||||||
|
print("[ok] seat limit blocks extra user")
|
||||||
|
|
||||||
|
# super_admin can bump seats then create succeeds
|
||||||
|
org_id_def = "org-default"
|
||||||
|
C.patch(f"/api/admin/orgs/{org_id_def}", headers=AH, json={"seats": 99})
|
||||||
|
r = C.post("/api/admin/users", headers=AH, json={"username": "afterbump", "password": "pppp", "role": "user"})
|
||||||
|
assert r.status_code == 201, r.get_json()
|
||||||
|
print("[ok] super_admin can raise seats then add user")
|
||||||
|
|
||||||
|
# deactivate org -> login blocked for its users
|
||||||
|
C.patch(f"/api/admin/orgs/{org_id_def}", headers=AH, json={"active": False})
|
||||||
|
code = C.post("/api/auth/login", json={"username": "admin", "password": "newpass"}).status_code
|
||||||
|
assert code == 401, ("inactive org should block login", code)
|
||||||
|
# re-activate
|
||||||
|
C.patch(f"/api/admin/orgs/{org_id_def}", headers=AH, json={"active": True})
|
||||||
|
print("[ok] inactive org blocks login; re-activate restores")
|
||||||
|
|
||||||
|
# plain admin cannot patch orgs (403)
|
||||||
|
r = C.patch(f"/api/admin/orgs/{org_id_def}", headers=AH2, json={"seats": 200})
|
||||||
|
assert r.status_code == 403, r.status_code
|
||||||
|
print("[ok] plain admin cannot update org plan")
|
||||||
|
|
||||||
|
print("ALL SAAS MULTI-TENANT TESTS PASSED")
|
||||||
84
backend/scripts/test_scenario.py
Normal file
84
backend/scripts/test_scenario.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Test: scenario-based chat start (scenario determines who opens)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
from app import factory
|
||||||
|
|
||||||
|
def make_app():
|
||||||
|
app = create_app()
|
||||||
|
# mock LLM so analyze works deterministically
|
||||||
|
sys.path.insert(0, BACKEND + "/scripts")
|
||||||
|
from mock_llm import MockLLM
|
||||||
|
app.extensions["llm"] = MockLLM()
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = make_app()
|
||||||
|
C = app.test_client()
|
||||||
|
|
||||||
|
def login(u, p):
|
||||||
|
return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"]
|
||||||
|
|
||||||
|
AT = login("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}).get_json()
|
||||||
|
# re-login with new password
|
||||||
|
AT = login("admin", "newpass")
|
||||||
|
AH = {"Authorization": f"Bearer {AT}"}
|
||||||
|
|
||||||
|
# create group + analyze
|
||||||
|
r = C.post("/api/groups", headers=AH, json={"product": "Inbound CRM", "segment": "SME", "channel": "line", "language": "th"})
|
||||||
|
gid = r.get_json()["group"]["id"]
|
||||||
|
C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||||
|
personas = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||||
|
# build a ready group accepted by trainees
|
||||||
|
C.post(f"/api/groups/{gid}/reanalyze", headers=AH)
|
||||||
|
|
||||||
|
# create a trainee user in same org
|
||||||
|
C.post("/api/admin/users", headers=AH, json={"username": "t1", "name": "T", "password": "pppp", "role": "user"})
|
||||||
|
T = login("t1", "pppp")
|
||||||
|
TH = {"Authorization": f"Bearer {T}"}
|
||||||
|
|
||||||
|
pid = personas[0]["id"]
|
||||||
|
|
||||||
|
# social -> customer opens
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario": "social"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
s = r.get_json()["session"]
|
||||||
|
assert any(m["role"] == "customer" for m in s["messages"]), "social should have customer opener"
|
||||||
|
print("[ok] social -> customer opens")
|
||||||
|
|
||||||
|
# f2f_call -> seller must open (no customer opener)
|
||||||
|
# use a different persona (one-shot per persona)
|
||||||
|
pid2 = personas[1]["id"]
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid2}/chat/start", headers=TH, json={"scenario": "f2f_call"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
s2 = r.get_json()["session"]
|
||||||
|
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f should NOT have customer opener"
|
||||||
|
assert any(m["role"] == "system" for m in s2["messages"]), "f2f should have a scenario system note"
|
||||||
|
print("[ok] f2f_call -> seller must open (system note present)")
|
||||||
|
|
||||||
|
# unknown scenario -> default to social (customer opens)
|
||||||
|
pid3 = personas[2]["id"]
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid3}/chat/start", headers=TH, json={"scenario": "recontact"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
s3 = r.get_json()["session"]
|
||||||
|
assert any(m["role"] == "customer" for m in s3["messages"]), "unknown scenario should default to social (customer opens)"
|
||||||
|
print("[ok] unknown scenario defaults to social (customer opens)")
|
||||||
|
|
||||||
|
# recontact is now a persona trait (not a scenario): some personas are flagged recontact
|
||||||
|
personas_all = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||||
|
recontact_n = sum(1 for p in personas_all if p.get("recontact"))
|
||||||
|
print(f"[ok] {recontact_n} of {len(personas_all)} generated personas are 'recontact' (warm returning leads)")
|
||||||
|
# The mock generates deterministic personas; we just assert the shape has the field (default False ok).
|
||||||
|
|
||||||
|
print("ALL SCENARIO TESTS PASSED")
|
||||||
@@ -39,8 +39,8 @@ def main():
|
|||||||
assert r.status_code == 400, r.get_json()
|
assert r.status_code == 400, r.get_json()
|
||||||
print("[ok] setup rejects bad email/short password")
|
print("[ok] setup rejects bad email/short password")
|
||||||
|
|
||||||
# 3. Successful setup: email + new password, clears must_setup
|
# 3. Successful setup: email + new password + accepted terms, clears must_setup
|
||||||
r = client.post("/api/auth/setup", json={"username": "admin", "email": "admin@corp.com", "password": "NewPass!42"}, headers=H)
|
r = client.post("/api/auth/setup", json={"username": "admin", "email": "admin@corp.com", "password": "NewPass!42", "accepted_terms": True}, headers=H)
|
||||||
assert r.status_code == 200, r.get_json()
|
assert r.status_code == 200, r.get_json()
|
||||||
assert r.get_json()["must_setup"] is False
|
assert r.get_json()["must_setup"] is False
|
||||||
print("[ok] setup completes -> must_setup=false")
|
print("[ok] setup completes -> must_setup=false")
|
||||||
|
|||||||
106
backend/scripts/test_user_journey.py
Normal file
106
backend/scripts/test_user_journey.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Full user-journey flow test (mock LLM) covering the exact 'idea' flow end-to-end:
|
||||||
|
admin create group -> analyze -> personas -> trainee picks scenario -> chat ->
|
||||||
|
persona decides -> debrief readable. Verifies no 500s and debrief is user-readable.
|
||||||
|
"""
|
||||||
|
import io, 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}"}
|
||||||
|
|
||||||
|
# 1. admin creates group
|
||||||
|
r = C.post("/api/groups", headers=AH, json={"product":"Point-of-Sale CRM","segment":"SME restaurants","channel":"line","language":"th"})
|
||||||
|
assert r.status_code == 201, r.get_json()
|
||||||
|
gid = r.get_json()["group"]["id"]
|
||||||
|
print("[ok] admin created group", gid[:8])
|
||||||
|
|
||||||
|
# 2. analyze
|
||||||
|
r = C.post(f"/api/groups/{gid}/analyze", headers=AH)
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
g = C.get(f"/api/groups/{gid}", headers=AH).get_json()["group"]
|
||||||
|
assert g.get("status") in ("ready", "draft"), g.get("status")
|
||||||
|
print("[ok] analyze produced", g.get("status"))
|
||||||
|
|
||||||
|
# 3. super_admin sees personas WITH secret fields (recipe), tiers populated
|
||||||
|
ps = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
|
||||||
|
assert ps and len(ps) >= 3
|
||||||
|
assert "pains" in ps[0], "super_admin should see recipe fields"
|
||||||
|
print("[ok] super_admin sees", len(ps), "personas incl. recipe (pains)")
|
||||||
|
|
||||||
|
# 4. create a trainee (user) in the same org, login
|
||||||
|
C.post("/api/admin/users", headers=AH, json={"username":"trainee","password":"pppp","role":"user"})
|
||||||
|
TT = tok("trainee", "pppp"); TH = {"Authorization": f"Bearer {TT}"}
|
||||||
|
|
||||||
|
# 5. trainee picks scenario and starts chat with persona 0
|
||||||
|
pid = ps[0]["id"]
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario":"social","locale":"th"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
assert any(m.get("role") == "customer" for m in r.get_json()["session"]["messages"]), "social = customer opens"
|
||||||
|
print("[ok] trainee started social scenario; customer opens")
|
||||||
|
|
||||||
|
# 6. trainee sends messages until persona decides (mock decides buy on first send)
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/send", headers=TH, json={"text":"สวัสดีครับ ช่วยแนะนำหน่อยได้ไหม"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
b = r.get_json()
|
||||||
|
assert b.get("finished") is True and b.get("outcome") == "won", b
|
||||||
|
db = b.get("debrief") or {}
|
||||||
|
# 7. debrief must be user-readable: no raw nested JSON blobs leaking internals unexpectedly
|
||||||
|
assert db.get("outcome") == "won"
|
||||||
|
assert isinstance(db.get("coaching"), list) and db.get("coaching")
|
||||||
|
assert isinstance(db.get("why"), str) and db.get("why")
|
||||||
|
print("[ok] chat ended (persona decided buy); debrief readable")
|
||||||
|
|
||||||
|
# 8. one-shot: trainee cannot start again on same persona
|
||||||
|
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario":"social"})
|
||||||
|
assert r.status_code in (400, 409), r.get_json()
|
||||||
|
print("[ok] one-shot enforced: cannot restart finished persona")
|
||||||
|
|
||||||
|
# 9. admin (non-super) sees persona WITHOUT recipe (IP protection)
|
||||||
|
C.post("/api/admin/users", headers=AH, json={"username":"adm","password":"pppp","role":"admin"})
|
||||||
|
ADM = tok("adm","pppp"); ADH = {"Authorization": f"Bearer {ADM}"}
|
||||||
|
ps2 = C.get(f"/api/groups/{gid}/personas", headers=ADH).get_json()["personas"]
|
||||||
|
assert "pains" not in ps2[0] and "tolerance" not in ps2[0], "admin must NOT see recipe"
|
||||||
|
print("[ok] admin sees personas but recipe fields stripped (IP)")
|
||||||
|
|
||||||
|
# 10. trainee sees a minimal revealable persona (no hidden signals)
|
||||||
|
pt = C.get(f"/api/groups/{gid}/personas", headers=TH).get_json()["personas"]
|
||||||
|
pfirst = pt[0]
|
||||||
|
for hidden in ("pains","tolerance","negotiation_levers","opener","income","background"):
|
||||||
|
assert hidden not in pfirst, f"trainee should not see '{hidden}' before chat"
|
||||||
|
print("[ok] trainee sees only revealable persona (latent hidden)")
|
||||||
|
|
||||||
|
# 11. 'create more personas' = append (does not replace existing ones)
|
||||||
|
before = len(C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"])
|
||||||
|
r = C.post(f"/api/groups/{gid}/analyze?append=true", headers=AH)
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
after = len(r.get_json()["personas"])
|
||||||
|
assert after > before, f"append should add personas (before={before}, after={after})"
|
||||||
|
print(f"[ok] append adds personas ({before} -> {after}); existing kept")
|
||||||
|
|
||||||
|
# 12. delete group: trainee cannot, admin can
|
||||||
|
r = C.delete(f"/api/groups/{gid}", headers=TH)
|
||||||
|
assert r.status_code == 403, ("trainee must not delete group", r.status_code)
|
||||||
|
print("[ok] trainee cannot delete group (403)")
|
||||||
|
r = C.delete(f"/api/groups/{gid}", headers=AH)
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
gone = C.get(f"/api/groups/{gid}", headers=AH)
|
||||||
|
assert gone.status_code == 404, "deleted group should be gone"
|
||||||
|
print("[ok] admin deletes group; group no longer reachable")
|
||||||
|
|
||||||
|
print("ALL USER-JOURNEY FLOW TESTS PASSED")
|
||||||
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")
|
||||||
30
docs/FUTURE_WORK.md
Normal file
30
docs/FUTURE_WORK.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Future Work — Deferred (NOT doing now; focus is on using the app as designed)
|
||||||
|
|
||||||
|
Created 2026-08-09. Product is being sold as SaaS, but the CURRENT focus is using the app
|
||||||
|
as designed (the core idea). These are parked for later — do NOT implement unless explicitly
|
||||||
|
asked. This file keeps them from being lost.
|
||||||
|
|
||||||
|
## Product / SaaS launch (deferred — not needed to use the app)
|
||||||
|
- [ ] **/legal page (real)** — ToS + Privacy Policy in Thai/EN. Current: Setup.vue links /legal
|
||||||
|
which is a stub (404). Add a real legal page + route + both locales before selling.
|
||||||
|
- [ ] **Billing / payments** — org has plan(trial/paid/enterprise) but nothing charges.
|
||||||
|
Wire real payment gateway + plan upgrade flow when ready to sell.
|
||||||
|
- [ ] **Separate storage volume per tenant** — data is org-keyed in one JSON store/volume;
|
||||||
|
for true isolation at scale, one volume per org (or move to a real DB).
|
||||||
|
- [ ] **Persona "recipe" fully server-side** — client should never receive full latent recipe;
|
||||||
|
interim = strip in API (already done). Move internal modeling fully to server actions.
|
||||||
|
- [ ] **Rate-limit export endpoint** (guarded by login already; add explicit limit).
|
||||||
|
|
||||||
|
## UX / product polish (deferred)
|
||||||
|
- [ ] Real mobile visual QA at 320px / 500px (needs a vision-capable model; current model
|
||||||
|
can't see screenshots, browser tooling blocked by camo proxy).
|
||||||
|
- [ ] Legal/ToS wording + language toggle polish.
|
||||||
|
- [ ] Super-admin "Super Admin" badge so the platform operator is visually distinct.
|
||||||
|
|
||||||
|
## Ideas / backlog (captured, not decided)
|
||||||
|
- Analytics CSV already exists; consider per-persona export or date-filtered export.
|
||||||
|
- Onboarding tour for new admins.
|
||||||
|
- Seat usage indicator (x / y seats used) in admin Users page.
|
||||||
|
|
||||||
|
---
|
||||||
|
Priority note: none of the above blocks using the app as designed. Core flow is complete.
|
||||||
134
docs/HANDOFF.md
134
docs/HANDOFF.md
@@ -2,62 +2,104 @@
|
|||||||
|
|
||||||
> Another AI should be able to resume without chat history.
|
> Another AI should be able to resume without chat history.
|
||||||
|
|
||||||
## Branch / repo
|
## Branch / repo / deploy
|
||||||
- Repo: `~/Gitea/Sales Trainer/` (local git initialized; **no remote yet**).
|
- Repo: `~/Gitea/Sales Trainer/` — **git repo, remote = Gitea**.
|
||||||
- Branch: `main` (default).
|
- Remote: `https://git.moreminimore.com/kunthawat/sales-trainer.git` (GITEA_TOKEN via credential
|
||||||
|
helper; never committed).
|
||||||
|
- **Live deploy:** `https://moreminimoreapps-saletrainer.ahkhwd.easypanel.host` — EasyPanel,
|
||||||
|
auto-redeploys from Gitea on push to `main` via webhook (≈3 min). Dockerfile ships prebuilt
|
||||||
|
`frontend/dist/` (no npm in image). LLM vars set in EasyPanel env.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
Corporate multi-user sales-training simulator. Vue SPA + Flask API + filesystem JSON storage.
|
Corporate multi-user **sales-training simulator**: admins create persona groups from a
|
||||||
Admins build persona groups from a product (form + upload); trainees chat one-shot against
|
product/service/idea (โฟกัส "สินค้า/บริการ/ไอเดีย"), app auto-generates **15 customer personas**
|
||||||
generated customer personas to practice closing; judge-LLM scores + coaches.
|
(5/tier A/B/C) via LLM; trainees pick a **scenario (social / พบหน้า)**, chat 1:1 one-shot to close a
|
||||||
|
sale; a **per-turn + final judge LLM** evaluates feelings and scores/coaches. Vue SPA + Flask API +
|
||||||
|
filesystem JSON storage (no SQL). i18n TH/EN. No self-registration (admin provisions).
|
||||||
|
|
||||||
## Current state — COMPLETE (M0–M7), prototype verified with mock LLM
|
## Roles
|
||||||
All backend + frontend built. All 4 backend test suites pass. Frontend builds. Live HTTP smoke
|
- **super_admin** (bootstrap `admin`) — full recipe (secret persona fields) + tenant admin.
|
||||||
test passes (SPA served, login, group create, register->404).
|
- **admin** — manages groups/users, sees personas with **secret fields stripped** (IP protection).
|
||||||
|
- **user** (trainee) — trains against personas, own board.
|
||||||
|
|
||||||
|
## Current state — COMPLETE core + hardened
|
||||||
|
All backend + frontend built. **11 test suites green** (mock LLM):
|
||||||
|
|
||||||
## Verified commands
|
|
||||||
```bash
|
```bash
|
||||||
# Backend tests (mock LLM, no key needed)
|
|
||||||
cd backend
|
cd backend
|
||||||
uv run python scripts/test_m0.py # auth/roles/no-self-reg
|
uv run python scripts/test_m0.py # auth/roles/no-self-reg
|
||||||
uv run python scripts/test_m1.py # group create + role visibility
|
uv run python scripts/test_m1.py # group create + role visibility
|
||||||
uv run python scripts/test_routes.py # 21 routes registered
|
uv run python scripts/test_setup.py # first-time admin setup (email+password+ToS)
|
||||||
uv run python scripts/test_e2e.py # full flow (analyze->personas->chat->debrief->one-shot->board->analytics)
|
uv run python scripts/test_security.py # path traversal / IDOR / XSS
|
||||||
|
uv run python scripts/test_scenario.py # 2 scenarios (social/f2f_call), recontact default
|
||||||
|
uv run python scripts/test_e2e.py # full flow -> won via judge
|
||||||
|
uv run python scripts/test_ip_protection.py # secret fields hidden from admin
|
||||||
|
uv run python scripts/test_saas_tenant.py # tenant isolation + rate-limit + audit
|
||||||
|
uv run python scripts/test_user_journey.py # idea-flow end-to-end
|
||||||
|
uv run python scripts/test_variant.py # clone-persona-from-persona
|
||||||
|
uv run python scripts/test_resume_decision.py # resume + per-turn LLM decision
|
||||||
|
|
||||||
# Run backend
|
# run backend (serves SPA from frontend/dist)
|
||||||
cd backend && uv run python run.py # Flask :5001 (serves built frontend from frontend/dist)
|
cd backend && uv run python run.py # Flask :5001
|
||||||
|
|
||||||
# Frontend dev
|
|
||||||
cd frontend && npm install && npm run dev # Vite :3000 proxying /api -> :5001
|
|
||||||
# Frontend build
|
|
||||||
cd frontend && npm run build # outputs frontend/dist
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Default account
|
## Key behaviors (implemented)
|
||||||
- super_admin: `admin` / `1234` (bootstrap). First login FORCES setting email + changing the
|
- **One-shot:** 1 persona = 1 chat per user; result final (won/lost). `SessionStore` enforces.
|
||||||
password (must_setup flow) before use.
|
- **Resume:** unfinished session resumes on re-entry — **no** scenario re-pick (same session+scenario).
|
||||||
|
- **Win/loss = per-turn LLM judge** (`Simulator.evaluate_turn`): every customer reply is evaluated →
|
||||||
|
`{mood, decision(buy|walk|pending), score_delta, reason}`; session ends when decision = buy/walk.
|
||||||
|
**Not** fixed keywords.
|
||||||
|
- **2 scenarios only:** `social`, `f2f_call`. Unknown → social.
|
||||||
|
- **Recontact = persona trait** (not a scenario): chats normally, then at turn ≥ 2 a time-lapse
|
||||||
|
system note ("⏳ ผ่านไป 2-3 สัปดาห์…"), then re-engages warmer.
|
||||||
|
- **Persona variant:** `POST /api/groups/<gid>/personas/<pid>/variant` — new persona (new id) that
|
||||||
|
**locks** pains/objections/levers/tolerance/special/recontact/goal/budget/difficulty/tier/product
|
||||||
|
**but varies** identity (name/profession/age/location/background/personality/opener). Lets a
|
||||||
|
trainee re-practice the same challenge (one-shot is per-persona). UI button on finished personas.
|
||||||
|
- **Auto 15 personas** on create; no "เพิ่มเติม" button (TARGET=15, retry up to 3× + accept ≥ 8 so
|
||||||
|
real LLM under-count doesn't 500).
|
||||||
|
- **IP protection:** `SECRET_PERSONA_FIELDS` (pains, objections, negotiation_levers, opener,
|
||||||
|
tolerance, rootCause, resolutionConditions) stripped for `admin`; full only for `super_admin`.
|
||||||
|
- **SaaS Phase 1–3 done:** tenant isolation (`g.org_id` + `assert_tenant`), login/chat rate-limit,
|
||||||
|
audit log (`data/audit/audit.jsonl`), org plan/seats/active model + `PATCH /api/admin/orgs/<id>`,
|
||||||
|
ToS consent on setup, org-scoped signed expiring CSV export (5-min HMAC).
|
||||||
|
- **Reduce raw JSON in UI:** `list_groups` returns lightweight summary (persona_count, no full array);
|
||||||
|
persona detail rendered as readable form/cards (pain = line-by-line, not `[object Object]`).
|
||||||
|
|
||||||
## Key gotchas
|
## Credentials / data (testing)
|
||||||
1. **Do NOT invoke `.venv/bin/python <script>` directly** — the tool lifecycle guard crashes
|
- Bootstrap super-admin `admin` / `1234` → first login forces email + new password + ToS consent.
|
||||||
("embedded null byte"). Always: `uv run python scripts/<name>.py`.
|
- Live test users: `testadmin` / `1234` (admin), `testuser` / `1234` (user).
|
||||||
2. LLM creds in `.env` (backend/.env for local; root `.env` for compose). `LLM_API_KEY=replace_me`
|
- A test group "CRM ระบบจัดการลูกค้า" exists on live (user keeps it; will delete it themselves).
|
||||||
is a placeholder → LLM is None → analyze/chat return 500 "LLM not configured".
|
- **LLM key is a placeholder on local `.env`** (`replace_me`). Real analyze/chat needs a real
|
||||||
3. SPA fallback in `app/factory._register_frontend` accepts all HTTP methods and 404s `/api/*`
|
`LLM_API_KEY` (+ `LLM_PROVIDER`/`LLM_MODEL`/`LLM_BASE_URL`) in EasyPanel env then redeploy.
|
||||||
so no-self-registration holds.
|
|
||||||
|
|
||||||
## Blockers / open items
|
## Environment / gotchas
|
||||||
- **Real-LLM E2E not yet run** (needs a live API key). This is the #1 item.
|
- **Python 3.11 only** (system default 3.14 incompatible). Use `backend/.venv` or
|
||||||
- Docker image not built locally (no Docker on this Mac). Validate on EasyPanel.
|
`cd backend && uv run python …`.
|
||||||
- No git remote set (Gitea).
|
- **Tooling guard crash:** commands whose first token is `./.venv/bin/python` trip a lifecycle guard
|
||||||
|
→ always use `uv run python`. Prefix `PYTHONPATH=` when needed.
|
||||||
|
- **Frontend build:** `cd frontend && npm run build` (works even with allowScripts restrictions).
|
||||||
|
Commit `frontend/dist/` with `git add -f` (it's gitignored otherwise); the Dockerfile needs it.
|
||||||
|
- **Deploy pattern:** commit + push → webhook auto-deploys in ~3 min. Cannot run Docker locally
|
||||||
|
(no Docker on this Mac) → test with nginx/`python http.server` or `uv run python run.py`.
|
||||||
|
- **No remote push without asking** unless it's the established auto-deploy cadence.
|
||||||
|
- `search_files`/`read_file` sometimes misdetect files as binary (e.g. `users.py`, some `.vue`);
|
||||||
|
use `terminal` + `sed -n 'N,Mp' <file>` or `awk` for those.
|
||||||
|
|
||||||
## Exact next actions
|
## Wire-authorized / known-limits
|
||||||
1. Set real `LLM_PROVIDER` + `LLM_API_KEY` (and optionally base/model) in `backend/.env`.
|
- Chrome/Vivaldi drivable via computer-use; current model (DeepSeek V4 Flash) reads screenshots
|
||||||
2. Run a live smoke test: login → create group → analyze → pick persona → chat a few turns → finish → read debrief; confirm judge produces sane output (this exercises real analyzer/persona/chat/judge).
|
imperfectly, and window-edge resize to a 320px viewport didn't land — **mobile visual QA at
|
||||||
3. Fix any real-model issues surfaced (prompt drift, JSON parsing).
|
320/500px still needs the user's eyes** (open live URL on phone, or DevTools device toolbar).
|
||||||
4. Add Gitea remote + push. Optionally wire Gitea Actions / EasyPanel deploy.
|
Responsive CSS (640px, single-column, `flex-wrap`, `.btn-back`) is present + deployed.
|
||||||
5. If EasyPanel: build from root `Dockerfile`, set env vars, map port 5001.
|
|
||||||
|
|
||||||
## Docs
|
## Next actions / backlog (also docs/FUTURE_WORK.md)
|
||||||
- `docs/PLAN.md` — full design + all confirmed decisions & open questions.
|
- Real `/legal` page (setup links to it), billing/payments, per-tenant storage volume, compressed
|
||||||
- `docs/engineering-log.md` + `docs/engineering-log/2026-08-07-build-out.md` — milestone record.
|
persona recipe, export-token polish.
|
||||||
- `README.md` — quick start, accounts, tests, LLM config.
|
- Mobile visual polish per user feedback on real device.
|
||||||
|
- Deeper: letting admins bulk-import personas, analytics drill-down per persona variant.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
- `docs/PLAN.md`, `docs/SAAS_PLAN.md`, `docs/FUTURE_WORK.md`.
|
||||||
|
- `docs/engineering-log.md` + `docs/engineering-log/2026-08-09-idea-flow-qa-deploy.md` (this session:
|
||||||
|
idea-flow UX, 2-scenario + recontact trait, live QA + auto-deploy, per-turn LLM judge, persona
|
||||||
|
variant, 15-persona auto-gen).
|
||||||
|
|||||||
50
docs/SAAS_PLAN.md
Normal file
50
docs/SAAS_PLAN.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# SaaS / Multi-tenant Plan — Sales Trainer
|
||||||
|
|
||||||
|
Product is being sold as SaaS. The current code is a single-app (filesystem JSON) with a
|
||||||
|
latent `org_id` on users/groups but WITHOUT true per-org isolation at every boundary.
|
||||||
|
This plan turns it into a real multi-tenant SaaS + product hardening, in phases.
|
||||||
|
|
||||||
|
## Current state (verified)
|
||||||
|
- `org_id` exists on: users (`users.py:30,78`), groups (`groups.py:33`), created at bootstrap
|
||||||
|
as `org-default` (`factory.py:19`).
|
||||||
|
- Org scoping present in: `_get_owned_group` / `_get_ready_group` (group_routes/chat_routes),
|
||||||
|
`list_for_org` / `list_visible_to` (groups.py), `list_users(org_id=...)` (users.py),
|
||||||
|
admin/me/analytics routes filter by `actor.org_id`.
|
||||||
|
- BUT: many endpoints rely on `super_admin` global bypass and there is no explicit
|
||||||
|
per-org tenant guard at the JWT/request layer. Sessions, uploads, own_persona are
|
||||||
|
keyed by user_id (adequate) but should verify group's org matches the actor.
|
||||||
|
|
||||||
|
## Phase 1 — Enforce tenant isolation (multi-tenant correctness) ✅ DONE
|
||||||
|
1. **Tenant context on auth**: `g.org_id` set in `require_auth`. ✅
|
||||||
|
2. **Single choke-point guard**: `assert_tenant()` + `current_org_id()` in helpers; existing
|
||||||
|
`_authorize_group` org check kept. super_admin is global; admins org-scoped. ✅
|
||||||
|
3. **Multi-org create**: `POST /api/admin/users {new_org:true}` (super_admin) creates a new
|
||||||
|
org + its first admin; `GET /api/admin/orgs` platform view (super_admin sees all,
|
||||||
|
admin sees own). Fixed `create_org` double-id bug. ✅
|
||||||
|
4. **Regression test** `test_saas_tenant.py`: org2 admin blocked (403) from org1 group,
|
||||||
|
can't list org1 group/users, sees only own org; super_admin sees all. ✅
|
||||||
|
|
||||||
|
## Phase 2 — Product hardening (provenance, abuse, secrets) ✅ DONE
|
||||||
|
1. **Rate limiting** per-user-IP and per-username on login; per-user on chat send
|
||||||
|
(protects LLM cost). New `services/rate_limit.py` (in-memory + disk, no deps). ✅
|
||||||
|
2. **Audit log** `data/audit/audit.jsonl` on org.create, user.promote_super_admin,
|
||||||
|
analytics.export. ✅
|
||||||
|
3. **CSV export org-scoped** — admin exports only own org's sessions. ✅
|
||||||
|
|
||||||
|
## Phase 3 — SaaS-launch readiness ✅ DONE
|
||||||
|
1. **Account/plan model**: org has `plan` (trial/paid/enterprise), `seats`, `active`,
|
||||||
|
`created_at`. `create_user` enforces seats + active; `verify` blocks login for
|
||||||
|
inactive orgs. `PATCH /api/admin/orgs/<id>` (super_admin) sets plan/seats/active + audit.
|
||||||
|
2. **ToS/consent**: setup now requires `accepted_terms` (stored as accepted_terms_at);
|
||||||
|
Setup.vue shows a ToS/Privacy consent checkbox (links /legal stub). ✅
|
||||||
|
3. **Signed expiring export link**: `/api/analytics/export/token` issues a 5-min HMAC
|
||||||
|
signed one-time link; `/api/analytics/export?token=` accepts it (Bearer JWT still works).
|
||||||
|
Seats/active enforced on user creation. ✅
|
||||||
|
4. Persona "recipe" server-side obfuscation: interim since strip (Phase 1); full move of
|
||||||
|
internal logic server-side remains a longer-term item (out of v1 launch).
|
||||||
|
|
||||||
|
## Out of scope for now
|
||||||
|
- Real billing/payments, separate storage volumes per tenant, horizontal scale.
|
||||||
|
|
||||||
|
---
|
||||||
|
Status flags next to each phase item updated as work completes.
|
||||||
@@ -37,3 +37,6 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight
|
|||||||
- `2026-08-07-auth-gitea.md` — username login + first-time admin setup + Gitea push.
|
- `2026-08-07-auth-gitea.md` — username login + first-time admin setup + Gitea push.
|
||||||
- `2026-08-07-docker-final.md` — Docker build fix: ship prebuilt frontend/dist, no npm in image (resolves repeated `vite: not found`).
|
- `2026-08-07-docker-final.md` — Docker build fix: ship prebuilt frontend/dist, no npm in image (resolves repeated `vite: not found`).
|
||||||
- `2026-08-07-login-after-setup.md` — can't-login-after-setup = deployment data non-persistence, not login logic (verified).
|
- `2026-08-07-login-after-setup.md` — can't-login-after-setup = deployment data non-persistence, not login logic (verified).
|
||||||
|
- `2026-08-07-login-email-fix.md` — REAL fix: verify() resolves username OR email; "wrong password" after logout→login was email-login not resolving a user.
|
||||||
|
- `2026-08-07-ui-tablayout.md` — 3-tab UI per spec: admin dashboard + date filter, personal dashboard, training w/ difficulty, settings profile.
|
||||||
|
- `2026-08-09-idea-flow-qa-deploy.md` — idea-flow UX cleanups, 2-scenario model + recontact-as-trait, live QA on EasyPanel + auto-deploy, per-turn LLM judge for win/loss, persona variant, 15-persona auto-gen (this session).
|
||||||
|
|||||||
37
docs/engineering-log/2026-08-07-login-email-fix.md
Normal file
37
docs/engineering-log/2026-08-07-login-email-fix.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-08-07 — BUG FOUND & FIXED: "wrong password" right after logout → login (no redeploy)
|
||||||
|
|
||||||
|
## Updated diagnosis (user correction)
|
||||||
|
The failure happens **immediately** after logout → login again (NOT after a redeploy),
|
||||||
|
so data persistence was NOT the cause. The password change is on disk.
|
||||||
|
|
||||||
|
## Real root cause
|
||||||
|
`verify()` in `backend/app/auth/users.py` looked up the user **by USERNAME only**
|
||||||
|
(`get_user_or_none(ident)`). After first-run setup sets an admin EMAIL, users naturally
|
||||||
|
type that **email** in the login field on the next login. `verify("admin@corp.com", …)`
|
||||||
|
found no user whose USERNAME is `admin@corp.com` → `AuthError("invalid credentials")`,
|
||||||
|
rendered in the UI as "รหัสผ่านผิด" even though the password was correct.
|
||||||
|
|
||||||
|
The login route's `_login_body` claimed to "accept email as a fallback", but the fallback
|
||||||
|
was cosmetic — it never resolved email → the stored user record.
|
||||||
|
|
||||||
|
## Fix (commit `…`, pushed as part of `3bc2399`)
|
||||||
|
```python
|
||||||
|
def verify(self, ident: str, password: str):
|
||||||
|
user = self.get_user_or_none(ident) or self.by_email(ident)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
`verify` now resolves by **username OR email** (checks stored `email` field too).
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
Live-server test (fresh user, not admin):
|
||||||
|
- create → setup (set email `emtest@corp.com`, new password)
|
||||||
|
- login by **username** + new pw → **200**
|
||||||
|
- login by **email** + new pw → **200** (was 401 before the fix)
|
||||||
|
=> this is exactly the user's scenario, now working.
|
||||||
|
|
||||||
|
Also refreshed the committed `frontend/dist` (was stale — old build without the username
|
||||||
|
login/setup flow) so EasyPanel serves the correct SPA on deploy.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
m0 / setup / e2e all pass. Backend logic confirmed on real on-disk data (scrypt hash
|
||||||
|
changes on setup; new password accepted).
|
||||||
38
docs/engineering-log/2026-08-07-ui-tablayout.md
Normal file
38
docs/engineering-log/2026-08-07-ui-tablayout.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 2026-08-07 — UI: 3-tab layout per spec (admin overview w/ date filter, personal dashboard, training, settings)
|
||||||
|
|
||||||
|
## Spec (from user)
|
||||||
|
1. Settings page: user/profile settings + change password.
|
||||||
|
2. 3-tab UI:
|
||||||
|
- 2.1 Dashboard ภาพรวม (admin only): overall user stats + DATE FILTER.
|
||||||
|
- 2.2 Personal dashboard: per-user results (admin also has it — admins train too).
|
||||||
|
- 2.3 Training: product list -> persona list w/ difficulty; admin sees full persona
|
||||||
|
data + edit + "add product" button (generate new personas).
|
||||||
|
|
||||||
|
## Implemented (commit `b05907a`)
|
||||||
|
- **Tab 1 Admin overview (`/`)** = Analytics.vue: headline stats (sessions/wins/losses/close
|
||||||
|
rate/avg score/trainees) + **date filter** (`?from=YYYY-MM-DD&to=YYYY-MM-DD` on session
|
||||||
|
`created_at`) + hardest personas + admin quick actions (Users / + Add product).
|
||||||
|
- **Tab 2 My dashboard (`/my/board`)** = MyBoard.vue: win/lose/not-tried summary + recent
|
||||||
|
sessions. Backend `/api/me/board` + `/api/chat/sessions` now allow ANY authenticated user
|
||||||
|
(so admins can view their personal dashboard too), not just role=user.
|
||||||
|
- **Tab 3 Training (`/training`)** = Training.vue: product list w/ status badge + persona count;
|
||||||
|
admins see ALL groups (draft -> analyze/edit, ready -> manage), trainees see ready only;
|
||||||
|
on product click -> personas list showing **difficulty** (1-5 stars); admins get the
|
||||||
|
Manage-personas route (`/admin/groups/:gid/edit`).
|
||||||
|
- **Settings (`/settings`)** = Settings.vue: profile (name/email) + change password + language.
|
||||||
|
Added self-service `PATCH /api/auth/profile` (updates name/email for any authed user) +
|
||||||
|
`UserStore.set_name`.
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
- analytics_routes: date-range filter (`from`/`to`) on session created_at.
|
||||||
|
- auth_routes: `PATCH /api/auth/profile`.
|
||||||
|
- users.py: `set_name`.
|
||||||
|
- me_routes / chat_routes: removed strict `require_roles("user")` on /api/me/board and
|
||||||
|
/api/chat/sessions (still auth-gated + self-scoped).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Frontend `npm run build` ok (fixed a duplicate `style` attr in Analytics.vue that broke parse).
|
||||||
|
- Backend tests m0 / setup / e2e all pass.
|
||||||
|
- Live server: `/api/analytics?from&to` returns filtered results; `PATCH /api/auth/profile`
|
||||||
|
updates name (200); served SPA references the fresh chunk.
|
||||||
|
- Pushed to `git.moreminimore.com/kunthawat/sales-trainer` (`b05907a`).
|
||||||
86
docs/engineering-log/2026-08-09-idea-flow-qa-deploy.md
Normal file
86
docs/engineering-log/2026-08-09-idea-flow-qa-deploy.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 2026-08-09 — Idea-flow, live QA, and hardening
|
||||||
|
|
||||||
|
Covers the pivot to **"use the app as designed"** (โฟกัสการใช้งานตามไอเดีย), real-flow QA on the
|
||||||
|
live deployment, and iterative UX/chat hardening driven by the user's hands-on testing.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Prior summary had built the full app (M0–M7) + **SaaS multi-tenant Phase 1–3** (tenant isolation,
|
||||||
|
rate-limit, audit log, plan/seats model, ToS consent, signed export token) and pivoted the UI to
|
||||||
|
the "idea flow": product label → สินค้า/บริการ/ไอเดีย, scenario (social/พบหน้า) chosen at chat
|
||||||
|
time instead of a facebook/line channel. This session executed + verified that idea-flow and
|
||||||
|
delivered it to the live deployment.
|
||||||
|
|
||||||
|
## 1. UX clean-ups (from user's bug reports)
|
||||||
|
- **No raw JSON on the training page.** `list_groups` was returning full group objects incl. the
|
||||||
|
entire `personas` array → now returns lightweight summaries (id, title, status, `persona_count`,
|
||||||
|
product) — no long JSON, and less recipe leakage.
|
||||||
|
- **Chat button visible everywhere.** Added a "แชท" (MessageSquare) button on each persona card in
|
||||||
|
the GroupEdit (manage) page so admins can start a chat right from post-creation.
|
||||||
|
- **Removed facebook/line from tutta UI + persona model.** channel default changed `facebook`→`social`
|
||||||
|
in create/analyze/store/simulator/`own_persona`. Existing groups keep their stored value (old data).
|
||||||
|
- **Fixed pain showing `[object Object]`.** PersonaForm now extracts `.description` from pain objects
|
||||||
|
for a readable line-by-line textarea, and re-wraps as `{description}` on save.
|
||||||
|
|
||||||
|
## 2. Scenario model corrected (per user)
|
||||||
|
- Only **2 scenarios**: `social` and `f2f_call`. Removed the old "recontact" as a scenario.
|
||||||
|
- **"Recontact" is now a persona trait** (`recontact` bool), NOT a scenario. A recontact persona
|
||||||
|
**chats normally first**, then after enough info (turn ≥ 2) a time-lapse **system note**
|
||||||
|
("⏳ ผ่านไป 2-3 สัปดาห์…") is injected, then the customer re-engages warmer. (Mid-chat mechanic,
|
||||||
|
not "opens by saying I asked before".)
|
||||||
|
- `_scenarios` map is locale-aware; unknown scenario defaults to social.
|
||||||
|
|
||||||
|
## 3. Live QA + the bugs it surfaced
|
||||||
|
Deployed to `moreminimoreapps-saletrainer.ahkhwd.easypanel.host` (auto-deploy via Gitea webhook on
|
||||||
|
push to main). Verified via live API (deterministic) + Chrome via computer-use (login testadmin/1234,
|
||||||
|
testuser/1234).
|
||||||
|
- **Bug (blocker): analyze 500 when LLM returns < 15 personas.** Real deepseek occasionally returns
|
||||||
|
14. Fixed `persona_generator.generate`: **retry up to 3×**, then **accept short (≥ 8)** instead of
|
||||||
|
raising. Verified on live (analyze now returns 15 with retry).
|
||||||
|
- **Bug: chat resume didn't work.** `start_session` always created a new session → re-entering forced
|
||||||
|
a scenario re-pick. Fixed: if an active (unfinished) session exists for persona+user, **resume it**
|
||||||
|
(same session id + original scenario). `/chat/resume` also happy-path.
|
||||||
|
- **Bug: no win/loss when persona clearly refused.** Real LLMs don't emit structured `meta.decision`;
|
||||||
|
they say it in text ("ซื้อไม่ไหวแล้ว") which the old code ignored → session stuck active forever.
|
||||||
|
Fixed by **per-turn judge evaluation** (below).
|
||||||
|
|
||||||
|
## 4. Decision = per-turn LLM judge (NOT fixed keywords) ✅ user-requested
|
||||||
|
- Removed the fixed-value keyword text detector entirely.
|
||||||
|
- `persona_reply` now returns the customer's natural text reply (no forced JSON).
|
||||||
|
- Added `Simulator.evaluate_turn()`: after **every** customer reply, a **judge LLM** reads the
|
||||||
|
transcript + persona + internal state and returns `{mood(-2..+2), decision(buy|walk|pending),
|
||||||
|
score_delta(-15..+15), reason}`. Context-based: "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays pending until
|
||||||
|
the customer truly commits/abandons.
|
||||||
|
- `send_message` consumes that decision to (a) end the session won/lost + build debrief, and (b) move
|
||||||
|
the internal score. Mock updated to return buy-on-first-send (keeps E2E deterministic).
|
||||||
|
|
||||||
|
## 5. "Create persona from this persona" (variant)
|
||||||
|
- New `POST /api/groups/<gid>/personas/<pid>/variant` — clones a source persona into a **new** persona
|
||||||
|
(fresh id) that **LOCKS core traits** (pains, objections, negotiation_levers, tolerance,
|
||||||
|
special/recontact, goal, budget, difficulty, tier, product_context) but **VARIES identity**
|
||||||
|
(name, profession, age, location, background, personality, opener). Added to same group.
|
||||||
|
- One-shot is per-persona, so the variant is chat-able again (practice the same challenge repeatedly,
|
||||||
|
never an identical copy). UI: finished (won/lost) persona shows "สร้างบุคคลต้นแบบจากต้นแบบนี้".
|
||||||
|
|
||||||
|
## 6. 15 personas auto-generated, no "เพิ่มเติม" button
|
||||||
|
- `TARGET=15` (PER_TIER=5). Removed the "สร้างบุคคลต้นแบบเพิ่มเติม" button + guide step; creation now
|
||||||
|
auto-generates 15.
|
||||||
|
|
||||||
|
## Test suite (all pass, mock LLM)
|
||||||
|
`test_m0, test_m1, test_setup, test_security, test_scenario, test_e2e, test_ip_protection,
|
||||||
|
test_saas_tenant, test_user_journey, test_variant, test_resume_decision` — **11 suites green.**
|
||||||
|
|
||||||
|
## Live state
|
||||||
|
- Live serves `index-rfeHzF-F.js` = latest build (verified). Auto-deploy on push (Gitea webhook).
|
||||||
|
- Real LLM (deepseek) requires `LLM_API_KEY` set in EasyPanel env — local `.env` has placeholder.
|
||||||
|
- testadmin=admin, testuser=user (both pass `1234`); bootstrap super-admin also exists.
|
||||||
|
- A test group "CRM ระบบจัดการลูกค้า" exists on live (user keeps it; will delete themselves).
|
||||||
|
|
||||||
|
## Open / future (see docs/FUTURE_WORK.md)
|
||||||
|
- `/legal` real page, billing/payments, per-tenant storage volume, compressed recipe, export-token polish.
|
||||||
|
- Mobile visual QA at 320/500px still needs the user's eyes (or a multimodal model); responsive CSS
|
||||||
|
(640px, single-column, flex-wrap, .btn-back) is present + deployed.
|
||||||
|
|
||||||
|
## Guardrails reaffirmed
|
||||||
|
- One persona = one chat per user; result final.
|
||||||
|
- Latent/secret persona fields hidden from admin (IP protection); content visible for super_admin.
|
||||||
|
- LLM creds in `.env` / EasyPanel env only; never logged.
|
||||||
1
frontend/dist/assets/AdminUsers-B0sl0uHS.css
vendored
Normal file
1
frontend/dist/assets/AdminUsers-B0sl0uHS.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.guide[data-v-924406be]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:16px}
|
||||||
11
frontend/dist/assets/AdminUsers-BGqSvhVH.js
vendored
Normal file
11
frontend/dist/assets/AdminUsers-BGqSvhVH.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const L=k("info",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const N=k("user-plus",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["line",{x1:"19",x2:"19",y1:"8",y2:"14",key:"1bvyxn"}],["line",{x1:"22",x2:"16",y1:"11",y2:"11",key:"1shjgl"}]]),B={class:"card guide"},I={class:"card",style:{"margin-bottom":"16px"}},A={style:{"margin-top":"0"}},D={class:"row"},F={class:"row",style:{"margin-top":"10px"}},H=["disabled"],S={key:0,class:"error"},T={key:0,class:"card empty-state"},j={style:{flex:"1"}},q={class:"badge"},E={__name:"AdminUsers",setup(P){const c=y([]),d=y(""),t=y({username:"",name:"",password:"",role:"user"});async function g(){c.value=(await w.adminListUsers()).users}async function _(){d.value="";try{await w.adminCreateUser({...t.value}),t.value={username:"",name:"",password:"",role:"user"},await g()}catch(x){d.value=x.message}}return b(g),(x,s)=>(i(),r("div",null,[e("h2",null,[m(o(z),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),l(" "+n(o(u).t("users")),1)]),e("div",B,[e("strong",null,[m(o(L),{size:17,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),s[4]||(s[4]=l(" วิธีเพิ่มผู้ใช้งาน",-1))]),s[5]||(s[5]=e("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[e("li",null,[l("กรอก "),e("strong",null,"ชื่อผู้ใช้ (สำหรับเข้าสู่ระบบ)"),l(", ชื่อ, และรหัสผ่านเริ่มต้น")]),e("li",null,[l("เลือกสิทธิ์: "),e("strong",null,"user"),l(" = ผู้เข้าฝึกขาย, "),e("strong",null,"admin"),l(" = จัดการระบบ")]),e("li",null,[l("กด "),e("strong",null,"สร้าง"),l(" — นำชื่อผู้ใช้ + รหัสผ่านไปแจ้งให้ผู้ใช้นั้นเข้าสู่ระบบได้เลย")])],-1))]),e("div",I,[e("h4",A,[m(o(N),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),l(" + "+n(o(u).t("create"))+" "+n(o(u).t("users").toLowerCase()),1)]),e("div",D,[p(e("input",{"onUpdate:modelValue":s[0]||(s[0]=a=>t.value.username=a),placeholder:"ชื่อผู้ใช้ (เข้าสู่ระบบ)",style:{flex:"1"}},null,512),[[v,t.value.username]]),p(e("input",{"onUpdate:modelValue":s[1]||(s[1]=a=>t.value.name=a),placeholder:"ชื่อจริง",style:{flex:"1"}},null,512),[[v,t.value.name]]),p(e("input",{"onUpdate:modelValue":s[2]||(s[2]=a=>t.value.password=a),type:"password",placeholder:"รหัสผ่านเริ่มต้น",style:{flex:"1"}},null,512),[[v,t.value.password]])]),e("div",F,[p(e("select",{"onUpdate:modelValue":s[3]||(s[3]=a=>t.value.role=a),style:{flex:"1"}},[...s[6]||(s[6]=[e("option",{value:"user"},"🙂 user — ผู้เข้าฝึก",-1),e("option",{value:"admin"},"🛠 admin — ผู้ดูแลระบบ",-1)])],512),[[U,t.value.role]]),e("button",{class:"primary",onClick:_,disabled:!t.value.username||!t.value.password},"+ "+n(o(u).t("create")),9,H)]),d.value?(i(),r("div",S,n(d.value),1)):f("",!0)]),c.value.length===0?(i(),r("div",T,[...s[7]||(s[7]=[e("strong",null,"ยังไม่มีผู้ใช้งาน",-1),e("span",null,"สร้างผู้ใช้งานคนแรกด้วยฟอร์มด้านบน",-1)])])):f("",!0),(i(!0),r(V,null,C(c.value,a=>(i(),r("div",{class:"card",key:a.id,style:{"margin-bottom":"8px",display:"flex","align-items":"center",gap:"12px"}},[e("strong",j,n(a.name)+" ("+n(a.username)+")",1),e("span",q,n(a.role),1),e("span",{class:M(["badge",a.active?"won":"lost"])},n(a.active?"active":"inactive"),3)]))),128))]))}},K=h(E,[["__scopeId","data-v-924406be"]]);export{K as default};
|
||||||
1
frontend/dist/assets/AdminUsers-DQOxPApl.js
vendored
1
frontend/dist/assets/AdminUsers-DQOxPApl.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{k as x,c as r,a,t,u,i as d,w as n,v as m,z as g,e as _,F as U,p as k,l as f,r as p,o as i,s as V}from"./index-BA-KDOrj.js";const b={class:"card",style:{"margin-bottom":"16px"}},C={class:"row"},h={key:0,class:"error"},B={style:{flex:"1"}},M={class:"badge"},D={__name:"AdminUsers",setup(N){const v=p([]),o=p(""),s=p({name:"",email:"",password:"",role:"user"});async function c(){v.value=(await f.adminListUsers()).users}async function w(){o.value="";try{await f.adminCreateUser({...s.value}),s.value={name:"",email:"",password:"",role:"user"},await c()}catch(y){o.value=y.message}}return x(c),(y,l)=>(i(),r("div",null,[a("h2",null,t(u(d).t("users")),1),a("div",b,[a("h4",null,"+ "+t(u(d).t("create"))+" user",1),a("div",C,[n(a("input",{"onUpdate:modelValue":l[0]||(l[0]=e=>s.value.name=e),placeholder:"Name",style:{flex:"1"}},null,512),[[m,s.value.name]]),n(a("input",{"onUpdate:modelValue":l[1]||(l[1]=e=>s.value.email=e),placeholder:"Email",style:{flex:"1"}},null,512),[[m,s.value.email]]),n(a("input",{"onUpdate:modelValue":l[2]||(l[2]=e=>s.value.password=e),type:"password",placeholder:"Temp password",style:{flex:"1"}},null,512),[[m,s.value.password]]),n(a("select",{"onUpdate:modelValue":l[3]||(l[3]=e=>s.value.role=e),style:{flex:"1"}},[...l[4]||(l[4]=[a("option",{value:"user"},"user",-1),a("option",{value:"admin"},"admin",-1)])],512),[[g,s.value.role]]),a("button",{class:"primary",onClick:w},t(u(d).t("create")),1)]),o.value?(i(),r("div",h,t(o.value),1)):_("",!0)]),(i(!0),r(U,null,k(v.value,e=>(i(),r("div",{class:"card",key:e.id,style:{"margin-bottom":"8px",display:"flex","align-items":"center",gap:"12px"}},[a("strong",B,t(e.name)+" ("+t(e.email)+")",1),a("span",M,t(e.role),1),a("span",{class:V(["badge",e.active?"won":"lost"])},t(e.active?"active":"inactive"),3)]))),128))]))}};export{D as default};
|
|
||||||
1
frontend/dist/assets/Analytics-2sy93Xf6.js
vendored
1
frontend/dist/assets/Analytics-2sy93Xf6.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as i,k as d,l as v,c as o,a as s,t,u as _,i as u,F as c,p as g,r as p,o as n}from"./index-BA-KDOrj.js";const m={class:"row",style:{gap:"16px",margin:"16px 0"}},y={class:"card stat"},f={class:"card stat"},w={style:{color:"var(--green)"}},x={class:"card stat"},h={style:{color:"var(--red)"}},k={class:"card stat"},b={class:"card stat"},A={class:"badge lost"},B={class:"badge won"},L={class:"muted"},F={__name:"Analytics",setup(S){const l=p({overall:{total_sessions:0,wins:0,losses:0,close_rate:0,avg_score:0},trainee_count:0,hardest_personas:[]});return d(async()=>{l.value=await v.analytics()}),(W,a)=>(n(),o("div",null,[s("h2",null,t(_(u).t("analytics")),1),s("div",m,[s("div",y,[a[0]||(a[0]=s("div",null,"Sessions",-1)),s("strong",null,t(l.value.overall.total_sessions),1)]),s("div",f,[a[1]||(a[1]=s("div",null,"Wins",-1)),s("strong",w,t(l.value.overall.wins),1)]),s("div",x,[a[2]||(a[2]=s("div",null,"Losses",-1)),s("strong",h,t(l.value.overall.losses),1)]),s("div",k,[a[3]||(a[3]=s("div",null,"Close rate",-1)),s("strong",null,t(l.value.overall.close_rate)+"%",1)]),s("div",b,[a[4]||(a[4]=s("div",null,"Avg score",-1)),s("strong",null,t(l.value.overall.avg_score),1)])]),s("h3",null,"Trainees: "+t(l.value.trainee_count),1),a[5]||(a[5]=s("h3",null,"Hardest personas",-1)),(n(!0),o(c,null,g(l.value.hardest_personas,(e,r)=>(n(),o("div",{class:"card",key:r,style:{"margin-bottom":"8px"}},[s("strong",null,t(e.persona_name),1),s("span",A,t(e.losses)+"L",1),s("span",B,t(e.wins)+"W",1),s("span",L,"· avg "+t(e.avg_score),1)]))),128))]))}},D=i(F,[["__scopeId","data-v-8828276e"]]);export{D as default};
|
|
||||||
11
frontend/dist/assets/Analytics-5UUWbKvU.js
vendored
Normal file
11
frontend/dist/assets/Analytics-5UUWbKvU.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const D=x("chart-column",[["path",{d:"M3 3v16a2 2 0 0 0 2 2h16",key:"c24i48"}],["path",{d:"M18 17V9",key:"2bz60n"}],["path",{d:"M13 17V5",key:"1frdt8"}],["path",{d:"M8 17v-3",key:"17ska0"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const k=x("download",[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]]),O={class:"row",style:{"align-items":"center","margin-bottom":"16px"}},T={style:{margin:"0"}},F={class:"row",style:{"margin-left":"auto",gap:"10px"}},I={class:"users-btn"},W={class:"primary"},q={class:"card filter-bar"},H={class:"muted"},$=["disabled"],G=["disabled"],J={class:"row",style:{gap:"16px",margin:"16px 0"}},K={class:"card stat"},Q={class:"card stat"},X={style:{color:"var(--green)"}},Y={class:"card stat"},Z={style:{color:"var(--red)"}},ss={class:"card stat"},ts={class:"card stat"},es={class:"card"},as={class:"row",style:{"justify-content":"space-between","align-items":"center"}},os={class:"bar"},ls={class:"muted",style:{"font-size":"12px","margin-top":"6px"}},ns={style:{"margin-top":"20px"}},is={key:0,class:"card empty-state"},rs={class:"grid"},ds={class:"row",style:{"justify-content":"space-between"}},cs={class:"muted"},us={class:"row",style:{"margin-top":"10px",gap:"8px"}},vs={class:"badge won"},_s={class:"badge lost"},ps={class:"badge not_tried"},ms={class:"muted",style:{"margin-top":"8px"}},ys={__name:"Analytics",setup(hs){const l=m({overall:{total_sessions:0,wins:0,losses:0,close_rate:0,avg_score:0},trainee_count:0,hardest_personas:[]}),u=m(""),v=m(""),_=m(!1);async function h(){_.value=!0;try{l.value=await A.analytics({from:u.value,to:v.value})}finally{_.value=!1}}function z(){h()}function C(){u.value="",v.value="",h()}async function V(){try{const r=await fetch("/api/analytics/export",{headers:{Authorization:`Bearer ${E.token}`}});if(!r.ok)throw new Error("export failed");const t=await r.blob(),d=URL.createObjectURL(t),o=document.createElement("a");o.href=d,o.download="sales-trainer-results.csv",o.click(),URL.revokeObjectURL(d)}catch(r){alert(r.message)}}return M(h),(r,t)=>{const d=B("router-link");return y(),p("div",null,[s("div",O,[s("div",null,[s("h2",T,[i(a(D),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),c(" "+e(a(n).t("adminOverview")),1)]),t[2]||(t[2]=s("div",{class:"muted",style:{"margin-top":"4px"}},"ภาพรวมผลการฝึกของทีมทั้งหมด — เลือกช่วงเวลาเพื่อดูสถิติ",-1))]),s("div",F,[i(d,{to:"/admin/users"},{default:w(()=>[s("button",I,[i(a(P),{size:18,"stroke-width":2}),c(" "+e(a(n).t("users")),1)])]),_:1}),i(d,{to:"/admin/new-group"},{default:w(()=>[s("button",W,[i(a(S),{size:18,"stroke-width":2}),c(" "+e(a(n).t("addProduct")),1)])]),_:1})])]),s("div",q,[s("span",H,[i(a(k),{size:15,"stroke-width":1.8,style:{"vertical-align":"-2px"}}),c(" "+e(a(n).t("dateRange")),1)]),f(s("input",{type:"date","onUpdate:modelValue":t[0]||(t[0]=o=>u.value=o)},null,512),[[b,u.value]]),t[4]||(t[4]=s("span",null,"–",-1)),f(s("input",{type:"date","onUpdate:modelValue":t[1]||(t[1]=o=>v.value=o)},null,512),[[b,v.value]]),s("button",{class:"primary",onClick:z,disabled:_.value},"→ "+e(a(n).t("apply")),9,$),s("button",{onClick:C,disabled:_.value},e(a(n).t("clear")),9,G),s("button",{class:"soft",onClick:V},[i(a(k),{size:16,"stroke-width":1.8}),t[3]||(t[3]=c(" CSV",-1))])]),s("div",J,[s("div",K,[t[5]||(t[5]=s("div",null,"Sessions",-1)),s("strong",null,e(l.value.overall.total_sessions),1)]),s("div",Q,[t[6]||(t[6]=s("div",null,"Wins",-1)),s("strong",X,e(l.value.overall.wins),1)]),s("div",Y,[t[7]||(t[7]=s("div",null,"Losses",-1)),s("strong",Z,e(l.value.overall.losses),1)]),s("div",ss,[t[8]||(t[8]=s("div",null,"Avg score",-1)),s("strong",null,e(l.value.overall.avg_score),1)]),s("div",ts,[t[9]||(t[9]=s("div",null,"Trainees",-1)),s("strong",null,e(l.value.trainee_count),1)])]),s("div",es,[s("div",as,[s("strong",null,e(a(n).t("closeRate")),1),s("strong",null,e(l.value.overall.close_rate)+"%",1)]),s("div",os,[s("div",{class:"bar-fill",style:U({width:(l.value.overall.close_rate||0)+"%"})},null,4)]),s("div",ls,e(l.value.overall.wins)+" "+e(a(n).t("won").toLowerCase())+" / "+e(l.value.overall.total_sessions)+" sessions ",1)]),s("h3",ns,e(a(n).t("hardestPersonas")),1),l.value.hardest_personas.length===0?(y(),p("div",is,[...t[10]||(t[10]=[s("strong",null,"No data",-1),s("span",null,"No sessions in this date range.",-1)])])):N("",!0),s("div",rs,[(y(!0),p(R,null,j(l.value.hardest_personas,(o,g)=>(y(),p("div",{key:g,class:"card lift tier-card"},[s("div",ds,[s("strong",null,e(o.persona_name),1),s("span",cs,"#"+e(g+1),1)]),s("div",us,[s("span",vs,e(o.wins)+"W",1),s("span",_s,e(o.losses)+"L",1),s("span",ps,e(o.plays)+" plays",1)]),s("div",ms,e(a(n).t("score"))+": "+e(o.avg_score),1)]))),128))])])}}},bs=L(ys,[["__scopeId","data-v-6eb0872b"]]);export{bs as default};
|
||||||
1
frontend/dist/assets/Analytics-CQgwlaXy.css
vendored
Normal file
1
frontend/dist/assets/Analytics-CQgwlaXy.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.stat[data-v-6eb0872b]{text-align:center;min-width:110px}.stat div[data-v-6eb0872b]{color:var(--muted);font-size:12px}.stat strong[data-v-6eb0872b]{font-size:20px}.filter-bar[data-v-6eb0872b]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.filter-bar input[type=date][data-v-6eb0872b]{width:auto}.grid[data-v-6eb0872b]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}.tier-card[data-v-6eb0872b]{display:flex;flex-direction:column}.badge.won[data-v-6eb0872b]{background:#dcfce7;color:#166534}.badge.lost[data-v-6eb0872b]{background:#fee2e2;color:#991b1b}.badge.not_tried[data-v-6eb0872b]{background:#eef2ff;color:#4338ca}.bar[data-v-6eb0872b]{background:#eef0f5;border-radius:999px;height:10px;overflow:hidden;margin-top:10px}.bar-fill[data-v-6eb0872b]{background:linear-gradient(90deg,var(--accent),var(--accent-2));height:100%;border-radius:999px;transition:width .4s ease}.users-btn[data-v-6eb0872b]{display:inline-flex;align-items:center;gap:6px;background:#0ea5e9;border-color:transparent;color:#fff;font-weight:600}.users-btn[data-v-6eb0872b]:not(:disabled):hover{background:#0284c7;box-shadow:0 6px 16px #0ea5e959}
|
||||||
1
frontend/dist/assets/Analytics-CtniKRSj.css
vendored
1
frontend/dist/assets/Analytics-CtniKRSj.css
vendored
@@ -1 +0,0 @@
|
|||||||
.stat[data-v-8828276e]{text-align:center;min-width:110px}.stat div[data-v-8828276e]{color:var(--muted);font-size:12px}.stat strong[data-v-8828276e]{font-size:20px}
|
|
||||||
1
frontend/dist/assets/Chat-D5cDjkYb.css
vendored
1
frontend/dist/assets/Chat-D5cDjkYb.css
vendored
@@ -1 +0,0 @@
|
|||||||
.thread[data-v-4f54526a]{background:#eceff4;border:1px solid var(--border);border-radius:var(--radius);padding:16px;min-height:320px;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px}.bubble[data-v-4f54526a]{max-width:72%;padding:10px 14px;white-space:pre-wrap;word-break:break-word}.composer[data-v-4f54526a]{display:flex;gap:8px;margin-top:12px}.task[data-v-4f54526a]{margin-bottom:12px;background:#fff7ed;border-color:#fed7aa}.debrief[data-v-4f54526a]{margin-top:16px}.json[data-v-4f54526a]{background:#0f172a;color:#9ca3af;padding:10px;border-radius:8px;font-size:11px;overflow:auto;max-height:260px}button.danger[data-v-4f54526a]{background:var(--red);color:#fff;border:none}
|
|
||||||
1
frontend/dist/assets/Chat-DWAKiJ5k.js
vendored
1
frontend/dist/assets/Chat-DWAKiJ5k.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as M,k as q,l as k,c as t,m as B,n as D,u as l,a as s,t as e,s as C,e as i,j as m,i as n,F,p as I,w as z,v as E,b as H,h as J,r as c,y as L,q as O,o}from"./index-BA-KDOrj.js";const P={class:"row",style:{"align-items":"center","margin-bottom":"12px"}},R={style:{margin:"0"}},U={key:0,class:"muted"},$=["disabled"],A={key:0,class:"spinner",style:{"margin-right":"4px"}},G={key:0,class:"card task"},Q={key:0},W={key:0,class:"bubble msg-customer muted"},X={key:2,class:"composer"},Y=["disabled","placeholder"],Z=["disabled"],ee={key:3,class:"card debrief"},se={key:0},ae={class:"json"},te={class:"primary",style:{"margin-top":"12px"}},le={__name:"Chat",setup(ne){const N=J(),_=N.params.gid,g=N.params.pid,r=c(null),f=c(!1),v=c([]),p=c(""),d=c(!1),u=c(null),w=c(""),j=c(null);function T(){L(()=>{y.value&&(y.value.scrollTop=y.value.scrollHeight)})}const y=c(null);q(async()=>{r.value=(await k.getPersona(_,g)).persona;const a=await k.chatStart(_,g);j.value=a.session.id,a.session.task&&(w.value=a.session.task),v.value=a.session.messages||[],f.value=!0,v.value.length&&T()});async function V(){if(p.value.trim()){d.value=!0;try{const a=await k.chatSend(_,g,p.value.trim());v.value=a.messages,p.value="",T()}catch(a){alert(a.message)}finally{d.value=!1}}}async function K(){if(confirm(n.t("finish")+"?")){d.value=!0;try{const a=await k.chatFinish(_,g);u.value=a.debrief,v.value=a.session.messages}catch(a){alert(a.message)}finally{d.value=!1}}}return(a,b)=>{const S=O("router-link");return o(),t("div",null,[B(S,{to:`/groups/${l(_)}/personas`,class:"btn-back"},{default:D(()=>[m("← "+e(l(n).t("personas")),1)]),_:1},8,["to"]),s("div",P,[s("h2",R,e(r.value?r.value.name:"..."),1),s("span",{class:C(["badge",r.value&&r.value.channel])},e(r.value?r.value.channel:""),3),r.value?(o(),t("span",U,e(r.value.profession)+" · "+e(r.value.age_group),1)):i("",!0),s("button",{class:"danger",style:{"margin-left":"auto"},onClick:K,disabled:v.value.length===0||!!u.value},[d.value?(o(),t("span",A)):i("",!0),m(e(l(n).t("finish")),1)],8,$)]),f.value?i("",!0):(o(),t("div",G,[s("strong",null,"📣 "+e(l(n).t("sellerInitiated")),1),w.value?(o(),t("div",Q,e(w.value),1)):i("",!0)])),f.value?(o(),t("div",{key:1,class:"thread",ref_key:"thread",ref:y},[(o(!0),t(F,null,I(v.value,(h,x)=>(o(),t("div",{key:x,class:C(["bubble",h.role==="seller"?"msg-seller":"msg-customer"])},e(h.text),3))),128)),d.value?(o(),t("div",W,"...")):i("",!0)],512)):i("",!0),f.value&&!u.value?(o(),t("div",X,[z(s("input",{"onUpdate:modelValue":b[0]||(b[0]=h=>p.value=h),onKeyup:H(V,["enter"]),disabled:d.value,placeholder:l(n).t("send")},null,40,Y),[[E,p.value]]),s("button",{class:"primary",onClick:V,disabled:d.value||!p.value.trim()},e(l(n).t("send")),9,Z)])):i("",!0),u.value?(o(),t("div",ee,[s("h3",null,e(l(n).t("debrief")),1),s("p",null,[s("span",{class:C(["badge",u.value.outcome])},e(u.value.outcome==="won"?l(n).t("won"):l(n).t("lost")),3),m(" — "+e(l(n).t("score"))+": ",1),s("strong",null,e(u.value.score),1)]),s("p",null,[s("strong",null,e(l(n).t("pain"))+":",1),m(" "+e(u.value.pain||"—"),1)]),s("p",null,[s("strong",null,e(l(n).t("why"))+":",1),m(" "+e(u.value.why),1)]),u.value.coaching&&u.value.coaching.length?(o(),t("div",se,[b[1]||(b[1]=s("strong",null,"Coaching:",-1)),s("ul",null,[(o(!0),t(F,null,I(u.value.coaching,(h,x)=>(o(),t("li",{key:x},e(h),1))),128))])])):i("",!0),s("details",null,[s("summary",null,e(l(n).t("reveal")),1),s("pre",ae,e(JSON.stringify(u.value.revealed_persona,null,2)),1)]),B(S,{to:"/"},{default:D(()=>[s("button",te,e(l(n).t("dashboard")),1)]),_:1})])):i("",!0)])}}},ue=M(le,[["__scopeId","data-v-4f54526a"]]);export{ue as default};
|
|
||||||
1
frontend/dist/assets/Chat-pa8hnBQD.css
vendored
Normal file
1
frontend/dist/assets/Chat-pa8hnBQD.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.thread[data-v-dd57873b]{background:#eceff4;border:1px solid var(--border);border-radius:var(--radius);padding:16px;min-height:320px;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px}.bubble[data-v-dd57873b]{max-width:72%;padding:10px 14px;white-space:pre-wrap;word-break:break-word}.msg-system[data-v-dd57873b]{align-self:center;background:#fef3c7;color:#92400e;font-size:12px;max-width:88%;border-radius:999px}.composer[data-v-dd57873b]{display:flex;gap:8px;margin-top:12px}.task[data-v-dd57873b]{margin-bottom:12px;background:#fff7ed;border-color:#fed7aa}.debrief[data-v-dd57873b]{margin-top:16px}button.danger[data-v-dd57873b]{background:var(--red);color:#fff;border:none}.guide[data-v-dd57873b]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:12px}.scenario[data-v-dd57873b]{border:2px solid var(--border);border-radius:12px;padding:12px 14px;margin:10px 0;cursor:pointer;transition:border-color .15s ease,background .15s ease}.scenario[data-v-dd57873b]:hover{border-color:var(--accent)}.scenario.sel[data-v-dd57873b]{border-color:var(--accent);background:#eef2ff}.reveal-grid[data-v-dd57873b]{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}.rev[data-v-dd57873b]{display:flex;flex-direction:column;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:8px 10px}.rk[data-v-dd57873b]{font-size:12px;color:var(--muted)}.rv[data-v-dd57873b]{font-size:13px;color:var(--ink);margin-top:2px}@media (max-width: 640px){.reveal-grid[data-v-dd57873b]{grid-template-columns:1fr}}
|
||||||
6
frontend/dist/assets/Chat-s9Pksmwi.js
vendored
Normal file
6
frontend/dist/assets/Chat-s9Pksmwi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/Dashboard-Btd0pap_.js
vendored
1
frontend/dist/assets/Dashboard-Btd0pap_.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as k,k as v,l as g,c as r,a as t,t as e,u as s,i as a,f as d,m as c,n as l,e as u,F as f,p as x,r as y,q as b,o,s as w,x as h}from"./index-BA-KDOrj.js";const A={class:"row",style:{"margin-bottom":"16px"}},B={style:{margin:"0"}},N={key:0,style:{"margin-left":"auto"}},C={class:"primary"},D={key:0,class:"row",style:{gap:"16px","margin-bottom":"20px"}},V={class:"card link-card"},F={class:"card link-card"},P={key:1,class:"row",style:{gap:"16px","margin-bottom":"20px"}},$={class:"card link-card"},j={class:"card link-card"},q={class:"card link-card"},z={key:2,class:"card",style:{"min-height":"120px"}},E={key:3,class:"card empty-state"},G={key:0},I={key:1},L={class:"grid"},M={class:"row",style:{"justify-content":"space-between"}},S={class:"muted",style:{margin:"6px 0 12px"}},T={class:"primary"},H={__name:"Dashboard",setup(J){const _=y([]),p=y(!0);return v(async()=>{try{_.value=(await g.listGroups()).groups}finally{p.value=!1}}),(K,m)=>{const i=b("router-link");return o(),r("div",null,[t("div",A,[t("h2",B,e(s(a).t("dashboard")),1),s(d).isAdmin?(o(),r("div",N,[c(i,{to:"/admin/new-group"},{default:l(()=>[t("button",C,e(s(a).t("groupBuilder"))+" +",1)]),_:1})])):u("",!0)]),s(d).isAdmin?(o(),r("div",D,[c(i,{to:"/admin/users",style:{"text-decoration":"none"}},{default:l(()=>[t("div",V,"👥 "+e(s(a).t("users")),1)]),_:1}),c(i,{to:"/admin/analytics",style:{"text-decoration":"none"}},{default:l(()=>[t("div",F,"📊 "+e(s(a).t("analytics")),1)]),_:1})])):u("",!0),s(d).role==="user"?(o(),r("div",P,[c(i,{to:"/my/sessions",style:{"text-decoration":"none"}},{default:l(()=>[t("div",$,"🎯 "+e(s(a).t("myTraining")),1)]),_:1}),c(i,{to:"/my/weak-areas",style:{"text-decoration":"none"}},{default:l(()=>[t("div",j,"⚠️ "+e(s(a).t("weakAreas")),1)]),_:1}),c(i,{to:"/my/generate",style:{"text-decoration":"none"}},{default:l(()=>[t("div",q,"✨ "+e(s(a).t("generatePersona")),1)]),_:1})])):u("",!0),t("h3",null,e(s(a).t("groups")),1),p.value?(o(),r("div",z,[...m[0]||(m[0]=[t("div",{class:"skeleton",style:{height:"60px"}},null,-1),t("div",{class:"skeleton",style:{height:"60px","margin-top":"10px"}},null,-1)])])):_.value.length===0?(o(),r("div",E,[t("strong",null,e(s(d).isAdmin?"No persona groups yet":"No groups available"),1),s(d).isAdmin?(o(),r("span",G,e(s(a).t("groupBuilder"))+" to start.",1)):(o(),r("span",I,"Ask an admin to create a group."))])):u("",!0),t("div",L,[(o(!0),r(f,null,x(_.value,n=>(o(),r("div",{key:n.id,class:"card group-card lift"},[t("div",M,[t("strong",null,e(n.title),1),t("span",{class:w(["badge",n.status])},e(n.status),3)]),t("div",S,e(n.sales_kit&&n.sales_kit.productName||n.input&&n.input.product||""),1),s(d).isAdmin?(o(),h(i,{key:0,to:`/admin/groups/${n.id}/edit`},{default:l(()=>[t("button",null,e(s(a).t("personas"))+" / "+e(s(a).t("create")),1)]),_:1},8,["to"])):n.status==="ready"?(o(),h(i,{key:1,to:`/groups/${n.id}/personas`},{default:l(()=>[t("button",T,e(s(a).t("selectPersona")),1)]),_:1},8,["to"])):u("",!0)]))),128))])])}}},Q=k(H,[["__scopeId","data-v-559fb44c"]]);export{Q as default};
|
|
||||||
1
frontend/dist/assets/Dashboard-DD-9mkhT.css
vendored
1
frontend/dist/assets/Dashboard-DD-9mkhT.css
vendored
@@ -1 +0,0 @@
|
|||||||
.grid[data-v-559fb44c]{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}.group-card[data-v-559fb44c]{display:flex;flex-direction:column}.group-card a[data-v-559fb44c]{margin-top:auto}.link-card[data-v-559fb44c]{text-align:center;min-width:150px}
|
|
||||||
1
frontend/dist/assets/GenPersona-CDFUWMM8.css
vendored
1
frontend/dist/assets/GenPersona-CDFUWMM8.css
vendored
@@ -1 +0,0 @@
|
|||||||
button.active[data-v-d299d1b3]{background:var(--accent);color:#fff;border-color:var(--accent)}.grid[data-v-d299d1b3]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}.pcard[data-v-d299d1b3]{display:flex;flex-direction:column;min-height:140px}
|
|
||||||
1
frontend/dist/assets/GenPersona-DWlMmhuC.js
vendored
1
frontend/dist/assets/GenPersona-DWlMmhuC.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as x,r as l,k as C,c as r,a as e,t,u as m,i as v,s as p,F as k,w as P,v as M,e as N,p as V,h as $,l as w,q as B,o as u,m as D,n as G}from"./index-BA-KDOrj.js";const I={class:"card",style:{"margin-bottom":"16px"}},T={class:"row"},q={key:1,class:"muted"},z=["disabled"],E={key:2,class:"error"},F={class:"grid"},L={class:"muted"},R={class:"primary",style:{width:"100%"}},S={__name:"GenPersona",setup(U){const b=$(),o=l(b.query.mode==="weak"?"weak-area":"manual"),i=l(""),d=l(!1),c=l(""),y=l([]),g=l(null);async function _(){const n=await w.myPersonas();g.value=n.group.id,y.value=n.personas}C(_);async function f(){d.value=!0,c.value="";try{const n=o.value==="weak-area"?{mode:"weak-area",spec:{}}:{mode:"manual",spec:{description:i.value}};await w.generatePersona(n),await _()}catch(n){c.value=n.message}finally{d.value=!1}}return(n,a)=>{const h=B("router-link");return u(),r("div",null,[e("h2",null,t(m(v).t("generatePersona")),1),e("div",I,[a[4]||(a[4]=e("label",null,"Mode",-1)),e("div",T,[e("button",{class:p({active:o.value==="manual"}),onClick:a[0]||(a[0]=s=>o.value="manual")},"✏️ "+t(m(v).t("manual")),3),e("button",{class:p({active:o.value==="weak-area"}),onClick:a[1]||(a[1]=s=>o.value="weak-area")},"🔒 Weak-area lock",2)]),o.value==="manual"?(u(),r(k,{key:0},[a[3]||(a[3]=e("label",null,"Describe the persona you want to practice against",-1)),P(e("textarea",{"onUpdate:modelValue":a[2]||(a[2]=s=>i.value=s),rows:"4",placeholder:"e.g. a price-hardball restaurant owner on LINE who stalls when I bring up costs"},null,512),[[M,i.value]])],64)):(u(),r("p",q,"The system will analyze your losses and auto-generate a harder persona targeting your weak points.")),e("button",{class:"primary",style:{"margin-top":"16px"},disabled:d.value||o.value==="manual"&&!i.value.trim(),onClick:f},t(d.value?"...":m(v).t("generatePersona")),9,z),c.value?(u(),r("div",E,t(c.value),1)):N("",!0)]),a[5]||(a[5]=e("h3",null,"My personas",-1)),e("div",F,[(u(!0),r(k,null,V(y.value,s=>(u(),r("div",{key:s.id,class:"card pcard"},[e("strong",null,t(s.name),1),e("span",{class:p(["badge",s.tier])},"Tier "+t(s.tier),3),e("div",L,t(s.profession)+" · "+t(s.age_group),1),D(h,{to:`/groups/${g.value}/chat/${s.id}`,style:{"margin-top":"auto"}},{default:G(()=>[e("button",R,t(m(v).t("chat")),1)]),_:1},8,["to"])]))),128))])])}}},j=x(S,[["__scopeId","data-v-d299d1b3"]]);export{j as default};
|
|
||||||
1
frontend/dist/assets/GroupBuilder-7-9ZskJo.css
vendored
Normal file
1
frontend/dist/assets/GroupBuilder-7-9ZskJo.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.guide[data-v-b566e48c]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:16px}.req[data-v-b566e48c]{color:var(--red)}
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{c as g,a as e,t as l,u as o,i as n,w as r,v,z as m,e as x,r as p,g as k,o as f,l as w}from"./index-BA-KDOrj.js";const V={class:"card"},B={class:"row"},U={style:{flex:"1"}},C={value:"facebook"},D={value:"line"},F={style:{flex:"1"}},S={value:"th"},E={value:"en"},G={key:0,class:"error"},M=["disabled"],A={__name:"GroupBuilder",setup(N){const h=k(),t=p({product:"",segment:"",description:"",channel:"facebook",language:"th"}),c=p([]),d=p(""),i=p(!1);function _(a){c.value=Array.from(a.target.files||[])}async function b(){i.value=!0,d.value="";try{const a=new FormData;a.append("product",t.value.product),a.append("segment",t.value.segment),a.append("description",t.value.description),a.append("channel",t.value.channel),a.append("language",t.value.language),c.value.forEach(y=>a.append("files",y));const u=(await w.createGroup(a)).group.id;h.push(`/admin/groups/${u}/edit`)}catch(a){d.value=a.message}finally{i.value=!1}}return(a,s)=>(f(),g("div",V,[e("h2",null,l(o(n).t("groupBuilder")),1),e("label",null,l(o(n).t("product")),1),r(e("textarea",{"onUpdate:modelValue":s[0]||(s[0]=u=>t.value.product=u),rows:"3",placeholder:"e.g. Cloud POS for small restaurants"},null,512),[[v,t.value.product]]),e("label",null,l(o(n).t("segment")),1),r(e("input",{"onUpdate:modelValue":s[1]||(s[1]=u=>t.value.segment=u)},null,512),[[v,t.value.segment]]),e("label",null,l(o(n).t("description")),1),r(e("textarea",{"onUpdate:modelValue":s[2]||(s[2]=u=>t.value.description=u),rows:"3"},null,512),[[v,t.value.description]]),e("div",B,[e("div",U,[e("label",null,l(o(n).t("channel")),1),r(e("select",{"onUpdate:modelValue":s[3]||(s[3]=u=>t.value.channel=u)},[e("option",C,l(o(n).t("facebook")),1),e("option",D,l(o(n).t("line")),1)],512),[[m,t.value.channel]])]),e("div",F,[e("label",null,l(o(n).t("language")),1),r(e("select",{"onUpdate:modelValue":s[4]||(s[4]=u=>t.value.language=u)},[e("option",S,l(o(n).t("thai")),1),e("option",E,l(o(n).t("english")),1)],512),[[m,t.value.language]])])]),e("label",null,"📎 Files (.pdf/.md/.txt) — "+l(o(n).t("product"))+" can come from here",1),e("input",{type:"file",multiple:"",accept:".pdf,.md,.txt",onChange:_},null,32),d.value?(f(),g("div",G,l(d.value),1)):x("",!0),e("button",{class:"primary",style:{"margin-top":"16px"},disabled:i.value||!t.value.product&&!c.value.length,onClick:b},l(i.value?"...":o(n).t("create")),9,M)]))}};export{A as default};
|
|
||||||
21
frontend/dist/assets/GroupBuilder-FWDh6ofr.js
vendored
Normal file
21
frontend/dist/assets/GroupBuilder-FWDh6ofr.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const E=v("box",[["path",{d:"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z",key:"hh9hay"}],["path",{d:"m3.3 7 8.7 5 8.7-5",key:"g66t2b"}],["path",{d:"M12 22V12",key:"d0xqtd"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const G=v("circle-plus",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M8 12h8",key:"1wcyev"}],["path",{d:"M12 8v8",key:"napkw2"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const N=v("list-checks",[["path",{d:"M13 5h8",key:"a7qcls"}],["path",{d:"M13 12h8",key:"h98zly"}],["path",{d:"M13 19h8",key:"c3s6r1"}],["path",{d:"m3 17 2 2 4-4",key:"1jhpwq"}],["path",{d:"m3 7 2 2 4-4",key:"1obspn"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const P=v("paperclip",[["path",{d:"m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551",key:"1miecu"}]]),S={class:"card guide"},U={class:"card"},D={style:{"margin-top":"0"}},I={value:"th"},L={value:"en"},j={key:0,class:"error"},F=["disabled"],T={__name:"GroupBuilder",setup($){const f=B(),a=m({product:"",segment:"",description:"",language:"th"}),h=m([]),d=m(""),c=m(!1);function b(s){h.value=Array.from(s.target.files||[])}async function _(){c.value=!0,d.value="";try{const s=new FormData;s.append("product",a.value.product),s.append("segment",a.value.segment),s.append("description",a.value.description),s.append("language",a.value.language),h.value.forEach(n=>s.append("files",n));const p=(await x.createGroup(s)).group.id;try{await x.analyzeGroup(p)}catch(n){d.value=n.message,f.push(`/admin/groups/${p}/edit`);return}f.push(`/admin/groups/${p}/edit`)}catch(s){d.value=s.message}finally{c.value=!1}}return(s,t)=>{const p=A("router-link");return w(),k("div",null,[u(p,{to:"/training",class:"btn-back"},{default:z(()=>[u(l(q),{size:16,"stroke-width":2}),o(" "+r(l(i).t("training")),1)]),_:1}),e("div",S,[e("strong",null,[u(l(N),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),t[4]||(t[4]=o(" วิธีสร้างกลุ่มบุคคลต้นแบบ",-1))]),t[5]||(t[5]=e("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[e("li",null,[o("กรอก "),e("strong",null,"สินค้า / บริการ / ไอเดีย"),o(" ที่อยากให้ทีมฝึกนำเสนอ (ช่องด้านล่าง)")]),e("li",null,"(ไม่บังคับ) ระบุกลุ่มเป้าหมาย หรืออัปโหลดไฟล์เอกสาร .pdf/.md/.txt"),e("li",null,[o("กด "),e("strong",null,"สร้าง"),o(" — ระบบจะสร้างบุคคลต้นแบบ (ลูกค้าจำลอง) ให้ทันที")]),e("li",null,[o("ถ้าอยากได้บุคคลต้นแบบเพิ่ม ไปที่หน้านั้นแล้วกด "),e("strong",null,"สร้างบุคคลต้นแบบเพิ่มเติม")])],-1))]),e("div",U,[e("h2",D,[u(l(E),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),o(" "+r(l(i).t("addProduct")),1)]),e("label",null,[o(r(l(i).t("product"))+" ",1),t[6]||(t[6]=e("span",{class:"req"},"*",-1))]),g(e("textarea",{"onUpdate:modelValue":t[0]||(t[0]=n=>a.value.product=n),rows:"3",placeholder:"สินค้า / บริการ / ไอเดีย — เช่น ระบบ POS สำหรับร้านอาหาร, บริการให้คำปรึกษา AI, แนวคิดเปิดร้านกาแฟ…"},null,512),[[y,a.value.product]]),e("label",null,r(l(i).t("segment")),1),g(e("input",{"onUpdate:modelValue":t[1]||(t[1]=n=>a.value.segment=n),placeholder:"เช่น SMEs ร้านอาหารในกรุงเทพ"},null,512),[[y,a.value.segment]]),e("label",null,r(l(i).t("description")),1),g(e("textarea",{"onUpdate:modelValue":t[2]||(t[2]=n=>a.value.description=n),rows:"3",placeholder:"รายละเอียดเพิ่มเติม เช่น จุดเด่น ราคา หรือกลุ่มคนที่เราจะนำเสนอ (ไม่บังคับ)"},null,512),[[y,a.value.description]]),e("label",null,r(l(i).t("language")),1),g(e("select",{"onUpdate:modelValue":t[3]||(t[3]=n=>a.value.language=n)},[e("option",I,r(l(i).t("thai")),1),e("option",L,r(l(i).t("english")),1)],512),[[V,a.value.language]]),e("label",null,[u(l(P),{size:15,"stroke-width":1.8,style:{"vertical-align":"-2px"}}),t[7]||(t[7]=o(" ไฟล์เอกสาร (.pdf/.md/.txt) — ใช้เป็นข้อมูลให้ระบบได้",-1))]),e("input",{type:"file",multiple:"",accept:".pdf,.md,.txt",onChange:b},null,32),d.value?(w(),k("div",j,r(d.value),1)):C("",!0),e("button",{class:"primary",style:{"margin-top":"16px",display:"inline-flex","align-items":"center",gap:"6px"},disabled:c.value||!a.value.product&&!h.value.length,onClick:_},[u(l(G),{size:18,"stroke-width":2}),o(" "+r(c.value?"กำลังสร้างบุคคลต้นแบบ…":l(i).t("create")),1)],8,F)])])}}},R=M(T,[["__scopeId","data-v-b566e48c"]]);export{R as default};
|
||||||
14
frontend/dist/assets/GroupEdit-BOSQCzGb.js
vendored
Normal file
14
frontend/dist/assets/GroupEdit-BOSQCzGb.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/GroupEdit-BpqVXcH5.css
vendored
1
frontend/dist/assets/GroupEdit-BpqVXcH5.css
vendored
@@ -1 +0,0 @@
|
|||||||
.grid[data-v-ee1c1944]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.pcard[data-v-ee1c1944]{display:flex;flex-direction:column}.pcard button[data-v-ee1c1944]{margin-top:auto}.json[data-v-ee1c1944]{background:#0f172a;color:#9ca3af;padding:10px;border-radius:8px;font-size:11px;overflow:auto;max-height:220px}
|
|
||||||
1
frontend/dist/assets/GroupEdit-gE6WVEsc.js
vendored
1
frontend/dist/assets/GroupEdit-gE6WVEsc.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as S,k as E,c as n,m as J,n as O,a as t,t as e,u as d,i as l,e as m,j as k,F as b,p as x,h as z,l as y,q as G,r as v,o}from"./index-BA-KDOrj.js";const V={class:"row",style:{"align-items":"center","margin-bottom":"16px"}},j={style:{margin:"0"}},A=["disabled"],P={key:0,class:"spinner",style:{"margin-right":"4px"}},F={key:0,class:"error"},I={key:1,class:"card empty-state"},L={class:"grid"},T={class:"row"},q={key:0,class:"badge"},D={class:"muted"},M={class:"muted",style:{"margin-top":"4px"}},R={style:{"margin-top":"8px"},open:""},$={class:"json"},H=["onClick"],K={__name:"GroupEdit",setup(Q){const _=z().params.gid,g=v(null),p=v([]),i=v(!1),u=v("");async function f(){const s=await y.getGroup(_);g.value=s.group,p.value=(await y.listPersonas(_)).personas}function N(s){return p.value.filter(r=>r.tier===s)}function w(s){return l.t(s==="A"?"tierA":s==="B"?"tierB":"tierC")}async function C(){i.value=!0,u.value="";try{const s=await y.analyzeGroup(_);p.value=s.personas,await f()}catch(s){u.value=s.message}finally{i.value=!1}}function B(s){const r=prompt("Edit persona JSON (full fields):",JSON.stringify(s,null,2));if(r)try{const c=JSON.parse(r);y.updatePersona(_,s.id,c).then(f)}catch(c){u.value="Invalid JSON: "+c.message}}return E(f),(s,r)=>{const c=G("router-link");return o(),n("div",null,[J(c,{to:"/",class:"btn-back"},{default:O(()=>[k("← "+e(d(l).t("dashboard")),1)]),_:1}),t("div",V,[t("h2",j,e(d(l).t("groupBuilder"))+" — "+e(g.value&&g.value.title),1),t("button",{class:"primary",style:{"margin-left":"auto"},onClick:C,disabled:i.value},[i.value?(o(),n("span",P)):m("",!0),k(e(d(l).t("analyze")),1)],8,A)]),u.value?(o(),n("div",F,e(u.value),1)):m("",!0),p.value.length===0&&!i.value?(o(),n("div",I,[r[0]||(r[0]=t("strong",null,"No personas yet",-1)),t("span",null,"Click "+e(d(l).t("analyze"))+" to generate the 15 personas (5 per tier).",1)])):m("",!0),(o(),n(b,null,x(["A","B","C"],h=>t("div",{key:h,style:{"margin-bottom":"20px"}},[t("h4",null,e(w(h)),1),t("div",L,[(o(!0),n(b,null,x(N(h),a=>(o(),n("div",{key:a.id,class:"card pcard"},[t("div",T,[t("strong",null,e(a.name),1),a.special==="wrong_text"?(o(),n("span",q,"⚠️ wrong_text")):m("",!0)]),t("div",D,e(a.profession)+" · "+e(a.age_group)+" · "+e(a.channel)+" · "+e(a.initiation_mode),1),t("div",M,"diff "+e(a.difficulty)+" · "+e(a.income)+" · "+e(a.personality),1),t("details",R,[t("summary",null,e(d(l).t("reveal")),1),t("pre",$,e(JSON.stringify(a,null,2)),1)]),t("button",{onClick:W=>B(a)},"✏️ Edit",8,H)]))),128))])])),64))])}}},Y=S(K,[["__scopeId","data-v-ee1c1944"]]);export{Y as default};
|
|
||||||
1
frontend/dist/assets/GroupEdit-sOm6hJOC.css
vendored
Normal file
1
frontend/dist/assets/GroupEdit-sOm6hJOC.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.sec[data-v-59429170]{margin-top:20px}.sec h4[data-v-59429170]{margin:0 0 10px;padding-bottom:6px;border-bottom:1px solid var(--border)}.row[data-v-59429170]{display:flex;gap:12px;flex-wrap:wrap}.f[data-v-59429170]{flex:1;min-width:140px}label[data-v-59429170]{font-size:13px;color:var(--muted);display:block;margin:10px 0 4px}input[data-v-59429170],select[data-v-59429170],textarea[data-v-59429170]{width:100%}.actions[data-v-59429170]{margin-top:20px}.locked[data-v-59429170]{background:#fffaf5;border:1px dashed #d6c7a1;border-radius:12px;padding:12px 14px}.locked h4[data-v-59429170]{color:#b45309}.grid[data-v-c3e7f19c]{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}.pcard[data-v-c3e7f19c]{display:flex;flex-direction:column}.edit-btn[data-v-c3e7f19c]{margin-top:auto;display:inline-flex;align-items:center;gap:6px}.star[data-v-c3e7f19c]{color:#d8dbe3}.star.on[data-v-c3e7f19c]{color:#f59e0b}.badge.tier-a[data-v-c3e7f19c]{background:#dcfce7;color:#166534}.badge.tier-b[data-v-c3e7f19c]{background:#fef9c3;color:#854d0e}.badge.tier-c[data-v-c3e7f19c]{background:#fee2e2;color:#991b1b}.guide[data-v-c3e7f19c]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:16px}.modal-backdrop[data-v-c3e7f19c]{position:fixed;top:0;right:0;bottom:0;left:0;background:#0f172a80;display:flex;justify-content:center;align-items:flex-start;padding:40px 16px;z-index:50;overflow:auto}.modal[data-v-c3e7f19c]{background:#fff;border-radius:14px;padding:24px;width:100%;max-width:720px;box-shadow:0 20px 50px #00000040}
|
||||||
11
frontend/dist/assets/Guide-CdnDfIjD.js
vendored
Normal file
11
frontend/dist/assets/Guide-CdnDfIjD.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const m=e("sparkles",[["path",{d:"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",key:"1s2grr"}],["path",{d:"M20 2v4",key:"1rf3ol"}],["path",{d:"M22 4h-4",key:"gwowj6"}],["circle",{cx:"4",cy:"20",r:"2",key:"6kqj1y"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const h=e("wrench",[["path",{d:"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z",key:"1ngwbx"}]]),k={class:"card"},c={style:{"margin-top":"0"}},v={class:"card"},x={style:{"margin-top":"0"}},f={class:"card"},w={style:{"margin-top":"0"}},z={class:"card"},_={style:{"margin-top":"0"}},b={__name:"Guide",setup(B){return(V,t)=>(u(),o("div",null,[l("h2",null,[n(i(g),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),s(" "+r(i(a).t("guideTitle")),1)]),t[8]||(t[8]=l("p",{class:"muted"},"คู่มือสั้นๆ สำหรับพนักงานใหม่ — เข้าใจระบบได้ใน 5 นาที",-1)),l("div",k,[l("h3",c,[n(i(p),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),t[0]||(t[0]=s(" 1. หน้า 3 แท็บ (menu ด้านบน)",-1))]),t[1]||(t[1]=l("ul",{style:{"line-height":"1.9"}},[l("li",null,[l("strong",null,"ภาพรวมผู้ดูแล"),s(" — เฉพาะ admin ดูสถิติผลการฝึกของทีม + กรองตามช่วงเวลา")]),l("li",null,[l("strong",null,"ภาพรวมผลการฝึก"),s(" — ของตัวคุณเอง: ชนะ/แพ้/ยังไม่ได้ฝึก")]),l("li",null,[l("strong",null,"การฝึก"),s(" — เลือกสินค้า → เลือกลูกค้า (บุคคลต้นแบบ) → เริ่มแชท")]),l("li",null,[l("strong",null,"การตั้งค่า (⚙︎)"),s(" — แก้ชื่อ/อีเมล, เปลี่ยนรหัสผ่าน, เปลี่ยนภาษา")])],-1))]),l("div",v,[l("h3",x,[n(i(y),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),t[2]||(t[2]=s(" 2. วิธีฝึก (start a session)",-1))]),t[3]||(t[3]=l("ol",{style:{"line-height":"1.9","padding-left":"20px"}},[l("li",null,[s("เข้าแท็บ "),l("strong",null,"การฝึก"),s(" → เลือกสินค้าที่อยากฝึก")]),l("li",null,"เลือกลูกค้า (บุคคลต้นแบบ) — ระดับ A ง่ายสุด / C ยากสุด (ดูดาวความยาก)"),l("li",null,[s("เลือก "),l("strong",null,"สถานการณ์"),s(": โซเชียล (ลูกค้าทักก่อน) / พบหน้า-โทร (คุณทักก่อน) / กลับมาติดต่อ")]),l("li",null,"แชทกับลูกค้า — ฝึกปิดการขาย ลูกค้าจะตัดสินใจเองว่าซื้อหรือไม่ซื้อ"),l("li",null,"หลังจบ ดูผล + ข้อเสนอแนะ + ข้อมูลลูกค้าที่ถูกซ่อนไว้")],-1))]),l("div",f,[l("h3",w,[n(i(m),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),t[4]||(t[4]=s(" 3. เคล็ดลับ",-1))]),t[5]||(t[5]=l("ul",{style:{"line-height":"1.9"}},[l("li",null,[s('ลูกค้าแต่ละคน หรือ "บุคคลต้นแบบ" ฝึกได้ '),l("strong",null,"คนละครั้ง"),s(" (one-shot) — ฝึกจนจบแล้วลองคนอื่น")]),l("li",null,"ถ้าออกกลางคัน กลับมาได้ คุยต่อจากเดิม (ยังไม่นับว่าจบ)"),l("li",null,"ปิดการขาย = แก้ปัญหาจริงของลูกค้า + รับมือข้อโต้แย้ง + ปิดราคา")],-1))]),l("div",z,[l("h3",_,[n(i(h),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),t[6]||(t[6]=s(" 4. สำหรับ Admin",-1))]),t[7]||(t[7]=d('<ul style="line-height:1.9;"><li>ปุ่ม <strong>เพิ่มสินค้า</strong> (หน้า การฝึก) → สร้างกลุ่มบุคคลต้นแบบใหม่</li><li>หน้า <strong>จัดการบุคคลต้นแบบ</strong> → แก้ไขรายละเอียดลูกค้าแต่ละคน แล้วเปิดใช้งาน</li><li>หน้า <strong>ผู้ใช้งาน</strong> → สร้างบัญชีให้ทีม (username + รหัสผ่านเริ่มต้น)</li><li>ปุ่ม <strong>CSV</strong> (หน้า ภาพรวม) → ดาวน์โหลดผลการฝึกทั้งหมด</li></ul>',1))])]))}};export{b as default};
|
||||||
1
frontend/dist/assets/Login-CY2HomOW.js
vendored
1
frontend/dist/assets/Login-CY2HomOW.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as b,c as d,a as e,t as s,u as c,i as o,w as y,v as x,b as w,d as k,e as S,r as l,f,g as V,h as B,o as v}from"./index-BA-KDOrj.js";const C={class:"login-wrap"},D={class:"card login-card"},K={class:"pw-wrap"},E=["type"],L=["aria-label"],M={key:0,class:"error",role:"alert"},N=["disabled"],R={key:0,class:"spinner"},U={key:1},q={__name:"Login",setup(H){const g=B(),_=V(),n=l(""),u=l(""),a=l(!1),r=l(""),i=l(!1);async function m(){r.value="",i.value=!0;try{await f.login(n.value.trim(),u.value),f.mustSetup?_.push({path:"/setup"}):_.push(g.query.redirect||"/")}catch{r.value=o.t("loginError")}finally{i.value=!1}}return(h,t)=>(v(),d("div",C,[e("div",D,[e("h1",null,"🎯 "+s(c(o).t("app")),1),t[3]||(t[3]=e("p",{class:"muted",style:{"margin-top":"-8px"}},"Sales training simulator",-1)),e("label",null,s(c(o).t("username")),1),y(e("input",{"onUpdate:modelValue":t[0]||(t[0]=p=>n.value=p),type:"text",autocomplete:"username",onKeyup:w(m,["enter"])},null,544),[[x,n.value]]),e("label",null,s(c(o).t("password")),1),e("div",K,[y(e("input",{"onUpdate:modelValue":t[1]||(t[1]=p=>u.value=p),type:a.value?"text":"password",autocomplete:"current-password",onKeyup:w(m,["enter"])},null,40,E),[[k,u.value]]),e("button",{type:"button",class:"pw-toggle",onClick:t[2]||(t[2]=p=>a.value=!a.value),"aria-label":a.value?"Hide password":"Show password"},s(a.value?"🙈":"👁"),9,L)]),r.value?(v(),d("div",M,s(r.value),1)):S("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:i.value||!n.value||!u.value,onClick:m},[i.value?(v(),d("span",R)):(v(),d("span",U,s(c(o).t("login")),1))],8,N)])]))}},P=b(q,[["__scopeId","data-v-f7904d9f"]]);export{P as default};
|
|
||||||
1
frontend/dist/assets/Login-D3svPLnW.css
vendored
1
frontend/dist/assets/Login-D3svPLnW.css
vendored
@@ -1 +0,0 @@
|
|||||||
.login-wrap[data-v-f7904d9f]{display:flex;justify-content:center;padding-top:10vh}.login-card[data-v-f7904d9f]{width:360px}h1[data-v-f7904d9f]{margin-top:0}.pw-wrap[data-v-f7904d9f]{position:relative}.pw-toggle[data-v-f7904d9f]{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;padding:6px;min-height:36px;cursor:pointer}
|
|
||||||
11
frontend/dist/assets/Login-ZlvtmZHE.js
vendored
Normal file
11
frontend/dist/assets/Login-ZlvtmZHE.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const z=k("eye-off",[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const C=k("eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]),D={class:"login-wrap"},K={class:"card login-card"},L={class:"pw-wrap"},j=["type"],I=["aria-label"],N={key:0,class:"error",role:"alert"},R=["disabled"],U={key:0,class:"spinner"},q={key:1},H={__name:"Login",setup(O){const g=V(),m=S(),u=r(""),i=r(""),o=r(!1),d=r(""),c=r(!1);async function v(){d.value="",c.value=!0;try{await f.login(u.value.trim(),i.value),f.mustSetup?m.push({path:"/setup"}):m.push(g.query.redirect||"/")}catch{d.value=n.t("loginError")}finally{c.value=!1}}return(b,t)=>(s(),y("div",D,[e("div",K,[e("h1",null,l(a(n).t("app")),1),t[3]||(t[3]=e("p",{class:"muted",style:{"margin-top":"-8px"}},"Sales training simulator",-1)),e("label",null,l(a(n).t("username")),1),h(e("input",{"onUpdate:modelValue":t[0]||(t[0]=p=>u.value=p),type:"text",autocomplete:"username",placeholder:"username",class:"wide",onKeyup:w(v,["enter"])},null,544),[[M,u.value]]),e("label",null,l(a(n).t("password")),1),e("div",L,[h(e("input",{"onUpdate:modelValue":t[1]||(t[1]=p=>i.value=p),type:o.value?"text":"password",autocomplete:"current-password",placeholder:"••••••••",class:"wide",onKeyup:w(v,["enter"])},null,40,j),[[B,i.value]]),e("button",{type:"button",class:"pw-toggle",onClick:t[2]||(t[2]=p=>o.value=!o.value),"aria-label":o.value?"Hide password":"Show password"},[o.value?(s(),_(a(z),{key:0,size:20,"stroke-width":1.8})):(s(),_(a(C),{key:1,size:20,"stroke-width":1.8}))],8,I)]),d.value?(s(),y("div",N,l(d.value),1)):E("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"18px"},disabled:c.value||!u.value||!i.value,onClick:v},[c.value?(s(),y("span",U)):(s(),y("span",q,l(a(n).t("login")),1))],8,R)])]))}},T=x(H,[["__scopeId","data-v-491f49d6"]]);export{T as default};
|
||||||
1
frontend/dist/assets/Login-j4sHK_z1.css
vendored
Normal file
1
frontend/dist/assets/Login-j4sHK_z1.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.login-wrap[data-v-491f49d6]{display:flex;justify-content:center;padding-top:8vh;padding-left:16px;padding-right:16px}.login-card[data-v-491f49d6]{width:460px;max-width:100%;padding:28px 30px}h1[data-v-491f49d6]{margin-top:0;font-size:24px}.wide[data-v-491f49d6]{width:100%;min-height:46px;font-size:15px;padding:12px 14px}.pw-wrap[data-v-491f49d6]{position:relative}.pw-wrap input[data-v-491f49d6]{padding-right:48px}.pw-toggle[data-v-491f49d6]{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;padding:8px;min-height:40px;cursor:pointer;color:var(--muted)}.pw-toggle[data-v-491f49d6]:hover{color:var(--ink)}
|
||||||
1
frontend/dist/assets/MyBoard-B7ypQ1JT.css
vendored
Normal file
1
frontend/dist/assets/MyBoard-B7ypQ1JT.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.stat-row[data-v-6767b6b8]{gap:16px}.stat[data-v-6767b6b8]{text-align:center;min-width:100px}.stat span[data-v-6767b6b8]{display:block;font-size:12px;color:var(--muted)}.stat strong[data-v-6767b6b8]{font-size:26px}.stat.won strong[data-v-6767b6b8]{color:var(--green)}.stat.lost strong[data-v-6767b6b8]{color:var(--red)}
|
||||||
1
frontend/dist/assets/MyBoard-Dc1z0Qjo.js
vendored
Normal file
1
frontend/dist/assets/MyBoard-Dc1z0Qjo.js
vendored
Normal file
@@ -0,0 +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-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};
|
||||||
1
frontend/dist/assets/MySessions-h4va9mB8.js
vendored
1
frontend/dist/assets/MySessions-h4va9mB8.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{k as l,l as c,c as a,a as s,t,u as d,i as u,F as m,p,e as i,r as _,o as n,s as y}from"./index-BA-KDOrj.js";const g={class:"row",style:{"justify-content":"space-between"}},f={class:"muted"},v={key:0,class:"muted",style:{"margin-top":"4px"}},k={key:0,class:"card empty-state"},S={__name:"MySessions",setup(b){const o=_([]);return l(async()=>{o.value=(await c.mySessions()).sessions}),(h,r)=>(n(),a("div",null,[s("h2",null,t(d(u).t("myTraining")),1),(n(!0),a(m,null,p(o.value,e=>(n(),a("div",{class:"card",key:e.id,style:{"margin-bottom":"10px"}},[s("div",g,[s("strong",null,t(e.persona_name),1),s("span",{class:y(["badge",e.outcome||"not_tried"])},t(e.outcome||"—"),3)]),s("div",f,t(e.persona_id)+" · "+t(new Date(e.created_at).toLocaleString()),1),e.debrief?(n(),a("div",v," Score "+t(e.debrief.score)+" — "+t(e.debrief.why),1)):i("",!0)]))),128)),o.value.length===0?(n(),a("div",k,[...r[0]||(r[0]=[s("strong",null,"No training sessions yet",-1),s("span",null,"Pick a persona from a group and practice closing a sale.",-1)])])):i("",!0)]))}};export{S as default};
|
|
||||||
1
frontend/dist/assets/Personas-CNMd2u86.js
vendored
1
frontend/dist/assets/Personas-CNMd2u86.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as B,k as C,c as l,m as L,n as y,a as t,t as s,u as r,i as o,F as p,p as f,h as T,l as A,q as I,r as g,o as i,j as h,s as v,x as N}from"./index-BA-KDOrj.js";const P={class:"row",style:{"align-items":"center"}},V={style:{margin:"0"}},z={class:"grid"},F={class:"row"},$={class:"muted"},j={class:"muted"},q={class:"muted",style:{"margin-top":"6px"}},D={class:"primary",style:{width:"100%"}},E={key:1,class:"muted",style:{"margin-top":"auto","font-size":"12px"}},M={__name:"Personas",setup(R){const d=T().params.gid,u=g([]),k=g(!0);async function b(){try{u.value=(await A.listPersonas(d)).personas}finally{k.value=!1}}function x(a){return u.value.filter(n=>n.tier===a)}function w(a){return o.t(a==="A"?"tierA":a==="B"?"tierB":"tierC")}function _(a){return a==="won"?o.t("won"):a==="lost"?o.t("lost"):o.t("notTried")}return C(b),(a,n)=>{const m=I("router-link");return i(),l("div",null,[L(m,{to:"/",class:"btn-back"},{default:y(()=>[h("← "+s(r(o).t("dashboard")),1)]),_:1}),t("div",P,[t("h2",V,s(r(o).t("personas")),1),n[0]||(n[0]=t("span",{class:"muted",style:{"margin-left":"auto"}},"Levels: choose one to practice (one-shot)",-1))]),(i(),l(p,null,f(["A","B","C"],c=>t("div",{key:c,style:{margin:"20px 0"}},[t("h4",null,s(w(c)),1),t("div",z,[(i(!0),l(p,null,f(x(c),e=>(i(),l("div",{key:e.id,class:"card pcard lift"},[t("div",F,[t("strong",null,s(e.name),1),t("span",{class:v(["badge",e.my_outcome])},s(_(e.my_outcome)),3)]),t("div",$,[h(s(e.profession)+" · "+s(e.age_group)+" · "+s(e.location),1),n[1]||(n[1]=t("br",null,null,-1)),t("span",{class:v(["badge",e.channel])},s(e.channel),3),t("span",j," · "+s(e.initiation_mode==="seller"?r(o).t("sellerInitiated"):r(o).t("customerInitiated")),1)]),t("div",q,s(e.product_context),1),e.my_outcome==="not_tried"?(i(),N(m,{key:0,to:`/groups/${r(d)}/chat/${e.id}`,style:{"margin-top":"auto"}},{default:y(()=>[t("button",D,s(r(o).t("chat")),1)]),_:1},8,["to"])):(i(),l("div",E,"✓ Trained ("+s(_(e.my_outcome))+")",1))]))),128))])])),64))])}}},H=B(M,[["__scopeId","data-v-88c74e17"]]);export{H as default};
|
|
||||||
1
frontend/dist/assets/Personas-CTGLZyPW.js
vendored
Normal file
1
frontend/dist/assets/Personas-CTGLZyPW.js
vendored
Normal 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};
|
||||||
1
frontend/dist/assets/Personas-CmLjB15L.css
vendored
Normal file
1
frontend/dist/assets/Personas-CmLjB15L.css
vendored
Normal 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}
|
||||||
1
frontend/dist/assets/Personas-GPRGTf-A.css
vendored
1
frontend/dist/assets/Personas-GPRGTf-A.css
vendored
@@ -1 +0,0 @@
|
|||||||
.grid[data-v-88c74e17]{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}.pcard[data-v-88c74e17]{display:flex;flex-direction:column;min-height:170px}
|
|
||||||
1
frontend/dist/assets/Settings-BCN2EZQ5.css
vendored
Normal file
1
frontend/dist/assets/Settings-BCN2EZQ5.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.active[data-v-4a5a3af4]{border-color:var(--accent);color:var(--accent);font-weight:600}.ok[data-v-4a5a3af4]{color:var(--green);font-size:13px;margin-top:10px}.error[data-v-4a5a3af4]{margin-top:10px}
|
||||||
11
frontend/dist/assets/Settings-DTl0HJtQ.js
vendored
Normal file
11
frontend/dist/assets/Settings-DTl0HJtQ.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const E=U("globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const I=U("user",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]),j={class:"card"},G={style:{"margin-top":"0"}},H={class:"muted"},O=["value"],q={class:"row",style:{gap:"10px","margin-top":"16px"}},A=["disabled"],F={key:0,class:"spinner"},J={class:"card",style:{"margin-top":"16px"}},K={style:{"margin-top":"0"}},Q={class:"row",style:{gap:"10px","margin-top":"16px"}},R=["disabled"],W={key:0,class:"spinner"},X={class:"card",style:{"margin-top":"16px"}},Y={style:{"margin-top":"0"}},Z={class:"row"},ee={key:0,class:"error",role:"alert"},ae={key:1,class:"ok",role:"status"},se={__name:"Settings",setup(te){var P,z;const y=c(((P=u.user)==null?void 0:P.name)||""),p=c(((z=u.user)==null?void 0:z.email)||""),i=c(""),_=c(""),r=c(""),m=c(""),f=c(!1),w=c(!1);async function B(){var d,t;r.value="",m.value="",f.value=!0;try{const n={};if(y.value&&y.value!==((d=u.user)==null?void 0:d.name)&&(n.name=y.value),p.value&&p.value!==((t=u.user)==null?void 0:t.email)&&(n.email=p.value),Object.keys(n).length){const l=await M.updateProfile(n);u.user=l.user,m.value=s.t("saved")}}catch(n){r.value=n.message}finally{f.value=!1}}async function L(){var d,t,n;if(r.value="",m.value="",i.value.length<4){r.value=s.t("passwordTooShort");return}if(i.value!==_.value){r.value=s.t("passwordMismatch");return}w.value=!0;try{const l=((d=u.user)==null?void 0:d.username)||((t=u.user)==null?void 0:t.id)||"",N=await M.setup({username:l,email:p.value||((n=u.user)==null?void 0:n.email),password:i.value});u.user=N.user,i.value="",_.value="",m.value=s.t("saved")}catch(l){r.value=l.message}finally{w.value=!1}}function C(d){s.set(d)}return(d,t)=>{var n;return g(),h("div",null,[e("h2",null,[k(a($),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),v(" "+o(a(s).t("settings")),1)]),e("div",j,[e("h3",G,[k(a(I),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),v(" "+o(a(s).t("profile")),1)]),e("label",null,[v(o(a(s).t("username"))+" ",1),e("span",H,"("+o(a(s).t("readonly"))+")",1)]),e("input",{value:((n=a(u).user)==null?void 0:n.username)||"",disabled:""},null,8,O),e("label",null,o(a(s).t("name")),1),b(e("input",{"onUpdate:modelValue":t[0]||(t[0]=l=>y.value=l),type:"text"},null,512),[[x,y.value]]),e("label",null,o(a(s).t("email")),1),b(e("input",{"onUpdate:modelValue":t[1]||(t[1]=l=>p.value=l),type:"email"},null,512),[[x,p.value]]),e("div",q,[e("button",{class:"primary",disabled:f.value,onClick:B},[f.value?(g(),h("span",F)):V("",!0),v(o(a(s).t("saveProfile")),1)],8,A)])]),e("div",J,[e("h3",K,[k(a(D),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),v(" "+o(a(s).t("changePassword")),1)]),e("label",null,o(a(s).t("newPassword")),1),b(e("input",{"onUpdate:modelValue":t[2]||(t[2]=l=>i.value=l),type:"password",autocomplete:"new-password"},null,512),[[x,i.value]]),e("label",null,o(a(s).t("confirmPassword")),1),b(e("input",{"onUpdate:modelValue":t[3]||(t[3]=l=>_.value=l),type:"password",autocomplete:"new-password"},null,512),[[x,_.value]]),e("div",Q,[e("button",{class:"primary",disabled:w.value||!i.value||i.value!==_.value,onClick:L},[w.value?(g(),h("span",W)):V("",!0),v(o(a(s).t("save")),1)],8,R)])]),e("div",X,[e("h3",Y,[k(a(E),{size:18,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),v(" "+o(a(s).t("language")),1)]),e("div",Z,[e("button",{class:S({active:a(s).locale==="th"}),onClick:t[4]||(t[4]=l=>C("th"))},"ไทย",2),e("button",{class:S({active:a(s).locale==="en"}),onClick:t[5]||(t[5]=l=>C("en"))},"English",2)])]),r.value?(g(),h("div",ee,o(r.value),1)):V("",!0),m.value?(g(),h("div",ae,"✅ "+o(m.value),1)):V("",!0)])}}},ne=T(se,[["__scopeId","data-v-4a5a3af4"]]);export{ne as default};
|
||||||
1
frontend/dist/assets/Setup-BHyRmSn1.css
vendored
Normal file
1
frontend/dist/assets/Setup-BHyRmSn1.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.setup-wrap[data-v-a3748c0a]{display:flex;justify-content:center;padding-top:8vh;padding-left:16px;padding-right:16px}.setup-card[data-v-a3748c0a]{width:460px;max-width:100%;padding:28px 30px}h1[data-v-a3748c0a]{margin-top:0;font-size:24px}.wide[data-v-a3748c0a]{width:100%;min-height:46px;font-size:15px;padding:12px 14px}.terms[data-v-a3748c0a]{display:flex;align-items:center;gap:8px;margin-top:14px;font-size:13px}.terms input[data-v-a3748c0a]{width:auto;min-height:auto;margin:0}.terms a[data-v-a3748c0a]{color:var(--accent)}
|
||||||
1
frontend/dist/assets/Setup-BZ-GDYWY.js
vendored
1
frontend/dist/assets/Setup-BZ-GDYWY.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as x,c as v,a as e,t,u as s,i as a,j as S,f as _,w,v as f,b as y,e as V,r,g as K,o as m}from"./index-BA-KDOrj.js";const T={class:"setup-wrap"},B={class:"card setup-card"},N={class:"muted"},U={key:0,class:"error",role:"alert"},C=["disabled"],D={key:0,class:"spinner"},M={key:1},P={__name:"Setup",setup(j){const k=K(),i=r(""),l=r(""),p=r(""),o=r(""),d=r(!1);async function c(){if(o.value="",l.value.length<4){o.value=a.t("passwordTooShort");return}if(l.value!==p.value){o.value=a.t("passwordMismatch");return}d.value=!0;try{await _.finishSetup(i.value.trim(),l.value),k.push("/")}catch(h){o.value=h.message}finally{d.value=!1}}return(h,u)=>{var b,g;return m(),v("div",T,[e("div",B,[e("h1",null,"🔐 "+t(s(a).t("setupTitle")),1),e("p",N,[S(t(s(a).t("setupSubtitle"))+" ",1),e("strong",null,t(((b=s(_).user)==null?void 0:b.name)||((g=s(_).user)==null?void 0:g.username)),1)]),e("label",null,t(s(a).t("email")),1),w(e("input",{"onUpdate:modelValue":u[0]||(u[0]=n=>i.value=n),type:"email",autocomplete:"email",onKeyup:y(c,["enter"])},null,544),[[f,i.value]]),e("label",null,t(s(a).t("newPassword")),1),w(e("input",{"onUpdate:modelValue":u[1]||(u[1]=n=>l.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,l.value]]),e("label",null,t(s(a).t("confirmPassword")),1),w(e("input",{"onUpdate:modelValue":u[2]||(u[2]=n=>p.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,p.value]]),o.value?(m(),v("div",U,t(o.value),1)):V("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:d.value||!i.value||!l.value||l.value!==p.value,onClick:c},[d.value?(m(),v("span",D)):(m(),v("span",M,t(s(a).t("save")),1))],8,C)])])}}},I=x(P,[["__scopeId","data-v-f190e120"]]);export{I as default};
|
|
||||||
1
frontend/dist/assets/Setup-BdS_6JlN.css
vendored
1
frontend/dist/assets/Setup-BdS_6JlN.css
vendored
@@ -1 +0,0 @@
|
|||||||
.setup-wrap[data-v-f190e120]{display:flex;justify-content:center;padding-top:8vh}.setup-card[data-v-f190e120]{width:380px}h1[data-v-f190e120]{margin-top:0}
|
|
||||||
1
frontend/dist/assets/Setup-CFifwHLU.js
vendored
Normal file
1
frontend/dist/assets/Setup-CFifwHLU.js
vendored
Normal file
@@ -0,0 +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-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};
|
||||||
6
frontend/dist/assets/Training-B5ukgpN-.js
vendored
Normal file
6
frontend/dist/assets/Training-B5ukgpN-.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const j=x("trash-2",[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]]),G={class:"row",style:{"align-items":"center","margin-bottom":"16px"}},L={style:{margin:"0"}},D={class:"row",style:{"margin-left":"auto",gap:"10px"}},F={class:"soft"},I={class:"primary"},S={class:"muted"},q={key:0,class:"card",style:{"min-height":"80px"}},E={key:1,class:"card empty-state"},H={key:0},O={key:1},J={class:"grid"},K={class:"row",style:{"justify-content":"space-between"}},Q={class:"row",style:{gap:"6px","align-items":"center"}},R=["onClick"],U={class:"muted",style:{margin:"6px 0 12px"}},W={class:"row",style:{gap:"8px"}},X={class:"muted"},Y={class:"primary",style:{width:"100%","margin-top":"12px"}},Z={__name:"Training",setup(tt){const u=v([]),y=v(!0);function k(e){return e.sales_kit&&e.sales_kit.productName||e.input&&e.input.product||""}function g(e){return e.personas||e.persona_count||0}async function w(e){if(confirm(`ลบกลุ่ม "${e.title}" และบุคคลต้นแบบทั้งหมด? (ติดลบถาวร)`))try{await f.deleteGroup(e.id),u.value=u.value.filter(i=>i.id!==e.id)}catch(i){alert(i.message)}}return C(async()=>{try{const e=(await f.listGroups()).groups||[];u.value=l.isAdmin?e:e.filter(i=>i.status==="ready")}finally{y.value=!1}}),(e,i)=>{const p=T("router-link");return r(),d("div",null,[s("div",G,[s("h2",L,[c(t(P),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),m(" "+o(t(n).t("training")),1)]),s("div",D,[c(p,{to:"/guide"},{default:_(()=>[s("button",F,[c(t(V),{size:18,"stroke-width":1.8}),i[0]||(i[0]=m(" คู่มือ",-1))])]),_:1}),t(l).isAdmin?(r(),A(p,{key:0,to:"/admin/new-group"},{default:_(()=>[s("button",I,[c(t($),{size:18,"stroke-width":2}),m(" "+o(t(n).t("addProduct")),1)])]),_:1})):h("",!0)])]),s("p",S,o(t(n).t("trainingSubtitle")),1),y.value?(r(),d("div",q,[...i[1]||(i[1]=[s("div",{class:"skeleton",style:{height:"50px"}},null,-1)])])):u.value.length===0?(r(),d("div",E,[s("strong",null,o(t(n).t("noTraining")),1),t(l).isAdmin?(r(),d("span",H,'Click "'+o(t(n).t("addProduct"))+'" to create a persona group.',1)):(r(),d("span",O,"Ask an admin to create a persona group first."))])):h("",!0),s("div",J,[(r(!0),d(z,null,M(u.value,a=>(r(),d("div",{key:a.id,class:"card lift train-card"},[s("div",K,[c(p,{to:t(l).isAdmin?`/admin/groups/${a.id}/edit`:`/groups/${a.id}/personas`,style:{"text-decoration":"none",color:"inherit","min-width":"0"}},{default:_(()=>[s("strong",null,o(a.title),1)]),_:2},1032,["to"]),s("div",Q,[s("span",{class:B(["badge",a.status==="ready"?"ready":"draft"])},o(a.status),3),t(l).isAdmin?(r(),d("button",{key:0,class:"del-btn",title:"ลบกลุ่มนี้ (ติดลบถาวร)",onClick:N(st=>w(a),["prevent","stop"])},[c(t(j),{size:16,"stroke-width":1.8})],8,R)):h("",!0)])]),c(p,{to:t(l).isAdmin?`/admin/groups/${a.id}/edit`:`/groups/${a.id}/personas`,style:{"text-decoration":"none",color:"inherit"}},{default:_(()=>[s("div",U,o(k(a)),1),s("div",W,[s("span",X,o(g(a))+" "+o(t(n).t("personas").toLowerCase()),1)]),s("button",Y,o(t(l).isAdmin?a.status==="ready"?t(n).t("managePersonas"):t(n).t("analyze"):t(n).t("selectPersona")),1)]),_:2},1032,["to"])]))),128))])])}}},nt=b(Z,[["__scopeId","data-v-10820ca0"]]);export{nt as default};
|
||||||
1
frontend/dist/assets/Training-Cb1U9s84.css
vendored
Normal file
1
frontend/dist/assets/Training-Cb1U9s84.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.grid[data-v-10820ca0]{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}.train-card[data-v-10820ca0]{display:flex;flex-direction:column;height:100%}.badge.ready[data-v-10820ca0]{background:#dcfce7;color:#166534}.badge.draft[data-v-10820ca0]{background:#fef9c3;color:#854d0e}button.soft[data-v-10820ca0]{background:#f1f3f9;border-color:transparent}.badge.facebook[data-v-10820ca0]{background:#e0f2fe;color:#0369a1}.badge.line[data-v-10820ca0]{background:#dcfce7;color:#15803d}.del-btn[data-v-10820ca0]{background:transparent;border:1px solid var(--border);border-radius:8px;min-height:32px;width:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--muted);cursor:pointer}.del-btn[data-v-10820ca0]:hover{background:#fee2e2;border-color:#fecaca;color:var(--red)}
|
||||||
1
frontend/dist/assets/WeakAreas-CZWFyB_H.css
vendored
1
frontend/dist/assets/WeakAreas-CZWFyB_H.css
vendored
@@ -1 +0,0 @@
|
|||||||
.stat[data-v-dd8a8071]{text-align:center;min-width:100px}.stat div[data-v-dd8a8071]{color:var(--muted);font-size:12px}.stat strong[data-v-dd8a8071]{font-size:22px}
|
|
||||||
1
frontend/dist/assets/WeakAreas-mVHNUVcG.js
vendored
1
frontend/dist/assets/WeakAreas-mVHNUVcG.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{_ as p,k as m,l as c,c as l,a as s,t,u as v,i as y,m as g,n as k,F as r,p as i,e as w,r as x,q as f,o as n,s as b,j as d}from"./index-BA-KDOrj.js";const N={class:"row",style:{"align-items":"center"}},h={style:{margin:"0"}},A={class:"row",style:{gap:"16px",margin:"16px 0"}},C={class:"card stat"},T={class:"card stat"},V={class:"card stat"},B={key:0,class:"card muted"},W={class:"muted"},F={__name:"WeakAreas",setup(L){const a=x({wins:0,losses:0,total_sessions:0,by_tier:[],top_loss_personas:[]});return m(async()=>{a.value=(await c.weakAreas()).insight}),(j,e)=>{const u=f("router-link");return n(),l("div",null,[s("div",N,[s("h2",h,t(v(y).t("weakAreas")),1),g(u,{to:"/my/generate?mode=weak",style:{"margin-left":"auto"}},{default:k(()=>[...e[0]||(e[0]=[s("button",{class:"primary"},"🔒 Generate a lock persona",-1)])]),_:1})]),s("div",A,[s("div",C,[e[1]||(e[1]=s("div",null,"Wins",-1)),s("strong",null,t(a.value.wins),1)]),s("div",T,[e[2]||(e[2]=s("div",null,"Losses",-1)),s("strong",null,t(a.value.losses),1)]),s("div",V,[e[3]||(e[3]=s("div",null,"Total",-1)),s("strong",null,t(a.value.total_sessions),1)])]),(n(!0),l(r,null,i(a.value.by_tier,o=>(n(),l("div",{key:o.value,class:"card",style:{"margin-bottom":"8px"}},[s("span",{class:b(["badge",o.value])},"Tier "+t(o.value),3),d(" — "+t(o.losses)+" losses ",1)]))),128)),e[5]||(e[5]=s("h3",{style:{"margin-top":"20px"}},"Top loss personas",-1)),!a.value.top_loss_personas||!a.value.top_loss_personas.length?(n(),l("div",B,"No losses yet — 🎉")):w("",!0),(n(!0),l(r,null,i(a.value.top_loss_personas,(o,_)=>(n(),l("div",{class:"card",key:_,style:{"margin-bottom":"8px"}},[s("strong",null,t(o.persona_name),1),d(" — score "+t(o.score),1),e[4]||(e[4]=s("br",null,null,-1)),s("span",W,t(o.why),1)]))),128))])}}},z=p(F,[["__scopeId","data-v-dd8a8071"]]);export{z as default};
|
|
||||||
6
frontend/dist/assets/arrow-left-C-Wn16Dy.js
vendored
Normal file
6
frontend/dist/assets/arrow-left-C-Wn16Dy.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const t=e("arrow-left",[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]]);export{t as A};
|
||||||
6
frontend/dist/assets/book-open-BDMN_yKI.js
vendored
Normal file
6
frontend/dist/assets/book-open-BDMN_yKI.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const e=a("book-open",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);export{e as B};
|
||||||
1
frontend/dist/assets/index-B9WjPRES.css
vendored
Normal file
1
frontend/dist/assets/index-B9WjPRES.css
vendored
Normal file
File diff suppressed because one or more lines are too long
27
frontend/dist/assets/index-BA-KDOrj.js
vendored
27
frontend/dist/assets/index-BA-KDOrj.js
vendored
File diff suppressed because one or more lines are too long
82
frontend/dist/assets/index-C--0e2U-.js
vendored
Normal file
82
frontend/dist/assets/index-C--0e2U-.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-CYRckR7U.css
vendored
1
frontend/dist/assets/index-CYRckR7U.css
vendored
@@ -1 +0,0 @@
|
|||||||
.topnav[data-v-6307d773]{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;background:#fff;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10}.brand[data-v-6307d773]{font-weight:800;text-decoration:none;color:var(--ink)}.nav-right[data-v-6307d773]{display:flex;align-items:center;gap:12px}.lang[data-v-6307d773]{padding:6px 10px}.main[data-v-6307d773]{max-width:1080px;margin:0 auto;padding:24px}:root{--bg: #f6f7fb;--card: #ffffff;--border: #e5e8ef;--ink: #1a1d29;--muted: #6b7280;--accent: #4f46e5;--accent-2: #7c3aed;--green: #16a34a;--red: #dc2626;--amber: #d97706;--radius: 14px;--shadow: 0 1px 3px rgba(20, 24, 40, .08)}*{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans Thai,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5}#app{min-height:100vh}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow)}button{font-family:inherit;cursor:pointer;border:1px solid var(--border);background:var(--card);padding:10px 16px;border-radius:10px;font-size:14px;color:var(--ink)}button.primary{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;border:none}button:disabled{opacity:.5;cursor:not-allowed}input,select,textarea{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:10px;font-family:inherit;font-size:14px}label{font-size:13px;color:var(--muted);display:block;margin:10px 0 4px}.row{display:flex;gap:12px;flex-wrap:wrap}.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600}.badge.A{background:#dcfce7;color:#166534}.badge.B{background:#fef9c3;color:#854d0e}.badge.C{background:#fee2e2;color:#991b1b}.badge.won{background:#dcfce7;color:#166534}.badge.lost{background:#fee2e2;color:#991b1b}.badge.not_tried{background:#eef2ff;color:#4338ca}.error{color:var(--red);font-size:13px}.muted{color:var(--muted)}.msg-seller{background:var(--accent);color:#fff;align-self:flex-end;border-radius:16px 16px 4px}.msg-customer{background:#fff;align-self:flex-start;border-radius:16px 16px 16px 4px;border:1px solid var(--border)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}a{color:inherit}a:focus{outline:2px solid var(--accent);outline-offset:2px}button{min-height:44px;transition:transform .15s ease,box-shadow .2s ease,background .2s ease,opacity .2s ease}button:not(:disabled):hover{box-shadow:0 4px 12px #1418281a}button:not(:disabled):active{transform:scale(.97)}button.primary:not(:disabled):hover{box-shadow:0 6px 18px #4f46e559}input,select,textarea{min-height:44px;transition:border-color .15s ease,box-shadow .15s ease}input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #4f46e526}textarea{min-height:88px;resize:vertical}.card.lift{transition:transform .2s ease,box-shadow .25s ease}.card.lift:hover{transform:translateY(-2px);box-shadow:0 10px 24px #1418281a}button:disabled{opacity:.5;cursor:not-allowed;box-shadow:none}.btn-back{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;margin-bottom:12px;background:transparent;border:1px solid var(--border);border-radius:10px;color:var(--muted);font-size:13px;text-decoration:none}.btn-back:hover{color:var(--ink);border-color:var(--accent)}.spinner{width:16px;height:16px;border-radius:50%;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .7s linear infinite;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:8px;background:linear-gradient(90deg,#eef0f5 25%,#e2e5ec 37%,#eef0f5 63%);background-size:400% 100%;animation:shimmer 1.4s ease infinite}@keyframes shimmer{0%{background-position:100% 0}to{background-position:-100% 0}}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important}}.empty-state{text-align:center;padding:40px 20px;color:var(--muted)}.empty-state strong{display:block;margin-bottom:4px;color:var(--ink)}.field-error{color:var(--red);font-size:12px;margin-top:4px}@media (max-width: 640px){.main{padding:16px}.row{gap:10px}.msg-seller,.msg-customer{max-width:84%}}
|
|
||||||
6
frontend/dist/assets/layout-dashboard-qMD4csbw.js
vendored
Normal file
6
frontend/dist/assets/layout-dashboard-qMD4csbw.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const h=t("layout-dashboard",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);export{h as L};
|
||||||
6
frontend/dist/assets/lock-CdhbyMuc.js
vendored
Normal file
6
frontend/dist/assets/lock-CdhbyMuc.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const t=e("lock",[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]]);export{t as L};
|
||||||
6
frontend/dist/assets/plus-DQOnWKR_.js
vendored
Normal file
6
frontend/dist/assets/plus-DQOnWKR_.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const a=e("plus",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);export{a as P};
|
||||||
6
frontend/dist/assets/target-dGrJgR_B.js
vendored
Normal file
6
frontend/dist/assets/target-dGrJgR_B.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import{c}from"./index-C--0e2U-.js";/**
|
||||||
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const e=c("target",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["circle",{cx:"12",cy:"12",r:"6",key:"1vlfrh"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}]]);export{e as T};
|
||||||
6
frontend/dist/assets/users-d_q-MTMu.js
vendored
Normal file
6
frontend/dist/assets/users-d_q-MTMu.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const c=e("users",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["path",{d:"M16 3.128a4 4 0 0 1 0 7.744",key:"16gr8j"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}]]);export{c as U};
|
||||||
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sales Trainer</title>
|
<title>Sales Trainer</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BA-KDOrj.js"></script>
|
<script type="module" crossorigin src="/assets/index-C--0e2U-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CYRckR7U.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B9WjPRES.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "sales-trainer-frontend",
|
"name": "sales-trainer-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
@@ -1071,6 +1072,16 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-vue-next": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
|
||||||
|
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,15 +2,27 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<nav v-if="auth.user" class="topnav">
|
<nav v-if="auth.user" class="topnav">
|
||||||
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
|
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
|
||||||
|
<div class="tabs">
|
||||||
|
<router-link v-if="auth.isAdmin" to="/" class="tab" :class="{ active: $route.path === '/' }">
|
||||||
|
{{ i18n.t('adminOverview') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/my/board" class="tab" :class="{ active: $route.path === '/my/board' }">
|
||||||
|
{{ i18n.t('myDashboard') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/training" class="tab" :class="{ active: $route.path === '/training' }">
|
||||||
|
{{ i18n.t('training') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
|
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
|
||||||
<span class="muted">{{ auth.user.name }} ({{ auth.role }})</span>
|
<router-link to="/settings" class="icon-btn" :title="i18n.t('settings')">
|
||||||
<button @click="logout">⏻ {{ i18n.t('logout') }}</button>
|
<SettingsIcon :size="20" :stroke-width="1.8" />
|
||||||
|
</router-link>
|
||||||
|
<button @click="logout" class="logout-btn"><LogOut :size="18" :stroke-width="1.8" /> <span class="txt">{{ i18n.t('logout') }}</span></button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<!-- :key forces each view to re-evaluate i18n.t() when the locale changes,
|
<!-- :key forces each view to re-evaluate i18n.t() when locale changes -->
|
||||||
so switching language updates the whole page reliably. -->
|
|
||||||
<router-view :key="i18n.locale" />
|
<router-view :key="i18n.locale" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,6 +31,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Settings as SettingsIcon, LogOut } from 'lucide-vue-next'
|
||||||
import { auth } from './store/auth'
|
import { auth } from './store/auth'
|
||||||
import { i18n } from './i18n'
|
import { i18n } from './i18n'
|
||||||
|
|
||||||
@@ -41,15 +54,45 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 24px;
|
gap: 16px;
|
||||||
|
padding: 10px 24px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.brand { font-weight: 800; text-decoration: none; color: var(--ink); }
|
.brand { font-weight: 800; text-decoration: none; color: var(--ink); white-space: nowrap; }
|
||||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
.lang { padding: 6px 10px; }
|
.tab {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab:hover { background: #f1f3f9; color: var(--ink); }
|
||||||
|
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; }
|
||||||
|
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.lang, .logout-btn { padding: 8px 12px; }
|
||||||
|
.logout-btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.icon-btn { text-decoration: none; display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 8px; color: var(--ink); }
|
||||||
|
.icon-btn:hover { background: #f1f3f9; }
|
||||||
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topnav { padding: 8px 10px; gap: 8px; }
|
||||||
|
.brand { font-size: 14px; }
|
||||||
|
.tabs { order: 3; width: 100%; justify-content: center; }
|
||||||
|
.tab { padding: 6px 10px; font-size: 12px; flex: 1; text-align: center; }
|
||||||
|
.nav-right { margin-left: auto; }
|
||||||
|
.lang { padding: 6px 8px; font-size: 12px; }
|
||||||
|
.logout-btn { padding: 6px 8px; }
|
||||||
|
.logout-btn .txt { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.brand { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,17 +36,21 @@ export const api = {
|
|||||||
login: (username, password) => request('POST', '/api/auth/login', { username, password }),
|
login: (username, password) => request('POST', '/api/auth/login', { username, password }),
|
||||||
me: () => request('GET', '/api/auth/me'),
|
me: () => request('GET', '/api/auth/me'),
|
||||||
setup: (b) => request('POST', '/api/auth/setup', b),
|
setup: (b) => request('POST', '/api/auth/setup', b),
|
||||||
|
updateProfile: (b) => request('PATCH', '/api/auth/profile', b),
|
||||||
adminCreateUser: (b) => request('POST', '/api/admin/users', b),
|
adminCreateUser: (b) => request('POST', '/api/admin/users', b),
|
||||||
adminListUsers: () => request('GET', '/api/admin/users'),
|
adminListUsers: () => request('GET', '/api/admin/users'),
|
||||||
adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b),
|
adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b),
|
||||||
createGroup: (formData) => request('POST', '/api/groups', formData, true),
|
createGroup: (formData) => request('POST', '/api/groups', formData, true),
|
||||||
listGroups: () => request('GET', '/api/groups'),
|
listGroups: () => request('GET', '/api/groups'),
|
||||||
getGroup: (id) => request('GET', `/api/groups/${id}`),
|
getGroup: (id) => request('GET', `/api/groups/${id}`),
|
||||||
analyzeGroup: (id) => request('POST', `/api/groups/${id}/analyze`),
|
deleteGroup: (id) => request('DELETE', `/api/groups/${id}`),
|
||||||
|
analyzeGroup: (id, opts = {}) => request('POST', `/api/groups/${id}/analyze${opts.append ? '?append=true' : ''}`),
|
||||||
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
|
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
|
||||||
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
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),
|
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
|
||||||
chatStart: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`),
|
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`),
|
||||||
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
|
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
|
||||||
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
||||||
mySessions: () => request('GET', '/api/chat/sessions'),
|
mySessions: () => request('GET', '/api/chat/sessions'),
|
||||||
@@ -54,5 +58,11 @@ export const api = {
|
|||||||
weakAreas: () => request('GET', '/api/me/weak-areas'),
|
weakAreas: () => request('GET', '/api/me/weak-areas'),
|
||||||
myPersonas: () => request('GET', '/api/me/personas'),
|
myPersonas: () => request('GET', '/api/me/personas'),
|
||||||
generatePersona: (b) => request('POST', '/api/me/personas/generate', b),
|
generatePersona: (b) => request('POST', '/api/me/personas/generate', b),
|
||||||
analytics: () => request('GET', '/api/analytics'),
|
analytics: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.from) qs.set('from', params.from)
|
||||||
|
if (params.to) qs.set('to', params.to)
|
||||||
|
const q = qs.toString()
|
||||||
|
return request('GET', `/api/analytics${q ? `?${q}` : ''}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
135
frontend/src/components/PersonaForm.vue
Normal file
135
frontend/src/components/PersonaForm.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="persona-form">
|
||||||
|
<h3>{{ persona.name || 'New persona' }}</h3>
|
||||||
|
<p class="muted">{{ persona.profession }} · {{ persona.personality }}</p>
|
||||||
|
|
||||||
|
<!-- Section: ข้อมูลพื้นฐาน -->
|
||||||
|
<div class="sec">
|
||||||
|
<h4>👤 ข้อมูลพื้นฐาน</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="f"><label>ชื่อ</label><input v-model="d.name" /></div>
|
||||||
|
<div class="f"><label>ระดับความยาก (1-5)</label><input v-model.number="d.difficulty" type="number" min="1" max="5" /></div>
|
||||||
|
<div class="f"><label>ระดับลูกค้า</label>
|
||||||
|
<select v-model="d.tier"><option value="A">A — พร้อมตัดสินใจซื้อ</option><option value="B">B — ยังไม่แน่ใจ</option><option value="C">C — ไม่สนใจแต่มีปัญหา</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="f"><label>อาชีพ</label><input v-model="d.profession" /></div>
|
||||||
|
<div class="f"><label>ช่วงอายุ</label><input v-model="d.age_group" /></div>
|
||||||
|
<div class="f"><label>พื้นที่</label><input v-model="d.location" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: ข้อมูลลูกค้า (การขาย) -->
|
||||||
|
<div class="sec">
|
||||||
|
<h4>💼 ข้อมูลลูกค้า</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="f"><label>รายได้/สถานะการเงิน</label><input v-model="d.income" /></div>
|
||||||
|
<div class="f"><label>งบประมาณ</label><input v-model="d.budget" /></div>
|
||||||
|
<div class="f"><label>ไลฟ์สไตล์</label><input v-model="d.lifestyle" /></div>
|
||||||
|
</div>
|
||||||
|
<label>ภูมิหลัง</label>
|
||||||
|
<textarea v-model="d.background"></textarea>
|
||||||
|
<label>บุคลิก / วิธีพูดคุย</label>
|
||||||
|
<textarea v-model="d.personality"></textarea>
|
||||||
|
<label>สไตล์การสื่อสาร</label>
|
||||||
|
<textarea v-model="d.communication_style"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: การขาย (secret — super_admin only) -->
|
||||||
|
<div v-if="auth.isSuperAdmin" class="sec">
|
||||||
|
<h4>🎯 การขาย</h4>
|
||||||
|
<label>เป้าหมาย</label><textarea v-model="d.goal"></textarea>
|
||||||
|
<label>กรอบเวลาในการตัดสินใจ</label><input v-model="d.decision_timeline" />
|
||||||
|
<label>Pain (ปัญหา) แบบ 1 ต่อบรรทัด</label>
|
||||||
|
<textarea v-model="painsText" placeholder="เช่น ต้นทุนสูงเกินไป ระบบล้าสมัย"></textarea>
|
||||||
|
<label>ข้อโต้แย้ง (Objections) 1 ต่อบรรทัด</label>
|
||||||
|
<textarea v-model="objectionsText"></textarea>
|
||||||
|
<label>สิ่งที่ใช้ต่อรอง (เช่น ส่วนลด, ฟรีติดตั้ง)</label>
|
||||||
|
<textarea v-model="negLeversText"></textarea>
|
||||||
|
<label>ช่องทางสำหรับลูกค้าเปิดบทสนทนา (opener)</label>
|
||||||
|
<textarea v-model="d.opener"></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-else class="sec locked">
|
||||||
|
<h4>🔒 ส่วนสูตรการขาย</h4>
|
||||||
|
<p class="muted">ปิดการแก้ไข — เฉพาะผู้ดูแลระดับสูงเท่านั้นที่ดู/แก้ได้ (เพื่อรักษาความได้เปรียบ)</p>
|
||||||
|
<label>เป้าหมาย</label><textarea v-model="d.goal"></textarea>
|
||||||
|
<label>กรอบเวลาในการตัดสินใจ</label><input v-model="d.decision_timeline" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: ช่องทาง/โหมด (removed — scenario chosen at chat time) -->
|
||||||
|
<div v-if="auth.isSuperAdmin && d.special" class="sec">
|
||||||
|
<label class="muted">พิเศษ: {{ d.special }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row actions">
|
||||||
|
<button class="primary" @click="save" :disabled="busy">{{ busy ? 'กำลังบันทึก…' : '💾 บันทึกบุคคลต้นแบบ' }}</button>
|
||||||
|
<button @click="$emit('cancel')">ปิด</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { auth } from '../store/auth'
|
||||||
|
|
||||||
|
const props = defineProps({ persona: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['save', 'cancel'])
|
||||||
|
|
||||||
|
const d = reactive({
|
||||||
|
name: '', difficulty: 1, tier: 'B', profession: '', age_group: '', location: '',
|
||||||
|
income: '', budget: '', lifestyle: '', background: '', personality: '',
|
||||||
|
communication_style: '', goal: '', decision_timeline: '', opener: '', channel: 'line',
|
||||||
|
initiation_mode: 'customer', special: '',
|
||||||
|
})
|
||||||
|
const painsText = ref('')
|
||||||
|
const objectionsText = ref('')
|
||||||
|
const negLeversText = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
function toLines(a) {
|
||||||
|
if (!a) return ''
|
||||||
|
if (Array.isArray(a)) {
|
||||||
|
return a.map((x) => (typeof x === 'string' ? x : (x && x.description) || (x && x.text) || '')).filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
|
return String(a)
|
||||||
|
}
|
||||||
|
function fromLines(s) { return s.split('\n').map(x => x.trim()).filter(Boolean) }
|
||||||
|
|
||||||
|
watch(() => props.persona, (p) => {
|
||||||
|
Object.assign(d, {
|
||||||
|
name: p?.name || '', difficulty: p?.difficulty ?? 1, tier: p?.tier || 'B',
|
||||||
|
profession: p?.profession || '', age_group: p?.age_group || '', location: p?.location || '',
|
||||||
|
income: p?.income || '', budget: p?.budget || '', lifestyle: p?.lifestyle || '',
|
||||||
|
background: p?.background || '', personality: p?.personality || '',
|
||||||
|
communication_style: p?.communication_style || '', goal: p?.goal || '',
|
||||||
|
decision_timeline: p?.decision_timeline || '', opener: p?.opener || '',
|
||||||
|
channel: p?.channel || 'line', initiation_mode: p?.initiation_mode || 'customer',
|
||||||
|
special: p?.special || '',
|
||||||
|
})
|
||||||
|
painsText.value = toLines(p?.pains)
|
||||||
|
objectionsText.value = toLines(p?.objections)
|
||||||
|
negLeversText.value = toLines(p?.negotiation_levers)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', {
|
||||||
|
...d,
|
||||||
|
pains: fromLines(painsText.value).map((t) => ({ description: t })),
|
||||||
|
objections: fromLines(objectionsText.value),
|
||||||
|
negotiation_levers: fromLines(negLeversText.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sec { margin-top: 20px; }
|
||||||
|
.sec h4 { margin: 0 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.f { flex: 1; min-width: 140px; }
|
||||||
|
label { font-size: 13px; color: var(--muted); display: block; margin: 10px 0 4px; }
|
||||||
|
input, select, textarea { width: 100%; }
|
||||||
|
.actions { margin-top: 20px; }
|
||||||
|
.locked { background: #fffaf5; border: 1px dashed #d6c7a1; border-radius: 12px; padding: 12px 14px; }
|
||||||
|
.locked h4 { color: #b45309; }
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,31 @@ const messages = {
|
|||||||
setupSubtitle: 'First login for ',
|
setupSubtitle: 'First login for ',
|
||||||
loginError: 'Invalid credentials',
|
loginError: 'Invalid credentials',
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
|
training: 'Training',
|
||||||
|
myDashboard: 'My dashboard',
|
||||||
|
adminOverview: 'Admin overview',
|
||||||
|
guideTitle: 'How to use / คู่มือใช้งาน',
|
||||||
|
settings: 'Settings',
|
||||||
|
account: 'Account',
|
||||||
|
profile: 'Profile',
|
||||||
|
readonly: 'read-only',
|
||||||
|
name: 'Display name',
|
||||||
|
saveProfile: 'Save profile',
|
||||||
|
changePassword: 'Change password',
|
||||||
|
optional: 'optional',
|
||||||
|
saved: 'Saved',
|
||||||
|
total: 'Total',
|
||||||
|
dateRange: 'Date range',
|
||||||
|
apply: 'Apply',
|
||||||
|
clear: 'Clear',
|
||||||
|
hardestPersonas: 'Hardest personas',
|
||||||
|
closeRate: 'Close rate',
|
||||||
|
managePersonas: 'Manage personas',
|
||||||
|
difficulty: 'Difficulty',
|
||||||
|
trained: 'Trained',
|
||||||
|
addProduct: 'Add product',
|
||||||
|
trainingSubtitle: 'Pick a product group, then choose a persona to practice closing the sale.',
|
||||||
|
noTraining: 'No training groups available',
|
||||||
groups: 'Persona Groups',
|
groups: 'Persona Groups',
|
||||||
myTraining: 'My Training',
|
myTraining: 'My Training',
|
||||||
adminTools: 'Admin Tools',
|
adminTools: 'Admin Tools',
|
||||||
@@ -42,6 +67,9 @@ const messages = {
|
|||||||
tierB: 'Tier B — Unsure',
|
tierB: 'Tier B — Unsure',
|
||||||
tierC: 'Tier C — Not interested but has pain',
|
tierC: 'Tier C — Not interested but has pain',
|
||||||
selectPersona: 'Select a persona to practice',
|
selectPersona: 'Select a persona to practice',
|
||||||
|
chooseScenario: 'Choose a scenario',
|
||||||
|
chatGuideTitle: 'How to practice',
|
||||||
|
chatGuideText: 'You are playing the salesperson. Chat with this customer and try to close the sale. Ask about their needs, solve their pain, and handle their objections. When you are done, press "Finish & get result" to see your score and coaching.',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
@@ -62,7 +90,7 @@ const messages = {
|
|||||||
customerInitiated: 'The customer will message you first',
|
customerInitiated: 'The customer will message you first',
|
||||||
},
|
},
|
||||||
th: {
|
th: {
|
||||||
app: 'ตัวฝึกขาย',
|
app: 'ระบบฝึกทักษะการขาย',
|
||||||
login: 'เข้าสู่ระบบ',
|
login: 'เข้าสู่ระบบ',
|
||||||
logout: 'ออกจากระบบ',
|
logout: 'ออกจากระบบ',
|
||||||
username: 'ชื่อผู้ใช้',
|
username: 'ชื่อผู้ใช้',
|
||||||
@@ -71,54 +99,81 @@ const messages = {
|
|||||||
newPassword: 'รหัสผ่านใหม่',
|
newPassword: 'รหัสผ่านใหม่',
|
||||||
confirmPassword: 'ยืนยันรหัสผ่าน',
|
confirmPassword: 'ยืนยันรหัสผ่าน',
|
||||||
save: 'บันทึก',
|
save: 'บันทึก',
|
||||||
passwordTooShort: 'รหัสผ่านต้องอย่างน้อย 4 ตัวอักษร',
|
passwordTooShort: 'รหัสผ่านต้องมีความยาวอย่างน้อย 4 ตัวอักษร',
|
||||||
passwordMismatch: 'รหัสผ่านไม่ตรงกัน',
|
passwordMismatch: 'รหัสผ่านที่ยืนยันไม่ตรงกัน',
|
||||||
setupTitle: 'ตั้งค่าบัญชีของคุณ',
|
setupTitle: 'ตั้งค่าบัญชีของคุณ',
|
||||||
setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ',
|
setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ',
|
||||||
loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
|
loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง กรุณาลองอีกครั้ง',
|
||||||
dashboard: 'หน้าหลัก',
|
dashboard: 'หน้าหลัก',
|
||||||
groups: 'กลุ่มลูกค้า (Persona)',
|
training: 'การฝึก',
|
||||||
myTraining: 'การฝึกของฉัน',
|
myDashboard: 'ภาพรวมผลการฝึก',
|
||||||
adminTools: 'เครื่องมือ Admin',
|
adminOverview: 'ภาพรวมผู้ดูแล',
|
||||||
users: 'ผู้ใช้',
|
settings: 'การตั้งค่า',
|
||||||
|
account: 'บัญชี',
|
||||||
|
profile: 'ข้อมูลส่วนตัว',
|
||||||
|
readonly: 'อ่านอย่างเดียว',
|
||||||
|
name: 'ชื่อที่แสดง',
|
||||||
|
saveProfile: 'บันทึกข้อมูล',
|
||||||
|
changePassword: 'เปลี่ยนรหัสผ่าน',
|
||||||
|
optional: 'ไม่บังคับ',
|
||||||
|
saved: 'บันทึกเรียบร้อย',
|
||||||
|
total: 'ทั้งหมด',
|
||||||
|
dateRange: 'ช่วงเวลา',
|
||||||
|
apply: 'กรอง',
|
||||||
|
clear: 'ล้าง',
|
||||||
|
hardestPersonas: 'บุคคลต้นแบบที่ขายยากที่สุด',
|
||||||
|
closeRate: 'อัตราปิดการขาย',
|
||||||
|
managePersonas: 'จัดการบุคคลต้นแบบ',
|
||||||
|
difficulty: 'ระดับความยาก',
|
||||||
|
trained: 'ฝึกแล้ว',
|
||||||
|
addProduct: 'เพิ่มสินค้า',
|
||||||
|
trainingSubtitle: 'เลือกกลุ่มสินค้า แล้วเลือกบุคคลต้นแบบเพื่อฝึกปิดการขาย',
|
||||||
|
noTraining: 'ยังไม่มีกลุ่มฝึก',
|
||||||
|
groups: 'กลุ่มบุคคลต้นแบบลูกค้า (Persona)',
|
||||||
|
myTraining: 'ประวัติการฝึก',
|
||||||
|
adminTools: 'เครื่องมือผู้ดูแลระบบ',
|
||||||
|
users: 'ผู้ใช้งาน',
|
||||||
analytics: 'สถิติ',
|
analytics: 'สถิติ',
|
||||||
groupBuilder: 'สร้างกลุ่มลูกค้า',
|
groupBuilder: 'สร้างกลุ่มบุคคลต้นแบบ',
|
||||||
create: 'สร้าง',
|
create: 'สร้าง',
|
||||||
analyze: 'วิเคราะห์',
|
analyze: 'วิเคราะห์',
|
||||||
manual: 'กำหนดเอง',
|
manual: 'ระบุเอง',
|
||||||
edit: 'แก้ไข',
|
edit: 'แก้ไข',
|
||||||
product: 'สินค้า/บริการ',
|
product: 'สินค้า/บริการ/ไอเดีย',
|
||||||
segment: 'กลุ่มลูกค้าเบื้องต้น (ไม่บังคับ)',
|
segment: 'กลุ่มลูกค้าเป้าหมายเบื้องต้น (ไม่บังคับ)',
|
||||||
description: 'คำอธิบาย/สถานการณ์เพิ่มเติม (ไม่บังคับ)',
|
description: 'คำอธิบายหรือรายละเอียดเพิ่มเติม (ไม่บังคับ)',
|
||||||
channel: 'ช่องทาง',
|
channel: 'ช่องทางติดต่อ',
|
||||||
facebook: 'Facebook',
|
facebook: 'Facebook',
|
||||||
line: 'LINE',
|
line: 'LINE',
|
||||||
language: 'ภาษา',
|
language: 'ภาษา',
|
||||||
thai: 'ไทย',
|
thai: 'ไทย',
|
||||||
english: 'อังกฤษ',
|
english: 'อังกฤษ',
|
||||||
personas: 'Persona',
|
personas: 'บุคคลต้นแบบ',
|
||||||
tierA: 'ระดับ A — ตั้งใจซื้อ',
|
tierA: 'ระดับ A — พร้อมตัดสินใจซื้อ',
|
||||||
tierB: 'ระดับ B — ยังไม่แน่ใจ',
|
tierB: 'ระดับ B — ยังไม่แน่ใจ',
|
||||||
tierC: 'ระดับ C — ไม่สนใจแต่มี pain',
|
tierC: 'ระดับ C — ไม่สนใจแต่มีปัญหา',
|
||||||
selectPersona: 'เลือก persona เพื่อฝึก',
|
selectPersona: 'เลือกบุคคลต้นแบบเพื่อฝึก',
|
||||||
|
chooseScenario: 'เลือกสถานการณ์',
|
||||||
|
chatGuideTitle: 'วิธีฝึก',
|
||||||
|
chatGuideText: 'คุณรับบทเป็นพนักงานขาย พูดคุยกับลูกค้าคนนี้เพื่อพยายามปิดการขายให้ได้ สอบถามความต้องการ แก้ไขปัญหาของลูกค้า และรับมือกับข้อโต้แย้ง เมื่อพอใจแล้วกด "สรุปผล" เพื่อดูคะแนนและข้อเสนอแนะ',
|
||||||
chat: 'แชท',
|
chat: 'แชท',
|
||||||
start: 'เริ่ม',
|
start: 'เริ่มต้น',
|
||||||
send: 'ส่ง',
|
send: 'ส่ง',
|
||||||
finish: 'สรุปผล',
|
finish: 'สรุปผล',
|
||||||
debrief: 'ผลลัพธ์และคำแนะนำ',
|
debrief: 'ผลลัพธ์และข้อเสนอแนะ',
|
||||||
won: 'ขายได้',
|
won: 'ปิดการขายได้',
|
||||||
lost: 'ขายไม่ได้',
|
lost: 'ปิดการขายไม่ได้',
|
||||||
notTried: 'ยังไม่ได้ฝึก',
|
notTried: 'ยังไม่ได้ฝึก',
|
||||||
score: 'คะแนน',
|
score: 'คะแนน',
|
||||||
pain: 'Pain',
|
pain: 'ปัญหาของลูกค้า',
|
||||||
why: 'เหตุผล',
|
why: 'เหตุผล',
|
||||||
reveal: 'ข้อมูล persona ที่ถูกซ่อนไว้',
|
reveal: 'รายละเอียดบุคคลต้นแบบที่ถูกซ่อนไว้',
|
||||||
generatePersona: 'สร้าง persona ของฉัน',
|
generatePersona: 'สร้างบุคคลต้นแบบเพิ่มเติม',
|
||||||
weakAreas: 'จุดที่ฉันแพ้บ่อย',
|
weakAreas: 'จุดอ่อนที่ควรฝึกเพิ่มเติม',
|
||||||
mySessions: 'การฝึกของฉัน',
|
mySessions: 'ประวัติการฝึกของฉัน',
|
||||||
openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง',
|
openSaleTask: 'ลูกค้ายังไม่ได้ทักเข้ามา คุณต้องเป็นฝ่ายเริ่มบทสนทนาการขายเอง',
|
||||||
sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)',
|
sellerInitiated: 'คุณต้องเริ่มการขายในเชิงรุก',
|
||||||
customerInitiated: 'ลูกค้าจะทักมาเองก่อน',
|
customerInitiated: 'ลูกค้าจะติดต่อเข้ามาก่อน',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import { auth } from '../store/auth'
|
|||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||||
{ path: '/setup', component: () => import('../views/Setup.vue') },
|
{ path: '/setup', component: () => import('../views/Setup.vue') },
|
||||||
{ path: '/', component: () => import('../views/Dashboard.vue') },
|
// Tab 1: Admin overview (aggregate stats + date filter, admin only)
|
||||||
|
{ path: '/', component: () => import('../views/Analytics.vue'), meta: { admin: true } },
|
||||||
|
// Tab 2: My dashboard (everyone, incl admin)
|
||||||
|
{ path: '/my/board', component: () => import('../views/MyBoard.vue') },
|
||||||
|
// Tab 3: Training — product list -> personas -> chat
|
||||||
|
{ path: '/training', component: () => import('../views/Training.vue') },
|
||||||
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') },
|
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') },
|
||||||
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
|
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
|
||||||
{ path: '/my/sessions', component: () => import('../views/MySessions.vue') },
|
// Settings
|
||||||
{ path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') },
|
{ path: '/settings', component: () => import('../views/Settings.vue') },
|
||||||
{ path: '/my/generate', component: () => import('../views/GenPersona.vue') },
|
// Guide (how to use, non-IT friendly)
|
||||||
|
{ path: '/guide', component: () => import('../views/Guide.vue') },
|
||||||
|
// Admin management
|
||||||
{ path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } },
|
{ path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } },
|
||||||
{ path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } },
|
{ path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } },
|
||||||
{ path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } },
|
{ path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } },
|
||||||
@@ -33,8 +40,12 @@ router.beforeEach(async (to) => {
|
|||||||
if (auth.mustSetup && to.path !== '/setup') {
|
if (auth.mustSetup && to.path !== '/setup') {
|
||||||
return { path: '/setup' }
|
return { path: '/setup' }
|
||||||
}
|
}
|
||||||
|
// Non-admin landing page: send to My dashboard.
|
||||||
|
if (to.path === '/' && !auth.isAdmin) {
|
||||||
|
return { path: '/my/board' }
|
||||||
|
}
|
||||||
if (to.meta.admin && !auth.isAdmin) {
|
if (to.meta.admin && !auth.isAdmin) {
|
||||||
return { path: '/' }
|
return { path: '/my/board' }
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const auth = reactive({
|
|||||||
get isAdmin() {
|
get isAdmin() {
|
||||||
return this.role === 'admin' || this.role === 'super_admin'
|
return this.role === 'admin' || this.role === 'super_admin'
|
||||||
},
|
},
|
||||||
|
get isSuperAdmin() {
|
||||||
|
return this.role === 'super_admin'
|
||||||
|
},
|
||||||
async load() {
|
async load() {
|
||||||
if (!this.token) return null
|
if (!this.token) return null
|
||||||
try {
|
try {
|
||||||
@@ -34,8 +37,8 @@ export const auth = reactive({
|
|||||||
this.mustSetup = !!data.must_setup
|
this.mustSetup = !!data.must_setup
|
||||||
return data.user
|
return data.user
|
||||||
},
|
},
|
||||||
async finishSetup(email, password) {
|
async finishSetup(email, password, acceptedTerms = false) {
|
||||||
const data = await api.setup({ username: this.user.username || this.user.id, email, password })
|
const data = await api.setup({ username: this.user.username || this.user.id, email, password, accepted_terms: acceptedTerms })
|
||||||
this.user = data.user
|
this.user = data.user
|
||||||
this.mustSetup = false
|
this.mustSetup = false
|
||||||
return data.user
|
return data.user
|
||||||
|
|||||||
@@ -86,10 +86,14 @@ a { color: inherit; }
|
|||||||
a:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
a:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
/* Comfortable touch density + consistent transitions */
|
/* Comfortable touch density + consistent transitions */
|
||||||
button { min-height: 44px; transition: transform .15s ease, box-shadow .2s ease, background .2s ease, opacity .2s ease; }
|
button { min-height: 44px; transition: transform .15s ease, box-shadow .2s ease, background .2s ease, opacity .2s ease; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||||
button:not(:disabled):hover { box-shadow: 0 4px 12px rgba(20,24,40,.1); }
|
button:not(:disabled):hover { box-shadow: 0 4px 12px rgba(20,24,40,.1); }
|
||||||
button:not(:disabled):active { transform: scale(.97); }
|
button:not(:disabled):active { transform: scale(.97); }
|
||||||
button.primary:not(:disabled):hover { box-shadow: 0 6px 18px rgba(79,70,229,.35); }
|
button.primary:not(:disabled):hover { box-shadow: 0 6px 18px rgba(79,70,229,.35); }
|
||||||
|
button.soft { background: #f1f3f9; border-color: transparent; color: var(--ink); }
|
||||||
|
button.soft:not(:disabled):hover { background: #e6eaf3; }
|
||||||
|
/* Align inline SVG (lucide) icons inside buttons/links */
|
||||||
|
button svg, .icon-btn svg, .brand svg { vertical-align: -2px; }
|
||||||
|
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -151,4 +155,11 @@ button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
|
|||||||
.main { padding: 16px; }
|
.main { padding: 16px; }
|
||||||
.row { gap: 10px; }
|
.row { gap: 10px; }
|
||||||
.msg-seller, .msg-customer { max-width: 84%; }
|
.msg-seller, .msg-customer { max-width: 84%; }
|
||||||
|
/* Moreminimore convention: centered content when a section collapses to one column */
|
||||||
|
.stat-row > .card, .row.stat > .card { flex: 1 1 40%; text-align: center; }
|
||||||
|
.stat div { text-align: center; }
|
||||||
|
}
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.row.stat, .stat-row, .row.stat-row { justify-content: center; }
|
||||||
|
.stat-row > .card { min-width: 42%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ i18n.t('users') }}</h2>
|
<h2><UsersIcon :size="22" :stroke-width="1.8" style="vertical-align:-4px" /> {{ i18n.t('users') }}</h2>
|
||||||
|
|
||||||
|
<div class="card guide">
|
||||||
|
<strong><Info :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> วิธีเพิ่มผู้ใช้งาน</strong>
|
||||||
|
<ol style="margin:8px 0 0;padding-left:20px;line-height:1.8">
|
||||||
|
<li>กรอก <strong>ชื่อผู้ใช้ (สำหรับเข้าสู่ระบบ)</strong>, ชื่อ, และรหัสผ่านเริ่มต้น</li>
|
||||||
|
<li>เลือกสิทธิ์: <strong>user</strong> = ผู้เข้าฝึกขาย, <strong>admin</strong> = จัดการระบบ</li>
|
||||||
|
<li>กด <strong>สร้าง</strong> — นำชื่อผู้ใช้ + รหัสผ่านไปแจ้งให้ผู้ใช้นั้นเข้าสู่ระบบได้เลย</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:16px">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<h4>+ {{ i18n.t('create') }} user</h4>
|
<h4 style="margin-top:0"><UserPlus :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> + {{ i18n.t('create') }} {{ i18n.t('users').toLowerCase() }}</h4>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input v-model="form.name" placeholder="Name" style="flex:1" />
|
<input v-model="form.username" placeholder="ชื่อผู้ใช้ (เข้าสู่ระบบ)" style="flex:1" />
|
||||||
<input v-model="form.email" placeholder="Email" style="flex:1" />
|
<input v-model="form.name" placeholder="ชื่อจริง" style="flex:1" />
|
||||||
<input v-model="form.password" type="password" placeholder="Temp password" style="flex:1" />
|
<input v-model="form.password" type="password" placeholder="รหัสผ่านเริ่มต้น" style="flex:1" />
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
<select v-model="form.role" style="flex:1">
|
<select v-model="form.role" style="flex:1">
|
||||||
<option value="user">user</option>
|
<option value="user">🙂 user — ผู้เข้าฝึก</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">🛠 admin — ผู้ดูแลระบบ</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="primary" @click="create">{{ i18n.t('create') }}</button>
|
<button class="primary" @click="create" :disabled="!form.username || !form.password">+ {{ i18n.t('create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="error" v-if="error">{{ error }}</div>
|
<div class="error" v-if="error">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="users.length === 0" class="card empty-state">
|
||||||
|
<strong>ยังไม่มีผู้ใช้งาน</strong><span>สร้างผู้ใช้งานคนแรกด้วยฟอร์มด้านบน</span>
|
||||||
|
</div>
|
||||||
<div class="card" v-for="u in users" :key="u.id" style="margin-bottom:8px;display:flex;align-items:center;gap:12px">
|
<div class="card" v-for="u in users" :key="u.id" style="margin-bottom:8px;display:flex;align-items:center;gap:12px">
|
||||||
<strong style="flex:1">{{ u.name }} ({{ u.email }})</strong>
|
<strong style="flex:1">{{ u.name }} ({{ u.username }})</strong>
|
||||||
<span class="badge">{{ u.role }}</span>
|
<span class="badge">{{ u.role }}</span>
|
||||||
<span class="badge" :class="u.active ? 'won' : 'lost'">{{ u.active ? 'active' : 'inactive' }}</span>
|
<span class="badge" :class="u.active ? 'won' : 'lost'">{{ u.active ? 'active' : 'inactive' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,21 +41,26 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Users as UsersIcon, UserPlus, Info } from 'lucide-vue-next'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { i18n } from '../i18n'
|
import { i18n } from '../i18n'
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const form = ref({ name: '', email: '', password: '', role: 'user' })
|
const form = ref({ username: '', name: '', password: '', role: 'user' })
|
||||||
|
|
||||||
async function load() { users.value = (await api.adminListUsers()).users }
|
async function load() { users.value = (await api.adminListUsers()).users }
|
||||||
async function create() {
|
async function create() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await api.adminCreateUser({ ...form.value })
|
await api.adminCreateUser({ ...form.value })
|
||||||
form.value = { name: '', email: '', password: '', role: 'user' }
|
form.value = { username: '', name: '', password: '', role: 'user' }
|
||||||
await load()
|
await load()
|
||||||
} catch (e) { error.value = e.message }
|
} catch (e) { error.value = e.message }
|
||||||
}
|
}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,36 +1,123 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ i18n.t('analytics') }}</h2>
|
<div class="row" style="align-items:center;margin-bottom:16px">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0"><BarChart3 :size="22" :stroke-width="1.8" style="vertical-align:-4px" /> {{ i18n.t('adminOverview') }}</h2>
|
||||||
|
<div class="muted" style="margin-top:4px">ภาพรวมผลการฝึกของทีมทั้งหมด — เลือกช่วงเวลาเพื่อดูสถิติ</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-left:auto;gap:10px">
|
||||||
|
<router-link to="/admin/users"><button class="users-btn"><UsersIcon :size="18" :stroke-width="2" /> {{ i18n.t('users') }}</button></router-link>
|
||||||
|
<router-link to="/admin/new-group"><button class="primary"><Plus :size="18" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button></router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date filter -->
|
||||||
|
<div class="card filter-bar">
|
||||||
|
<span class="muted"><Download :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ i18n.t('dateRange') }}</span>
|
||||||
|
<input type="date" v-model="from" />
|
||||||
|
<span>–</span>
|
||||||
|
<input type="date" v-model="to" />
|
||||||
|
<button class="primary" @click="apply" :disabled="loading">→ {{ i18n.t('apply') }}</button>
|
||||||
|
<button @click="clear" :disabled="loading">{{ i18n.t('clear') }}</button>
|
||||||
|
<button class="soft" @click="download"><Download :size="16" :stroke-width="1.8" /> CSV</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headline metrics -->
|
||||||
<div class="row" style="gap:16px;margin:16px 0">
|
<div class="row" style="gap:16px;margin:16px 0">
|
||||||
<div class="card stat"><div>Sessions</div><strong>{{ a.overall.total_sessions }}</strong></div>
|
<div class="card stat"><div>Sessions</div><strong>{{ a.overall.total_sessions }}</strong></div>
|
||||||
<div class="card stat"><div>Wins</div><strong style="color:var(--green)">{{ a.overall.wins }}</strong></div>
|
<div class="card stat"><div>Wins</div><strong style="color:var(--green)">{{ a.overall.wins }}</strong></div>
|
||||||
<div class="card stat"><div>Losses</div><strong style="color:var(--red)">{{ a.overall.losses }}</strong></div>
|
<div class="card stat"><div>Losses</div><strong style="color:var(--red)">{{ a.overall.losses }}</strong></div>
|
||||||
<div class="card stat"><div>Close rate</div><strong>{{ a.overall.close_rate }}%</strong></div>
|
|
||||||
<div class="card stat"><div>Avg score</div><strong>{{ a.overall.avg_score }}</strong></div>
|
<div class="card stat"><div>Avg score</div><strong>{{ a.overall.avg_score }}</strong></div>
|
||||||
|
<div class="card stat"><div>Trainees</div><strong>{{ a.trainee_count }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Trainees: {{ a.trainee_count }}</h3>
|
<!-- Close rate with progress bar -->
|
||||||
<h3>Hardest personas</h3>
|
<div class="card">
|
||||||
<div class="card" v-for="(p, i) in a.hardest_personas" :key="i" style="margin-bottom:8px">
|
<div class="row" style="justify-content:space-between;align-items:center">
|
||||||
<strong>{{ p.persona_name }}</strong>
|
<strong>{{ i18n.t('closeRate') }}</strong>
|
||||||
<span class="badge lost">{{ p.losses }}L</span>
|
<strong>{{ a.overall.close_rate }}%</strong>
|
||||||
<span class="badge won">{{ p.wins }}W</span>
|
</div>
|
||||||
<span class="muted">· avg {{ p.avg_score }}</span>
|
<div class="bar"><div class="bar-fill" :style="{ width: (a.overall.close_rate || 0) + '%' }"></div></div>
|
||||||
|
<div class="muted" style="font-size:12px;margin-top:6px">
|
||||||
|
{{ a.overall.wins }} {{ i18n.t('won').toLowerCase() }} / {{ a.overall.total_sessions }} sessions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardest personas -->
|
||||||
|
<h3 style="margin-top:20px">{{ i18n.t('hardestPersonas') }}</h3>
|
||||||
|
<div v-if="a.hardest_personas.length === 0" class="card empty-state">
|
||||||
|
<strong>No data</strong><span>No sessions in this date range.</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="(p, i) in a.hardest_personas" :key="i" class="card lift tier-card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<strong>{{ p.persona_name }}</strong>
|
||||||
|
<span class="muted">#{{ i + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:10px;gap:8px">
|
||||||
|
<span class="badge won">{{ p.wins }}W</span>
|
||||||
|
<span class="badge lost">{{ p.losses }}L</span>
|
||||||
|
<span class="badge not_tried">{{ p.plays }} plays</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:8px">{{ i18n.t('score') }}: {{ p.avg_score }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { BarChart3, Users as UsersIcon, Plus, Download } from 'lucide-vue-next'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { auth } from '../store/auth'
|
||||||
import { i18n } from '../i18n'
|
import { i18n } from '../i18n'
|
||||||
|
|
||||||
const a = ref({ overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, trainee_count: 0, hardest_personas: [] })
|
const a = ref({ overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, trainee_count: 0, hardest_personas: [] })
|
||||||
onMounted(async () => { a.value = await api.analytics() })
|
const from = ref('')
|
||||||
|
const to = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
a.value = await api.analytics({ from: from.value, to: to.value })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function apply() { load() }
|
||||||
|
function clear() { from.value = ''; to.value = ''; load() }
|
||||||
|
async function download() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/analytics/export', { headers: { Authorization: `Bearer ${auth.token}` } })
|
||||||
|
if (!resp.ok) throw new Error('export failed')
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const aEl = document.createElement('a')
|
||||||
|
aEl.href = url
|
||||||
|
aEl.download = 'sales-trainer-results.csv'
|
||||||
|
aEl.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat { text-align: center; min-width: 110px; }
|
.stat { text-align: center; min-width: 110px; }
|
||||||
.stat div { color: var(--muted); font-size: 12px; }
|
.stat div { color: var(--muted); font-size: 12px; }
|
||||||
.stat strong { font-size: 20px; }
|
.stat strong { font-size: 20px; }
|
||||||
|
.filter-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.filter-bar input[type=date] { width: auto; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||||
|
.tier-card { display: flex; flex-direction: column; }
|
||||||
|
.badge.won { background: #dcfce7; color: #166534; }
|
||||||
|
.badge.lost { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge.not_tried { background: #eef2ff; color: #4338ca; }
|
||||||
|
.bar { background: #eef0f5; border-radius: 999px; height: 10px; overflow: hidden; margin-top: 10px; }
|
||||||
|
.bar-fill { background: linear-gradient(90deg, var(--accent), var(--accent-2)); height: 100%; border-radius: 999px; transition: width .4s ease; }
|
||||||
|
.users-btn { display: inline-flex; align-items: center; gap: 6px; background: #0ea5e9; border-color: transparent; color: #fff; font-weight: 600; }
|
||||||
|
.users-btn:not(:disabled):hover { background: #0284c7; box-shadow: 0 6px 16px rgba(14,165,233,.35); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user