- 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
236 lines
7.7 KiB
Python
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)
|