Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)

- Auth/roles (no self-reg), admin user provision, JWT
- Analyze: sales kit + initial pain-fit from form/upload
- Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special
- Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching
- Trainee loop: win/lose board, weak-areas, user-generated personas
- Admin analytics; EN+TH Vue SPA served by Flask
- Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF
- Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
Macky
2026-08-07 15:31:06 +07:00
commit c3d31c06e2
70 changed files with 6135 additions and 0 deletions

6
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Backend entry point."""
from __future__ import annotations
from .factory import create_app
__all__ = ["create_app"]

View File

@@ -0,0 +1 @@
"""API package."""

View File

@@ -0,0 +1,88 @@
"""Admin routes: user provisioning + role management (no self-registration)."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from ..config import Config
from .helpers import ApiError, current_user, require_auth, require_roles
admin_bp = Blueprint("admin", __name__)
def _store():
from flask import current_app
return current_app.extensions["user_store"]
@admin_bp.post("/users")
@require_auth
@require_roles("admin")
def create_user():
"""Create a user + provision a password (invite). Admin or super-admin only."""
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
role = (data.get("role") or "user").strip()
org_id = (data.get("org_id") or current_user().get("org_id") or "org-default").strip()
if not email or not password:
raise ApiError("email and password are required")
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
# Only super_admin can create another admin/super_admin
actor_role = current_user().get("role")
if role in ("admin", "super_admin") and actor_role != "super_admin":
raise ApiError("only super_admin can grant admin roles", 403)
try:
user = _store().create_user(
org_id=org_id, email=email, password=password, name=name, role=role
)
except AuthError as exc:
raise ApiError(str(exc))
return jsonify({"user": _store().public_user(user)}), 201
@admin_bp.get("/users")
@require_auth
@require_roles("admin")
def list_users():
actor = current_user()
if actor.get("role") == "super_admin":
users = _store().list_users()
else:
users = _store().list_users(org_id=actor.get("org_id"))
return jsonify({"users": users})
@admin_bp.put("/users/<email>")
@require_auth
@require_roles("admin")
def update_user(email: str):
data = request.get_json(silent=True) or {}
email = email.strip().lower()
actor = current_user()
target = _store().get_user_or_none(email)
if not target:
raise ApiError("user not found", 404)
# Role changes / admin-modification restricted to super_admin
if "role" in data:
role = (data.get("role") or "").strip()
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
if actor.get("role") != "super_admin":
raise ApiError("only super_admin can change roles")
_store().set_role(email, role)
if "active" in data:
if actor.get("role") != "super_admin":
raise ApiError("only super_admin can activate/deactivate users")
_store().set_active(email, bool(data.get("active")))
if "password" in data and data.get("password"):
_store().set_password(email, data.get("password"))
return jsonify({"user": _store().public_user(_store().get_user(email))})

View File

@@ -0,0 +1,83 @@
"""Admin analytics: aggregate trainee results."""
from __future__ import annotations
from flask import Blueprint, jsonify
from .helpers import ApiError, current_user, require_auth, require_roles
analytics_bp = Blueprint("analytics", __name__)
def _stores():
from flask import current_app
return {
"sessions": current_app.extensions["session_store"],
"groups": current_app.extensions["group_store"],
"users": current_app.extensions["user_store"],
}
@analytics_bp.get("")
@require_auth
@require_roles("admin")
def analytics():
s = _stores()
actor = current_user()
if actor.get("role") == "super_admin":
sessions = s["sessions"].sessions.all()
users = s["users"].list_users()
else:
org_id = actor.get("org_id")
# users in this org
users = s["users"].list_users(org_id=org_id)
user_ids = {u["id"] for u in users}
sessions = [
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
]
overall = {
"total_sessions": len(sessions),
"wins": sum(1 for x in sessions if x.get("outcome") == "won"),
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
}
overall["close_rate"] = round(
overall["wins"] / overall["total_sessions"] * 100, 1
) if overall["total_sessions"] else 0
# average score
scores = [ (x.get("debrief") or {}).get("score", 0) for x in sessions if x.get("outcome") ]
overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
# hardest personas = personas with most losses (lowest avg score)
by_persona: dict = {}
for x in sessions:
key = (x.get("group_id"), x.get("persona_id"), x.get("persona_name", "?"))
if key not in by_persona:
by_persona[key] = {"plays": 0, "losses": 0, "wins": 0, "scores": []}
rec = by_persona[key]
rec["plays"] += 1
rec["scores"].append((x.get("debrief") or {}).get("score", 0))
if x.get("outcome") == "won":
rec["wins"] += 1
elif x.get("outcome") == "lost":
rec["losses"] += 1
hardest = sorted(
(
{
"persona_name": k[2],
"plays": v["plays"],
"wins": v["wins"],
"losses": v["losses"],
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1) if v["scores"] else 0,
}
for k, v in by_persona.items()
),
key=lambda r: (r["losses"], -r["avg_score"]),
)[:10]
return jsonify({
"overall": overall,
"trainee_count": len(users),
"hardest_personas": hardest,
})

View File

