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.
1152 lines
46 KiB
Python
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)
|