Files
sales-trainer/backend/app/api/group_routes.py
Macky 6a2e6a326f fix: restore legacy training data hidden by new visibility schema (migrate-on-read)
Records written before the visibility field existed carry none; the new
fail-closed authorization treated missing visibility as invalid, making every
legacy group unlistable and unreadable. Add resolved_visibility(group) that
derives effective visibility for legacy records only (owner present => private,
absent => public), leaves explicit-malformed visibility fail-closed (None), and
never derives demo/hidden. Apply it at every list, authorization, chat, and
analytics boundary while keeping demo and hidden-preview paths raw and
owner_user_id-based private isolation intact. No persisted data is rewritten.

Backend full suite passes 517; frontend 26/26; production build passes.
2026-08-25 08:59:22 +07:00

1152 lines
46 KiB
Python

"""Group API: create, analyze (sales kit + personas), read, edit, report."""
from __future__ import annotations
import copy
import functools
import math
import threading
import uuid
from pathlib import Path
from flask import Blueprint, Response, current_app, jsonify, request
from werkzeug.exceptions import RequestEntityTooLarge
from ..config import Config
from ..llm import LLMClient, LLMError
from ..services.groups import (
GroupStore,
group_visibility,
is_canonical_private_owner,
is_ready_group,
is_valid_owner_visibility,
resolved_visibility,
safe_group_input,
)
from ..services.store import (
PERSONA_CHANNELS,
ensure_persona_shape,
revealable_view,
validate_persona_traits,
)
from ..storage.store import contains_control_characters
from .helpers import (
ApiError,
current_user,
internal_error,
is_valid_tenant_id,
request_json_object,
require_auth,
require_roles,
)
groups_bp = Blueprint("groups", __name__)
def _group_analysis_lock(fn):
"""Serialize analysis and prevent stale LLM results from overwriting a group."""
@functools.wraps(fn)
def wrapped(gid: str, *args, **kwargs):
s = _stores()
with s["groups"].record_lock(gid):
return fn(gid, *args, **kwargs)
return wrapped
# 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",
}
# Tenant admins receive the approved review/edit surface, not arbitrary fields that
# may be added to the persona schema later. Add new fields deliberately.
ADMIN_VISIBLE_PERSONA_FIELDS = {
"id", "name", "tier", "initiation_mode", "channel", "profession", "age_group",
"location", "product_context", "background", "income", "lifestyle", "personality",
"communication_style", "budget", "decision_timeline", "goal", "difficulty", "notes",
"recontact",
}
ADMIN_EDITABLE_PERSONA_FIELDS = ADMIN_VISIBLE_PERSONA_FIELDS - {"id"}
# Keep the group envelope closed as well as the nested persona envelope. New
# internal fields must be deliberately added here before they can cross the API.
GROUP_VISIBLE_FIELDS = {
"id", "org_id", "title", "status",
"visibility", "created_at", "updated_at", "input", "sales_kit", "personas", "report", "error",
}
GROUP_INPUT_LIMITS = {
"product": 2000,
"segment": 2000,
"description": 4000,
"channel": 32,
"language": 16,
}
GROUP_INPUT_ENUMS = {
"channel": {"facebook", "line", "social"},
"language": {"th", "en"},
}
def strip_secret_fields(persona: dict) -> dict:
"""Return only explicitly approved tenant-admin persona fields."""
return {
field: persona[field]
for field in ADMIN_VISIBLE_PERSONA_FIELDS
if field in persona and field not in SECRET_PERSONA_FIELDS
}
def _validated_persona(persona: object) -> dict | None:
"""Normalize one persona only when its id and behavior traits are valid."""
if not isinstance(persona, dict):
return None
try:
shaped = ensure_persona_shape(persona)
if not isinstance(shaped.get("id"), str) or not shaped["id"].strip():
return None
shaped["id"] = shaped["id"].strip()
validate_persona_traits(shaped)
except (AttributeError, TypeError, ValueError):
return None
return shaped
def serialize_persona(persona: dict, actor: dict) -> dict:
"""Serialize a persona through the single role-aware policy."""
shaped = _validated_persona(persona)
if shaped is None:
raise ApiError("persona not found", 404)
if actor.get("role") == "super_admin":
return shaped
if actor.get("role") in {"user", "demo"}:
return revealable_view(shaped)
return strip_secret_fields(shaped)
def _canonical_personas(value: object) -> list[dict]:
"""Keep only persona records that can be addressed consistently by id."""
if not isinstance(value, list):
return []
return [persona for raw in value if (persona := _validated_persona(raw)) is not None]
def serialize_group(group: dict, actor: dict) -> dict:
"""Serialize a group without exposing latent persona recipe data."""
source = safe_group_input(group.get("input"))
personas = _canonical_personas(group.get("personas"))
view = {
field: group[field]
for field in GROUP_VISIBLE_FIELDS
if field in group
}
view["is_owned"] = (
is_canonical_private_owner(
group,
user_id=actor.get("id"),
org_id=actor.get("org_id"),
)
)
# Historical groups may contain legacy raw exception text. Normalize at the API
# boundary so old records cannot leak paths/provider responses either.
if view.get("error"):
view["error"] = "analysis_failed" if view.get("status") == "failed" else "operation_failed"
view["personas"] = (
[
serialize_persona(persona, actor)
for persona in personas
]
if group.get("status") == "ready"
else []
)
# Every client role receives only the bounded high-level input allowlist;
# uploaded filenames, parsed source text, and future internal fields stay
# server-side even for privileged API callers.
view["input"] = source
if actor.get("role") != "super_admin":
view["sales_kit"] = None
view["report"] = None
return view
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
_ANALYZE_GUARD = threading.Lock()
def _merge_preserved_variants(
existing: object, generated: list[dict]
) -> list[dict]:
"""Keep accepted variants when a ready group is re-analyzed.
Variants carry ``source_persona_id`` and are deliberate user-visible work.
Analysis replaces the generated base set, but must not silently discard those
appended records when the variant won the record lock just before analysis.
"""
generated_ids = {
persona.get("id")
for persona in generated
if isinstance(persona, dict) and isinstance(persona.get("id"), str)
}
prior_variants = [
persona
for raw in (existing if isinstance(existing, list) else [])
if (persona := _validated_persona(raw)) is not None
and isinstance(persona.get("source_persona_id"), str)
and bool(persona["source_persona_id"].strip())
and persona.get("id") not in generated_ids
]
return generated + prior_variants
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,
even to platform super-admins. Platform administration applies only to shared
records; an owner must explicitly share data before privileged access exists.
"""
if not isinstance(group, dict):
raise ApiError("group not found", 404)
actor = current_user()
if resolved_visibility(group) is None:
raise ApiError("group not found", 404)
owner_marker_present = "owner_user_id" in group
owner = group.get("owner_user_id")
actor_is_owner = is_canonical_private_owner(
group,
user_id=actor.get("id"),
org_id=actor.get("org_id"),
)
if actor_is_owner:
return
if actor.get("role") == "super_admin":
if not is_valid_owner_visibility(group) or "owner_user_id" in group:
raise ApiError("group not found", 404)
return
if actor.get("role") == "demo":
if (
actor.get("org_id") != Config.DEMO_ORG_ID
or group.get("org_id") != Config.DEMO_ORG_ID
or group.get("visibility") != "demo"
or "owner_user_id" in group
):
raise ApiError("group not found", 404)
return
if not is_valid_tenant_id(actor.get("org_id")) or not is_valid_tenant_id(group.get("org_id")):
raise ApiError("group not found", 404)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("group not found", 404)
if not is_valid_owner_visibility(group):
raise ApiError("permission denied", 403)
if actor.get("role") == "admin" and (
owner_marker_present or resolved_visibility(group) not in {"public", "hidden"}
):
raise ApiError("permission denied", 403)
if owner_marker_present and (
not isinstance(owner, str) or not owner.strip() or owner != actor.get("id")
):
raise ApiError("permission denied", 403)
def _get_owned_group(s, gid: str, *, require_ready: bool = True) -> dict:
group = s["groups"].get_or_none(gid)
if group is None:
raise ApiError("group not found", 404)
if not isinstance(group, dict):
raise ApiError("group not found", 404)
_authorize_group(group)
status = group.get("status")
if status not in {"draft", "analyzing", "ready", "failed"}:
raise ApiError("group not found", 404)
if status == "ready" and not is_ready_group(group):
raise ApiError("group not ready", 403)
if current_user().get("role") in {"user", "demo"}:
# Trainees may read only ready groups. Normal trainees see public shared
# groups and their own private groups; demos see only demo-visible groups.
is_demo = current_user().get("role") == "demo"
allowed_visibility = "demo" if is_demo else "public"
visibility = resolved_visibility(group) if not is_demo else group_visibility(group.get("visibility"))
if visibility is None:
raise ApiError("permission denied", 403)
if "owner_user_id" not in group and visibility != allowed_visibility:
raise ApiError("permission denied", 403)
owner_can_reopen = (
not require_ready
and current_user().get("role") == "user"
and group.get("owner_user_id") == current_user().get("id")
and group.get("status") in {"draft", "failed", "analyzing"}
)
if status != "ready" and not owner_can_reopen:
raise ApiError("group not ready", 403)
return group
def _upload_dir():
d = Config.DATA_DIR / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
def _cleanup_uploads(names: list[str]) -> None:
for name in names:
if (
not isinstance(name, str)
or not name
or Path(name).name != name
or "/" in name
or "\\" in name
or any(ord(char) < 32 or ord(char) == 127 for char in name)
):
current_app.logger.warning("skipping unsafe uploaded-file cleanup name")
continue
try:
name.encode("utf-8")
except UnicodeError:
current_app.logger.warning("skipping invalid uploaded-file cleanup name")
continue
try:
destination = _upload_dir() / name
destination.resolve(strict=False).relative_to(_upload_dir().resolve(strict=True))
destination.unlink(missing_ok=True)
except (UnicodeError, ValueError):
current_app.logger.warning("skipping uploaded-file cleanup outside upload directory")
except OSError as exc:
current_app.logger.error(
"uploaded-file cleanup failed (error_type=%s)", type(exc).__name__
)
def _inspect_upload_size(stream) -> int:
"""Return a known nonnegative upload size; unknown sizes fail closed."""
try:
stream.seek(0, 2)
size = stream.tell()
stream.seek(0)
except (OSError, AttributeError, TypeError, ValueError) as exc:
raise ApiError("could not inspect uploaded file", 400) from exc
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
raise ApiError("could not determine uploaded file size", 400)
return size
def _validate_upload_budget(
*, content_length: int | None, file_count: int, total_bytes: int
) -> None:
"""Fail closed before saving files when the request budget is unknowable/exceeded."""
max_bytes = Config.UPLOAD_MAX_MB * 1024 * 1024
if content_length is None:
raise ApiError("upload request size is unknown", 413)
if (
isinstance(content_length, bool)
or not isinstance(content_length, int)
or content_length < 0
or content_length > max_bytes
):
raise ApiError("upload request too large", 413)
if isinstance(file_count, bool) or not isinstance(file_count, int) or file_count < 0:
raise ApiError("invalid upload file count", 400)
if file_count > Config.UPLOAD_MAX_FILES:
raise ApiError("too many uploaded files", 413)
if isinstance(total_bytes, bool) or not isinstance(total_bytes, int) or total_bytes < 0:
raise ApiError("invalid upload size", 400)
if total_bytes > max_bytes:
raise ApiError("uploaded files too large", 413)
@groups_bp.post("")
@require_auth
@require_roles("user", "admin")
def create_group():
"""Create a persona group from a setup form + optional files.
A ``user`` (trainee) creates a PRIVATE group owned by themselves
(owner_user_id = self). An admin creates an org-shared product
(สินค้าขององค์กร) with a visibility of public (default) or hidden.
"""
s = _stores()
file_text = ""
saved_files = []
try:
is_multipart = bool(
request.content_type and "multipart/form-data" in request.content_type
)
if is_multipart:
_validate_upload_budget(
content_length=request.content_length,
file_count=0,
total_bytes=0,
)
files = request.files.getlist("files") if is_multipart else []
total_bytes = 0
destinations = []
for file in 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
or contains_control_characters(safe_name)
):
raise ApiError("invalid file name")
ext = safe_name.rsplit(".", 1)[-1].lower()
if ext not in Config.ALLOWED_UPLOAD_EXTS:
raise ApiError("unsupported file type")
max_bytes = Config.UPLOAD_MAX_MB * 1024 * 1024
size = _inspect_upload_size(file.stream)
if size > max_bytes:
raise ApiError("uploaded file too large", 413)
total_bytes += size
_validate_upload_budget(
content_length=request.content_length,
file_count=len(files),
total_bytes=total_bytes,
)
dest = _upload_dir() / (
f"{current_user()['id'].replace('@','_')}__{uuid.uuid4().hex[:12]}__{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")
# Register the destination before saving so a partial write is also
# removed if the storage operation raises.
destinations.append((file, dest))
for file, dest in destinations:
saved_files.append(dest.name)
file.save(dest)
except RequestEntityTooLarge:
_cleanup_uploads(saved_files)
raise
except ApiError:
_cleanup_uploads(saved_files)
raise
except Exception as exc:
_cleanup_uploads(saved_files)
raise internal_error("could not save uploaded file", exc, 400)
keep_uploads = False
try:
try:
if request.content_type and "multipart/form-data" in request.content_type:
data = request.form.to_dict()
else:
data = request_json_object()
except RequestEntityTooLarge:
raise
except Exception as exc:
raise internal_error("invalid upload request", exc, 400)
# Keep parser import and every post-save parse operation inside the same
# cleanup-safe boundary. Import failures can happen after files are saved.
try:
from ..services.file_parser import ParseError, parse_document
max_chars = Config.UPLOAD_MAX_EXTRACTED_CHARS
for name in saved_files:
parsed = parse_document(_upload_dir() / name)
if not isinstance(parsed, str):
raise ValueError("parser returned non-text output")
remaining = max_chars - len(file_text) - 2
if remaining < 0 or len(parsed) > remaining:
raise ParseError("extracted text too large")
file_text += "\n\n" + parsed
except Exception as exc:
raise internal_error("could not parse uploaded file", exc, 400)
string_fields = {"product": "", "segment": "", "description": "", "channel": "facebook", "language": "th"}
for field, default in string_fields.items():
value = data.get(field)
if value is not None and not isinstance(value, str):
raise ApiError(f"{field} must be a string", 400)
normalized = (value if value is not None else default).strip()
if len(normalized) > GROUP_INPUT_LIMITS[field]:
raise ApiError(f"{field} is too long", 400)
allowed = GROUP_INPUT_ENUMS.get(field)
if allowed is not None and normalized not in allowed:
raise ApiError(f"{field} is invalid", 400)
string_fields[field] = normalized
product = string_fields["product"]
if product == "" and not file_text.strip():
raise ApiError("provide product info in the form or via file upload")
actor = current_user()
actor_org_id = actor.get("org_id")
if not is_valid_tenant_id(actor_org_id):
raise ApiError("permission denied", 403)
is_trainee = actor.get("role") == "user"
# Trainees always create a PRIVATE group owned by themselves. Admins
# create org-shared products. Only a super_admin may target another
# organization or publish a demo-visible group.
visibility_raw = data.get("visibility")
requested_org_raw = data.get("org_id")
if is_trainee:
owner_user_id = actor.get("id")
visibility = "private"
target_org_id = actor_org_id
else:
owner_user_id = None
if visibility_raw is not None and not isinstance(visibility_raw, str):
raise ApiError("visibility must be a string", 400)
visibility = "public" if visibility_raw is None else visibility_raw.strip()
if actor.get("role") == "super_admin":
if requested_org_raw is not None and not isinstance(requested_org_raw, str):
raise ApiError("org_id must be a string", 400)
target_org_id = (requested_org_raw or actor_org_id).strip()
if visibility == "demo":
if requested_org_raw is not None and target_org_id != Config.DEMO_ORG_ID:
raise ApiError("demo groups must use the demo organization", 403)
target_org_id = Config.DEMO_ORG_ID
elif not is_valid_tenant_id(target_org_id):
raise ApiError("invalid organization", 400)
else:
if requested_org_raw is not None and requested_org_raw != actor_org_id:
raise ApiError("permission denied", 403)
target_org_id = actor_org_id
allowed_visibility = ("public", "hidden", "demo") if actor.get("role") == "super_admin" else ("public", "hidden")
if visibility not in allowed_visibility:
raise ApiError("visibility must be 'public' or 'hidden'")
if visibility == "demo" and target_org_id != Config.DEMO_ORG_ID:
raise ApiError("demo groups must use the demo organization", 403)
if not is_valid_tenant_id(target_org_id):
raise ApiError("permission denied", 403)
if visibility == "demo":
try:
s["users"].ensure_demo_org()
except Exception as exc:
raise internal_error("demo organization unavailable", exc, 503)
else:
target_org = s["users"].get_org_or_none(target_org_id)
if not target_org or target_org.get("active") is not True:
raise ApiError("organization is inactive or missing", 403)
try:
group = s["groups"].create(
org_id=target_org_id,
creator_id=actor["id"],
title=(product or file_text[:80] or "Untitled group").strip()[:200],
owner_user_id=owner_user_id,
visibility=visibility,
input_data={
"product": product,
"segment": string_fields["segment"],
"description": string_fields["description"],
"channel": string_fields["channel"],
"language": string_fields["language"],
"files": saved_files,
"file_text": file_text[:60000],
},
)
except Exception as exc:
raise internal_error("could not create group", exc, 500)
response = jsonify({
"group": serialize_group(s["groups"].get(group["id"]), current_user())
})
keep_uploads = True
return response, 201
finally:
if not keep_uploads:
_cleanup_uploads(saved_files)
@groups_bp.get("")
@require_auth
def list_groups():
s = _stores()
actor = current_user()
if actor.get("role") != "super_admin" and (
not is_valid_tenant_id(actor.get("org_id"))
):
raise ApiError("invalid tenant context", 403)
visible = s["groups"].list_visible_to(
role=actor.get("role"),
# Platform super-admins need the cross-tenant group index; all other
# roles remain constrained to the authenticated organization's scope.
org_id=None if actor.get("role") == "super_admin" else actor.get("org_id"),
user_id=actor.get("id"),
actor_org_id=actor.get("org_id"),
)
# ``list_visible_to`` applies the trainee owner/redaction policy at the
# service boundary; keep this route limited to lightweight summaries.
# 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 = _canonical_personas(g.get("personas")) if g.get("status") == "ready" else []
group_input = safe_group_input(g.get("input"))
summaries.append({
"id": g.get("id"),
"title": g.get("title", ""),
"status": g.get("status", "draft"),
"visibility": resolved_visibility(g) or "public",
"is_owned": g.get("is_owned") is True,
"channel": group_input.get("channel"),
"org_id": g.get("org_id"),
"persona_count": len(personas),
"input": {"product": group_input.get("product", "")},
})
return jsonify({"groups": summaries})
@groups_bp.post("/<gid>/analyze")
@require_auth
@require_roles("user", "admin")
@_group_analysis_lock
def analyze_group(gid: str):
"""Run one deterministic analysis: sales kit + exactly 15 personas.
A trainee may analyze their own private group (owner marker); an admin may
analyze (or re-analyze) any org-shared group.
"""
s = _stores()
# The owner (user or admin) may analyze; super_admin may analyze any group.
# Use direct authorization so a trainee owner can analyze their own draft
# private group (the ready gate in _get_owned_group must not block it).
group = s["groups"].get_or_none(gid)
if group is None:
raise ApiError("group not found", 404)
if not isinstance(group, dict):
raise ApiError("group not found", 404)
_authorize_group(group)
# A trainee may analyze only their OWN private group; they must never
# trigger (re)analysis of an org-shared product.
if current_user().get("role") == "user" and "owner_user_id" not in group:
raise ApiError("permission denied", 403)
if request.args.get("append") == "true":
raise ApiError("append mode is no longer supported", 400)
raw_input = group.get("input")
inp = safe_group_input(raw_input)
if isinstance(raw_input, dict):
for field, limit in (("description", 4000), ("file_text", 60000)):
value = raw_input.get(field)
if isinstance(value, str):
inp[field] = value[:limit]
if not s["llm"]:
raise ApiError("LLM not configured", 500)
requested_channel = inp.get("channel")
persona_channel = (
requested_channel
if isinstance(requested_channel, str) and requested_channel in PERSONA_CHANNELS
else "facebook"
)
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", "social"),
)
except Exception as exc:
s["groups"].update(gid, status="failed", error="analysis_failed")
raise internal_error("analysis failed", exc)
try:
generated_personas = PersonaGenerator(s["llm"]).generate(
sales_kit=sales_kit,
language=inp.get("language", "th"),
channel=persona_channel,
)
if not isinstance(generated_personas, list) or not generated_personas:
raise ValueError("persona generation returned no usable personas")
personas = [_validated_persona(persona) for persona in generated_personas]
if any(persona is None for persona in personas):
raise ValueError("persona generation returned malformed personas")
personas = [persona for persona in personas if persona is not None]
except Exception as exc:
s["groups"].update(gid, status="failed", error="analysis_failed")
raise internal_error("analysis failed", exc)
# Analysis replaces the generated base personas, but preserves accepted
# variants that were appended in the previous ready state.
current_before_publish = s["groups"].get(gid)
personas = _merge_preserved_variants(
current_before_publish.get("personas") if isinstance(current_before_publish, dict) else [],
personas,
)
from ..services.report import build_report
try:
report = build_report(sales_kit=sales_kit, personas=personas, language=inp.get("language", "th"))
except Exception as exc:
s["groups"].update(gid, status="failed", error="analysis_failed")
raise internal_error("analysis failed", exc)
# Publish the completed analysis as one atomic record replacement. Readers
# do not take the group mutation lock, so status must never become ready
# before personas/report/sales_kit are visible together.
try:
published = s["groups"].publish_analysis(
gid,
sales_kit=sales_kit,
personas=personas,
report=report,
)
except (TypeError, ValueError) as exc:
s["groups"].update(gid, status="failed", error="analysis_failed")
raise internal_error("analysis failed", exc)
serialized = serialize_group(published, current_user())
return jsonify({
"group": serialized,
"sales_kit": serialized.get("sales_kit"),
"personas": serialized["personas"],
})
@groups_bp.get("/<gid>")
@require_auth
def get_group(gid: str):
s = _stores()
# Owners need to reopen draft/failed products to retry analysis. Personas
# and chat routes keep the default ready-only gate below.
group = _get_owned_group(s, gid, require_ready=False)
actor = current_user()
return jsonify({"group": serialize_group(group, actor)})
@groups_bp.patch("/<gid>")
@groups_bp.patch("/<gid>/metadata")
@groups_bp.patch("/<gid>/visibility")
@require_auth
@require_roles("admin")
def update_group_metadata(gid: str):
"""Safely update group title/visibility without exposing arbitrary fields.
``/<gid>/visibility`` is a compatibility alias for the frontend client;
all three paths intentionally share the same allowlist and authorization.
"""
s = _stores()
group = s["groups"].get_or_none(gid)
if group is None:
raise ApiError("group not found", 404)
if not isinstance(group, dict):
raise ApiError("group not found", 404)
_authorize_group(group)
data = request_json_object()
unknown = sorted(set(data) - {"title", "visibility"})
if unknown:
raise ApiError(f"field '{unknown[0]}' cannot be changed", 400)
fields = {}
if "title" in data:
title = data["title"]
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 200:
raise ApiError("title must be a non-empty string of at most 200 characters", 400)
if contains_control_characters(title):
raise ApiError("title contains invalid characters", 400)
fields["title"] = title.strip()
if "visibility" in data:
visibility = data["visibility"]
if not isinstance(visibility, str):
raise ApiError("visibility must be a string", 400)
visibility = visibility.strip()
if visibility not in {"public", "hidden", "private", "demo"}:
raise ApiError("visibility is invalid", 400)
if "owner_user_id" in group:
if visibility != "private":
raise ApiError("private groups cannot change visibility", 403)
elif visibility == "private":
raise ApiError("shared groups cannot become private", 403)
elif visibility == "demo":
if current_user().get("role") != "super_admin":
raise ApiError("only super_admin can publish demo groups", 403)
if group.get("org_id") != Config.DEMO_ORG_ID:
raise ApiError("demo groups must use the demo organization", 403)
fields["visibility"] = visibility
if not fields:
raise ApiError("no editable fields supplied", 400)
try:
updated = s["groups"].update(gid, **fields)
except ValueError as exc:
raise internal_error("group update failed", exc, 400)
return jsonify({"group": serialize_group(updated, current_user())})
def _report_scalar(value, *, limit: int = 1000) -> str | None:
"""Allow only bounded scalar report values; never stringify nested internals."""
if isinstance(value, bool):
return None
if isinstance(value, int):
text = str(value)
elif isinstance(value, float):
if not math.isfinite(value):
return None
text = str(value)
elif isinstance(value, str):
text = value.strip()
else:
return None
return text[:limit] if text else None
def _report_scalar_list(value) -> list[str]:
if not isinstance(value, list):
return []
return [
scalar
for item in value[:20]
if (scalar := _report_scalar(item)) is not None
]
@groups_bp.get("/<gid>/report")
@require_auth
@require_roles("admin")
def group_report(gid: str):
"""Return a human-readable report without latent persona recipe fields."""
s = _stores()
group = _get_owned_group(s, gid)
if group.get("status") != "ready":
raise ApiError("group not ready", 404)
raw_sales_kit = group.get("sales_kit")
sales_kit: dict = raw_sales_kit if isinstance(raw_sales_kit, dict) else {}
raw_group_input = group.get("input")
group_input: dict = raw_group_input if isinstance(raw_group_input, dict) else {}
title = _report_scalar(group.get("title"), limit=200) or "Sales training report"
product = (
_report_scalar(sales_kit.get("productName"), limit=1000)
or _report_scalar(group_input.get("product"), limit=1000)
or "-"
)
lines = [
f"# {title}",
"",
"## Sales Kit",
f"- Product: {product}",
]
for label, key in (("Category", "category"), ("Value props", "valueProps"), ("Features", "features"), ("Pricing", "pricingAnchors")):
value = sales_kit.get(key)
if isinstance(value, list):
values = _report_scalar_list(value)
if values:
lines.append(f"- {label}: {', '.join(values)}")
else:
scalar = _report_scalar(value)
if scalar is not None:
lines.append(f"- {label}: {scalar}")
audience = sales_kit.get("targetAudience")
if isinstance(audience, dict):
segment = _report_scalar(audience.get("segment"))
if segment is not None:
lines.append(f"- Target segment: {segment}")
lines.extend(["", "## Personas"])
persona_fields = (
"name", "tier", "difficulty", "profession", "age_group", "location",
"product_context", "goal", "decision_timeline", "channel", "initiation_mode",
)
raw_personas = group.get("personas")
persona_records = raw_personas if isinstance(raw_personas, list) else []
for persona in persona_records:
if not isinstance(persona, dict):
continue
name = _report_scalar(persona.get("name"), limit=200) or "Persona"
lines.append(f"### {name}")
for field in persona_fields[1:]:
scalar = _report_scalar(persona.get(field))
if scalar is not None:
lines.append(f"- {field}: {scalar}")
lines.append("")
markdown = "\n".join(lines).strip() + "\n"
if request.args.get("format") == "json":
title = _report_scalar(group.get("title"), limit=200) or "Sales training report"
return jsonify({"title": title, "markdown": markdown})
return Response(
markdown,
mimetype="text/markdown",
headers={"Content-Disposition": "attachment; filename=sales-training-report.md"},
)
@groups_bp.get("/<gid>/personas")
@require_auth
def list_personas(gid: str):
s = _stores()
group = _get_owned_group(s, gid)
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
actor = current_user()
persona_records = _canonical_personas(group.get("personas")) if group.get("status") == "ready" else []
persona_ids = {
persona.get("id")
for persona in persona_records
if isinstance(persona, dict) and isinstance(persona.get("id"), str)
}
personas = [serialize_persona(p, actor) for p in persona_records]
# Attach per-user status (won/lost/not-tried) for EVERY role. The one-shot
# rule is per (user, persona): each user may chat a persona once, but the
# same persona can be trained by many different users. So my_outcome reflects
# THIS user's own finished sessions, regardless of admin/trainee role.
# Always set the key (default 'not_tried') so the frontend's all-roles logic
# never sees an undefined value; a global super_admin without an org simply
# has no org-scoped sessions and correctly shows every persona as untrained.
sess = s.get("session_store")
store = sess.sessions if sess else None
outcome_by_pid = {}
if store and isinstance(actor.get("org_id"), str):
mine = store.where(
lambda r: isinstance(r, dict)
and r.get("org_id") == actor.get("org_id")
and r.get("user_id") == actor["id"]
and r.get("group_id") == gid
and (r.get("mode") or "trainee") == "trainee"
and r.get("status") == "finished"
and r.get("outcome") in {"won", "lost"}
and r.get("persona_id") in persona_ids
)
from .chat_routes import _authorize_session_context
authorized_mine = []
for session in mine:
try:
_authorize_session_context(
s,
session,
gid=gid,
pid=session.get("persona_id"),
required_status="finished",
)
except ApiError:
continue
authorized_mine.append(session)
outcome_by_pid = {
r.get("persona_id"): r.get("outcome") for r in authorized_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)
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
p = s["groups"].get_persona(gid, pid)
if not p:
raise ApiError("persona not found", 404)
actor = current_user()
ensure = ensure_persona_shape(p)
return jsonify({"persona": serialize_persona(ensure, actor)})
@groups_bp.put("/<gid>/personas/<pid>")
@require_auth
@require_roles("admin")
@_group_analysis_lock
def update_persona(gid: str, pid: str):
s = _stores()
group = _get_owned_group(s, gid)
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
data = request_json_object()
actor = current_user()
# IP protection: only super_admin may set/alter secret formula fields.
if actor.get("role") != "super_admin":
unknown = sorted(set(data) - ADMIN_EDITABLE_PERSONA_FIELDS)
if unknown:
raise ApiError(f"field '{unknown[0]}' is locked (super_admin only)", 403)
current = s["groups"].get_persona(gid, pid)
if current is None:
raise ApiError("persona not found", 404)
try:
validate_persona_traits(ensure_persona_shape({**current, **data, "id": pid}))
except ValueError:
raise ApiError("invalid persona traits", 400)
try:
updated = s["groups"].update_persona(gid, pid, data)
except ValueError as exc:
raise internal_error("persona not found", exc, 404)
raw_personas = updated.get("personas") if isinstance(updated, dict) else None
persona = next(
(
candidate
for candidate in raw_personas or []
if isinstance(candidate, dict) and candidate.get("id") == pid
),
None,
) if isinstance(raw_personas, list) else None
if persona is None:
raise internal_error("persona update unavailable", ValueError("updated persona missing"))
full = ensure_persona_shape(persona)
return jsonify({"persona": serialize_persona(full, actor)})
@groups_bp.post("/<gid>/personas/<pid>/variant")
@require_auth
@require_roles("user", "admin")
def create_persona_variant(gid: str, pid: str):
"""Create a NEW persona cloned from an existing one (fresh identity, same core traits).
Lets a trainee practice the SAME selling challenge repeatedly even though each persona
can only be chatted once — the variant is a different person with the same pain points /
personality / temperament, so the training repeats but never as an identical copy.
Anyone who has access to the group can create a variant (admin or trainee).
"""
s = _stores()
group = _get_owned_group(s, gid)
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
raw_personas = group.get("personas")
persona_records = raw_personas if isinstance(raw_personas, list) else []
src = next((p for p in persona_records if isinstance(p, dict) and p.get("id") == pid), None)
if src is None:
raise ApiError("persona not found", 404)
group_input = safe_group_input(group.get("input"))
source_snapshot = {
"persona": copy.deepcopy(src),
"sales_kit": copy.deepcopy(group.get("sales_kit")),
"input": copy.deepcopy(group_input),
"visibility": group_visibility(group.get("visibility")),
"analysis_revision": group.get("analysis_revision", 0),
}
lang = group_input.get("language", "th")
try:
from ..services.persona_generator import PersonaGenerator
variant = PersonaGenerator(s["llm"]).generate_variant(
source=src,
sales_kit=group.get("sales_kit") or {},
language=lang,
)
except Exception as exc:
raise internal_error("variant failed", exc)
# Assign a unique id. Trainee-created variants belong to the trainee's private
# group; only admin-created variants extend the shared admin pool.
import uuid as _uuid
try:
variant = ensure_persona_shape(variant)
validate_persona_traits(variant)
except (AttributeError, TypeError, ValueError) as exc:
raise internal_error("variant returned invalid persona", exc, 502)
variant["id"] = f"persona-{_uuid.uuid4().hex[:10]}"
variant["source_persona_id"] = pid
actor = current_user()
target_group_id = gid
# Generation stays outside the lock. Publication re-authorizes and compares
# the complete generation source while locked, catching deletion, hiding,
# reanalysis, and same-ID trait replacement for both actor paths.
with s["groups"].record_lock(gid):
current_group = _get_owned_group(s, gid)
if not is_ready_group(current_group):
raise ApiError("group not ready", 403)
raw_current_personas = current_group.get("personas")
current_personas = (
[p for p in raw_current_personas if isinstance(p, dict)]
if isinstance(raw_current_personas, list)
else []
)
current_source = next(
(persona for persona in current_personas if persona.get("id") == pid),
None,
)
current_snapshot = {
"persona": current_source,
"sales_kit": current_group.get("sales_kit"),
"input": safe_group_input(current_group.get("input")),
"visibility": group_visibility(current_group.get("visibility")),
"analysis_revision": current_group.get("analysis_revision", 0),
}
if current_source is None or current_snapshot != source_snapshot:
raise ApiError("source persona changed during variant generation", 409)
if actor.get("role") == "user":
actor_org_id = actor.get("org_id")
if not is_valid_tenant_id(actor_org_id):
raise ApiError("permission denied", 403)
try:
private_group = s["groups"].append_private_persona(
org_id=actor_org_id,
owner_user_id=actor["id"],
owner_name=actor.get("name", "User"),
persona=variant,
input_data=group_input or None,
sales_kit=group.get("sales_kit") or None,
)
except ValueError as exc:
raise internal_error("private group unavailable", exc, 409)
target_group_id = private_group["id"]
else:
s["groups"].update(gid, personas=current_personas + [variant])
full = ensure_persona_shape(variant)
persona_out = serialize_persona(full, actor)
return jsonify({
"persona": persona_out,
"group_id": target_group_id,
"source_group_id": gid,
}), 201
@groups_bp.delete("/<gid>")
@require_auth
@require_roles("admin")
def delete_group(gid: str):
"""Delete a persona group (admin only, own org)."""
s = _stores()
# Hold the group lock while checking/deleting the group and every related
# session. Session start also takes this lock, so it cannot publish a new
# session after the cascade's snapshot and before group deletion.
with s["groups"].record_lock(gid):
group = _get_owned_group(s, gid, require_ready=False)
raw_input = group.get("input")
upload_names = (
[name for name in raw_input.get("files", []) if isinstance(name, str)]
if isinstance(raw_input, dict) and isinstance(raw_input.get("files"), list)
else []
)
sess = s.get("session_store")
group_org_id = group.get("org_id")
if sess and hasattr(sess, "sessions"):
for r in sess.sessions.all():
if (
not isinstance(r, dict)
or r.get("group_id") != gid
or r.get("org_id") != group_org_id
):
continue
key = r.get("id") or r.get("sid")
if not key:
continue
with sess.sessions.record_lock(key):
current = sess.sessions.get_or_none(key)
if (
isinstance(current, dict)
and current.get("group_id") == gid
and current.get("org_id") == group_org_id
):
sess.sessions.delete(key)
s["groups"].delete(gid)
_cleanup_uploads(upload_names)
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)