@@ -0,0 +1,36 @@
"""Auth routes: login + current user. No self-registration."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from .helpers import ApiError, current_user, require_auth
auth_bp = Blueprint("auth", __name__)
def _store():
from flask import current_app
return current_app.extensions["user_store"]
@auth_bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
raise ApiError("email and password are required")
try:
user = _store().verify(email, password)
token = _store().issue_token(user)
except AuthError as exc:
raise ApiError(str(exc), 401)
return jsonify({"token": token, "user": _store().public_user(user)})
@auth_bp.get("/me")
@require_auth
def me():
return jsonify({"user": _store().public_user(current_user())})

View File

@@ -0,0 +1,171 @@
"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..llm import LLMError
from ..services.simulator import Simulator
from .helpers import ApiError, current_user, require_auth, require_roles
chat_bp = Blueprint("chat", __name__)
def _stores():
from flask import current_app
return {
"groups": current_app.extensions["group_store"],
"sessions": current_app.extensions["session_store"],
"llm": current_app.extensions["llm"],
}
def _sim(group, persona):
llm = _stores()["llm"]
if not llm:
raise ApiError("LLM not configured", 500)
return Simulator(llm)
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
@require_auth
@require_roles("user")
def start_session(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group or group.get("status") != "ready":
raise ApiError("group not ready", 404)
persona = s["groups"].get_persona(gid, pid)
if not persona:
raise ApiError("persona not found", 404)
actor = current_user()
# One-shot: reject if already finished this persona
try:
session = s["sessions"].create(
user_id=actor["id"], group_id=gid, persona_id=pid,
persona_name=persona.get("name", "?"),
persona_meta={
"tier": persona.get("tier"),
"initiation_mode": persona.get("initiation_mode"),
"channel": persona.get("channel"),
},
)
except ValueError as exc:
raise ApiError(str(exc), 400)
sim = _sim(group, persona)
# Seller-initiated: give the trainee an opening task (no persona message yet).
init_mode = persona.get("initiation_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."
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
else:
s["sessions"].update(
session["id"],
task="The customer did NOT message first. You must open the sale — start the "
"conversation with this lead (e.g. introduce yourself and engage with interest).",
)
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
@require_auth
@require_roles("user")
def send_message(gid: str, pid: str):
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)
data = request.get_json(silent=True) or {}
text = (data.get("text") or "").strip()
if not text:
raise ApiError("message is empty")
if len(text) > 2000:
raise ApiError("message too long")
group = s["groups"].get_or_none(gid)
persona = s["groups"].get_persona(gid, pid)
messages = list(session.get("messages", []))
messages.append({"role": "seller", "text": text})
sim = _sim(group, persona)
try:
reply = sim.persona_reply(
persona=persona,
sales_kit=group.get("sales_kit") or {},
messages=messages,
internal=session.get("internal", {}),
)
except LLMError as exc:
raise ApiError(f"LLM error: {exc}", 500)
messages.append({"role": "customer", "text": reply})
s["sessions"].update(session["id"], messages=messages)
return jsonify({"reply": reply, "messages": messages})
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
@require_auth
@require_roles("user")
def finish_session(gid: str, pid: str):
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
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)
group = s["groups"].get_or_none(gid)
persona = s["groups"].get_persona(gid, pid)
sim = _sim(group, persona)
messages = session.get("messages", [])
try:
verdict = sim.judge(persona=persona, messages=messages)
except LLMError as exc:
raise ApiError(f"LLM error: {exc}", 500)
outcome = "won" if verdict.get("outcome") == "won" else "lost"
debrief = {
**verdict,
"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", ""),
},
}
s["sessions"].update(
session["id"],
status="finished",
outcome=outcome,
debrief=debrief,
internal=session.get("internal", {}),
)
return jsonify({"session": s["sessions"].get(session["id"]), "debrief": debrief})
@chat_bp.get("/sessions")
@require_auth
@require_roles("user")
def my_sessions():
s = _stores()
uid = current_user()["id"]
sessions = s["sessions"].list_for_user(uid)
return jsonify({"sessions": sessions})
@chat_bp.get("/sessions/<sid>")
@require_auth
@require_roles("user")
def get_session(sid: str):
s = _stores()
session = s["sessions"].get_or_none(sid)
if not session or session.get("user_id") != current_user()["id"]:
raise ApiError("session not found", 404)
return jsonify({"session": session})

View File

@@ -0,0 +1,235 @@
"""Group API: create, analyze (sales kit + personas), read, edit, report."""
from __future__ import annotations
import threading
from pathlib import Path
from flask import Blueprint, jsonify, request
from ..config import Config
from ..llm import LLMClient, LLMError
from ..services.groups import GroupStore
from ..services.store import ensure_persona_shape, revealable_view
from .helpers import ApiError, current_user, require_auth, require_roles
groups_bp = Blueprint("groups", __name__)
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
_ANALYZE_GUARD = threading.Lock()
def _stores():
from flask import current_app
return {
"groups": current_app.extensions.get("group_store"),
"users": current_app.extensions["user_store"],
"session_store": current_app.extensions.get("session_store"),
"llm": current_app.extensions["llm"],
}
def _upload_dir():
d = Config.DATA_DIR / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
@groups_bp.post("")
@require_auth
@require_roles("admin")
def create_group():
"""Create a persona group from a setup form + optional files."""
s = _stores()
file_text = ""
saved_files = []
if request.files:
for file in request.files.getlist("files"):
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
if ext not in Config.ALLOWED_UPLOAD_EXTS:
raise ApiError(f"unsupported file type: {ext}")
dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{file.filename}"
file.save(dest)
saved_files.append(dest.name)
data = request.form.to_dict() if request.files else (request.get_json(silent=True) or {})
from ..services.file_parser import parse_document
for name in saved_files:
try:
file_text += "\n\n" + parse_document(_upload_dir() / name)
except Exception as exc:
raise ApiError(f"could not parse file {name}: {exc}")
product = (data.get("product") or "").strip()
if product == "" and not file_text.strip():
raise ApiError("provide product info in the form or via file upload")
group = s["groups"].create(
org_id=current_user().get("org_id") or "org-default",
creator_id=current_user()["id"],
title=(product or file_text[:80] or "Untitled group").strip()[:200],
)
s["groups"].update(
group["id"],
input={
"product": product,
"segment": (data.get("segment") or ""),
"description": (data.get("description") or ""),
"channel": (data.get("channel") or "facebook"),
"language": (data.get("language") or "th"),
"files": saved_files,
"file_text": file_text[:60000],
},
)
return jsonify({"group": s["groups"].get(group["id"])}), 201
@groups_bp.get("")
@require_auth
def list_groups():
s = _stores()
actor = current_user()
visible = s["groups"].list_visible_to(
role=actor.get("role"), org_id=actor.get("org_id")
)
return jsonify({"groups": visible})
@groups_bp.post("/<gid>/analyze")
@require_auth
@require_roles("admin")
def analyze_group(gid: str):
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen)."""
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
if group.get("org_id") != (current_user().get("org_id") or "org-default"):
raise ApiError("permission denied", 403)
inp = group.get("input", {})
if not s["llm"]:
raise ApiError("LLM not configured", 500)
from ..services.analyzer import Analyzer
from ..services.persona_generator import PersonaGenerator
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", "facebook"),
)
personas = PersonaGenerator(s["llm"]).generate(
sales_kit=sales_kit,
language=inp.get("language", "th"),
channel=inp.get("channel", "facebook"),
)
except Exception as exc:
s["groups"].update(gid, status="failed", error=str(exc))
raise ApiError(f"analysis failed: {exc}", 500)
from ..services.report import build_report
report = build_report(sales_kit=sales_kit, personas=personas, language=inp.get("language", "th"))
s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None)
s["groups"].set_personas(gid, personas)
s["groups"].update(gid, report=report)
return jsonify({
"group": s["groups"].get(gid),
"sales_kit": sales_kit,
"personas": s["groups"].get(gid)["personas"],
})
@groups_bp.get("/<gid>")
@require_auth
def get_group(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
actor = current_user()
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
view = dict(group)
if actor.get("role") == "user":
# Trainee: hide latent persona fields + sales kit details they shouldn't see
view["personas"] = [
revealable_view(p) for p in group.get("personas", [])
]
return jsonify({"group": view})
@groups_bp.get("/<gid>/personas")
@require_auth
def list_personas(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
actor = current_user()
if actor.get("role") == "user":
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
personas = [revealable_view(p) for p in group.get("personas", [])]
else:
personas = group.get("personas", [])
# attach per-user status (won/lost/not-tried) for trainees
if actor.get("role") == "user":
sess = _stores().get("session_store")
store = sess.sessions if sess else None
mine = store.where(lambda r: r.get("user_id") == actor["id"] and r.get("group_id") == gid) if store else []
outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine}
for p in personas:
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
return jsonify({"personas": personas, "tiers": ["A", "B", "C"]})
@groups_bp.get("/<gid>/personas/<pid>")
@require_auth
def get_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
p = s["groups"].get_persona(gid, pid)
if not p:
raise ApiError("persona not found", 404)
actor = current_user()
ensure = ensure_persona_shape(p)
if actor.get("role") == "user":
return jsonify({"persona": revealable_view(ensure)})
return jsonify({"persona": ensure})
@groups_bp.put("/<gid>/personas/<pid>")
@require_auth
@require_roles("admin")
def update_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
data = request.get_json(silent=True) or {}
try:
updated = s["groups"].update_persona(gid, pid, data)
except ValueError as exc:
raise ApiError(str(exc), 404)
return jsonify({"persona": ensure_persona_shape(updated["personas"][
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
])})
@groups_bp.post("/<gid>/reanalyze")
@require_auth
@require_roles("admin")
def reanalyze_group(gid: str):
return analyze_group(gid)

View File

@@ -0,0 +1,73 @@
"""JWT auth decorators + role guards + shared API helpers."""
from __future__ import annotations
import functools
from typing import Any, Callable
from flask import g, jsonify, request
from ..auth.users import AuthError
from ..config import Config
class ApiError(Exception):
def __init__(self, message: str, status: int = 400):
super().__init__(message)
self.message = message
self.status = status
def _get_store():
from flask import current_app
return current_app.extensions["user_store"]
def current_user() -> dict[str, Any]:
return g.user
def require_auth(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
header = request.headers.get("Authorization", "")
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token:
raise ApiError("authentication required", 401)
try:
payload = _get_store().decode_token(token)
except AuthError as exc:
raise ApiError(str(exc), 401)
user = _get_store().get_user_or_none(payload.get("sub", ""))
if not user or not user.get("active", True):
raise ApiError("account is inactive", 401)
g.user = user
g.token_payload = payload
return fn(*args, **kwargs)
return wrapper
def require_roles(*roles: str) -> Callable:
def deco(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
role = g.user.get("role")
# super_admin passes any role gate
allowed = {"super_admin", *roles}
if role not in allowed:
raise ApiError("permission denied", 403)
return fn(*args, **kwargs)
return wrapper
return deco
def api_error_handler(err: ApiError):
return jsonify({"error": err.message}), err.status
def register_error_handlers(app) -> None:
app.register_error_handler(ApiError, api_error_handler)
app.register_error_handler(ValueError, lambda e: (jsonify({"error": str(e)}), 400))

View File

@@ -0,0 +1,121 @@
"""Trainee routes: win/lose board, weak-areas, generate own persona."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..llm import LLMError
from ..services.trainee import MyPersonaStore, analyze_weak_areas
from .helpers import ApiError, current_user, require_auth, require_roles
me_bp = Blueprint("me", __name__)
def _stores():
from flask import current_app
return {
"groups": current_app.extensions["group_store"],
"sessions": current_app.extensions["session_store"],
"my_personas": current_app.extensions.get("my_persona_store"),
"llm": current_app.extensions["llm"],
}
@me_bp.get("/board")
@require_auth
@require_roles("user")
def win_lose_board():
"""Per-persona won/lost/not-tried across all groups the user sees."""
s = _stores()
uid = current_user()["id"]
my_sessions = s["sessions"].list_for_user(uid)
outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions}
groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id"))
items = []
for g in groups:
for p in g.get("personas", []):
key = (g["id"], p["id"])
items.append({
"group_id": g["id"],
"group_title": g.get("title"),
"persona_id": p["id"],
"persona_name": p.get("name"),
"tier": p.get("tier"),
"my_outcome": outcome_by.get(key, "not_tried"),
})
return jsonify({"board": items})
@me_bp.get("/weak-areas")
@require_auth
@require_roles("user")
def weak_areas():
s = _stores()
uid = current_user()["id"]
sessions = s["sessions"].list_for_user(uid)
insight = analyze_weak_areas(sessions)
return jsonify({"insight": insight})
def _personal_group(s, actor) -> dict:
"""Return (or create) the user's private group holding their own personas."""
groups = s["groups"].list_for_org(org_id=actor.get("org_id"))
for g in groups:
if g.get("owner_user_id") == actor["id"]:
return g
g = s["groups"].create(
org_id=actor.get("org_id") or "org-default",
creator_id=actor["id"],
title=f"{actor.get('name','User')}'s private personas",
)
s["groups"].update(
g["id"],
status="ready",
owner_user_id=actor["id"],
input={"channel": "facebook", "language": "th"},
sales_kit={"productName": "personal practice", "valueProps": [], "features": []},
)
return s["groups"].get(g["id"])
@me_bp.get("/personas")
@require_auth
@require_roles("user")
def my_personas():
s = _stores()
uid = current_user()["id"]
group = _personal_group(s, current_user())
return jsonify({"group": group, "personas": group.get("personas", [])})
@me_bp.post("/personas/generate")
@require_auth
@require_roles("user")
def generate_persona():
s = _stores()
actor = current_user()
data = request.get_json(silent=True) or {}
mode = data.get("mode", "manual") # "weak-area" | "manual"
spec = data.get("spec") or {}
llm = s["llm"]
if not llm:
raise ApiError("LLM not configured", 500)
if mode == "weak-area" and not spec:
# auto-detect weak areas from this user's losses if no spec given
sessions = s["sessions"].list_for_user(actor["id"])
spec = analyze_weak_areas(sessions)
from ..services.own_persona import generate_own_persona
try:
persona = generate_own_persona(llm, mode=mode, spec=spec)
except (LLMError, ValueError) as exc:
raise ApiError(f"generation failed: {exc}", 500)
group = _personal_group(s, actor)
group = s["groups"].get(group["id"])
existing = group.get("personas", [])
persona["id"] = f"myp-{len(existing)+1:02d}"
existing.append(persona)
s["groups"].set_personas(group["id"], existing)
return jsonify({"persona": persona, "group": s["groups"].get(group["id"])}), 201

View File

@@ -0,0 +1 @@
"""Auth package."""

128
backend/app/auth/users.py Normal file
View File

@@ -0,0 +1,128 @@
"""User + organization store and auth logic (JWT, password hashing, roles)."""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
import jwt
from werkzeug.security import check_password_hash, generate_password_hash
from ..config import Config
from ..storage.store import JsonStore, StoreError, new_id
class AuthError(Exception):
pass
class UserStore:
def __init__(self, data_dir: Path) -> None:
self.users = JsonStore(data_dir / "users")
self.orgs = JsonStore(data_dir / "orgs")
# ── org ────────────────────────────────────────────────────────────
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
return self.orgs.create(
{"name": name, "id": org_id or new_id("org")},
key=org_id or new_id("org"),
)
def get_org(self, org_id: str) -> dict[str, Any]:
return self.orgs.get(org_id)
# ── users ──────────────────────────────────────────────────────────
def create_user(
self,
*,
org_id: str,
email: str,
password: str,
name: str,
role: str = "user",
) -> dict[str, Any]:
if role not in Config.ROLES:
raise AuthError(f"invalid role: {role}")
org = self.orgs.get(org_id)
email = email.strip().lower()
if not email or not password:
raise AuthError("email and password are required")
if self.users.get_or_none(email) is not None:
raise AuthError("a user with this email already exists")
user = {
"id": email, # email = unique id/username
"email": email,
"org_id": org_id,
"org_name": org.get("name", ""),
"name": name.strip() or email,
"password_hash": generate_password_hash(password),
"role": role,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"active": True,
}
return self.users.create(user, key=email)
def get_user(self, email: str) -> dict[str, Any]:
email = email.strip().lower()
return self.users.get(email)
def get_user_or_none(self, email: str) -> dict[str, Any] | None:
return self.users.get_or_none(email.strip().lower())
def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]:
users = self.users.all()
if org_id:
users = [u for u in users if u.get("org_id") == org_id]
# Redact password hash
for u in users:
u.pop("password_hash", None)
return users
def set_active(self, email: str, active: bool) -> dict[str, Any]:
return self.users.update(email.strip().lower(), active=active)
def set_role(self, email: str, role: str) -> dict[str, Any]:
if role not in Config.ROLES:
raise AuthError(f"invalid role: {role}")
return self.users.update(email.strip().lower(), role=role)
def set_password(self, email: str, new_password: str) -> dict[str, Any]:
if not new_password:
raise AuthError("password is required")
return self.users.update(
email.strip().lower(),
password_hash=generate_password_hash(new_password),
)
# ── auth ───────────────────────────────────────────────────────────
def verify(self, email: str, password: str) -> dict[str, Any]:
user = self.get_user_or_none(email)
if not user or not user.get("active", True):
raise AuthError("invalid credentials")
if not check_password_hash(user["password_hash"], password):
raise AuthError("invalid credentials")
return user
def issue_token(self, user: dict[str, Any]) -> str:
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": user["email"],
"org_id": user["org_id"],
"role": user["role"],
"iat": now,
"exp": now + datetime.timedelta(hours=Config.JWT_EXPIRES_HOURS),
}
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
def decode_token(self, token: str) -> dict[str, Any]:
try:
return jwt.decode(
token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO]
)
except jwt.PyJWTError as exc:
raise AuthError("invalid or expired token") from exc
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
u = dict(user)
u.pop("password_hash", None)
return u

