Files
sales-trainer/backend/app/api/group_routes.py
Macky c3d31c06e2 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
2026-08-07 15:31:06 +07:00

236 lines
7.7 KiB
Python

"""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)