Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)
- Auth/roles (no self-reg), admin user provision, JWT - Analyze: sales kit + initial pain-fit from form/upload - Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special - Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching - Trainee loop: win/lose board, weak-areas, user-generated personas - Admin analytics; EN+TH Vue SPA served by Flask - Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF - Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
6
backend/app/__init__.py
Normal file
6
backend/app/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Backend entry point."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .factory import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
88
backend/app/api/admin_routes.py
Normal file
88
backend/app/api/admin_routes.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Admin routes: user provisioning + role management (no self-registration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError
|
||||
from ..config import Config
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
def _store():
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["user_store"]
|
||||
|
||||
|
||||
@admin_bp.post("/users")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def create_user():
|
||||
"""Create a user + provision a password (invite). Admin or super-admin only."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
role = (data.get("role") or "user").strip()
|
||||
org_id = (data.get("org_id") or current_user().get("org_id") or "org-default").strip()
|
||||
|
||||
if not email or not password:
|
||||
raise ApiError("email and password are required")
|
||||
if role not in Config.ROLES:
|
||||
raise ApiError(f"invalid role: {role}")
|
||||
# Only super_admin can create another admin/super_admin
|
||||
actor_role = current_user().get("role")
|
||||
if role in ("admin", "super_admin") and actor_role != "super_admin":
|
||||
raise ApiError("only super_admin can grant admin roles", 403)
|
||||
try:
|
||||
user = _store().create_user(
|
||||
org_id=org_id, email=email, password=password, name=name, role=role
|
||||
)
|
||||
except AuthError as exc:
|
||||
raise ApiError(str(exc))
|
||||
return jsonify({"user": _store().public_user(user)}), 201
|
||||
|
||||
|
||||
@admin_bp.get("/users")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def list_users():
|
||||
actor = current_user()
|
||||
if actor.get("role") == "super_admin":
|
||||
users = _store().list_users()
|
||||
else:
|
||||
users = _store().list_users(org_id=actor.get("org_id"))
|
||||
return jsonify({"users": users})
|
||||
|
||||
|
||||
@admin_bp.put("/users/<email>")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def update_user(email: str):
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = email.strip().lower()
|
||||
actor = current_user()
|
||||
target = _store().get_user_or_none(email)
|
||||
if not target:
|
||||
raise ApiError("user not found", 404)
|
||||
|
||||
# Role changes / admin-modification restricted to super_admin
|
||||
if "role" in data:
|
||||
role = (data.get("role") or "").strip()
|
||||
if role not in Config.ROLES:
|
||||
raise ApiError(f"invalid role: {role}")
|
||||
if actor.get("role") != "super_admin":
|
||||
raise ApiError("only super_admin can change roles")
|
||||
_store().set_role(email, role)
|
||||
|
||||
if "active" in data:
|
||||
if actor.get("role") != "super_admin":
|
||||
raise ApiError("only super_admin can activate/deactivate users")
|
||||
_store().set_active(email, bool(data.get("active")))
|
||||
|
||||
if "password" in data and data.get("password"):
|
||||
_store().set_password(email, data.get("password"))
|
||||
|
||||
return jsonify({"user": _store().public_user(_store().get_user(email))})
|
||||
83
backend/app/api/analytics_routes.py
Normal file
83
backend/app/api/analytics_routes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Admin analytics: aggregate trainee results."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
|
||||
analytics_bp = Blueprint("analytics", __name__)
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
return {
|
||||
"sessions": current_app.extensions["session_store"],
|
||||
"groups": current_app.extensions["group_store"],
|
||||
"users": current_app.extensions["user_store"],
|
||||
}
|
||||
|
||||
|
||||
@analytics_bp.get("")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def analytics():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
if actor.get("role") == "super_admin":
|
||||
sessions = s["sessions"].sessions.all()
|
||||
users = s["users"].list_users()
|
||||
else:
|
||||
org_id = actor.get("org_id")
|
||||
# users in this org
|
||||
users = s["users"].list_users(org_id=org_id)
|
||||
user_ids = {u["id"] for u in users}
|
||||
sessions = [
|
||||
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
|
||||
]
|
||||
|
||||
overall = {
|
||||
"total_sessions": len(sessions),
|
||||
"wins": sum(1 for x in sessions if x.get("outcome") == "won"),
|
||||
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
|
||||
}
|
||||
overall["close_rate"] = round(
|
||||
overall["wins"] / overall["total_sessions"] * 100, 1
|
||||
) if overall["total_sessions"] else 0
|
||||
|
||||
# average score
|
||||
scores = [ (x.get("debrief") or {}).get("score", 0) for x in sessions if x.get("outcome") ]
|
||||
overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
|
||||
|
||||
# hardest personas = personas with most losses (lowest avg score)
|
||||
by_persona: dict = {}
|
||||
for x in sessions:
|
||||
key = (x.get("group_id"), x.get("persona_id"), x.get("persona_name", "?"))
|
||||
if key not in by_persona:
|
||||
by_persona[key] = {"plays": 0, "losses": 0, "wins": 0, "scores": []}
|
||||
rec = by_persona[key]
|
||||
rec["plays"] += 1
|
||||
rec["scores"].append((x.get("debrief") or {}).get("score", 0))
|
||||
if x.get("outcome") == "won":
|
||||
rec["wins"] += 1
|
||||
elif x.get("outcome") == "lost":
|
||||
rec["losses"] += 1
|
||||
hardest = sorted(
|
||||
(
|
||||
{
|
||||
"persona_name": k[2],
|
||||
"plays": v["plays"],
|
||||
"wins": v["wins"],
|
||||
"losses": v["losses"],
|
||||
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1) if v["scores"] else 0,
|
||||
}
|
||||
for k, v in by_persona.items()
|
||||
),
|
||||
key=lambda r: (r["losses"], -r["avg_score"]),
|
||||
)[:10]
|
||||
|
||||
return jsonify({
|
||||
"overall": overall,
|
||||
"trainee_count": len(users),
|
||||
"hardest_personas": hardest,
|
||||
})
|
||||
36
backend/app/api/auth_routes.py
Normal file
36
backend/app/api/auth_routes.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Auth routes: login + current user. No self-registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError
|
||||
from .helpers import ApiError, current_user, require_auth
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def _store():
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["user_store"]
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
def login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
if not email or not password:
|
||||
raise ApiError("email and password are required")
|
||||
try:
|
||||
user = _store().verify(email, password)
|
||||
token = _store().issue_token(user)
|
||||
except AuthError as exc:
|
||||
raise ApiError(str(exc), 401)
|
||||
return jsonify({"token": token, "user": _store().public_user(user)})
|
||||
|
||||
|
||||
@auth_bp.get("/me")
|
||||
@require_auth
|
||||
def me():
|
||||
return jsonify({"user": _store().public_user(current_user())})
|
||||
171
backend/app/api/chat_routes.py
Normal file
171
backend/app/api/chat_routes.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..llm import LLMError
|
||||
from ..services.simulator import Simulator
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
|
||||
chat_bp = Blueprint("chat", __name__)
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
return {
|
||||
"groups": current_app.extensions["group_store"],
|
||||
"sessions": current_app.extensions["session_store"],
|
||||
"llm": current_app.extensions["llm"],
|
||||
}
|
||||
|
||||
|
||||
def _sim(group, persona):
|
||||
llm = _stores()["llm"]
|
||||
if not llm:
|
||||
raise ApiError("LLM not configured", 500)
|
||||
return Simulator(llm)
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def start_session(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group or group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 404)
|
||||
persona = s["groups"].get_persona(gid, pid)
|
||||
if not persona:
|
||||
raise ApiError("persona not found", 404)
|
||||
actor = current_user()
|
||||
# One-shot: reject if already finished this persona
|
||||
try:
|
||||
session = s["sessions"].create(
|
||||
user_id=actor["id"], group_id=gid, persona_id=pid,
|
||||
persona_name=persona.get("name", "?"),
|
||||
persona_meta={
|
||||
"tier": persona.get("tier"),
|
||||
"initiation_mode": persona.get("initiation_mode"),
|
||||
"channel": persona.get("channel"),
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ApiError(str(exc), 400)
|
||||
|
||||
sim = _sim(group, persona)
|
||||
# Seller-initiated: give the trainee an opening task (no persona message yet).
|
||||
init_mode = persona.get("initiation_mode", "customer")
|
||||
if init_mode == "customer":
|
||||
# Customer opens: inject the persona's opener as the first message.
|
||||
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
||||
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
|
||||
else:
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
task="The customer did NOT message first. You must open the sale — start the "
|
||||
"conversation with this lead (e.g. introduce yourself and engage with interest).",
|
||||
)
|
||||
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def send_message(gid: str, pid: str):
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
session = s["sessions"].active_for_persona(actor["id"], pid)
|
||||
if not session or session.get("group_id") != gid:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ApiError("message is empty")
|
||||
if len(text) > 2000:
|
||||
raise ApiError("message too long")
|
||||
|
||||
group = s["groups"].get_or_none(gid)
|
||||
persona = s["groups"].get_persona(gid, pid)
|
||||
messages = list(session.get("messages", []))
|
||||
messages.append({"role": "seller", "text": text})
|
||||
|
||||
sim = _sim(group, persona)
|
||||
try:
|
||||
reply = sim.persona_reply(
|
||||
persona=persona,
|
||||
sales_kit=group.get("sales_kit") or {},
|
||||
messages=messages,
|
||||
internal=session.get("internal", {}),
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
messages.append({"role": "customer", "text": reply})
|
||||
|
||||
s["sessions"].update(session["id"], messages=messages)
|
||||
return jsonify({"reply": reply, "messages": messages})
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def finish_session(gid: str, pid: str):
|
||||
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
session = s["sessions"].active_for_persona(actor["id"], pid)
|
||||
if not session or session.get("group_id") != gid:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
group = s["groups"].get_or_none(gid)
|
||||
persona = s["groups"].get_persona(gid, pid)
|
||||
|
||||
sim = _sim(group, persona)
|
||||
messages = session.get("messages", [])
|
||||
try:
|
||||
verdict = sim.judge(persona=persona, messages=messages)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
|
||||
outcome = "won" if verdict.get("outcome") == "won" else "lost"
|
||||
debrief = {
|
||||
**verdict,
|
||||
"revealed_persona": {
|
||||
"pains": persona.get("pains", []),
|
||||
"income": persona.get("income", ""),
|
||||
"personality": persona.get("personality", ""),
|
||||
"budget": persona.get("budget", ""),
|
||||
"negotiation_levers": persona.get("negotiation_levers", []),
|
||||
"opener": persona.get("opener", ""),
|
||||
"background": persona.get("background", ""),
|
||||
},
|
||||
}
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
status="finished",
|
||||
outcome=outcome,
|
||||
debrief=debrief,
|
||||
internal=session.get("internal", {}),
|
||||
)
|
||||
return jsonify({"session": s["sessions"].get(session["id"]), "debrief": debrief})
|
||||
|
||||
|
||||
@chat_bp.get("/sessions")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def my_sessions():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
sessions = s["sessions"].list_for_user(uid)
|
||||
return jsonify({"sessions": sessions})
|
||||
|
||||
|
||||
@chat_bp.get("/sessions/<sid>")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def get_session(sid: str):
|
||||
s = _stores()
|
||||
session = s["sessions"].get_or_none(sid)
|
||||
if not session or session.get("user_id") != current_user()["id"]:
|
||||
raise ApiError("session not found", 404)
|
||||
return jsonify({"session": session})
|
||||
235
backend/app/api/group_routes.py
Normal file
235
backend/app/api/group_routes.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Group API: create, analyze (sales kit + personas), read, edit, report."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import Config
|
||||
from ..llm import LLMClient, LLMError
|
||||
from ..services.groups import GroupStore
|
||||
from ..services.store import ensure_persona_shape, revealable_view
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
|
||||
groups_bp = Blueprint("groups", __name__)
|
||||
|
||||
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_ANALYZE_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
return {
|
||||
"groups": current_app.extensions.get("group_store"),
|
||||
"users": current_app.extensions["user_store"],
|
||||
"session_store": current_app.extensions.get("session_store"),
|
||||
"llm": current_app.extensions["llm"],
|
||||
}
|
||||
|
||||
|
||||
def _upload_dir():
|
||||
d = Config.DATA_DIR / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@groups_bp.post("")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def create_group():
|
||||
"""Create a persona group from a setup form + optional files."""
|
||||
s = _stores()
|
||||
file_text = ""
|
||||
saved_files = []
|
||||
|
||||
if request.files:
|
||||
for file in request.files.getlist("files"):
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
|
||||
if ext not in Config.ALLOWED_UPLOAD_EXTS:
|
||||
raise ApiError(f"unsupported file type: {ext}")
|
||||
dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{file.filename}"
|
||||
file.save(dest)
|
||||
saved_files.append(dest.name)
|
||||
|
||||
data = request.form.to_dict() if request.files else (request.get_json(silent=True) or {})
|
||||
|
||||
from ..services.file_parser import parse_document
|
||||
|
||||
for name in saved_files:
|
||||
try:
|
||||
file_text += "\n\n" + parse_document(_upload_dir() / name)
|
||||
except Exception as exc:
|
||||
raise ApiError(f"could not parse file {name}: {exc}")
|
||||
|
||||
product = (data.get("product") or "").strip()
|
||||
if product == "" and not file_text.strip():
|
||||
raise ApiError("provide product info in the form or via file upload")
|
||||
|
||||
group = s["groups"].create(
|
||||
org_id=current_user().get("org_id") or "org-default",
|
||||
creator_id=current_user()["id"],
|
||||
title=(product or file_text[:80] or "Untitled group").strip()[:200],
|
||||
)
|
||||
s["groups"].update(
|
||||
group["id"],
|
||||
input={
|
||||
"product": product,
|
||||
"segment": (data.get("segment") or ""),
|
||||
"description": (data.get("description") or ""),
|
||||
"channel": (data.get("channel") or "facebook"),
|
||||
"language": (data.get("language") or "th"),
|
||||
"files": saved_files,
|
||||
"file_text": file_text[:60000],
|
||||
},
|
||||
)
|
||||
return jsonify({"group": s["groups"].get(group["id"])}), 201
|
||||
|
||||
|
||||
@groups_bp.get("")
|
||||
@require_auth
|
||||
def list_groups():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
visible = s["groups"].list_visible_to(
|
||||
role=actor.get("role"), org_id=actor.get("org_id")
|
||||
)
|
||||
return jsonify({"groups": visible})
|
||||
|
||||
|
||||
@groups_bp.post("/<gid>/analyze")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def analyze_group(gid: str):
|
||||
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen)."""
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group:
|
||||
raise ApiError("group not found", 404)
|
||||
if group.get("org_id") != (current_user().get("org_id") or "org-default"):
|
||||
raise ApiError("permission denied", 403)
|
||||
|
||||
inp = group.get("input", {})
|
||||
if not s["llm"]:
|
||||
raise ApiError("LLM not configured", 500)
|
||||
|
||||
from ..services.analyzer import Analyzer
|
||||
from ..services.persona_generator import PersonaGenerator
|
||||
|
||||
s["groups"].update(gid, status="analyzing", error=None)
|
||||
try:
|
||||
sales_kit = Analyzer(s["llm"]).analyze(
|
||||
product=inp.get("product", ""),
|
||||
segment=inp.get("segment", ""),
|
||||
description=inp.get("description", ""),
|
||||
file_text=inp.get("file_text", ""),
|
||||
channel=inp.get("channel", "facebook"),
|
||||
)
|
||||
personas = PersonaGenerator(s["llm"]).generate(
|
||||
sales_kit=sales_kit,
|
||||
language=inp.get("language", "th"),
|
||||
channel=inp.get("channel", "facebook"),
|
||||
)
|
||||
except Exception as exc:
|
||||
s["groups"].update(gid, status="failed", error=str(exc))
|
||||
raise ApiError(f"analysis failed: {exc}", 500)
|
||||
|
||||
from ..services.report import build_report
|
||||
|
||||
report = build_report(sales_kit=sales_kit, personas=personas, language=inp.get("language", "th"))
|
||||
s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None)
|
||||
s["groups"].set_personas(gid, personas)
|
||||
s["groups"].update(gid, report=report)
|
||||
return jsonify({
|
||||
"group": s["groups"].get(gid),
|
||||
"sales_kit": sales_kit,
|
||||
"personas": s["groups"].get(gid)["personas"],
|
||||
})
|
||||
|
||||
|
||||
@groups_bp.get("/<gid>")
|
||||
@require_auth
|
||||
def get_group(gid: str):
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group:
|
||||
raise ApiError("group not found", 404)
|
||||
actor = current_user()
|
||||
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
|
||||
raise ApiError("permission denied", 403)
|
||||
|
||||
view = dict(group)
|
||||
if actor.get("role") == "user":
|
||||
# Trainee: hide latent persona fields + sales kit details they shouldn't see
|
||||
view["personas"] = [
|
||||
revealable_view(p) for p in group.get("personas", [])
|
||||
]
|
||||
return jsonify({"group": view})
|
||||
|
||||
|
||||
@groups_bp.get("/<gid>/personas")
|
||||
@require_auth
|
||||
def list_personas(gid: str):
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group:
|
||||
raise ApiError("group not found", 404)
|
||||
actor = current_user()
|
||||
if actor.get("role") == "user":
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
personas = [revealable_view(p) for p in group.get("personas", [])]
|
||||
else:
|
||||
personas = group.get("personas", [])
|
||||
# attach per-user status (won/lost/not-tried) for trainees
|
||||
if actor.get("role") == "user":
|
||||
sess = _stores().get("session_store")
|
||||
store = sess.sessions if sess else None
|
||||
mine = store.where(lambda r: r.get("user_id") == actor["id"] and r.get("group_id") == gid) if store else []
|
||||
outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine}
|
||||
for p in personas:
|
||||
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
|
||||
return jsonify({"personas": personas, "tiers": ["A", "B", "C"]})
|
||||
|
||||
|
||||
@groups_bp.get("/<gid>/personas/<pid>")
|
||||
@require_auth
|
||||
def get_persona(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group:
|
||||
raise ApiError("group not found", 404)
|
||||
p = s["groups"].get_persona(gid, pid)
|
||||
if not p:
|
||||
raise ApiError("persona not found", 404)
|
||||
actor = current_user()
|
||||
ensure = ensure_persona_shape(p)
|
||||
if actor.get("role") == "user":
|
||||
return jsonify({"persona": revealable_view(ensure)})
|
||||
return jsonify({"persona": ensure})
|
||||
|
||||
|
||||
@groups_bp.put("/<gid>/personas/<pid>")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def update_persona(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not group:
|
||||
raise ApiError("group not found", 404)
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
updated = s["groups"].update_persona(gid, pid, data)
|
||||
except ValueError as exc:
|
||||
raise ApiError(str(exc), 404)
|
||||
return jsonify({"persona": ensure_persona_shape(updated["personas"][
|
||||
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
|
||||
])})
|
||||
|
||||
|
||||
@groups_bp.post("/<gid>/reanalyze")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def reanalyze_group(gid: str):
|
||||
return analyze_group(gid)
|
||||
73
backend/app/api/helpers.py
Normal file
73
backend/app/api/helpers.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""JWT auth decorators + role guards + shared API helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import g, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError
|
||||
from ..config import Config
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, message: str, status: int = 400):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def _get_store():
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["user_store"]
|
||||
|
||||
|
||||
def current_user() -> dict[str, Any]:
|
||||
return g.user
|
||||
|
||||
|
||||
def require_auth(fn: Callable) -> Callable:
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
header = request.headers.get("Authorization", "")
|
||||
scheme, _, token = header.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
raise ApiError("authentication required", 401)
|
||||
try:
|
||||
payload = _get_store().decode_token(token)
|
||||
except AuthError as exc:
|
||||
raise ApiError(str(exc), 401)
|
||||
user = _get_store().get_user_or_none(payload.get("sub", ""))
|
||||
if not user or not user.get("active", True):
|
||||
raise ApiError("account is inactive", 401)
|
||||
g.user = user
|
||||
g.token_payload = payload
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_roles(*roles: str) -> Callable:
|
||||
def deco(fn: Callable) -> Callable:
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
role = g.user.get("role")
|
||||
# super_admin passes any role gate
|
||||
allowed = {"super_admin", *roles}
|
||||
if role not in allowed:
|
||||
raise ApiError("permission denied", 403)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def api_error_handler(err: ApiError):
|
||||
return jsonify({"error": err.message}), err.status
|
||||
|
||||
|
||||
def register_error_handlers(app) -> None:
|
||||
app.register_error_handler(ApiError, api_error_handler)
|
||||
app.register_error_handler(ValueError, lambda e: (jsonify({"error": str(e)}), 400))
|
||||
121
backend/app/api/me_routes.py
Normal file
121
backend/app/api/me_routes.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Trainee routes: win/lose board, weak-areas, generate own persona."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..llm import LLMError
|
||||
from ..services.trainee import MyPersonaStore, analyze_weak_areas
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
|
||||
me_bp = Blueprint("me", __name__)
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
return {
|
||||
"groups": current_app.extensions["group_store"],
|
||||
"sessions": current_app.extensions["session_store"],
|
||||
"my_personas": current_app.extensions.get("my_persona_store"),
|
||||
"llm": current_app.extensions["llm"],
|
||||
}
|
||||
|
||||
|
||||
@me_bp.get("/board")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def win_lose_board():
|
||||
"""Per-persona won/lost/not-tried across all groups the user sees."""
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
my_sessions = s["sessions"].list_for_user(uid)
|
||||
outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions}
|
||||
|
||||
groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id"))
|
||||
items = []
|
||||
for g in groups:
|
||||
for p in g.get("personas", []):
|
||||
key = (g["id"], p["id"])
|
||||
items.append({
|
||||
"group_id": g["id"],
|
||||
"group_title": g.get("title"),
|
||||
"persona_id": p["id"],
|
||||
"persona_name": p.get("name"),
|
||||
"tier": p.get("tier"),
|
||||
"my_outcome": outcome_by.get(key, "not_tried"),
|
||||
})
|
||||
return jsonify({"board": items})
|
||||
|
||||
|
||||
@me_bp.get("/weak-areas")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def weak_areas():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
sessions = s["sessions"].list_for_user(uid)
|
||||
insight = analyze_weak_areas(sessions)
|
||||
return jsonify({"insight": insight})
|
||||
|
||||
|
||||
def _personal_group(s, actor) -> dict:
|
||||
"""Return (or create) the user's private group holding their own personas."""
|
||||
groups = s["groups"].list_for_org(org_id=actor.get("org_id"))
|
||||
for g in groups:
|
||||
if g.get("owner_user_id") == actor["id"]:
|
||||
return g
|
||||
g = s["groups"].create(
|
||||
org_id=actor.get("org_id") or "org-default",
|
||||
creator_id=actor["id"],
|
||||
title=f"{actor.get('name','User')}'s private personas",
|
||||
)
|
||||
s["groups"].update(
|
||||
g["id"],
|
||||
status="ready",
|
||||
owner_user_id=actor["id"],
|
||||
input={"channel": "facebook", "language": "th"},
|
||||
sales_kit={"productName": "personal practice", "valueProps": [], "features": []},
|
||||
)
|
||||
return s["groups"].get(g["id"])
|
||||
|
||||
|
||||
@me_bp.get("/personas")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def my_personas():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
group = _personal_group(s, current_user())
|
||||
return jsonify({"group": group, "personas": group.get("personas", [])})
|
||||
|
||||
|
||||
@me_bp.post("/personas/generate")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
def generate_persona():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
mode = data.get("mode", "manual") # "weak-area" | "manual"
|
||||
spec = data.get("spec") or {}
|
||||
llm = s["llm"]
|
||||
if not llm:
|
||||
raise ApiError("LLM not configured", 500)
|
||||
if mode == "weak-area" and not spec:
|
||||
# auto-detect weak areas from this user's losses if no spec given
|
||||
sessions = s["sessions"].list_for_user(actor["id"])
|
||||
spec = analyze_weak_areas(sessions)
|
||||
from ..services.own_persona import generate_own_persona
|
||||
|
||||
try:
|
||||
persona = generate_own_persona(llm, mode=mode, spec=spec)
|
||||
except (LLMError, ValueError) as exc:
|
||||
raise ApiError(f"generation failed: {exc}", 500)
|
||||
|
||||
group = _personal_group(s, actor)
|
||||
group = s["groups"].get(group["id"])
|
||||
existing = group.get("personas", [])
|
||||
persona["id"] = f"myp-{len(existing)+1:02d}"
|
||||
existing.append(persona)
|
||||
s["groups"].set_personas(group["id"], existing)
|
||||
return jsonify({"persona": persona, "group": s["groups"].get(group["id"])}), 201
|
||||
1
backend/app/auth/__init__.py
Normal file
1
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auth package."""
|
||||
128
backend/app/auth/users.py
Normal file
128
backend/app/auth/users.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""User + organization store and auth logic (JWT, password hashing, roles)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ..config import Config
|
||||
from ..storage.store import JsonStore, StoreError, new_id
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.users = JsonStore(data_dir / "users")
|
||||
self.orgs = JsonStore(data_dir / "orgs")
|
||||
|
||||
# ── org ────────────────────────────────────────────────────────────
|
||||
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
|
||||
return self.orgs.create(
|
||||
{"name": name, "id": org_id or new_id("org")},
|
||||
key=org_id or new_id("org"),
|
||||
)
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any]:
|
||||
return self.orgs.get(org_id)
|
||||
|
||||
# ── users ──────────────────────────────────────────────────────────
|
||||
def create_user(
|
||||
self,
|
||||
*,
|
||||
org_id: str,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
role: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
if role not in Config.ROLES:
|
||||
raise AuthError(f"invalid role: {role}")
|
||||
org = self.orgs.get(org_id)
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
raise AuthError("email and password are required")
|
||||
if self.users.get_or_none(email) is not None:
|
||||
raise AuthError("a user with this email already exists")
|
||||
user = {
|
||||
"id": email, # email = unique id/username
|
||||
"email": email,
|
||||
"org_id": org_id,
|
||||
"org_name": org.get("name", ""),
|
||||
"name": name.strip() or email,
|
||||
"password_hash": generate_password_hash(password),
|
||||
"role": role,
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"active": True,
|
||||
}
|
||||
return self.users.create(user, key=email)
|
||||
|
||||
def get_user(self, email: str) -> dict[str, Any]:
|
||||
email = email.strip().lower()
|
||||
return self.users.get(email)
|
||||
|
||||
def get_user_or_none(self, email: str) -> dict[str, Any] | None:
|
||||
return self.users.get_or_none(email.strip().lower())
|
||||
|
||||
def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]:
|
||||
users = self.users.all()
|
||||
if org_id:
|
||||
users = [u for u in users if u.get("org_id") == org_id]
|
||||
# Redact password hash
|
||||
for u in users:
|
||||
u.pop("password_hash", None)
|
||||
return users
|
||||
|
||||
def set_active(self, email: str, active: bool) -> dict[str, Any]:
|
||||
return self.users.update(email.strip().lower(), active=active)
|
||||
|
||||
def set_role(self, email: str, role: str) -> dict[str, Any]:
|
||||
if role not in Config.ROLES:
|
||||
raise AuthError(f"invalid role: {role}")
|
||||
return self.users.update(email.strip().lower(), role=role)
|
||||
|
||||
def set_password(self, email: str, new_password: str) -> dict[str, Any]:
|
||||
if not new_password:
|
||||
raise AuthError("password is required")
|
||||
return self.users.update(
|
||||
email.strip().lower(),
|
||||
password_hash=generate_password_hash(new_password),
|
||||
)
|
||||
|
||||
# ── auth ───────────────────────────────────────────────────────────
|
||||
def verify(self, email: str, password: str) -> dict[str, Any]:
|
||||
user = self.get_user_or_none(email)
|
||||
if not user or not user.get("active", True):
|
||||
raise AuthError("invalid credentials")
|
||||
if not check_password_hash(user["password_hash"], password):
|
||||
raise AuthError("invalid credentials")
|
||||
return user
|
||||
|
||||
def issue_token(self, user: dict[str, Any]) -> str:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
payload = {
|
||||
"sub": user["email"],
|
||||
"org_id": user["org_id"],
|
||||
"role": user["role"],
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(hours=Config.JWT_EXPIRES_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
|
||||
|
||||
def decode_token(self, token: str) -> dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(
|
||||
token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO]
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthError("invalid or expired token") from exc
|
||||
|
||||
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
|
||||
u = dict(user)
|
||||
u.pop("password_hash", None)
|
||||
return u
|
||||
73
backend/app/config.py
Normal file
73
backend/app/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Configuration from environment / .env."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from backend/ (project root for this app)
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(_BACKEND_DIR / ".env", override=True)
|
||||
|
||||
|
||||
def _get_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def resolve_llm() -> tuple[str, str, str, str | None]:
|
||||
"""Return (base_url, model, api_key, provider_name)."""
|
||||
provider = os.environ.get("LLM_PROVIDER", "").strip()
|
||||
explicit_base = os.environ.get("LLM_BASE_URL", "").strip()
|
||||
explicit_model = os.environ.get("LLM_MODEL_NAME", "").strip()
|
||||
api_key = os.environ.get("LLM_API_KEY", "").strip() or None
|
||||
|
||||
presets = {
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
|
||||
"custom": ("", ""),
|
||||
}
|
||||
if provider and provider in presets:
|
||||
base_url, model = presets[provider]
|
||||
if explicit_base:
|
||||
base_url = explicit_base
|
||||
if explicit_model:
|
||||
model = explicit_model
|
||||
return base_url, model, api_key or "", provider
|
||||
# No/unknown provider: fall back to explicit config
|
||||
return (
|
||||
explicit_base or "https://api.openai.com/v1",
|
||||
explicit_model or "gpt-4o-mini",
|
||||
api_key or "",
|
||||
provider or None,
|
||||
)
|
||||
|
||||
|
||||
class Config:
|
||||
APP_NAME = "Sales Trainer"
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET", "dev-secret-change-me")
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
|
||||
|
||||
DATA_DIR = Path(
|
||||
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
|
||||
).resolve()
|
||||
|
||||
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.environ.get("FLASK_PORT", "5001"))
|
||||
FLASK_DEBUG = _get_bool("FLASK_DEBUG", True)
|
||||
|
||||
UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15"))
|
||||
ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"}
|
||||
|
||||
# LLM
|
||||
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm()
|
||||
|
||||
ROLES = ("super_admin", "admin", "user")
|
||||
|
||||
@classmethod
|
||||
def ensure_dirs(cls) -> None:
|
||||
for name in ("users", "orgs", "groups", "sessions"):
|
||||
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)
|
||||
103
backend/app/factory.py
Normal file
103
backend/app/factory.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Flask application factory."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
from .auth.users import AuthError, UserStore
|
||||
from .config import Config
|
||||
|
||||
|
||||
def bootstrap_admin(users: UserStore) -> None:
|
||||
"""Ensure a default org + super-admin exists on first run (no self-registration)."""
|
||||
email = "admin@salestrainer.local"
|
||||
org = users.orgs.get_or_none("org-default")
|
||||
if org is None:
|
||||
org = users.create_org("Default Organization", org_id="org-default")
|
||||
if users.get_user_or_none(email) is None:
|
||||
users.create_user(
|
||||
org_id=org["id"],
|
||||
email=email,
|
||||
password="admin123",
|
||||
name="Super Admin",
|
||||
role="super_admin",
|
||||
)
|
||||
print("[bootstrap] created default super-admin:", email, "/ admin123")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
Config.ensure_dirs()
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = Config.SECRET_KEY
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
from .api.auth_routes import auth_bp
|
||||
from .api.admin_routes import admin_bp
|
||||
from .api.group_routes import groups_bp
|
||||
from .api.chat_routes import chat_bp
|
||||
from .api.me_routes import me_bp
|
||||
from .api.analytics_routes import analytics_bp
|
||||
from .api.helpers import register_error_handlers
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
||||
app.register_blueprint(admin_bp, url_prefix="/api/admin")
|
||||
app.register_blueprint(groups_bp, url_prefix="/api/groups")
|
||||
app.register_blueprint(chat_bp, url_prefix="/api/chat")
|
||||
app.register_blueprint(me_bp, url_prefix="/api/me")
|
||||
app.register_blueprint(analytics_bp, url_prefix="/api/analytics")
|
||||
|
||||
register_error_handlers(app)
|
||||
|
||||
from .auth.users import UserStore
|
||||
from .llm import LLMClient
|
||||
from .llm import LLMError
|
||||
from .services.groups import GroupStore
|
||||
from .services.sessions import SessionStore
|
||||
from .services.trainee import MyPersonaStore
|
||||
|
||||
app.extensions["user_store"] = UserStore(Config.DATA_DIR)
|
||||
app.extensions["group_store"] = GroupStore(Config.DATA_DIR)
|
||||
app.extensions["session_store"] = SessionStore(Config.DATA_DIR)
|
||||
app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR)
|
||||
try:
|
||||
app.extensions["llm"] = LLMClient()
|
||||
except LLMError as exc:
|
||||
print(f"[warn] LLM not configured yet: {exc}")
|
||||
app.extensions["llm"] = None
|
||||
|
||||
bootstrap_admin(app.extensions["user_store"])
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": Config.APP_NAME}
|
||||
|
||||
# Serve built Vue frontend if present (production single-app mode).
|
||||
_register_frontend(app)
|
||||
return app
|
||||
|
||||
|
||||
def _register_frontend(app: Flask) -> None:
|
||||
from flask import send_from_directory
|
||||
|
||||
# repo-root frontend/dist (factory.py -> app/ -> backend/ -> repo root)
|
||||
dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||
if not (dist / "index.html").exists():
|
||||
print(f"[info] frontend build not found at {dist}; API-only mode")
|
||||
return
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(dist, "index.html")
|
||||
|
||||
@app.route("/<path:path>", methods=["GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"])
|
||||
def assets(path: str):
|
||||
# Never let the SPA fallback shadow API/auth routes: return 404 for them.
|
||||
if path.startswith("api/") or path.startswith("health"):
|
||||
return ("not found", 404)
|
||||
candidate = dist / path
|
||||
if candidate.is_file():
|
||||
return send_from_directory(dist, path)
|
||||
# SPA fallback for client-side routes
|
||||
return send_from_directory(dist, "index.html")
|
||||
131
backend/app/llm.py
Normal file
131
backend/app/llm.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""OpenAI-compatible LLM client (OpenAI / DeepSeek / custom base URL).
|
||||
|
||||
Mirrors the MiroFish provider-agnostic pattern. Credentials live in .env only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _strip_thinking_trace(text: str) -> str:
|
||||
"""Remove ReACT-style chain-of-thought / fences, keep the final JSON text."""
|
||||
for fence in ("```json", "```"):
|
||||
idx = text.rfind(fence)
|
||||
if idx != -1:
|
||||
after = text[idx:].lstrip()
|
||||
lang_len = after.find("\n")
|
||||
body = after[lang_len:] if lang_len != -1 else after
|
||||
end = body.rfind("```")
|
||||
if end != -1:
|
||||
body = body[:end]
|
||||
body = body.strip()
|
||||
if body:
|
||||
return body
|
||||
for marker in ("\n\n[", "\n\n{"):
|
||||
idx = text.rfind(marker)
|
||||
if idx != -1:
|
||||
candidate = text[idx:].strip()
|
||||
if candidate and candidate[0] in "{[":
|
||||
return candidate
|
||||
return text
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.model = model or Config.LLM_MODEL
|
||||
if not self.api_key:
|
||||
raise LLMError("LLM_API_KEY is not configured in .env")
|
||||
if not self.base_url:
|
||||
raise LLMError("LLM_BASE_URL is not configured (unknown provider)")
|
||||
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
temperature: float = 0.5,
|
||||
max_tokens: int = 3000,
|
||||
) -> str:
|
||||
try:
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
)
|
||||
except Exception as exc: # network/auth/provider
|
||||
raise LLMError(f"LLM call failed: {exc}") from exc
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
if not text:
|
||||
raise LLMError("LLM returned empty response")
|
||||
return text
|
||||
|
||||
def complete_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 6000,
|
||||
) -> dict[str, Any]:
|
||||
text = self.complete(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
text = _strip_thinking_trace(text)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
# Last-ditch: strip leading text before the first { or [
|
||||
match = re.search(r"[{\[].*[}\]]", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
raise LLMError(f"LLM returned invalid JSON: {exc}") from exc
|
||||
|
||||
def complete_conversation(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
temperature: float = 0.6,
|
||||
max_tokens: int = 1200,
|
||||
) -> str:
|
||||
try:
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise LLMError(f"LLM call failed: {exc}") from exc
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
if not text:
|
||||
raise LLMError("LLM returned empty response")
|
||||
return text
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service layer."""
|
||||
96
backend/app/services/analyzer.py
Normal file
96
backend/app/services/analyzer.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Analyzer: extracts a Sales Kit (product facts) + initial pain-fit from inputs.
|
||||
|
||||
Product data is used primarily to derive pains that persona generation can build
|
||||
against. The result also carries a `scenario` prompt that frames persona creation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..llm import LLMClient
|
||||
|
||||
SALES_KIT_SYSTEM = """You are an expert ecommerce/B2B analyst. Given product information
|
||||
(typed in a form and/or extracted from uploaded files), produce a structured Sales Kit.
|
||||
|
||||
Rules:
|
||||
- Output ONLY valid JSON with the exact keys requested.
|
||||
- Pain-fit: judge which pains / customer pain categories the product can PLAUSIBLY solve,
|
||||
and clearly distinguish "strong fit" from "partial / weak fit".
|
||||
- The product info is only initial grounding; personas may be reused across similar products.
|
||||
- If some fields are unknown, leave them as empty lists / empty strings (never invent specifics).
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"productName": string,
|
||||
"category": string,
|
||||
"valueProps": [string],
|
||||
"features": [string],
|
||||
"pricingAnchors": [string],
|
||||
"targetAudience": { "segment": string, "demographics": string, "useCases": [string] },
|
||||
"objectionHandlers": [string],
|
||||
"initialPainFit": [
|
||||
{ "pain": string, "fit": "strong"|"partial"|"weak", "evidence": string }
|
||||
],
|
||||
"scenarioFrame": string
|
||||
}
|
||||
The scenarioFrame is a one-paragraph description of the selling situation (who the seller,
|
||||
what channel, target segment) that will frame persona creation.
|
||||
"""
|
||||
|
||||
|
||||
class Analyzer:
|
||||
def __init__(self, llm: LLMClient) -> None:
|
||||
self.llm = llm
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
*,
|
||||
product: str = "",
|
||||
segment: str = "",
|
||||
description: str = "",
|
||||
file_text: str = "",
|
||||
channel: str = "facebook",
|
||||
) -> dict[str, Any]:
|
||||
# Build the merged product context (form wins over file text)
|
||||
product_src = product.strip() or file_text.strip() or ""
|
||||
context = (
|
||||
f"PRODUCT (form/typed):\n{product}\n\n" if product.strip() else ""
|
||||
)
|
||||
if segment.strip():
|
||||
context += f"INITIAL CUSTOMER SEGMENT:\n{segment}\n\n"
|
||||
if description.strip():
|
||||
context += f"ADDITIONAL DESCRIPTION / SCENARIO:\n{description}\n\n"
|
||||
if file_text.strip():
|
||||
context += f"UPLOADED FILE CONTENT:\n{file_text[:12000]}\n"
|
||||
if not context.strip():
|
||||
raise ValueError("no product information provided (form or file)")
|
||||
|
||||
user_prompt = (
|
||||
f"Channel: {channel}\n\n"
|
||||
f"Analyze the following and return the Sales Kit JSON:\n\n{context}"
|
||||
)
|
||||
result = self.llm.complete_json(
|
||||
SALES_KIT_SYSTEM, user_prompt, temperature=0.2, max_tokens=5000
|
||||
)
|
||||
|
||||
# Normalize shape defensively
|
||||
result.setdefault("productName", product_src[:200] or "Untitled product")
|
||||
result.setdefault("category", "")
|
||||
result.setdefault("valueProps", [])
|
||||
result.setdefault("features", [])
|
||||
result.setdefault("pricingAnchors", [])
|
||||
result.setdefault("targetAudience", {
|
||||
"segment": segment or "",
|
||||
"demographics": "",
|
||||
"useCases": [],
|
||||
})
|
||||
result.setdefault("objectionHandlers", [])
|
||||
result.setdefault("initialPainFit", [])
|
||||
result.setdefault("scenarioFrame", description or "")
|
||||
|
||||
for k in ("valueProps", "features", "pricingAnchors", "objectionHandlers"):
|
||||
if not isinstance(result[k], list):
|
||||
result[k] = []
|
||||
if not isinstance(result.get("initialPainFit"), list):
|
||||
result["initialPainFit"] = []
|
||||
return result
|
||||
46
backend/app/services/file_parser.py
Normal file
46
backend/app/services/file_parser.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""File parsing for uploaded documents (pdf / markdown / txt)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_pdf(path: Path) -> str:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
try:
|
||||
doc = fitz.open(path)
|
||||
except Exception as exc:
|
||||
raise ParseError(f"cannot open PDF: {exc}") from exc
|
||||
parts = []
|
||||
for page in doc:
|
||||
parts.append(page.get_text())
|
||||
doc.close()
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def parse_text(path: Path) -> str:
|
||||
import chardet
|
||||
|
||||
raw = path.read_bytes()
|
||||
# Try utf-8 first, else detect encoding
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
guess = chardet.detect(raw)
|
||||
enc = guess.get("encoding") or "utf-8"
|
||||
try:
|
||||
return raw.decode(enc, errors="replace")
|
||||
except Exception:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def parse_document(path: Path) -> str:
|
||||
ext = path.suffix.lower().lstrip(".")
|
||||
if ext == "pdf":
|
||||
return parse_pdf(path)
|
||||
return parse_text(path)
|
||||
97
backend/app/services/groups.py
Normal file
97
backend/app/services/groups.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Persona group store: groups hold a sale kit + personas + report.
|
||||
|
||||
A group is created by an admin from a setup form + optional files. After analyze,
|
||||
it contains `personas` (15 by default = 5 per tier) and a `report`. Groups are
|
||||
editable/re-analyzeable by admins. Trainees only read revealable views of personas
|
||||
and run one-shot sessions (sessions are stored separately).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..storage.store import JsonStore, new_id
|
||||
from .store import ensure_persona_shape
|
||||
|
||||
DEFAULT_TIERS = ["A", "B", "C"]
|
||||
PERSONAS_PER_TIER = 5
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
class GroupStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.groups = JsonStore(data_dir / "groups")
|
||||
|
||||
def create(self, *, org_id: str, creator_id: str, title: str) -> dict[str, Any]:
|
||||
gid = new_id("group")
|
||||
group = {
|
||||
"id": gid,
|
||||
"org_id": org_id,
|
||||
"creator_id": creator_id,
|
||||
"title": title or "Untitled group",
|
||||
"status": "draft", # draft -> analyzing -> ready | failed
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"input": {}, # form fields
|
||||
"sales_kit": None,
|
||||
"personas": [], # full persona dicts
|
||||
"report": None,
|
||||
"error": None,
|
||||
}
|
||||
return self.groups.create(group, key=gid)
|
||||
|
||||
def get(self, gid: str) -> dict[str, Any]:
|
||||
return self.groups.get(gid)
|
||||
|
||||
def get_or_none(self, gid: str) -> dict[str, Any] | None:
|
||||
return self.groups.get_or_none(gid)
|
||||
|
||||
def update(self, gid: str, **fields: Any) -> dict[str, Any]:
|
||||
fields.setdefault("updated_at", _now())
|
||||
return self.groups.update(gid, **fields)
|
||||
|
||||
def list_for_org(self, org_id: str | None = None) -> list[dict[str, Any]]:
|
||||
groups = self.groups.all()
|
||||
if org_id:
|
||||
groups = [g for g in groups if g.get("org_id") == org_id]
|
||||
return groups
|
||||
|
||||
def list_visible_to(
|
||||
self, *, role: str, org_id: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List groups a given role/user can see. Trainees see only ready groups."""
|
||||
groups = self.groups.all()
|
||||
if org_id:
|
||||
groups = [g for g in groups if g.get("org_id") == org_id]
|
||||
if role == "user":
|
||||
groups = [g for g in groups if g.get("status") == "ready"]
|
||||
return groups
|
||||
|
||||
# ── personas ────────────────────────────────────────────────────────
|
||||
def set_personas(self, gid: str, personas: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
personas = [ensure_persona_shape(p) for p in personas]
|
||||
return self.groups.update(gid, personas=personas)
|
||||
|
||||
def get_persona(self, gid: str, pid: str) -> dict[str, Any] | None:
|
||||
group = self.get(gid)
|
||||
for p in group.get("personas", []):
|
||||
if p.get("id") == pid:
|
||||
return p
|
||||
return None
|
||||
|
||||
def update_persona(self, gid: str, pid: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
group = self.get(gid)
|
||||
found = False
|
||||
for i, p in enumerate(group.get("personas", [])):
|
||||
if p.get("id") == pid:
|
||||
merged = {**p, **patch, "id": pid}
|
||||
group["personas"][i] = ensure_persona_shape(merged)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
raise ValueError("persona not found")
|
||||
return self.groups.replace(gid, group)
|
||||
42
backend/app/services/own_persona.py
Normal file
42
backend/app/services/own_persona.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Generate a user's own persona (private) from weak-area spec or a manual form."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..llm import LLMClient
|
||||
|
||||
OWN_PERSONA_SYSTEM = """You generate ONE customer persona for a sales-training simulator,
|
||||
PRIVATE to a specific trainee. You produce valid JSON only: {"persona": { ... }}.
|
||||
|
||||
The persona dict must contain: name, tier, channel, initiation_mode, profession, age_group,
|
||||
location, product_context (revealable), plus background, income, lifestyle, personality,
|
||||
communication_style, budget, decision_timeline, goal, objections[], pains[] (with fit + rootCause
|
||||
+ resolutionConditions), negotiation_levers[], opener, difficulty, special, notes.
|
||||
|
||||
The trainee wants to specifically practice against the described weakness/profile, so make this
|
||||
persona HARD in exactly that dimension (e.g. heavy price negotiation, seller-initiated cold lead,
|
||||
skeptical). Keep pains partially product-solvable for realism.
|
||||
"""
|
||||
|
||||
|
||||
def build_own_persona_user_prompt(*, mode: str, spec: dict[str, Any]) -> str:
|
||||
if mode == "weak-area":
|
||||
return (
|
||||
"Mode: WEAK-AREA 'lock' persona. Generate a persona specifically targeting the "
|
||||
"trainee's reported weaknesses:\n" + str(spec)
|
||||
)
|
||||
return "Mode: MANUAL. Generate a persona matching the trainee's description:\n" + str(spec)
|
||||
|
||||
|
||||
def generate_own_persona(llm: LLMClient, *, mode: str, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
user_prompt = build_own_persona_user_prompt(mode=mode, spec=spec)
|
||||
result = llm.complete_json(OWN_PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=7000)
|
||||
persona = result.get("persona") or result
|
||||
if not isinstance(persona, dict):
|
||||
raise ValueError("own-persona generator returned invalid data")
|
||||
persona.setdefault("tier", "B")
|
||||
persona.setdefault("channel", "facebook")
|
||||
persona.setdefault("initiation_mode", "customer")
|
||||
persona.setdefault("pains", [])
|
||||
persona.setdefault("negotiation_levers", [])
|
||||
return persona
|
||||
74
backend/app/services/persona_generator.py
Normal file
74
backend/app/services/persona_generator.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Persona generator: builds 15 personas (5 per tier) from a Sales Kit + scenario."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..llm import LLMClient
|
||||
from .persona_prompts import PERSONA_SYSTEM
|
||||
|
||||
TIERS = ["A", "B", "C"]
|
||||
PER_TIER = 5
|
||||
|
||||
|
||||
class PersonaGenerator:
|
||||
def __init__(self, llm: LLMClient) -> None:
|
||||
self.llm = llm
|
||||
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
sales_kit: dict[str, Any],
|
||||
language: str = "en",
|
||||
channel: str = "facebook",
|
||||
) -> list[dict[str, Any]]:
|
||||
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
|
||||
lang_name = "Thai" if language == "th" else "English"
|
||||
scenario = (sales_kit.get("scenarioFrame") or "").strip() or "a general product sale"
|
||||
user_prompt = (
|
||||
f"Platform/channel preference: {channel}\n"
|
||||
f"Language: {lang_name} (all persona text in {lang_name})\n"
|
||||
f"Sales Kit:\n{kit_json}\n\n"
|
||||
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
|
||||
)
|
||||
result = self.llm.complete_json(
|
||||
PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=14000
|
||||
)
|
||||
personas = result.get("personas") or []
|
||||
if not isinstance(personas, list) or not personas:
|
||||
raise ValueError("persona generator returned no personas")
|
||||
|
||||
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
|
||||
for idx, p in enumerate(personas, start=1):
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
tier = p.get("tier", p.get("intent_tier"))
|
||||
if tier not in TIERS:
|
||||
tier = "B"
|
||||
if counts[tier] >= PER_TIER:
|
||||
continue # skip overflow per tier
|
||||
counts[tier] += 1
|
||||
p["id"] = f"persona-{idx:02d}"
|
||||
p["tier"] = tier
|
||||
p["channel"] = p.get("channel", channel)
|
||||
p.setdefault("initiation_mode", "customer")
|
||||
p.setdefault("special", "")
|
||||
p.setdefault("difficulty", 1)
|
||||
p.setdefault("pains", [])
|
||||
p.setdefault("negotiation_levers", [])
|
||||
p.setdefault("objections", [])
|
||||
normalized.append(p)
|
||||
|
||||
# Wrap tier-C: ensure at least one wrong_text persona
|
||||
if "C" in counts and not any(
|
||||
p.get("special") == "wrong_text" for p in normalized
|
||||
):
|
||||
# find first tier-C and mark it
|
||||
for p in normalized:
|
||||
if p["tier"] == "C":
|
||||
p["special"] = "wrong_text"
|
||||
break
|
||||
|
||||
if len(normalized) < 15:
|
||||
raise ValueError(f"expected 15 personas, generated {len(normalized)}")
|
||||
return normalized
|
||||
43
backend/app/services/persona_prompts.py
Normal file
43
backend/app/services/persona_prompts.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Persona generation prompts (system + output schema instructions)."""
|
||||
from __future__ import annotations
|
||||
|
||||
PERSONA_SYSTEM = """You are a world-class market-research persona designer for a sales-training
|
||||
simulator. Given a Sales Kit (product facts + initial pain-fit) and a scenario frame, you generate
|
||||
REALISTIC customer personas that a trainee will chat with to practice closing a sale.
|
||||
|
||||
Generate exactly 15 personas = 5 in tier A + 5 in tier B + 5 in tier C.
|
||||
|
||||
TIER MEANING:
|
||||
- A = Ready to buy (has budget+authority+urgency, but still expects fit confirmation & handles 1-2
|
||||
objections; can still WALK AWAY if the seller is rude or clearly wrong).
|
||||
- B = Unsure / educating (researching; needs discovery, trust, proof, reason-to-act-now; stalls easily).
|
||||
- C = Not interested but has pain (resistant, unaware/skeptical/budget-constrained, BUT has a real
|
||||
unresolved pain; the ONLY path to close is surfacing and resolving it).
|
||||
|
||||
EACH persona MUST include ALL of these fields:
|
||||
- name, tier, channel, initiation_mode
|
||||
- profession, age_group, location, product_context (REVEALABLE - what a real seller could know)
|
||||
- background, income, lifestyle, personality, communication_style (LATENT)
|
||||
- budget, decision_timeline, goal, objections[] (LATENT)
|
||||
- pains[] (LATENT)
|
||||
- negotiation_levers[] (LATENT)
|
||||
- opener, special, difficulty, notes
|
||||
|
||||
RULES:
|
||||
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
|
||||
personalities. Consistent with the product's target audience + scenario frame.
|
||||
2. PAIN VARIETY: most pains do NOT map 1:1 to the product. Include pains the product solves
|
||||
DIRECTLY (fit=strong), some only PARTIALLY solve (fit=partial), and some UNRELATED (fit=weak /
|
||||
red herring). For each pain give: id, name, fit, description, rootCause, and resolutionConditions[]
|
||||
(what the seller must satisfy to resolve it).
|
||||
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
|
||||
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
|
||||
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
|
||||
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
|
||||
5. CHANNEL: "facebook" or "line".
|
||||
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
|
||||
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
|
||||
7. difficulty 1-5. special="" unless wrong_text.
|
||||
8. Language: output all human text in the requested language.
|
||||
Only output valid JSON: {"personas": [ ... ]}
|
||||
"""
|
||||
92
backend/app/services/report.py
Normal file
92
backend/app/services/report.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Report builder: assemble a human-readable analysis report from sales kit + personas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
TIER_NAMES = {
|
||||
"A": ("Ready to buy", "ตั้งใจซื้อ"),
|
||||
"B": ("Unsure / educating", "ไม่แน่ใจ"),
|
||||
"C": ("Not interested but has pain", "ไม่สนใจแต่มี pain"),
|
||||
}
|
||||
|
||||
|
||||
def build_report(*, sales_kit: dict[str, Any], personas: list[dict[str, Any]], language: str = "th") -> dict[str, Any]:
|
||||
thai = language == "th"
|
||||
tiers: dict[str, list[dict[str, Any]]] = {"A": [], "B": [], "C": []}
|
||||
for p in personas:
|
||||
tiers.get(p.get("tier", "B"), []).append(p)
|
||||
|
||||
sections = []
|
||||
sections.append({
|
||||
"title": "Sales Kit / ข้อมูลสินค้า" if thai else "Sales Kit",
|
||||
"content": _render_sales_kit(sales_kit, thai),
|
||||
})
|
||||
for tier, personas_list in tiers.items():
|
||||
label = TIER_NAMES[tier][1 if thai else 0]
|
||||
sections.append({
|
||||
"title": f"Tier {tier} — {label}",
|
||||
"content": _render_tier(personas_list, thai),
|
||||
})
|
||||
|
||||
return {
|
||||
"title": f"{sales_kit.get('productName', 'Product')} — Sales Training Analysis",
|
||||
"summary": "Customer personas + pain analysis for sales training.",
|
||||
"language": language,
|
||||
"sections": sections,
|
||||
"raw_personas": personas,
|
||||
}
|
||||
|
||||
|
||||
def _render_sales_kit(kit: dict[str, Any], thai: bool) -> str:
|
||||
lines = []
|
||||
lines.append(f"**{'สินค้า' if thai else 'Product'}:** {kit.get('productName', '-')}")
|
||||
if kit.get("category"):
|
||||
lines.append(f"**{'หมวดหมู่' if thai else 'Category'}:** {kit['category']}")
|
||||
if kit.get("valueProps"):
|
||||
lines.append(f"**{'คุณค่า' if thai else 'Value props'}:** " + "; ".join(kit["valueProps"]))
|
||||
if kit.get("features"):
|
||||
lines.append(f"**{'ฟีเจอร์' if thai else 'Features'}:** " + "; ".join(kit["features"]))
|
||||
if kit.get("pricingAnchors"):
|
||||
lines.append(f"**{'ราคา' if thai else 'Pricing'}:** " + "; ".join(kit["pricingAnchors"]))
|
||||
ta = kit.get("targetAudience") or {}
|
||||
if ta.get("segment"):
|
||||
lines.append(f"**{'กลุ่มเป้าหมาย' if thai else 'Target segment'}:** {ta['segment']}")
|
||||
if kit.get("initialPainFit"):
|
||||
lines.append(f"**{'Pain ที่สินค้าแก้ได้เบื้องต้น' if thai else 'Initial pain-fit'}:**")
|
||||
for p in kit["initialPainFit"]:
|
||||
lines.append(f"- ({p.get('fit', '?')}) {p.get('pain', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_tier(personas: list[dict[str, Any]], thai: bool) -> str:
|
||||
if not personas:
|
||||
return "_" + ("ไม่มี" if thai else "none") + "_"
|
||||
blocks = []
|
||||
for p in personas:
|
||||
blocks.append(_render_persona(p, thai))
|
||||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
|
||||
def _render_persona(p: dict[str, Any], thai: bool) -> str:
|
||||
lines = [f"### {p.get('name', '-')} (difficulty {p.get('difficulty', 1)})"]
|
||||
lines.append(f"- {'อาชีพ' if thai else 'Profession'}: {p.get('profession', '-')} | "
|
||||
f"{'อายุ' if thai else 'Age'}: {p.get('age_group', '-')} | "
|
||||
f"{'ช่องทาง' if thai else 'Channel'}: {p.get('channel', 'facebook')} | "
|
||||
f"{'เปิดบท' if thai else 'Initiation'}: {p.get('initiation_mode', 'customer')}")
|
||||
if p.get("special"):
|
||||
lines.append(f"- SPECIAL: {p['special']}")
|
||||
lines.append(f"- {'พื้นหลัง' if thai else 'Background'}: {p.get('background', '-')}")
|
||||
lines.append(f"- {'รายได้' if thai else 'Income'}: {p.get('income', '-')} | "
|
||||
f"{'ไลฟ์สไตล์' if thai else 'Lifestyle'}: {p.get('lifestyle', '-')}")
|
||||
lines.append(f"- {'นิสัย' if thai else 'Personality'}: {p.get('personality', '-')}")
|
||||
if p.get("pains"):
|
||||
lines.append(f"- {'Pain points (latent)' if thai else 'Pains (latent)'}:")
|
||||
for pain in p.get("pains", []):
|
||||
conds = "; ".join(pain.get("resolutionConditions", [])) if isinstance(pain, dict) else ""
|
||||
lines.append(f" - [{pain.get('fit', '?') if isinstance(pain, dict) else '?'}] "
|
||||
f"{pain.get('name', pain) if isinstance(pain, dict) else pain}"
|
||||
f"{' — resolve: ' + conds if conds else ''}")
|
||||
if p.get("negotiation_levers"):
|
||||
levers = p.get("negotiation_levers") or []
|
||||
lines.append(f"- {'ต่อรอง' if thai else 'Negotiation levers'}: " + ", ".join(str(x) for x in levers))
|
||||
return "\n".join(lines)
|
||||
81
backend/app/services/sessions.py
Normal file
81
backend/app/services/sessions.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Training session store.
|
||||
|
||||
A session = one trainee's one-shot chat attempt against one persona. It records the
|
||||
full transcript + internal state + outcome + debrief. One user may have at most one
|
||||
session per persona (one-shot rule), enforced here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..storage.store import JsonStore, new_id
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.sessions = JsonStore(data_dir / "sessions")
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
group_id: str,
|
||||
persona_id: str,
|
||||
persona_name: str,
|
||||
persona_meta: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# One-shot: reject if the user already has a finished session on this persona
|
||||
existing = self.sessions.where(
|
||||
lambda r: r.get("user_id") == user_id
|
||||
and r.get("persona_id") == persona_id
|
||||
and r.get("outcome") in ("won", "lost")
|
||||
)
|
||||
if existing:
|
||||
raise ValueError("you have already trained on this persona (one-shot)")
|
||||
sid = new_id("session")
|
||||
session = {
|
||||
"id": sid,
|
||||
"user_id": user_id,
|
||||
"group_id": group_id,
|
||||
"persona_id": persona_id,
|
||||
"persona_name": persona_name,
|
||||
"persona_meta": persona_meta or {},
|
||||
"status": "active", # active | finished
|
||||
"outcome": None, # won | lost | abandoned
|
||||
"messages": [], # [{role, text, ts}]
|
||||
"internal": {"trust": 50, "pain_progress": {}, "buying_signals": [], "tier": None},
|
||||
"debrief": None,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
return self.sessions.create(session, key=sid)
|
||||
|
||||
def get(self, sid: str) -> dict[str, Any]:
|
||||
return self.sessions.get(sid)
|
||||
|
||||
def get_or_none(self, sid: str) -> dict[str, Any] | None:
|
||||
return self.sessions.get_or_none(sid)
|
||||
|
||||
def update(self, sid: str, **fields: Any) -> dict[str, Any]:
|
||||
fields.setdefault("updated_at", _now())
|
||||
return self.sessions.update(sid, **fields)
|
||||
|
||||
def active_for_persona(self, user_id: str, persona_id: str) -> dict[str, Any] | None:
|
||||
hits = self.sessions.where(
|
||||
lambda r: r.get("user_id") == user_id
|
||||
and r.get("persona_id") == persona_id
|
||||
and r.get("status") == "active"
|
||||
)
|
||||
return hits[0] if hits else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
return sorted(
|
||||
self.sessions.where(lambda r: r.get("user_id") == user_id),
|
||||
key=lambda r: r.get("created_at", ""),
|
||||
)
|
||||
186
backend/app/services/simulator.py
Normal file
186
backend/app/services/simulator.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Sales chat simulator: the trainee's chat engine against one persona.
|
||||
|
||||
Reuses the persona card + sales kit + chat history + internal state. A separate
|
||||
judge-LLM decides outcome (won/lost) + scoring + coaching. Hidden/latent data is
|
||||
never exposed mid-chat. Initiation is per-persona (customer or seller).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..llm import LLMClient, LLMError
|
||||
|
||||
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
|
||||
Stay perfectly in character at ALL times. Use {tone}.
|
||||
|
||||
CONTEXT ABOUT YOU (USE THIS — it is your truth, but DO NOT reveal latent details unless asked
|
||||
naturally and it makes sense for a real customer to reveal them):
|
||||
- Profession: {profession} | Age: {age_group} | Channel: {channel}
|
||||
- Background: {background}
|
||||
- Personality: {personality}
|
||||
- Lifestyle: {lifestyle} | Income: {income}
|
||||
- Budget: {budget} | Decision timeline: {decision_timeline}
|
||||
- Your pains (some may be product-solvable, some NOT): {pains}
|
||||
- Your negotiation levers: {levers}
|
||||
- Your goal/mood: {goal}
|
||||
Initiation mode: {init_mode}. {special_instr}
|
||||
|
||||
BEHAVIOR RULES:
|
||||
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
|
||||
delivery time, scope, payment).
|
||||
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
|
||||
and you may refuse to continue / walk away — even if you wanted the product.
|
||||
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
|
||||
pains unprompted.
|
||||
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
|
||||
5. Stay in character; never mention that you are a simulation or an AI persona.
|
||||
|
||||
Reply with a JSON object: {{"reply": "<your message>"}}
|
||||
Only output that JSON.
|
||||
"""
|
||||
|
||||
JUDGE_SYSTEM = """You are the JUDGE of a sales-training chat. Decide the outcome and score it.
|
||||
|
||||
A sale is CLOSED only if BOTH:
|
||||
1. The seller resolved the customer's real pain(s) (the conditions that matter to this persona),
|
||||
AND
|
||||
2. The customer verbally accepts the offer/price (in the final exchange).
|
||||
|
||||
Otherwise it is LOST (or abandoned if the user ended early).
|
||||
|
||||
Scoring (0-100): painResolution + trust + objectionHandling are the only factors.
|
||||
Return JSON:
|
||||
{
|
||||
"outcome": "won" | "lost",
|
||||
"score": 0-100,
|
||||
"pain": "the persona's key pain",
|
||||
"why": "brief reason for won/lost",
|
||||
"failurePoints": ["what went wrong, or []"],
|
||||
"coaching": ["for each weak point, a concrete 'you should have said/asked this instead']",
|
||||
"painProgress": {"painName": 0-100}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Simulator:
|
||||
def __init__(self, llm: LLMClient, judge_llm: LLMClient | None = None) -> None:
|
||||
self.llm = llm
|
||||
self.judge_llm = judge_llm or llm
|
||||
|
||||
# ── persona reply ──────────────────────────────────────────────────
|
||||
def persona_reply(
|
||||
self,
|
||||
*,
|
||||
persona: dict[str, Any],
|
||||
sales_kit: dict[str, Any],
|
||||
messages: list[dict[str, str]],
|
||||
internal: dict[str, Any],
|
||||
) -> str:
|
||||
pains_txt = self._describe_pains(persona.get("pains", []))
|
||||
system = CHAT_SYSTEM.format(
|
||||
name=persona.get("name", "Customer"),
|
||||
tone=persona.get("communication_style", "natural, casual"),
|
||||
profession=persona.get("profession", "customer"),
|
||||
age_group=persona.get("age_group", "adult"),
|
||||
channel=persona.get("channel", "facebook"),
|
||||
background=persona.get("background", ""),
|
||||
personality=persona.get("personality", ""),
|
||||
lifestyle=persona.get("lifestyle", ""),
|
||||
income=persona.get("income", ""),
|
||||
budget=persona.get("budget", ""),
|
||||
decision_timeline=persona.get("decision_timeline", ""),
|
||||
pains=pains_txt,
|
||||
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
||||
goal=persona.get("goal", ""),
|
||||
init_mode="you contacted the seller first (customer-initiated)"
|
||||
if persona.get("initiation_mode") == "customer"
|
||||
else "the seller opened the sale to you (you are a lead)",
|
||||
special_instr=self._special_instr(persona),
|
||||
)
|
||||
msgs = [{"role": "system", "content": system}]
|
||||
# send a compact recap of internal state to the persona ad
|
||||
# (doesn't leak to trainee)
|
||||
msgs.append({
|
||||
"role": "system",
|
||||
"content": "Internal state (for your role-play only): "
|
||||
+ json.dumps(internal, ensure_ascii=False),
|
||||
})
|
||||
msgs.extend(messages[-30:]) # context window
|
||||
try:
|
||||
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
||||
except LLMError as exc:
|
||||
raise
|
||||
# extract {reply: ...}
|
||||
try:
|
||||
data = json.loads(self._extract_json(resp))
|
||||
reply = data.get("reply") or data.get("response") or str(resp)
|
||||
except Exception:
|
||||
reply = resp
|
||||
return reply.strip()
|
||||
|
||||
# ── judge ──────────────────────────────────────────────────────────
|
||||
def judge(
|
||||
self,
|
||||
*,
|
||||
persona: dict[str, Any],
|
||||
messages: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
persona_summary = json.dumps({
|
||||
"name": persona.get("name"),
|
||||
"pains": persona.get("pains", []),
|
||||
"budget": persona.get("budget"),
|
||||
"negotiation_levers": persona.get("negotiation_levers"),
|
||||
"special": persona.get("special"),
|
||||
}, ensure_ascii=False)
|
||||
transcript = "\n".join(
|
||||
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
|
||||
)
|
||||
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}"
|
||||
try:
|
||||
result = self.judge_llm.complete_json(
|
||||
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise
|
||||
result.setdefault("outcome", "lost")
|
||||
result.setdefault("score", 0)
|
||||
result.setdefault("pain", "")
|
||||
result.setdefault("why", "")
|
||||
result.setdefault("failurePoints", [])
|
||||
result.setdefault("coaching", [])
|
||||
result.setdefault("painProgress", {})
|
||||
return result
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
def _describe_pains(self, pains: list[Any]) -> str:
|
||||
if not pains:
|
||||
return "(you have some personal frustrations, but the seller must find out)"
|
||||
out = []
|
||||
for p in pains:
|
||||
if isinstance(p, dict):
|
||||
out.append(
|
||||
f"{p.get('name','pain')} (fit={p.get('fit','?')}): {p.get('description','')} "
|
||||
f"root={p.get('rootCause','')}"
|
||||
)
|
||||
else:
|
||||
out.append(str(p))
|
||||
return "; ".join(out)
|
||||
|
||||
def _special_instr(self, persona: dict[str, Any]) -> str:
|
||||
if persona.get("special") == "wrong_text":
|
||||
return (
|
||||
"SPECIAL: You opened as if ready to buy, but the moment the seller replies you act "
|
||||
"disinterested and try to end the chat (e.g. 'never mind, forget it'). Deep down your "
|
||||
"pain is still real. A seller who gently re-engages without pushing may earn a second "
|
||||
"chance; a pushy seller drives you away for good."
|
||||
)
|
||||
return ""
|
||||
|
||||
def _extract_json(self, text: str) -> str:
|
||||
text = text.strip()
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start : end + 1]
|
||||
return text
|
||||
75
backend/app/services/store.py
Normal file
75
backend/app/services/store.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Persona data model + shape normalization.
|
||||
|
||||
A persona has a canonical schema. Fields are split into:
|
||||
- revealable: shown to trainees up front (what a real seller could plausibly know)
|
||||
- latent: hidden until the conversation ends (pain, income, personality, budget,
|
||||
negotiation levers, hidden opener, etc.)
|
||||
Every persona also carries an `intent_tier` (A/B/C), an `initiation_mode`
|
||||
(customer/seller), a `channel` (facebook/line), a set of `pains` with resolution
|
||||
conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_TIERS = ["A", "B", "C"]
|
||||
|
||||
|
||||
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Fill defaults so a persona dict is always structurally complete."""
|
||||
pid = p.get("id") or p.get("name", "persona")
|
||||
base = {
|
||||
"id": pid,
|
||||
"name": p.get("name", ""),
|
||||
"tier": p.get("tier", p.get("intent_tier", "B")),
|
||||
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
|
||||
"channel": p.get("channel", "facebook"), # facebook | line
|
||||
# revealable
|
||||
"profession": p.get("profession", ""),
|
||||
"age_group": p.get("age_group", ""),
|
||||
"location": p.get("location", ""),
|
||||
"product_context": p.get("product_context", ""),
|
||||
# latent (hidden until end)
|
||||
"background": p.get("background", ""),
|
||||
"income": p.get("income", ""),
|
||||
"lifestyle": p.get("lifestyle", ""),
|
||||
"personality": p.get("personality", ""),
|
||||
"communication_style": p.get("communication_style", ""),
|
||||
"budget": p.get("budget", ""),
|
||||
"decision_timeline": p.get("decision_timeline", ""),
|
||||
"goal": p.get("goal", ""),
|
||||
"objections": p.get("objections", []),
|
||||
"pains": p.get("pains", []),
|
||||
"negotiation_levers": p.get("negotiation_levers", []),
|
||||
"opener": p.get("opener", ""),
|
||||
"special": p.get("special", ""), # e.g. "wrong_text" | ""
|
||||
"difficulty": p.get("difficulty", 1), # 1..5
|
||||
"notes": p.get("notes", ""),
|
||||
}
|
||||
# validate
|
||||
if base["tier"] not in DEFAULT_TIERS:
|
||||
base["tier"] = "B"
|
||||
if base["initiation_mode"] not in ("customer", "seller"):
|
||||
base["initiation_mode"] = "customer"
|
||||
if base["channel"] not in ("facebook", "line"):
|
||||
base["channel"] = "facebook"
|
||||
return base
|
||||
|
||||
|
||||
def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return ONLY the fields a trainee may see before/while chatting."""
|
||||
return {
|
||||
"id": p.get("id"),
|
||||
"name": p.get("name"),
|
||||
"tier": p.get("tier"),
|
||||
"channel": p.get("channel"),
|
||||
"initiation_mode": p.get("initiation_mode"),
|
||||
"profession": p.get("profession"),
|
||||
"age_group": p.get("age_group"),
|
||||
"location": p.get("location"),
|
||||
"product_context": p.get("product_context"),
|
||||
}
|
||||
|
||||
|
||||
def full_view(p: dict[str, Any]) -> dict[str, Any]:
|
||||
return ensure_persona_shape(p)
|
||||
81
backend/app/services/trainee.py
Normal file
81
backend/app/services/trainee.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Trainee loop: win/lose board, weak-area analysis, user-generated personas.
|
||||
|
||||
A user never re-chats a persona. To keep training, they generate new personas —
|
||||
either auto from their weak areas ("lock") or from a manual form. Generated
|
||||
personas are private to the user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..storage.store import JsonStore, new_id
|
||||
from .store import ensure_persona_shape
|
||||
|
||||
|
||||
class MyPersonaStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.personas = JsonStore(data_dir / "my_personas")
|
||||
|
||||
def _path_key(self, user_id: str, pid: str) -> str:
|
||||
return f"{user_id}__{pid}"
|
||||
|
||||
def create(self, *, user_id: str, persona: dict[str, Any]) -> dict[str, Any]:
|
||||
p = ensure_persona_shape(persona)
|
||||
if "id" not in p or not p["id"]:
|
||||
p["id"] = new_id("myp")
|
||||
record = {
|
||||
"key": self._path_key(user_id, p["id"]),
|
||||
"user_id": user_id,
|
||||
"persona": p,
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
return self.personas.create(record, key=record["key"])
|
||||
|
||||
def list_for(self, user_id: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
r.get("persona")
|
||||
for r in self.personas.where(lambda x: x.get("user_id") == user_id)
|
||||
]
|
||||
|
||||
|
||||
def analyze_weak_areas(sessions: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Summarize which persona attributes a user tends to lose against."""
|
||||
losses, wins = [], []
|
||||
for ses in sessions:
|
||||
if ses.get("outcome") == "won":
|
||||
wins.append(ses)
|
||||
elif ses.get("outcome") == "lost":
|
||||
losses.append(ses)
|
||||
|
||||
def tally(key: str, label: str) -> list[dict[str, Any]]:
|
||||
from collections import Counter
|
||||
|
||||
c = Counter()
|
||||
for l in losses:
|
||||
meta = l.get("persona_meta") or {}
|
||||
v = meta.get(key)
|
||||
if v is not None:
|
||||
c[v] += 1
|
||||
return [{"value": k, "losses": v} for k, v in c.most_common(3)]
|
||||
|
||||
return {
|
||||
"total_sessions": len(sessions),
|
||||
"wins": len(wins),
|
||||
"losses": len(losses),
|
||||
"by_tier": tally("tier", "tier"),
|
||||
"by_initiation": tally("initiation_mode", "initiation"),
|
||||
"by_channel": tally("channel", "channel"),
|
||||
"top_loss_personas": [
|
||||
{
|
||||
"persona_id": l.get("persona_id"),
|
||||
"persona_name": l.get("persona_name"),
|
||||
"score": (l.get("debrief") or {}).get("score", 0),
|
||||
"why": (l.get("debrief") or {}).get("why", ""),
|
||||
}
|
||||
for l in sorted(
|
||||
losses, key=lambda x: (x.get("debrief") or {}).get("score", 0)
|
||||
)[:5]
|
||||
],
|
||||
}
|
||||
1
backend/app/storage/__init__.py
Normal file
1
backend/app/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Storage layer."""
|
||||
138
backend/app/storage/store.py
Normal file
138
backend/app/storage/store.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Durable filesystem JSON store.
|
||||
|
||||
Each entity is stored as its own JSON file under a per-type directory. Writes are
|
||||
atomic (temp file + os.replace + fsync). Thread-safe via a per-path lock.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..config import Config
|
||||
|
||||
|
||||
class StoreError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _Locks:
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, threading.RLock] = {}
|
||||
self._guard = threading.Lock()
|
||||
|
||||
def get(self, key: str) -> threading.RLock:
|
||||
with self._guard:
|
||||
if key not in self._locks:
|
||||
self._locks[key] = threading.RLock()
|
||||
return self._locks[key]
|
||||
|
||||
|
||||
_locks = _Locks()
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _atomic_write(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with tmp.open("w", encoding="utf-8") as fh:
|
||||
json.dump(value, fh, ensure_ascii=False, indent=2)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if tmp.exists():
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
class JsonStore:
|
||||
"""Simple JSON-file collection with CRUD + locking."""
|
||||
|
||||
def __init__(self, root: Path, *, key_attr: str = "id") -> None:
|
||||
self.root = root
|
||||
self.key_attr = key_attr
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, key: str) -> Path:
|
||||
if not key or "/" in key or ".." in key:
|
||||
raise StoreError("invalid id")
|
||||
return self.root / f"{key}.json"
|
||||
|
||||
def create(self, value: dict[str, Any], *, key: str | None = None) -> dict[str, Any]:
|
||||
key = key or value.get(self.key_attr) or new_id(self.key_attr)
|
||||
if self.key_attr not in value:
|
||||
value = dict(value)
|
||||
value[self.key_attr] = key
|
||||
path = self._path(key)
|
||||
lock = _locks.get(str(path))
|
||||
with lock:
|
||||
if path.exists():
|
||||
raise StoreError(f"already exists: {key}")
|
||||
_atomic_write(path, value)
|
||||
return value
|
||||
|
||||
def get(self, key: str) -> dict[str, Any]:
|
||||
path = self._path(key)
|
||||
lock = _locks.get(str(path))
|
||||
with lock:
|
||||
if not path.exists():
|
||||
raise StoreError(f"not found: {key}")
|
||||
return _read_json(path)
|
||||
|
||||
def get_or_none(self, key: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return self.get(key)
|
||||
except StoreError:
|
||||
return None
|
||||
|
||||
def update(self, key: str, **fields: Any) -> dict[str, Any]:
|
||||
path = self._path(key)
|
||||
lock = _locks.get(str(path))
|
||||
with lock:
|
||||
if not path.exists():
|
||||
raise StoreError(f"not found: {key}")
|
||||
cur = _read_json(path)
|
||||
cur.update(fields)
|
||||
_atomic_write(path, cur)
|
||||
return cur
|
||||
|
||||
def replace(self, key: str, value: dict[str, Any]) -> dict[str, Any]:
|
||||
path = self._path(key)
|
||||
lock = _locks.get(str(path))
|
||||
with lock:
|
||||
_atomic_write(path, value)
|
||||
return value
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = self._path(key)
|
||||
lock = _locks.get(str(path))
|
||||
with lock:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def all(self) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
try:
|
||||
out.append(_read_json(path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
def where(self, pred: Callable[[dict[str, Any]], bool]) -> list[dict[str, Any]]:
|
||||
return [row for row in self.all() if pred(row)]
|
||||
Reference in New Issue
Block a user