73
backend/app/config.py Normal file
View File

@@ -0,0 +1,73 @@
"""Configuration from environment / .env."""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from backend/ (project root for this app)
_BACKEND_DIR = Path(__file__).resolve().parent.parent
load_dotenv(_BACKEND_DIR / ".env", override=True)
def _get_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def resolve_llm() -> tuple[str, str, str, str | None]:
"""Return (base_url, model, api_key, provider_name)."""
provider = os.environ.get("LLM_PROVIDER", "").strip()
explicit_base = os.environ.get("LLM_BASE_URL", "").strip()
explicit_model = os.environ.get("LLM_MODEL_NAME", "").strip()
api_key = os.environ.get("LLM_API_KEY", "").strip() or None
presets = {
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
"custom": ("", ""),
}
if provider and provider in presets:
base_url, model = presets[provider]
if explicit_base:
base_url = explicit_base
if explicit_model:
model = explicit_model
return base_url, model, api_key or "", provider
# No/unknown provider: fall back to explicit config
return (
explicit_base or "https://api.openai.com/v1",
explicit_model or "gpt-4o-mini",
api_key or "",
provider or None,
)
class Config:
APP_NAME = "Sales Trainer"
SECRET_KEY = os.environ.get("JWT_SECRET", "dev-secret-change-me")
JWT_ALGO = "HS256"
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
DATA_DIR = Path(
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
).resolve()
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.environ.get("FLASK_PORT", "5001"))
FLASK_DEBUG = _get_bool("FLASK_DEBUG", True)
UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15"))
ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"}
# LLM
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm()
ROLES = ("super_admin", "admin", "user")
@classmethod
def ensure_dirs(cls) -> None:
for name in ("users", "orgs", "groups", "sessions"):
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)

