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:
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
88
backend/app/api/admin_routes.py
Normal file
88
backend/app/api/admin_routes.py
Normal 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))})
|
||||
83
backend/app/api/analytics_routes.py
Normal file
83
backend/app/api/analytics_routes.py
Normal 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,
|
||||
})
|
||||
36
backend/app/api/auth_routes.py
Normal file
36
backend/app/api/auth_routes.py
Normal 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())})
|
||||
171
backend/app/api/chat_routes.py
Normal file
171
backend/app/api/chat_routes.py
Normal 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})
|
||||
235
backend/app/api/group_routes.py
Normal file
235
backend/app/api/group_routes.py
Normal 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)
|
||||
73
backend/app/api/helpers.py
Normal file
73
backend/app/api/helpers.py
Normal 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))
|
||||
121
backend/app/api/me_routes.py
Normal file
121
backend/app/api/me_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user