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:
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)
|
||||
Reference in New Issue
Block a user