103
backend/app/factory.py Normal file
View File

@@ -0,0 +1,103 @@
"""Flask application factory."""
from __future__ import annotations
from pathlib import Path
from flask import Flask
from flask_cors import CORS
from .auth.users import AuthError, UserStore
from .config import Config
def bootstrap_admin(users: UserStore) -> None:
"""Ensure a default org + super-admin exists on first run (no self-registration)."""
email = "admin@salestrainer.local"
org = users.orgs.get_or_none("org-default")
if org is None:
org = users.create_org("Default Organization", org_id="org-default")
if users.get_user_or_none(email) is None:
users.create_user(
org_id=org["id"],
email=email,
password="admin123",
name="Super Admin",
role="super_admin",
)
print("[bootstrap] created default super-admin:", email, "/ admin123")
def create_app() -> Flask:
Config.ensure_dirs()
app = Flask(__name__)
app.config["SECRET_KEY"] = Config.SECRET_KEY
CORS(app, resources={r"/api/*": {"origins": "*"}})
from .api.auth_routes import auth_bp
from .api.admin_routes import admin_bp
from .api.group_routes import groups_bp
from .api.chat_routes import chat_bp
from .api.me_routes import me_bp
from .api.analytics_routes import analytics_bp
from .api.helpers import register_error_handlers
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(admin_bp, url_prefix="/api/admin")
app.register_blueprint(groups_bp, url_prefix="/api/groups")
app.register_blueprint(chat_bp, url_prefix="/api/chat")
app.register_blueprint(me_bp, url_prefix="/api/me")
app.register_blueprint(analytics_bp, url_prefix="/api/analytics")
register_error_handlers(app)
from .auth.users import UserStore
from .llm import LLMClient
from .llm import LLMError
from .services.groups import GroupStore
from .services.sessions import SessionStore
from .services.trainee import MyPersonaStore
app.extensions["user_store"] = UserStore(Config.DATA_DIR)
app.extensions["group_store"] = GroupStore(Config.DATA_DIR)
app.extensions["session_store"] = SessionStore(Config.DATA_DIR)
app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR)
try:
app.extensions["llm"] = LLMClient()
except LLMError as exc:
print(f"[warn] LLM not configured yet: {exc}")
app.extensions["llm"] = None
bootstrap_admin(app.extensions["user_store"])
@app.get("/health")
def health():
return {"status": "ok", "service": Config.APP_NAME}
# Serve built Vue frontend if present (production single-app mode).
_register_frontend(app)
return app
def _register_frontend(app: Flask) -> None:
from flask import send_from_directory
# repo-root frontend/dist (factory.py -> app/ -> backend/ -> repo root)
dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if not (dist / "index.html").exists():
print(f"[info] frontend build not found at {dist}; API-only mode")
return
@app.route("/")
def index():
return send_from_directory(dist, "index.html")
@app.route("/<path:path>", methods=["GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"])
def assets(path: str):
# Never let the SPA fallback shadow API/auth routes: return 404 for them.
if path.startswith("api/") or path.startswith("health"):
return ("not found", 404)
candidate = dist / path
if candidate.is_file():
return send_from_directory(dist, path)
# SPA fallback for client-side routes
return send_from_directory(dist, "index.html")

131
backend/app/llm.py Normal file
View File

@@ -0,0 +1,131 @@
"""OpenAI-compatible LLM client (OpenAI / DeepSeek / custom base URL).
Mirrors the MiroFish provider-agnostic pattern. Credentials live in .env only.
"""
from __future__ import annotations
import json
import re
from typing import Any
from openai import OpenAI
from .config import Config
class LLMError(Exception):
pass
def _strip_thinking_trace(text: str) -> str:
"""Remove ReACT-style chain-of-thought / fences, keep the final JSON text."""
for fence in ("```json", "```"):
idx = text.rfind(fence)
if idx != -1:
after = text[idx:].lstrip()
lang_len = after.find("\n")
body = after[lang_len:] if lang_len != -1 else after
end = body.rfind("```")
if end != -1:
body = body[:end]
body = body.strip()
if body:
return body
for marker in ("\n\n[", "\n\n{"):
idx = text.rfind(marker)
if idx != -1:
candidate = text[idx:].strip()
if candidate and candidate[0] in "{[":
return candidate
return text
class LLMClient:
def __init__(
self,
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
) -> None:
self.base_url = base_url or Config.LLM_BASE_URL
self.api_key = api_key or Config.LLM_API_KEY
self.model = model or Config.LLM_MODEL
if not self.api_key:
raise LLMError("LLM_API_KEY is not configured in .env")
if not self.base_url:
raise LLMError("LLM_BASE_URL is not configured (unknown provider)")
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
def complete(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.5,
max_tokens: int = 3000,
) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
temperature=temperature,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
except Exception as exc: # network/auth/provider
raise LLMError(f"LLM call failed: {exc}") from exc
text = (resp.choices[0].message.content or "").strip()
if not text:
raise LLMError("LLM returned empty response")
return text
def complete_json(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.2,
max_tokens: int = 6000,
) -> dict[str, Any]:
text = self.complete(
system_prompt,
user_prompt,
temperature=temperature,
max_tokens=max_tokens,
)
text = _strip_thinking_trace(text)
try:
return json.loads(text)
except json.JSONDecodeError as exc:
# Last-ditch: strip leading text before the first { or [
match = re.search(r"[{\[].*[}\]]", text, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise LLMError(f"LLM returned invalid JSON: {exc}") from exc
def complete_conversation(
self,
messages: list[dict[str, str]],
*,
temperature: float = 0.6,
max_tokens: int = 1200,
) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
temperature=temperature,
max_tokens=max_tokens,
messages=messages,
)
except Exception as exc:
raise LLMError(f"LLM call failed: {exc}") from exc
text = (resp.choices[0].message.content or "").strip()
if not text:
raise LLMError("LLM returned empty response")
return text

View File

@@ -0,0 +1 @@
"""Service layer."""

View File

