Files
sales-trainer/backend/app/api/group_routes.py
Macky cb2642a659 fix(ui): no raw JSON on Training list, pain no longer [object Object], add Chat button on GroupEdit, drop facebook/line from persona
- list_groups returns lightweight summaries (id/title/status/persona_count + product) —
  never the full personas array, so Training no longer shows a long JSON blob.
- PersonaForm toLines extracts .description/.text from pain/objection objects (fixes
  '[object Object]'); save re-wraps pains as {description}.
- GroupEdit persona cards get a 'แชท' (MessageSquare) button so you can start chatting
  right where you land after creating (chat was only on Personas page before).
- channel: default neutral 'social', removed facebook/line validation + removed from
  revealable_view (scenario now sets the tone, not fb/line).
All 9 backend suites pass. Rebuilt dist.
2026-08-09 11:15:13 +07:00

368 lines
13 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__)
# Fields that encode the "formula"/process of a persona. Only super_admin may see/edit
# them; admins get the persona but NOT these — so a casual copy yields inferior results.
SECRET_PERSONA_FIELDS = {
"pains", "objections", "negotiation_levers", "opener",
"rootCause", "resolutionConditions", "tolerance",
}
def strip_secret_fields(persona: dict) -> dict:
"""Return a copy of a persona with secret/process fields removed."""
out = dict(persona)
for f in SECRET_PERSONA_FIELDS:
out.pop(f, None)
pains = out.get("pains")
if isinstance(pains, list):
cleaned = []
for p in pains:
if isinstance(p, dict):
p = {k: v for k, v in p.items() if k not in ("rootCause", "resolutionConditions")}
cleaned.append(p)
out["pains"] = cleaned
return out
_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 _authorize_group(group: dict) -> None:
"""Enforce org-scoped access (IDOR defense). super_admin may access any org.
Private/personal groups (owner_user_id set) are only accessible by their owner
(or super_admin), even within the same org.
"""
actor = current_user()
if actor.get("role") == "super_admin":
return
owner = group.get("owner_user_id")
if owner and owner != actor["id"]:
raise ApiError("permission denied", 403)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
def _get_owned_group(s, gid: str) -> dict:
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
_authorize_group(group)
return group
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"):
raw_name = file.filename or ""
# Path traversal defense: take only the basename, drop any directory
# segments and reject empty/unsafe names. Never trust client filename as a path.
safe_name = Path(raw_name).name
if not safe_name or safe_name in (".", "..", "/", "\\") or "/" in raw_name or "\\" in raw_name:
raise ApiError("invalid file name")
ext = safe_name.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('@','_')}__{safe_name}"
# Ensure resolved path stays inside the upload dir (defense in depth).
try:
dest.resolve(strict=False).relative_to(_upload_dir().resolve(strict=True))
except ValueError:
raise ApiError("invalid file path")
file.save(dest)
saved_files.append(dest.name)
if request.content_type and "multipart/form-data" in request.content_type:
data = request.form.to_dict()
else:
data = 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")
)
# Expose personal/private groups only to their owner (IDOR defense in listing).
if actor.get("role") != "super_admin":
visible = [
g
for g in visible
if not g.get("owner_user_id") or g.get("owner_user_id") == actor["id"]
]
# Lightweight summaries only — never send the full personas/sales_kit/report to a
# list view (huge, heavy, leaks the recipe). Training shows title + persona count.
summaries = []
for g in visible:
personas = g.get("personas") or []
summaries.append({
"id": g.get("id"),
"title": g.get("title", ""),
"status": g.get("status", "draft"),
"channel": g.get("channel"),
"org_id": g.get("org_id"),
"persona_count": len(personas),
"input": {"product": (g.get("input") or {}).get("product", "")},
})
return jsonify({"groups": summaries})
@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).
?append=true generates MORE personas and appends to existing ones instead of
replacing them (used by 'create more personas')."""
s = _stores()
group = _get_owned_group(s, gid)
append = request.args.get("append") == "true"
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
# If appending, reuse the existing sales kit; else re-run the full analysis.
sales_kit = group.get("sales_kit")
if not append or not sales_kit:
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"),
)
except Exception as exc:
s["groups"].update(gid, status="failed", error=str(exc))
raise ApiError(f"analysis failed: {exc}", 500)
s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None)
existing = []
if append:
existing = s["groups"].get_or_none(gid).get("personas", []) or []
try:
personas = PersonaGenerator(s["llm"]).generate(
sales_kit=sales_kit,
language=inp.get("language", "th"),
channel=inp.get("channel", "facebook"),
)
personas = existing + personas
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 = _get_owned_group(s, gid)
actor = current_user()
view = dict(group)
if actor.get("role") == "user":
# Trainee: hide latent persona fields + sales kit + report (they contain
# pain analysis / latent data a real seller wouldn't know before a result).
view["personas"] = [revealable_view(p) for p in group.get("personas", [])]
view["sales_kit"] = None
view["report"] = None
elif actor.get("role") != "super_admin":
# admin: see group, but not secret/process persona fields nor the sales-kit/report
# (pain-fit analysis is the IP to protect).
view["personas"] = [strip_secret_fields(p) for p in group.get("personas", [])]
view["sales_kit"] = None
view["report"] = None
return jsonify({"group": view})
@groups_bp.get("/<gid>/personas")
@require_auth
def list_personas(gid: str):
s = _stores()
group = _get_owned_group(s, gid)
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", [])]
elif actor.get("role") == "super_admin":
personas = group.get("personas", [])
else:
# admin: see persona but not the secret/process fields (IP protection)
personas = [strip_secret_fields(p) for p in 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 = _get_owned_group(s, gid)
p = s["groups"].get_persona(gid, pid)
if not p:
raise ApiError("persona not found", 404)
actor = current_user()
# Trainees may only view personas from ready groups (parity with list_personas).
if actor.get("role") == "user" and group.get("status") != "ready":
raise ApiError("group not ready", 403)
ensure = ensure_persona_shape(p)
if actor.get("role") == "user":
return jsonify({"persona": revealable_view(ensure)})
if actor.get("role") == "super_admin":
return jsonify({"persona": ensure})
# admin: hidden secret/process fields (IP protection)
return jsonify({"persona": strip_secret_fields(ensure)})
@groups_bp.put("/<gid>/personas/<pid>")
@require_auth
@require_roles("admin")
def update_persona(gid: str, pid: str):
s = _stores()
_get_owned_group(s, gid)
data = request.get_json(silent=True) or {}
actor = current_user()
# IP protection: only super_admin may set/alter secret formula fields.
if actor.get("role") != "super_admin":
for f in SECRET_PERSONA_FIELDS:
if f in data:
raise ApiError(f"field '{f}' is locked (super_admin only)", 403)
try:
updated = s["groups"].update_persona(gid, pid, data)
except ValueError as exc:
raise ApiError(str(exc), 404)
full = ensure_persona_shape(updated["personas"][
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
])
if actor.get("role") == "super_admin":
return jsonify({"persona": full})
return jsonify({"persona": strip_secret_fields(full)})
@groups_bp.delete("/<gid>")
@require_auth
@require_roles("admin")
def delete_group(gid: str):
"""Delete a persona group (admin only, own org)."""
s = _stores()
group = _get_owned_group(s, gid) # also enforces tenant org (super_admin global)
s["groups"].delete(gid)
# cascade: also remove finished/active sessions for this group's personas
try:
sess = s.get("session_store")
if sess and hasattr(sess, "sessions"):
for r in sess.sessions.all():
if r.get("group_id") == gid:
key = r.get("id") or r.get("sid")
if key:
sess.sessions.delete(key)
except Exception:
pass
return jsonify({"ok": True, "deleted": gid})
@groups_bp.post("/<gid>/reanalyze")
@require_auth
@require_roles("admin")
def reanalyze_group(gid: str):
return analyze_group(gid)