Per product-idea focus: - GroupBuilder: 'สินค้า/บริการ/ไอเดีย' label; removed the channel select (channel is now chosen at chat time as a scenario, not at persona create). Create now auto-runs analyze so personas are generated immediately (no separate Analyze button). - GroupEdit: analyze button becomes 'สร้างบุคคลต้นแบบเพิ่มเติม' which APPENDS more personas (backend analyze?append=true reuses sales kit + keeps existing instead of replacing). - i18n product label updated. - User-journey test asserts append adds personas (15->30) and existing kept. All 9 backend suites pass. Rebuilt dist.
332 lines
12 KiB
Python
332 lines
12 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"]
|
|
]
|
|
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).
|
|
|
|
?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.post("/<gid>/reanalyze")
|
|
@require_auth
|
|
@require_roles("admin")
|
|
def reanalyze_group(gid: str):
|
|
return analyze_group(gid)
|