@@ -0,0 +1,96 @@
"""Analyzer: extracts a Sales Kit (product facts) + initial pain-fit from inputs.
Product data is used primarily to derive pains that persona generation can build
against. The result also carries a `scenario` prompt that frames persona creation.
"""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
SALES_KIT_SYSTEM = """You are an expert ecommerce/B2B analyst. Given product information
(typed in a form and/or extracted from uploaded files), produce a structured Sales Kit.
Rules:
- Output ONLY valid JSON with the exact keys requested.
- Pain-fit: judge which pains / customer pain categories the product can PLAUSIBLY solve,
and clearly distinguish "strong fit" from "partial / weak fit".
- The product info is only initial grounding; personas may be reused across similar products.
- If some fields are unknown, leave them as empty lists / empty strings (never invent specifics).
Output schema:
{
"productName": string,
"category": string,
"valueProps": [string],
"features": [string],
"pricingAnchors": [string],
"targetAudience": { "segment": string, "demographics": string, "useCases": [string] },
"objectionHandlers": [string],
"initialPainFit": [
{ "pain": string, "fit": "strong"|"partial"|"weak", "evidence": string }
],
"scenarioFrame": string
}
The scenarioFrame is a one-paragraph description of the selling situation (who the seller,
what channel, target segment) that will frame persona creation.
"""
class Analyzer:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def analyze(
self,
*,
product: str = "",
segment: str = "",
description: str = "",
file_text: str = "",
channel: str = "facebook",
) -> dict[str, Any]:
# Build the merged product context (form wins over file text)
product_src = product.strip() or file_text.strip() or ""
context = (
f"PRODUCT (form/typed):\n{product}\n\n" if product.strip() else ""
)
if segment.strip():
context += f"INITIAL CUSTOMER SEGMENT:\n{segment}\n\n"
if description.strip():
context += f"ADDITIONAL DESCRIPTION / SCENARIO:\n{description}\n\n"
if file_text.strip():
context += f"UPLOADED FILE CONTENT:\n{file_text[:12000]}\n"
if not context.strip():
raise ValueError("no product information provided (form or file)")
user_prompt = (
f"Channel: {channel}\n\n"
f"Analyze the following and return the Sales Kit JSON:\n\n{context}"
)
result = self.llm.complete_json(
SALES_KIT_SYSTEM, user_prompt, temperature=0.2, max_tokens=5000
)
# Normalize shape defensively
result.setdefault("productName", product_src[:200] or "Untitled product")
result.setdefault("category", "")
result.setdefault("valueProps", [])
result.setdefault("features", [])
result.setdefault("pricingAnchors", [])
result.setdefault("targetAudience", {
"segment": segment or "",
"demographics": "",
"useCases": [],
})
result.setdefault("objectionHandlers", [])
result.setdefault("initialPainFit", [])
result.setdefault("scenarioFrame", description or "")
for k in ("valueProps", "features", "pricingAnchors", "objectionHandlers"):
if not isinstance(result[k], list):
result[k] = []
if not isinstance(result.get("initialPainFit"), list):
result["initialPainFit"] = []
return result

View File

@@ -0,0 +1,46 @@
"""File parsing for uploaded documents (pdf / markdown / txt)."""
from __future__ import annotations
from pathlib import Path
class ParseError(Exception):
pass
def parse_pdf(path: Path) -> str:
import fitz # PyMuPDF
try:
doc = fitz.open(path)
except Exception as exc:
raise ParseError(f"cannot open PDF: {exc}") from exc
parts = []
for page in doc:
parts.append(page.get_text())
doc.close()
return "\n".join(parts)
def parse_text(path: Path) -> str:
import chardet
raw = path.read_bytes()
# Try utf-8 first, else detect encoding
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
guess = chardet.detect(raw)
enc = guess.get("encoding") or "utf-8"
try:
return raw.decode(enc, errors="replace")
except Exception:
return raw.decode("utf-8", errors="replace")
def parse_document(path: Path) -> str:
ext = path.suffix.lower().lstrip(".")
if ext == "pdf":
return parse_pdf(path)
return parse_text(path)

View File

@@ -0,0 +1,97 @@
"""Persona group store: groups hold a sale kit + personas + report.
A group is created by an admin from a setup form + optional files. After analyze,
it contains `personas` (15 by default = 5 per tier) and a `report`. Groups are
editable/re-analyzeable by admins. Trainees only read revealable views of personas
and run one-shot sessions (sessions are stored separately).
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
DEFAULT_TIERS = ["A", "B", "C"]
PERSONAS_PER_TIER = 5
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class GroupStore:
def __init__(self, data_dir: Path) -> None:
self.groups = JsonStore(data_dir / "groups")
def create(self, *, org_id: str, creator_id: str, title: str) -> dict[str, Any]:
gid = new_id("group")
group = {
"id": gid,
"org_id": org_id,
"creator_id": creator_id,
"title": title or "Untitled group",
"status": "draft", # draft -> analyzing -> ready | failed
"created_at": _now(),
"updated_at": _now(),
"input": {}, # form fields
"sales_kit": None,
"personas": [], # full persona dicts
"report": None,
"error": None,
}
return self.groups.create(group, key=gid)
def get(self, gid: str) -> dict[str, Any]:
return self.groups.get(gid)
def get_or_none(self, gid: str) -> dict[str, Any] | None:
return self.groups.get_or_none(gid)
def update(self, gid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.groups.update(gid, **fields)
def list_for_org(self, org_id: str | None = None) -> list[dict[str, Any]]:
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
return groups
def list_visible_to(
self, *, role: str, org_id: str | None = None
) -> list[dict[str, Any]]:
"""List groups a given role/user can see. Trainees see only ready groups."""
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
if role == "user":
groups = [g for g in groups if g.get("status") == "ready"]
return groups
# ── personas ────────────────────────────────────────────────────────
def set_personas(self, gid: str, personas: list[dict[str, Any]]) -> dict[str, Any]:
personas = [ensure_persona_shape(p) for p in personas]
return self.groups.update(gid, personas=personas)
def get_persona(self, gid: str, pid: str) -> dict[str, Any] | None:
group = self.get(gid)
for p in group.get("personas", []):
if p.get("id") == pid:
return p
return None
def update_persona(self, gid: str, pid: str, patch: dict[str, Any]) -> dict[str, Any]:
group = self.get(gid)
found = False
for i, p in enumerate(group.get("personas", [])):
if p.get("id") == pid:
merged = {**p, **patch, "id": pid}
group["personas"][i] = ensure_persona_shape(merged)
found = True
break
if not found:
raise ValueError("persona not found")
return self.groups.replace(gid, group)

View File

@@ -0,0 +1,42 @@
"""Generate a user's own persona (private) from weak-area spec or a manual form."""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
OWN_PERSONA_SYSTEM = """You generate ONE customer persona for a sales-training simulator,
PRIVATE to a specific trainee. You produce valid JSON only: {"persona": { ... }}.
The persona dict must contain: name, tier, channel, initiation_mode, profession, age_group,
location, product_context (revealable), plus background, income, lifestyle, personality,
communication_style, budget, decision_timeline, goal, objections[], pains[] (with fit + rootCause
+ resolutionConditions), negotiation_levers[], opener, difficulty, special, notes.
The trainee wants to specifically practice against the described weakness/profile, so make this
persona HARD in exactly that dimension (e.g. heavy price negotiation, seller-initiated cold lead,
skeptical). Keep pains partially product-solvable for realism.
"""
def build_own_persona_user_prompt(*, mode: str, spec: dict[str, Any]) -> str:
if mode == "weak-area":
return (
"Mode: WEAK-AREA 'lock' persona. Generate a persona specifically targeting the "
"trainee's reported weaknesses:\n" + str(spec)
)
return "Mode: MANUAL. Generate a persona matching the trainee's description:\n" + str(spec)
def generate_own_persona(llm: LLMClient, *, mode: str, spec: dict[str, Any]) -> dict[str, Any]:
user_prompt = build_own_persona_user_prompt(mode=mode, spec=spec)
result = llm.complete_json(OWN_PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=7000)
persona = result.get("persona") or result
if not isinstance(persona, dict):
raise ValueError("own-persona generator returned invalid data")
persona.setdefault("tier", "B")
persona.setdefault("channel", "facebook")
persona.setdefault("initiation_mode", "customer")
persona.setdefault("pains", [])
persona.setdefault("negotiation_levers", [])
return persona

