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

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