View File

@@ -0,0 +1,74 @@
"""Persona generator: builds 15 personas (5 per tier) from a Sales Kit + scenario."""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient
from .persona_prompts import PERSONA_SYSTEM
TIERS = ["A", "B", "C"]
PER_TIER = 5
class PersonaGenerator:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def generate(
self,
*,
sales_kit: dict[str, Any],
language: str = "en",
channel: str = "facebook",
) -> list[dict[str, Any]]:
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
lang_name = "Thai" if language == "th" else "English"
scenario = (sales_kit.get("scenarioFrame") or "").strip() or "a general product sale"
user_prompt = (
f"Platform/channel preference: {channel}\n"
f"Language: {lang_name} (all persona text in {lang_name})\n"
f"Sales Kit:\n{kit_json}\n\n"
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
)
result = self.llm.complete_json(
PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=14000
)
personas = result.get("personas") or []
if not isinstance(personas, list) or not personas:
raise ValueError("persona generator returned no personas")
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
for idx, p in enumerate(personas, start=1):
if not isinstance(p, dict):
continue
tier = p.get("tier", p.get("intent_tier"))
if tier not in TIERS:
tier = "B"
if counts[tier] >= PER_TIER:
continue # skip overflow per tier
counts[tier] += 1
p["id"] = f"persona-{idx:02d}"
p["tier"] = tier
p["channel"] = p.get("channel", channel)
p.setdefault("initiation_mode", "customer")
p.setdefault("special", "")
p.setdefault("difficulty", 1)
p.setdefault("pains", [])
p.setdefault("negotiation_levers", [])
p.setdefault("objections", [])
normalized.append(p)
# Wrap tier-C: ensure at least one wrong_text persona
if "C" in counts and not any(
p.get("special") == "wrong_text" for p in normalized
):
# find first tier-C and mark it
for p in normalized:
if p["tier"] == "C":
p["special"] = "wrong_text"
break
if len(normalized) < 15:
raise ValueError(f"expected 15 personas, generated {len(normalized)}")
return normalized

View File

@@ -0,0 +1,43 @@
"""Persona generation prompts (system + output schema instructions)."""
from __future__ import annotations
PERSONA_SYSTEM = """You are a world-class market-research persona designer for a sales-training
simulator. Given a Sales Kit (product facts + initial pain-fit) and a scenario frame, you generate
REALISTIC customer personas that a trainee will chat with to practice closing a sale.
Generate exactly 15 personas = 5 in tier A + 5 in tier B + 5 in tier C.
TIER MEANING:
- A = Ready to buy (has budget+authority+urgency, but still expects fit confirmation & handles 1-2
objections; can still WALK AWAY if the seller is rude or clearly wrong).
- B = Unsure / educating (researching; needs discovery, trust, proof, reason-to-act-now; stalls easily).
- C = Not interested but has pain (resistant, unaware/skeptical/budget-constrained, BUT has a real
unresolved pain; the ONLY path to close is surfacing and resolving it).
EACH persona MUST include ALL of these fields:
- name, tier, channel, initiation_mode
- profession, age_group, location, product_context (REVEALABLE - what a real seller could know)
- background, income, lifestyle, personality, communication_style (LATENT)
- budget, decision_timeline, goal, objections[] (LATENT)
- pains[] (LATENT)
- negotiation_levers[] (LATENT)
- opener, special, difficulty, notes
RULES:
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
personalities. Consistent with the product's target audience + scenario frame.
2. PAIN VARIETY: most pains do NOT map 1:1 to the product. Include pains the product solves
DIRECTLY (fit=strong), some only PARTIALLY solve (fit=partial), and some UNRELATED (fit=weak /
red herring). For each pain give: id, name, fit, description, rootCause, and resolutionConditions[]
(what the seller must satisfy to resolve it).
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).
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
5. CHANNEL: "facebook" or "line".
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.
7. difficulty 1-5. special="" unless wrong_text.
8. Language: output all human text in the requested language.
Only output valid JSON: {"personas": [ ... ]}
"""

View File

@@ -0,0 +1,92 @@
"""Report builder: assemble a human-readable analysis report from sales kit + personas."""
from __future__ import annotations
from typing import Any
TIER_NAMES = {
"A": ("Ready to buy", "ตั้งใจซื้อ"),
"B": ("Unsure / educating", "ไม่แน่ใจ"),
"C": ("Not interested but has pain", "ไม่สนใจแต่มี pain"),
}
def build_report(*, sales_kit: dict[str, Any], personas: list[dict[str, Any]], language: str = "th") -> dict[str, Any]:
thai = language == "th"
tiers: dict[str, list[dict[str, Any]]] = {"A": [], "B": [], "C": []}
for p in personas:
tiers.get(p.get("tier", "B"), []).append(p)
sections = []
sections.append({
"title": "Sales Kit / ข้อมูลสินค้า" if thai else "Sales Kit",
"content": _render_sales_kit(sales_kit, thai),
})
for tier, personas_list in tiers.items():
label = TIER_NAMES[tier][1 if thai else 0]
sections.append({
"title": f"Tier {tier}{label}",
"content": _render_tier(personas_list, thai),
})
return {
"title": f"{sales_kit.get('productName', 'Product')} — Sales Training Analysis",
"summary": "Customer personas + pain analysis for sales training.",
"language": language,
"sections": sections,
"raw_personas": personas,
}
def _render_sales_kit(kit: dict[str, Any], thai: bool) -> str:
lines = []
lines.append(f"**{'สินค้า' if thai else 'Product'}:** {kit.get('productName', '-')}")
if kit.get("category"):
lines.append(f"**{'หมวดหมู่' if thai else 'Category'}:** {kit['category']}")
if kit.get("valueProps"):
lines.append(f"**{'คุณค่า' if thai else 'Value props'}:** " + "; ".join(kit["valueProps"]))
if kit.get("features"):
lines.append(f"**{'ฟีเจอร์' if thai else 'Features'}:** " + "; ".join(kit["features"]))
if kit.get("pricingAnchors"):
lines.append(f"**{'ราคา' if thai else 'Pricing'}:** " + "; ".join(kit["pricingAnchors"]))
ta = kit.get("targetAudience") or {}
if ta.get("segment"):
lines.append(f"**{'กลุ่มเป้าหมาย' if thai else 'Target segment'}:** {ta['segment']}")
if kit.get("initialPainFit"):
lines.append(f"**{'Pain ที่สินค้าแก้ได้เบื้องต้น' if thai else 'Initial pain-fit'}:**")
for p in kit["initialPainFit"]:
lines.append(f"- ({p.get('fit', '?')}) {p.get('pain', '')}")
return "\n".join(lines)
def _render_tier(personas: list[dict[str, Any]], thai: bool) -> str:
if not personas:
return "_" + ("ไม่มี" if thai else "none") + "_"
blocks = []
for p in personas:
blocks.append(_render_persona(p, thai))
return "\n\n---\n\n".join(blocks)
def _render_persona(p: dict[str, Any], thai: bool) -> str:
lines = [f"### {p.get('name', '-')} (difficulty {p.get('difficulty', 1)})"]
lines.append(f"- {'อาชีพ' if thai else 'Profession'}: {p.get('profession', '-')} | "
f"{'อายุ' if thai else 'Age'}: {p.get('age_group', '-')} | "
f"{'ช่องทาง' if thai else 'Channel'}: {p.get('channel', 'facebook')} | "
f"{'เปิดบท' if thai else 'Initiation'}: {p.get('initiation_mode', 'customer')}")
if p.get("special"):
lines.append(f"- SPECIAL: {p['special']}")
lines.append(f"- {'พื้นหลัง' if thai else 'Background'}: {p.get('background', '-')}")
lines.append(f"- {'รายได้' if thai else 'Income'}: {p.get('income', '-')} | "
f"{'ไลฟ์สไตล์' if thai else 'Lifestyle'}: {p.get('lifestyle', '-')}")
lines.append(f"- {'นิสัย' if thai else 'Personality'}: {p.get('personality', '-')}")
if p.get("pains"):
lines.append(f"- {'Pain points (latent)' if thai else 'Pains (latent)'}:")
for pain in p.get("pains", []):
conds = "; ".join(pain.get("resolutionConditions", [])) if isinstance(pain, dict) else ""
lines.append(f" - [{pain.get('fit', '?') if isinstance(pain, dict) else '?'}] "
f"{pain.get('name', pain) if isinstance(pain, dict) else pain}"
f"{' — resolve: ' + conds if conds else ''}")
if p.get("negotiation_levers"):
levers = p.get("negotiation_levers") or []
lines.append(f"- {'ต่อรอง' if thai else 'Negotiation levers'}: " + ", ".join(str(x) for x in levers))
return "\n".join(lines)

View File

@@ -0,0 +1,81 @@
"""Training session store.
A session = one trainee's one-shot chat attempt against one persona. It records the
full transcript + internal state + outcome + debrief. One user may have at most one
session per persona (one-shot rule), enforced here.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class SessionStore:
def __init__(self, data_dir: Path) -> None:
self.sessions = JsonStore(data_dir / "sessions")
def create(
self,
*,
user_id: str,
group_id: str,
persona_id: str,
persona_name: str,
persona_meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
# One-shot: reject if the user already has a finished session on this persona
existing = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("outcome") in ("won", "lost")
)
if existing:
raise ValueError("you have already trained on this persona (one-shot)")
sid = new_id("session")
session = {
"id": sid,
"user_id": user_id,
"group_id": group_id,
"persona_id": persona_id,
"persona_name": persona_name,
"persona_meta": persona_meta or {},
"status": "active", # active | finished
"outcome": None, # won | lost | abandoned
"messages": [], # [{role, text, ts}]
"internal": {"trust": 50, "pain_progress": {}, "buying_signals": [], "tier": None},
"debrief": None,
"created_at": _now(),
"updated_at": _now(),
}
return self.sessions.create(session, key=sid)
def get(self, sid: str) -> dict[str, Any]:
return self.sessions.get(sid)
def get_or_none(self, sid: str) -> dict[str, Any] | None:
return self.sessions.get_or_none(sid)
def update(self, sid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.sessions.update(sid, **fields)
def active_for_persona(self, user_id: str, persona_id: str) -> dict[str, Any] | None:
hits = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("status") == "active"
)
return hits[0] if hits else None
def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
return sorted(
self.sessions.where(lambda r: r.get("user_id") == user_id),
key=lambda r: r.get("created_at", ""),
)

View File

@@ -0,0 +1,186 @@
"""Sales chat simulator: the trainee's chat engine against one persona.
Reuses the persona card + sales kit + chat history + internal state. A separate
judge-LLM decides outcome (won/lost) + scoring + coaching. Hidden/latent data is
never exposed mid-chat. Initiation is per-persona (customer or seller).
"""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient, LLMError
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
Stay perfectly in character at ALL times. Use {tone}.
CONTEXT ABOUT YOU (USE THIS — it is your truth, but DO NOT reveal latent details unless asked
naturally and it makes sense for a real customer to reveal them):
- Profession: {profession} | Age: {age_group} | Channel: {channel}
- Background: {background}
- Personality: {personality}
- Lifestyle: {lifestyle} | Income: {income}
- Budget: {budget} | Decision timeline: {decision_timeline}
- Your pains (some may be product-solvable, some NOT): {pains}
- Your negotiation levers: {levers}
- Your goal/mood: {goal}
Initiation mode: {init_mode}. {special_instr}
BEHAVIOR RULES:
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
delivery time, scope, payment).
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
and you may refuse to continue / walk away — even if you wanted the product.
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
pains unprompted.
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
5. Stay in character; never mention that you are a simulation or an AI persona.
Reply with a JSON object: {{"reply": "<your message>"}}
Only output that JSON.
"""
JUDGE_SYSTEM = """You are the JUDGE of a sales-training chat. Decide the outcome and score it.
A sale is CLOSED only if BOTH:
1. The seller resolved the customer's real pain(s) (the conditions that matter to this persona),
AND
2. The customer verbally accepts the offer/price (in the final exchange).
Otherwise it is LOST (or abandoned if the user ended early).
Scoring (0-100): painResolution + trust + objectionHandling are the only factors.
Return JSON:
{
"outcome": "won" | "lost",
"score": 0-100,
"pain": "the persona's key pain",
"why": "brief reason for won/lost",
"failurePoints": ["what went wrong, or []"],
"coaching": ["for each weak point, a concrete 'you should have said/asked this instead']",
"painProgress": {"painName": 0-100}
}
"""
class Simulator:
def __init__(self, llm: LLMClient, judge_llm: LLMClient | None = None) -> None:
self.llm = llm
self.judge_llm = judge_llm or llm
# ── persona reply ──────────────────────────────────────────────────
def persona_reply(
self,
*,
persona: dict[str, Any],
sales_kit: dict[str, Any],
messages: list[dict[str, str]],
internal: dict[str, Any],
) -> str:
pains_txt = self._describe_pains(persona.get("pains", []))
system = CHAT_SYSTEM.format(
name=persona.get("name", "Customer"),
tone=persona.get("communication_style", "natural, casual"),
profession=persona.get("profession", "customer"),
age_group=persona.get("age_group", "adult"),
channel=persona.get("channel", "facebook"),
background=persona.get("background", ""),
personality=persona.get("personality", ""),
lifestyle=persona.get("lifestyle", ""),
income=persona.get("income", ""),
budget=persona.get("budget", ""),
decision_timeline=persona.get("decision_timeline", ""),
pains=pains_txt,
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
goal=persona.get("goal", ""),
init_mode="you contacted the seller first (customer-initiated)"
if persona.get("initiation_mode") == "customer"
else "the seller opened the sale to you (you are a lead)",
special_instr=self._special_instr(persona),
)
msgs = [{"role": "system", "content": system}]
# send a compact recap of internal state to the persona ad
# (doesn't leak to trainee)
msgs.append({
"role": "system",
"content": "Internal state (for your role-play only): "
+ json.dumps(internal, ensure_ascii=False),
})
msgs.extend(messages[-30:]) # context window
try:
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
except LLMError as exc:
raise
# extract {reply: ...}
try:
data = json.loads(self._extract_json(resp))
reply = data.get("reply") or data.get("response") or str(resp)
except Exception:
reply = resp
return reply.strip()
# ── judge ──────────────────────────────────────────────────────────
def judge(
self,
*,
persona: dict[str, Any],
messages: list[dict[str, str]],
) -> dict[str, Any]:
persona_summary = json.dumps({
"name": persona.get("name"),
"pains": persona.get("pains", []),
"budget": persona.get("budget"),
"negotiation_levers": persona.get("negotiation_levers"),
"special": persona.get("special"),
}, ensure_ascii=False)
transcript = "\n".join(
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
)
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}"
try:
result = self.judge_llm.complete_json(
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
)
except LLMError as exc:
raise
result.setdefault("outcome", "lost")
result.setdefault("score", 0)
result.setdefault("pain", "")
result.setdefault("why", "")
result.setdefault("failurePoints", [])
result.setdefault("coaching", [])
result.setdefault("painProgress", {})
return result
# ── helpers ────────────────────────────────────────────────────────
def _describe_pains(self, pains: list[Any]) -> str:
if not pains:
return "(you have some personal frustrations, but the seller must find out)"
out = []
for p in pains:
if isinstance(p, dict):
out.append(
f"{p.get('name','pain')} (fit={p.get('fit','?')}): {p.get('description','')} "
f"root={p.get('rootCause','')}"
)
else:
out.append(str(p))
return "; ".join(out)
def _special_instr(self, persona: dict[str, Any]) -> str:
if persona.get("special") == "wrong_text":
return (
"SPECIAL: You opened as if ready to buy, but the moment the seller replies you act "
"disinterested and try to end the chat (e.g. 'never mind, forget it'). Deep down your "
"pain is still real. A seller who gently re-engages without pushing may earn a second "
"chance; a pushy seller drives you away for good."
)
return ""
def _extract_json(self, text: str) -> str:
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return text

View File

@@ -0,0 +1,75 @@
"""Persona data model + shape normalization.
A persona has a canonical schema. Fields are split into:
- revealable: shown to trainees up front (what a real seller could plausibly know)
- latent: hidden until the conversation ends (pain, income, personality, budget,
negotiation levers, hidden opener, etc.)
Every persona also carries an `intent_tier` (A/B/C), an `initiation_mode`
(customer/seller), a `channel` (facebook/line), a set of `pains` with resolution
conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text).
"""
from __future__ import annotations
from typing import Any
DEFAULT_TIERS = ["A", "B", "C"]
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
"""Fill defaults so a persona dict is always structurally complete."""
pid = p.get("id") or p.get("name", "persona")
base = {
"id": pid,
"name": p.get("name", ""),
"tier": p.get("tier", p.get("intent_tier", "B")),
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
"channel": p.get("channel", "facebook"), # facebook | line
# revealable
"profession": p.get("profession", ""),
"age_group": p.get("age_group", ""),
"location": p.get("location", ""),
"product_context": p.get("product_context", ""),
# latent (hidden until end)
"background": p.get("background", ""),
"income": p.get("income", ""),
"lifestyle": p.get("lifestyle", ""),
"personality": p.get("personality", ""),
"communication_style": p.get("communication_style", ""),
"budget": p.get("budget", ""),
"decision_timeline": p.get("decision_timeline", ""),
"goal": p.get("goal", ""),
"objections": p.get("objections", []),
"pains": p.get("pains", []),
"negotiation_levers": p.get("negotiation_levers", []),
"opener": p.get("opener", ""),
"special": p.get("special", ""), # e.g. "wrong_text" | ""
"difficulty": p.get("difficulty", 1), # 1..5
"notes": p.get("notes", ""),
}
# validate
if base["tier"] not in DEFAULT_TIERS:
base["tier"] = "B"
if base["initiation_mode"] not in ("customer", "seller"):
base["initiation_mode"] = "customer"
if base["channel"] not in ("facebook", "line"):
base["channel"] = "facebook"
return base
def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
"""Return ONLY the fields a trainee may see before/while chatting."""
return {
"id": p.get("id"),
"name": p.get("name"),
"tier": p.get("tier"),
"channel": p.get("channel"),
"initiation_mode": p.get("initiation_mode"),
"profession": p.get("profession"),
"age_group": p.get("age_group"),
"location": p.get("location"),
"product_context": p.get("product_context"),
}
def full_view(p: dict[str, Any]) -> dict[str, Any]:
return ensure_persona_shape(p)

View File

@@ -0,0 +1,81 @@
"""Trainee loop: win/lose board, weak-area analysis, user-generated personas.
A user never re-chats a persona. To keep training, they generate new personas —
either auto from their weak areas ("lock") or from a manual form. Generated
personas are private to the user.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
class MyPersonaStore:
def __init__(self, data_dir: Path) -> None:
self.personas = JsonStore(data_dir / "my_personas")
def _path_key(self, user_id: str, pid: str) -> str:
return f"{user_id}__{pid}"
def create(self, *, user_id: str, persona: dict[str, Any]) -> dict[str, Any]:
p = ensure_persona_shape(persona)
if "id" not in p or not p["id"]:
p["id"] = new_id("myp")
record = {
"key": self._path_key(user_id, p["id"]),
"user_id": user_id,
"persona": p,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
return self.personas.create(record, key=record["key"])
def list_for(self, user_id: str) -> list[dict[str, Any]]:
return [
r.get("persona")
for r in self.personas.where(lambda x: x.get("user_id") == user_id)
]
def analyze_weak_areas(sessions: list[dict[str, Any]]) -> dict[str, Any]:
"""Summarize which persona attributes a user tends to lose against."""
losses, wins = [], []
for ses in sessions:
if ses.get("outcome") == "won":
wins.append(ses)
elif ses.get("outcome") == "lost":
losses.append(ses)
def tally(key: str, label: str) -> list[dict[str, Any]]:
from collections import Counter
c = Counter()
for l in losses:
meta = l.get("persona_meta") or {}
v = meta.get(key)
if v is not None:
c[v] += 1
return [{"value": k, "losses": v} for k, v in c.most_common(3)]
return {
"total_sessions": len(sessions),
"wins": len(wins),
"losses": len(losses),
"by_tier": tally("tier", "tier"),
"by_initiation": tally("initiation_mode", "initiation"),
"by_channel": tally("channel", "channel"),
"top_loss_personas": [
{
"persona_id": l.get("persona_id"),
"persona_name": l.get("persona_name"),
"score": (l.get("debrief") or {}).get("score", 0),
"why": (l.get("debrief") or {}).get("why", ""),
}
for l in sorted(
losses, key=lambda x: (x.get("debrief") or {}).get("score", 0)
)[:5]
],
}

View File

@@ -0,0 +1 @@
"""Storage layer."""

View File

@@ -0,0 +1,138 @@
"""Durable filesystem JSON store.
Each entity is stored as its own JSON file under a per-type directory. Writes are
atomic (temp file + os.replace + fsync). Thread-safe via a per-path lock.
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from pathlib import Path
from typing import Any, Callable
from ..config import Config
class StoreError(Exception):
pass
class _Locks:
def __init__(self) -> None:
self._locks: dict[str, threading.RLock] = {}
self._guard = threading.Lock()
def get(self, key: str) -> threading.RLock:
with self._guard:
if key not in self._locks:
self._locks[key] = threading.RLock()
return self._locks[key]
_locks = _Locks()
def new_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:12]}"
def _atomic_write(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with tmp.open("w", encoding="utf-8") as fh:
json.dump(value, fh, ensure_ascii=False, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
finally:
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
def _read_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
class JsonStore:
"""Simple JSON-file collection with CRUD + locking."""
def __init__(self, root: Path, *, key_attr: str = "id") -> None:
self.root = root
self.key_attr = key_attr
self.root.mkdir(parents=True, exist_ok=True)
def _path(self, key: str) -> Path:
if not key or "/" in key or ".." in key:
raise StoreError("invalid id")
return self.root / f"{key}.json"
def create(self, value: dict[str, Any], *, key: str | None = None) -> dict[str, Any]:
key = key or value.get(self.key_attr) or new_id(self.key_attr)
if self.key_attr not in value:
value = dict(value)
value[self.key_attr] = key
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if path.exists():
raise StoreError(f"already exists: {key}")
_atomic_write(path, value)
return value
def get(self, key: str) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if not path.exists():
raise StoreError(f"not found: {key}")
return _read_json(path)
def get_or_none(self, key: str) -> dict[str, Any] | None:
try:
return self.get(key)
except StoreError:
return None
def update(self, key: str, **fields: Any) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if not path.exists():
raise StoreError(f"not found: {key}")
cur = _read_json(path)
cur.update(fields)
_atomic_write(path, cur)
return cur
def replace(self, key: str, value: dict[str, Any]) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
_atomic_write(path, value)
return value
def delete(self, key: str) -> None:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if path.exists():
path.unlink()
def all(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for path in sorted(self.root.glob("*.json")):
try:
out.append(_read_json(path))
except (OSError, ValueError):
continue
return out
def where(self, pred: Callable[[dict[str, Any]], bool]) -> list[dict[str, Any]]:
return [row for row in self.all() if pred(row)]