commit c3d31c06e2556bb012bf03a56dedf0e796ec4ac3 Author: Macky Date: Fri Aug 7 15:31:06 2026 +0700 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f09ee0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +**/.venv/ +venv/ +**/__pycache__/ +*.pyc +**/node_modules/ +frontend/dist/ +backend/data/ +data/ +.env +**/*.log +.DS_Store +.git/ +.tmp/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb52aff --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ===== Sales Trainer configuration ===== +# Copy to .env for docker-compose / deployment. + +# --- LLM (OpenAI / DeepSeek / any OpenAI-compatible) --- +# provider: deepseek | openai | custom (custom => set LLM_BASE_URL + LLM_MODEL_NAME) +LLM_PROVIDER=deepseek +LLM_BASE_URL= +LLM_MODEL_NAME=deepseek-chat +LLM_API_KEY=replace_me + +# --- Auth --- +# REQUIRED: use a long random value in production +JWT_SECRET=change_this_secret_to_a_long_random_string +JWT_EXPIRES_HOURS=24 + +# --- Runtime --- +FLASK_HOST=0.0.0.0 +FLASK_PORT=5001 +FLASK_DEBUG=0 +DATA_DIR=./data +UPLOAD_MAX_MB=15 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bc2471 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +dist/ +build/ + +# Node / Vue +node_modules/ +frontend/dist/ + +# Env / secrets +.env +*.local + +# Data +backend/data/ +data/ + +# OS / editor +.DS_Store +*.swp +.idea/ +.vscode/ + +# Logs +*.log +backend/logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..878a9f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Sales Trainer — single-container build (Vue frontend built + served by Flask) +FROM python:3.11-slim AS backend + +# Node 20 for building the Vue frontend +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production +WORKDIR /app + +# 1) Build frontend +COPY frontend/package.json frontend/package-lock.json* ./frontend/ +RUN cd frontend && npm install +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build + +# 2) Install backend deps +COPY backend/requirements.txt ./backend/ +RUN pip install --no-cache-dir -r backend/requirements.txt + +# 3) Copy backend source +COPY backend/ ./backend/ + +# Run +WORKDIR /app/backend +ENV FLASK_DEBUG=0 +EXPOSE 5001 +CMD ["python", "run.py"] diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..d2b5456 --- /dev/null +++ b/IDEA.md @@ -0,0 +1 @@ +App platform for Sales Training by develop persona with pain point. Sales trainee will try to chat for sell a product. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e373b04 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# 🎯 Sales Trainer + +A **corporate, multi-user sales-training simulator**. Admins upload/describe a product; the app +analyzes it and generates **15 realistic customer personas** (5 per buying-intent tier) with real, +varied pains. Trainees **chat one-on-one** with each persona to practice closing a sale — customers +negotiate, stall, and refuse unless their pain is genuinely resolved. Debrief reveals + coaches. + +Built on patterns from the **CrowdSight / MiroFish** swarm engine and clean-room Hermes Brain & Tools plugin. + +--- + +## Features + +- **Login + roles** (no self-registration): `super_admin` / `admin` / `user`. + - Admin builds & edits **persona groups** (product + 15 personas), hand-edits personas, sees analytics. + - User (trainee) **can't create** — only selects a group and practices; sees own results. +- **Input** via form (product / segment / description) **and/or file upload** (.pdf/.md/.txt). + Product data is used mainly to extract **pains**; personas are reusable across similar products. +- **15 personas** (5 × tier A/B/C): + - A = ready to buy · B = unsure · C = not interested but has pain (hardest). + - Varied demographics, income, occupation, lifestyle, personality — consistent with product. + - **Pain variety** (directly-solvable / partial / unrelated red-herring). + - **Negotiation levers** (price, freebies, delivery time, scope, payment). + - **Initiation mode**: customer opens OR seller must open the sale (outbound, e.g. insurance). + - **Channel**: Facebook / LINE. + - Special tier-C **"wrong_text"** persona (appears to buy, loses interest, but still has pain). +- **One-shot rule**: a persona is chatted **once per user** (final); shared across other users. +- **Chat realism**: all tiers can lose; everything negotiates; hidden internal signals + latent + fields (pain/income/personality/budget) revealed only after the result. +- **Debrief**: short summary + **coaching** (how to answer better on weak-score messages), + scored by a **separate judge LLM** (no speed factor). +- **Training loop**: win/lose board, **weak-area analysis**, and **user-generated personas** + (weak-area "lock" or manual form). +- **Admin analytics**: close rate, avg score, hardest personas. +- **EN + TH** UI. + +--- + +## Quick start + +### Local (dev) + +```bash +# backend (Python 3.11) +cd backend +uv venv --python 3.11 .venv +uv pip install -r requirements.txt --python .venv/bin/python +cp .env.example .env # edit LLM keys + JWT_SECRET +uv run python run.py # Flask on :5001 + +# frontend (separate terminal) +cd frontend +npm install +npm run dev # Vite on :3000 -> proxies /api to :5001 +``` + +The first run creates a default super-admin: **`admin@salestrainer.local` / `admin123`** (change it!). + +### Docker / EasyPanel + +```bash +cp .env.example .env # set LLM_API_KEY + a strong JWT_SECRET +docker compose up -d # single container serving frontend + API on :5001 +``` + +--- + +## LLM config + +Any OpenAI-compatible endpoint (OpenAI, DeepSeek, or custom base URL): + +```env +LLM_PROVIDER=deepseek # deepseek | openai | custom +LLM_BASE_URL= # optional override +LLM_MODEL_NAME=deepseek-chat # optional override +LLM_API_KEY=sk-... +``` + +--- + +## Architecture + +``` +frontend/ Vue 3 + Vite SPA (login, dashboard, group builder, personas, chat, debrief, + gen-persona, weak-areas, analytics). Built to dist/ and served by Flask. +backend/ Flask API (JWT auth, roles, groups, analyzer, persona generator, chat simulator, + judge, trainee loop, analytics). Filesystem JSON persistence (no external DB). + app/services/ analyzer · persona_generator · simulator (+ judge) · report · trainee · groups · sessions +docs/PLAN.md full design & decisions record +``` + +- **Storage**: `backend/data/` — JSON files per entity (users, orgs, groups, sessions, my_personas). +- **LLM calls**: analyzer (sales kit + pain-fit), persona generator, persona chat, judge. + +--- + +## Tests + +Run with the built-in deterministic **mock LLM** (no external key needed): + +```bash +cd backend +uv run python scripts/test_m0.py # auth/roles/no-self-registration +uv run python scripts/test_m1.py # group create + failure handling + role visibility +uv run python scripts/test_routes.py # all API routes registered +uv run python scripts/test_e2e.py # full flow: analyze→personas→chat→debrief→one-shot→board→analytics +``` + +Real-model verification requires a live `LLM_API_KEY` in `.env`. + +--- + +## Default accounts + +| Role | Email | Password | +|------|-------|----------| +| super_admin | `admin@salestrainer.local` | `admin123` (change after first login) | + +Admins create additional users (users/login has no self-registration). diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5ebee59 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,16 @@ +# LLM — OpenAI, DeepSeek, or any OpenAI-compatible endpoint +LLM_PROVIDER=deepseek +LLM_BASE_URL= +LLM_MODEL_NAME=deepseek-chat +LLM_API_KEY=replace_me + +# Auth +JWT_SECRET=change_this_secret + +# Storage root (relative to backend/) +DATA_DIR=./data + +# App +FLASK_HOST=0.0.0.0 +FLASK_PORT=5001 +FLASK_DEBUG=1 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..4c7c700 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,6 @@ +"""Backend entry point.""" +from __future__ import annotations + +from .factory import create_app + +__all__ = ["create_app"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/backend/app/api/admin_routes.py b/backend/app/api/admin_routes.py new file mode 100644 index 0000000..05f9992 --- /dev/null +++ b/backend/app/api/admin_routes.py @@ -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/") +@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))}) diff --git a/backend/app/api/analytics_routes.py b/backend/app/api/analytics_routes.py new file mode 100644 index 0000000..4c92183 --- /dev/null +++ b/backend/app/api/analytics_routes.py @@ -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, + }) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py new file mode 100644 index 0000000..03bda76 --- /dev/null +++ b/backend/app/api/auth_routes.py @@ -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())}) diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py new file mode 100644 index 0000000..10a2a1c --- /dev/null +++ b/backend/app/api/chat_routes.py @@ -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("//personas//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("//personas//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("//personas//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/") +@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}) diff --git a/backend/app/api/group_routes.py b/backend/app/api/group_routes.py new file mode 100644 index 0000000..96d56a3 --- /dev/null +++ b/backend/app/api/group_routes.py @@ -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("//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("/") +@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("//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("//personas/") +@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("//personas/") +@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("//reanalyze") +@require_auth +@require_roles("admin") +def reanalyze_group(gid: str): + return analyze_group(gid) diff --git a/backend/app/api/helpers.py b/backend/app/api/helpers.py new file mode 100644 index 0000000..9632e80 --- /dev/null +++ b/backend/app/api/helpers.py @@ -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)) diff --git a/backend/app/api/me_routes.py b/backend/app/api/me_routes.py new file mode 100644 index 0000000..ee1ddaa --- /dev/null +++ b/backend/app/api/me_routes.py @@ -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 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..b6dc1cc --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1 @@ +"""Auth package.""" diff --git a/backend/app/auth/users.py b/backend/app/auth/users.py new file mode 100644 index 0000000..06b0cf5 --- /dev/null +++ b/backend/app/auth/users.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..37852f9 --- /dev/null +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/factory.py b/backend/app/factory.py new file mode 100644 index 0000000..bb6ec4b --- /dev/null +++ b/backend/app/factory.py @@ -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("/", 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") diff --git a/backend/app/llm.py b/backend/app/llm.py new file mode 100644 index 0000000..2c009ef --- /dev/null +++ b/backend/app/llm.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..02dea84 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer.""" diff --git a/backend/app/services/analyzer.py b/backend/app/services/analyzer.py new file mode 100644 index 0000000..a40e9b1 --- /dev/null +++ b/backend/app/services/analyzer.py @@ -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 diff --git a/backend/app/services/file_parser.py b/backend/app/services/file_parser.py new file mode 100644 index 0000000..dad7b52 --- /dev/null +++ b/backend/app/services/file_parser.py @@ -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) diff --git a/backend/app/services/groups.py b/backend/app/services/groups.py new file mode 100644 index 0000000..28578fb --- /dev/null +++ b/backend/app/services/groups.py @@ -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) diff --git a/backend/app/services/own_persona.py b/backend/app/services/own_persona.py new file mode 100644 index 0000000..7170de1 --- /dev/null +++ b/backend/app/services/own_persona.py @@ -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 diff --git a/backend/app/services/persona_generator.py b/backend/app/services/persona_generator.py new file mode 100644 index 0000000..b9d8e95 --- /dev/null +++ b/backend/app/services/persona_generator.py @@ -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 diff --git a/backend/app/services/persona_prompts.py b/backend/app/services/persona_prompts.py new file mode 100644 index 0000000..1873acb --- /dev/null +++ b/backend/app/services/persona_prompts.py @@ -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": [ ... ]} +""" diff --git a/backend/app/services/report.py b/backend/app/services/report.py new file mode 100644 index 0000000..5ad2f01 --- /dev/null +++ b/backend/app/services/report.py @@ -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) diff --git a/backend/app/services/sessions.py b/backend/app/services/sessions.py new file mode 100644 index 0000000..4443232 --- /dev/null +++ b/backend/app/services/sessions.py @@ -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", ""), + ) diff --git a/backend/app/services/simulator.py b/backend/app/services/simulator.py new file mode 100644 index 0000000..1b7b4ad --- /dev/null +++ b/backend/app/services/simulator.py @@ -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": ""}} +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 diff --git a/backend/app/services/store.py b/backend/app/services/store.py new file mode 100644 index 0000000..3114924 --- /dev/null +++ b/backend/app/services/store.py @@ -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) diff --git a/backend/app/services/trainee.py b/backend/app/services/trainee.py new file mode 100644 index 0000000..8cb608c --- /dev/null +++ b/backend/app/services/trainee.py @@ -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] + ], + } diff --git a/backend/app/storage/__init__.py b/backend/app/storage/__init__.py new file mode 100644 index 0000000..0da8c4f --- /dev/null +++ b/backend/app/storage/__init__.py @@ -0,0 +1 @@ +"""Storage layer.""" diff --git a/backend/app/storage/store.py b/backend/app/storage/store.py new file mode 100644 index 0000000..21b572e --- /dev/null +++ b/backend/app/storage/store.py @@ -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)] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0382b98 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +flask>=3.0.0 +flask-cors>=6.0.0 +PyJWT>=2.8.0 +python-dotenv>=1.0.0 +openai>=1.0.0 +PyMuPDF>=1.24.0 +charset-normalizer>=3.0.0 +pydantic>=2.0.0 +werkzeug>=3.0.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..9bcca22 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,24 @@ +"""Sales Trainer backend — Flask app factory entry.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import create_app # noqa: E402 +from app.config import Config # noqa: E402 + + +def main() -> None: + app = create_app() + app.run( + host=Config.FLASK_HOST, + port=Config.FLASK_PORT, + debug=Config.FLASK_DEBUG, + threaded=True, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/mock_llm.py b/backend/scripts/mock_llm.py new file mode 100644 index 0000000..aadfe8c --- /dev/null +++ b/backend/scripts/mock_llm.py @@ -0,0 +1,111 @@ +"""Mock LLM for deterministic end-to-end tests (no external API needed). + +Substitutes for app.llm.LLMClient. Returns canned JSON for structured calls and +simple replies for chat calls, so the full analyze→persona→chat→debrief flow runs. +""" +from __future__ import annotations + +import json +from typing import Any + + +SAMPLE_SALES_KIT = { + "productName": "CloudPOS", + "category": "POS software", + "valueProps": ["faster checkout", "inventory sync"], + "features": ["tablets", "reports"], + "pricingAnchors": ["1,000 THB/month"], + "targetAudience": {"segment": "SME restaurants", "demographics": "", "useCases": ["front counter"]}, + "objectionHandlers": ["free trial", "setup included"], + "initialPainFit": [ + {"pain": "slow checkout queues", "fit": "strong", "evidence": "faster checkout"}, + {"pain": "lost sales from stockouts", "fit": "partial", "evidence": "inventory sync"}, + ], + "scenarioFrame": "Cloud POS sold over LINE to Bangkok SME restaurants.", +} + + +def _sample_persona(idx: int, tier: str) -> dict[str, Any]: + return { + "id": f"persona-{idx:02d}", + "name": f"Persona {idx}", + "tier": tier, + "channel": "line", + "initiation_mode": "customer" if idx % 3 else "seller", + "profession": "restaurant owner", + "age_group": "30s", + "location": "Bangkok", + "product_context": "running a small noodle shop", + "background": "Runs a family noodle shop for 8 years.", + "income": "60k THB/month", + "lifestyle": "works long hours", + "personality": "practical and cautious", + "communication_style": "short, direct, casual", + "budget": "1,500 THB/month max", + "decision_timeline": "within 2 weeks", + "goal": "reduce lunch-rush queues", + "objections": ["too expensive", "hard to learn"], + "pains": [ + {"id": "p1", "name": "slow checkout", "fit": "strong", + "description": "Long queues at lunch", "rootCause": "manual order taking", + "resolutionConditions": ["show faster checkout", "offer a trial"]}, + {"id": "p2", "name": "stockouts", "fit": "partial", + "description": "Runs out of ingredients", "rootCause": "no inventory tracking", + "resolutionConditions": ["show inventory feature"]}, + ], + "negotiation_levers": ["price reduction", "free setup"], + "opener": "Hi, I saw your POS ad. Does it work with small shops?", + "special": "wrong_text" if (tier == "C" and idx % 5 == 4) else "", + "difficulty": 2 if tier == "A" else (3 if tier == "B" else 4), + "notes": "sample", + } + + +def make_personas() -> list[dict[str, Any]]: + out = [] + idx = 1 + for tier in ["A", "B", "C"]: + for _ in range(5): + out.append(_sample_persona(idx, tier)) + idx += 1 + return out + + +class MockLLM: + """Drop-in for app.llm.LLMClient — reads config the same way.""" + + persona_count = 0 + + def __init__(self, **kwargs): + pass + + def complete(self, system_prompt: str, user_prompt: str, **kw) -> str: + if "Persona generation prompts" in system_prompt or "persona designer" in system_prompt.lower(): + return json.dumps({"personas": make_personas()}, ensure_ascii=False) + if "market-research persona designer" in system_prompt.lower(): + return json.dumps({"personas": make_personas()}, ensure_ascii=False) + return "ok" + + def complete_json(self, system_prompt: str, user_prompt: str, **kw) -> dict[str, Any]: + sp = system_prompt.lower() + if "ecommerce/b2b analyst" in sp: + return dict(SAMPLE_SALES_KIT) + if "market-research persona designer" in sp: + return {"personas": make_personas()} + if "sales-training simulator" in sp and "PRIVATE" in system_prompt: + return {"persona": _sample_persona(99, "C")} + if "judge" in sp and "sales-training chat" in sp: + return { + "outcome": "won", + "score": 82, + "pain": "slow checkout queues", + "why": "resolved the pain and secured acceptance", + "failurePoints": [], + "coaching": [], + "painProgress": {"slow checkout": 100}, + } + return {} + + def complete_conversation(self, messages, **kw) -> str: + # persona chat: echo a short in-character reply + return json.dumps({"reply": "I see. Tell me more about the price then."}, ensure_ascii=False) diff --git a/backend/scripts/test_e2e.py b/backend/scripts/test_e2e.py new file mode 100644 index 0000000..c86f3ff --- /dev/null +++ b/backend/scripts/test_e2e.py @@ -0,0 +1,141 @@ +"""Full E2E test with a mock LLM: analyze → personas → chat → debrief → board/analytics.""" +import os +import sys +import tempfile +import warnings +from pathlib import Path + +warnings.filterwarnings("ignore", message="The HMAC key is") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # for mock_llm + +tempdir = tempfile.mkdtemp(prefix="st_e2e_") +os.environ["DATA_DIR"] = tempdir +os.environ["JWT_SECRET"] = "test-secret-key-0123456789abcdef" + +from mock_llm import MockLLM # noqa: E402 +from app.factory import create_app # noqa: E402 +from app.config import Config # noqa: E402 + +Config.DATA_DIR = Path(tempdir) +Config.LLM_API_KEY = "" +Config.LLM_BASE_URL = "" + + +def main(): + app = create_app() + app.extensions["llm"] = MockLLM() + client = app.test_client() + + # admin login + r = client.post("/api/auth/login", json={"email": "admin@salestrainer.local", "password": "admin123"}) + AT = r.get_json()["token"] + AH = {"Authorization": f"Bearer {AT}"} + + # create group + r = client.post("/api/groups", json={ + "product": "Cloud POS for small restaurants", "segment": "SME restaurants", + "channel": "line", "language": "th"}, headers=AH) + assert r.status_code == 201, r.get_json() + gid = r.get_json()["group"]["id"] + + # analyze -> sales kit + 15 personas + r = client.post(f"/api/groups/{gid}/analyze", headers=AH) + assert r.status_code == 200, r.get_json() + body = r.get_json() + assert body["sales_kit"]["productName"] == "CloudPOS", body["sales_kit"] + personas = body["personas"] + assert len(personas) == 15, f"expected 15 personas, got {len(personas)}" + tiers = {} + for p in personas: + tiers.setdefault(p["tier"], 0) + tiers[p["tier"]] += 1 + assert tiers == {"A": 5, "B": 5, "C": 5}, tiers + # wrong_text special in tier C + assert any(p["tier"] == "C" and p["special"] == "wrong_text" for p in personas), "no wrong_text persona" + print(f"[ok] analyze -> sales kit + 15 personas (tiers {tiers}), wrong_text present") + + # create a trainee + client.post("/api/admin/users", json={ + "name": "Trainee", "email": "t@x.com", "password": "pass123", "role": "user"}, headers=AH) + r = client.post("/api/auth/login", json={"email": "t@x.com", "password": "pass123"}) + UT = r.get_json()["token"] + UH = {"Authorization": f"Bearer {UT}"} + + # trainee sees group + personas but revealable-only (no pain/income) + r = client.get(f"/api/groups/{gid}/personas", headers=UH) + assert r.status_code == 200 + plist = r.get_json()["personas"] + assert len(plist) == 15 + first = plist[1] + assert "pains" not in first and "income" not in first, "latent fields leaked!" + assert "profession" in first and "initiation_mode" in first + print("[ok] trainee sees revealable-only persona fields (latent hidden)") + + # pick a customer-initiated persona -> start session (customer opens) + cust = next(p for p in personas if p["initiation_mode"] == "customer") + pid = cust["id"] + r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH) + assert r.status_code == 200, r.get_json() + session = r.get_json()["session"] + assert session["status"] == "active" + assert len(session["messages"]) >= 1 and session["messages"][0]["role"] == "customer", "customer should open" + print("[ok] customer-initiated session starts with customer opener") + + # send messages + r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send", + json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH) + assert r.status_code == 200, r.get_json() + assert r.get_json()["reply"] + print("[ok] send message -> persona replies") + + # finish -> debrief reveals latent + outcome won + r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/finish", headers=UH) + assert r.status_code == 200, r.get_json() + debrief = r.get_json()["debrief"] + assert debrief["outcome"] == "won" + assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"] + print("[ok] finish -> debrief with latent reveal + outcome") + + # ONE-SHOT: cannot start again on same persona + r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH) + assert r.status_code == 400, r.get_json() + print("[ok] one-shot enforced (cannot re-chat same persona)") + + # seller-initiated persona -> session starts WITHOUT opener (task for seller) + sel = next(p for p in personas if p["initiation_mode"] == "seller") + r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start", headers=UH) + assert r.status_code == 200, r.get_json() + s2 = r.get_json()["session"] + assert "task" in r.get_json() or "initiation_mode" in r.get_json() + assert s2["messages"] == [] , "seller-initiated should not have a customer opener" + print("[ok] seller-initiated session (no customer opener, seller must open)") + + # board + r = client.get("/api/me/board", headers=UH) + board = r.get_json()["board"] + assert any(b["persona_id"] == pid and b["my_outcome"] == "won" for b in board) + print("[ok] win/lose board reflects won persona") + + # weak-areas (no losses yet -> empty insight but endpoint works) + r = client.get("/api/me/weak-areas", headers=UH) + assert r.status_code == 200 + print("[ok] weak-areas endpoint") + + # generate own persona (manual, mock) + r = client.post("/api/me/personas/generate", json={"mode": "manual", "spec": {"target": "price-hardball"}}, headers=UH) + assert r.status_code == 201, r.get_json() + print("[ok] user generates own persona (manual)") + + # analytics (admin) + r = client.get("/api/analytics", headers=AH) + assert r.status_code == 200 + a = r.get_json() + assert a["overall"]["wins"] >= 1 + print("[ok] admin analytics aggregates wins") + + print("\nALL E2E TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/test_m0.py b/backend/scripts/test_m0.py new file mode 100644 index 0000000..91ee4a2 --- /dev/null +++ b/backend/scripts/test_m0.py @@ -0,0 +1,107 @@ +"""M0 smoke test: auth + roles via Flask test client.""" +import json +import os +import sys +import tempfile +import warnings +from pathlib import Path + +warnings.filterwarnings("ignore", message="The HMAC key is") + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +os_env_data = tempfile.mkdtemp(prefix="salestrainer_m0_") +os.environ["DATA_DIR"] = os_env_data +os.environ["JWT_SECRET"] = "test-secret" + +from app.factory import create_app # noqa: E402 +from app.config import Config # noqa: E402 +from pathlib import Path as _Path + +# .env with override=True would clobber DATA_DIR, so pin the store root directly. +Config.DATA_DIR = _Path(os_env_data) + + +def main() -> None: + app = create_app() + client = app.test_client() + + # 1. Health + r = client.get("/health") + assert r.status_code == 200 and r.get_json()["status"] == "ok", r.get_json() + print("[ok] health") + + # 2. No self-registration: register route must NOT exist (404) + r = client.post("/api/auth/register", json={"email": "a@b.c", "password": "x"}) + assert r.status_code == 404, f"register should not exist, got {r.status_code}" + print("[ok] no self-registration (register -> 404)") + + # 3. Bootstrap super-admin login + r = client.post( + "/api/auth/login", + json={"email": "admin@salestrainer.local", "password": "admin123"}, + ) + assert r.status_code == 200, r.get_json() + admin_token = r.get_json()["token"] + print("[ok] super-admin login") + + # 4. /me with token + r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"}) + assert r.status_code == 200 + assert r.get_json()["user"]["role"] == "super_admin" + print("[ok] /me super_admin") + + # 5. Create a regular user (admin) + r = client.post( + "/api/admin/users", + json={"name": "Trainee One", "email": "t1@x.com", "password": "pass123", "role": "user"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert r.status_code == 201, r.get_json() + print("[ok] admin creates user") + + # 6. Trainee login + cannot access admin users list (403) + r = client.post("/api/auth/login", json={"email": "t1@x.com", "password": "pass123"}) + user_token = r.get_json()["token"] + r = client.get("/api/admin/users", headers={"Authorization": f"Bearer {user_token}"}) + assert r.status_code == 403, f"trainee should be denied, got {r.status_code}" + print("[ok] trainee denied admin route (403)") + + # 7. No token -> 401 + r = client.get("/api/admin/users") + assert r.status_code == 401 + print("[ok] no token -> 401") + + # 8. Role restriction: admin cannot create another admin (only super_admin) + # create an 'admin' actor first + client.post( + "/api/admin/users", + json={"name": "Admin Two", "email": "a2@x.com", "password": "pass123", "role": "admin"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + r = client.post( + "/api/auth/login", json={"email": "a2@x.com", "password": "pass123"} + ) + admin2_token = r.get_json()["token"] + r = client.post( + "/api/admin/users", + json={"name": "Bogus Admin", "email": "ba@x.com", "password": "pass123", "role": "super_admin"}, + headers={"Authorization": f"Bearer {admin2_token}"}, + ) + assert r.status_code == 403, f"admin should not promote, got {r.status_code}" + print("[ok] admin cannot grant super_admin (403)") + + # 9. Duplicate email rejected + r = client.post( + "/api/admin/users", + json={"name": "Dup", "email": "t1@x.com", "password": "pass123", "role": "user"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert r.status_code == 400 + print("[ok] duplicate email rejected (400)") + + print("\nALL M0 TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/test_m1.py b/backend/scripts/test_m1.py new file mode 100644 index 0000000..892251d --- /dev/null +++ b/backend/scripts/test_m1.py @@ -0,0 +1,79 @@ +"""Verify the whole backend imports without errors (no LLM calls).""" +import os +import sys +import tempfile +import warnings +from pathlib import Path + +warnings.filterwarnings("ignore", message="The HMAC key is") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +tempdir = tempfile.mkdtemp(prefix="st_import_") +os.environ["DATA_DIR"] = tempdir +os.environ["JWT_SECRET"] = "test-secret-key-0123456789abcdef" + +from app.factory import create_app # noqa: E402 +from app.config import Config # noqa: E402 + +Config.DATA_DIR = Path(tempdir) +# Force no LLM for import/structural test (analyze path tested separately) +Config.LLM_API_KEY = "" +Config.LLM_BASE_URL = "" + + +def main(): + app = create_app() + # LLM should be None (no API key in test env) + assert app.extensions["llm"] is None, "expect no LLM in test env" + client = app.test_client() + + # login as super-admin + r = client.post("/api/auth/login", json={ + "email": "admin@salestrainer.local", "password": "admin123"}) + assert r.status_code == 200, r.get_json() + token = r.get_json()["token"] + H = {"Authorization": f"Bearer {token}"} + + # create a group via JSON form (no files) + r = client.post("/api/groups", json={ + "product": "Cloud POS system for small restaurants", + "segment": "SME restaurants", + "description": "Target Bangkok SME restaurants, 1-3 branches", + "channel": "line", + "language": "th", + }, headers=H) + assert r.status_code == 201, r.get_json() + gid = r.get_json()["group"]["id"] + assert r.get_json()["group"]["status"] == "draft" + print("[ok] group created (draft)") + + # analyze should fail cleanly (LLM None) + r = client.post(f"/api/groups/{gid}/analyze", headers=H) + assert r.status_code == 500, r.get_json() + print("[ok] analyze fails cleanly when LLM unset (500)") + + # list groups as admin + r = client.get("/api/groups", headers=H) + assert r.status_code == 200 and len(r.get_json()["groups"]) == 1 + print("[ok] admin lists 1 group") + + # personas empty until analyze + r = client.get(f"/api/groups/{gid}", headers=H) + assert r.get_json()["group"]["personas"] == [] + print("[ok] group has no personas before analyze") + + # trainee created; can list groups but only 'ready' ones (this one is 'failed' -> hidden) + client.post("/api/admin/users", json={ + "name": "Trainee", "email": "t@x.com", "password": "pass123", "role": "user"}, headers=H) + r = client.post("/api/auth/login", json={"email": "t@x.com", "password": "pass123"}) + ut = r.get_json()["token"] + UH = {"Authorization": f"Bearer {ut}"} + r = client.get("/api/groups", headers=UH) + assert r.get_json()["groups"] == [], "trainee should not see non-ready groups" + print("[ok] trainee cannot see non-ready groups") + + print("\nALL M1/M2-IMPORT TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/test_routes.py b/backend/scripts/test_routes.py new file mode 100644 index 0000000..aca6e5d --- /dev/null +++ b/backend/scripts/test_routes.py @@ -0,0 +1,51 @@ +"""Verify full backend imports + all routes registered (no LLM needed).""" +import os +import sys +import tempfile +import warnings +from pathlib import Path + +warnings.filterwarnings("ignore", message="The HMAC key is") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +tempdir = tempfile.mkdtemp(prefix="st_import_") +os.environ["DATA_DIR"] = tempdir +os.environ["JWT_SECRET"] = "test-secret-key-0123456789abcdef" + +from app.factory import create_app # noqa: E402 +from app.config import Config # noqa: E402 + +Config.DATA_DIR = Path(tempdir) +Config.LLM_API_KEY = "" +Config.LLM_BASE_URL = "" + + +def main(): + app = create_app() + rules = sorted({str(rule) for rule in app.url_map.iter_rules() if str(rule).startswith("/api")}) + expected = [ + "/api/auth/login", "/api/auth/me", + "/api/admin/users", "/api/admin/users/", + "/api/groups", "/api/groups/", + "/api/groups//analyze", "/api/groups//personas", + "/api/groups//personas/", "/api/groups//personas/", + "/api/groups//reanalyze", + "/api/chat//personas//chat/start", + "/api/chat//personas//chat/send", + "/api/chat//personas//chat/finish", + "/api/chat/sessions", "/api/chat/sessions/", + "/api/me/board", "/api/me/weak-areas", "/api/me/personas", + "/api/me/personas/generate", + "/api/analytics", + ] + missing = [e for e in expected if e not in rules] + if missing: + raise SystemExit(f"MISSING ROUTES: {missing}") + print(f"[ok] all {len(expected)} expected routes registered") + for r in sorted(rules): + print(" ", r) + print("ALL ROUTE REGISTRATION TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/verify_env.py b/backend/scripts/verify_env.py new file mode 100644 index 0000000..d6c73a3 --- /dev/null +++ b/backend/scripts/verify_env.py @@ -0,0 +1,4 @@ +import flask, jwt, dotenv, openai, fitz, pydantic, werkzeug +print("flask", flask.__version__) +print("openai", openai.__version__) +print("all imports ok") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8603b90 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + sales-trainer: + build: . + container_name: sales-trainer + env_file: + - .env + environment: + - FLASK_HOST=0.0.0.0 + - FLASK_PORT=5001 + - FLASK_DEBUG=0 + ports: + - "5001:5001" + restart: unless-stopped + volumes: + - ./data:/app/backend/data diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..2e89fb3 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,62 @@ +# HANDOFF — Sales Trainer + +> Another AI should be able to resume without chat history. + +## Branch / repo +- Repo: `~/Gitea/Sales Trainer/` (local git initialized; **no remote yet**). +- Branch: `main` (default). + +## What this is +Corporate multi-user sales-training simulator. Vue SPA + Flask API + filesystem JSON storage. +Admins build persona groups from a product (form + upload); trainees chat one-shot against +generated customer personas to practice closing; judge-LLM scores + coaches. + +## Current state — COMPLETE (M0–M7), prototype verified with mock LLM +All backend + frontend built. All 4 backend test suites pass. Frontend builds. Live HTTP smoke +test passes (SPA served, login, group create, register->404). + +## Verified commands +```bash +# Backend tests (mock LLM, no key needed) +cd backend +uv run python scripts/test_m0.py # auth/roles/no-self-reg +uv run python scripts/test_m1.py # group create + role visibility +uv run python scripts/test_routes.py # 21 routes registered +uv run python scripts/test_e2e.py # full flow (analyze->personas->chat->debrief->one-shot->board->analytics) + +# Run backend +cd backend && uv run python run.py # Flask :5001 (serves built frontend from frontend/dist) + +# Frontend dev +cd frontend && npm install && npm run dev # Vite :3000 proxying /api -> :5001 +# Frontend build +cd frontend && npm run build # outputs frontend/dist +``` + +## Default account +- super_admin: `admin@salestrainer.local` / `admin123` (bootstrap; change in prod). + +## Key gotchas +1. **Do NOT invoke `.venv/bin/python + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..efad61b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1287 @@ +{ + "name": "sales-trainer-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sales-trainer-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..076933d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "sales-trainer-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + }, + "allowScripts": { + "esbuild@0.21.5": true + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..a03651e --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..37b75a0 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,57 @@ +// API client with JWT auth injection. +const TOKEN_KEY = 'st_token' + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) +} +export function setToken(t) { + if (t) localStorage.setItem(TOKEN_KEY, t) + else localStorage.removeItem(TOKEN_KEY) +} + +async function request(method, url, body, isForm = false) { + const headers = {} + const token = getToken() + if (token) headers['Authorization'] = `Bearer ${token}` + let payload = body + if (!isForm && body !== undefined && body !== null) { + headers['Content-Type'] = 'application/json' + payload = JSON.stringify(body) + } + const res = await fetch(url, { method, headers, body: payload }) + let data = null + try { + data = await res.json() + } catch (e) { + /* ignore json parse errors */ + } + if (!res.ok) { + const msg = (data && (data.error || data.message)) || `HTTP ${res.status}` + throw new Error(msg) + } + return data +} + +export const api = { + login: (email, password) => request('POST', '/api/auth/login', { email, password }), + me: () => request('GET', '/api/auth/me'), + adminCreateUser: (b) => request('POST', '/api/admin/users', b), + adminListUsers: () => request('GET', '/api/admin/users'), + adminUpdateUser: (email, b) => request('PUT', `/api/admin/users/${email}`, b), + createGroup: (formData) => request('POST', '/api/groups', formData, true), + listGroups: () => request('GET', '/api/groups'), + getGroup: (id) => request('GET', `/api/groups/${id}`), + analyzeGroup: (id) => request('POST', `/api/groups/${id}/analyze`), + listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`), + getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`), + updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b), + chatStart: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`), + chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }), + chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`), + mySessions: () => request('GET', '/api/chat/sessions'), + myBoard: () => request('GET', '/api/me/board'), + weakAreas: () => request('GET', '/api/me/weak-areas'), + myPersonas: () => request('GET', '/api/me/personas'), + generatePersona: (b) => request('POST', '/api/me/personas/generate', b), + analytics: () => request('GET', '/api/analytics'), +} diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 0000000..1d5c566 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -0,0 +1,122 @@ +// Minimal i18n (EN + TH) via a reactive locale. +import { reactive } from 'vue' + +const messages = { + en: { + app: 'Sales Trainer', + login: 'Login', + logout: 'Logout', + email: 'Email', + password: 'Password', + loginError: 'Invalid credentials', + dashboard: 'Dashboard', + groups: 'Persona Groups', + myTraining: 'My Training', + adminTools: 'Admin Tools', + users: 'Users', + analytics: 'Analytics', + groupBuilder: 'Group Builder', + create: 'Create', + analyze: 'Analyze', + manual: 'Manual', + edit: 'Edit', + product: 'Product', + segment: 'Initial customer segment (optional)', + description: 'Additional description / scenario (optional)', + channel: 'Channel', + facebook: 'Facebook', + line: 'LINE', + language: 'Language', + thai: 'Thai', + english: 'English', + personas: 'Personas', + tierA: 'Tier A — Ready to buy', + tierB: 'Tier B — Unsure', + tierC: 'Tier C — Not interested but has pain', + selectPersona: 'Select a persona to practice', + chat: 'Chat', + start: 'Start', + send: 'Send', + finish: 'Finish & get result', + debrief: 'Result & Coaching', + won: 'Won', + lost: 'Lost', + notTried: 'Not tried', + score: 'Score', + pain: 'Pain', + why: 'Reason', + reveal: 'Revealed persona details', + generatePersona: 'Generate my persona', + weakAreas: 'My weak areas', + mySessions: 'My sessions', + openSaleTask: 'The customer did NOT message first. You must open the sale.', + sellerInitiated: 'You must open the sale (outbound)', + customerInitiated: 'The customer will message you first', + }, + th: { + app: 'ตัวฝึกขาย', + login: 'เข้าสู่ระบบ', + logout: 'ออกจากระบบ', + email: 'อีเมล', + password: 'รหัสผ่าน', + loginError: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง', + dashboard: 'หน้าหลัก', + groups: 'กลุ่มลูกค้า (Persona)', + myTraining: 'การฝึกของฉัน', + adminTools: 'เครื่องมือ Admin', + users: 'ผู้ใช้', + analytics: 'สถิติ', + groupBuilder: 'สร้างกลุ่มลูกค้า', + create: 'สร้าง', + analyze: 'วิเคราะห์', + manual: 'กำหนดเอง', + edit: 'แก้ไข', + product: 'สินค้า/บริการ', + segment: 'กลุ่มลูกค้าเบื้องต้น (ไม่บังคับ)', + description: 'คำอธิบาย/สถานการณ์เพิ่มเติม (ไม่บังคับ)', + channel: 'ช่องทาง', + facebook: 'Facebook', + line: 'LINE', + language: 'ภาษา', + thai: 'ไทย', + english: 'อังกฤษ', + personas: 'Persona', + tierA: 'ระดับ A — ตั้งใจซื้อ', + tierB: 'ระดับ B — ยังไม่แน่ใจ', + tierC: 'ระดับ C — ไม่สนใจแต่มี pain', + selectPersona: 'เลือก persona เพื่อฝึก', + chat: 'แชท', + start: 'เริ่ม', + send: 'ส่ง', + finish: 'สรุปผล', + debrief: 'ผลลัพธ์และคำแนะนำ', + won: 'ขายได้', + lost: 'ขายไม่ได้', + notTried: 'ยังไม่ได้ฝึก', + score: 'คะแนน', + pain: 'Pain', + why: 'เหตุผล', + reveal: 'ข้อมูล persona ที่ถูกซ่อนไว้', + generatePersona: 'สร้าง persona ของฉัน', + weakAreas: 'จุดที่ฉันแพ้บ่อย', + mySessions: 'การฝึกของฉัน', + openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง', + sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)', + customerInitiated: 'ลูกค้าจะทักมาเองก่อน', + }, +} + +export const i18n = reactive({ + locale: localStorage.getItem('locale') || 'th', + t(key) { + return (messages[this.locale] && messages[this.locale][key]) || messages.en[key] || key + }, + set(locale) { + this.locale = locale + localStorage.setItem('locale', locale) + }, +}) + +export function useT() { + return (key) => i18n.t(key) +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..54ce204 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './style.css' + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..989d516 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,37 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { auth } from '../store/auth' + +const routes = [ + { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, + { path: '/', component: () => import('../views/Dashboard.vue') }, + { path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') }, + { path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') }, + { path: '/my/sessions', component: () => import('../views/MySessions.vue') }, + { path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') }, + { path: '/my/generate', component: () => import('../views/GenPersona.vue') }, + { path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } }, + { path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } }, + { path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } }, + { path: '/admin/analytics', component: () => import('../views/Analytics.vue'), meta: { admin: true } }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach(async (to) => { + if (to.meta.public) return true + if (!auth.user) { + await auth.load() + } + if (!auth.user) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (to.meta.admin && !auth.isAdmin) { + return { path: '/' } + } + return true +}) + +export default router diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js new file mode 100644 index 0000000..4146b2e --- /dev/null +++ b/frontend/src/store/auth.js @@ -0,0 +1,38 @@ +// Auth + role store (reactive). +import { reactive } from 'vue' +import { getToken, setToken, api } from '../api' + +export const auth = reactive({ + user: null, + token: getToken(), + get role() { + return this.user ? this.user.role : null + }, + get isAdmin() { + return this.role === 'admin' || this.role === 'super_admin' + }, + async load() { + if (!this.token) return null + try { + const data = await api.me() + this.user = data.user + return this.user + } catch (e) { + this.user = null + setToken(null) + return null + } + }, + async login(email, password) { + const data = await api.login(email, password) + this.token = data.token + setToken(data.token) + this.user = data.user + return data.user + }, + logout() { + this.user = null + this.token = null + setToken(null) + }, +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..11829f1 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,73 @@ +:root { + --bg: #f6f7fb; + --card: #ffffff; + --border: #e5e8ef; + --ink: #1a1d29; + --muted: #6b7280; + --accent: #4f46e5; + --accent-2: #7c3aed; + --green: #16a34a; + --red: #dc2626; + --amber: #d97706; + --radius: 14px; + --shadow: 0 1px 3px rgba(20, 24, 40, 0.08); +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans Thai', sans-serif; + background: var(--bg); + color: var(--ink); + line-height: 1.5; +} +#app { min-height: 100vh; } +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); +} +button { + font-family: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--card); + padding: 10px 16px; + border-radius: 10px; + font-size: 14px; + color: var(--ink); +} +button.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + border: none; +} +button:disabled { opacity: 0.5; cursor: not-allowed; } +input, select, textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + font-family: inherit; + font-size: 14px; +} +label { font-size: 13px; color: var(--muted); display: block; margin: 10px 0 4px; } +.row { display: flex; gap: 12px; flex-wrap: wrap; } +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} +.badge.A { background: #dcfce7; color: #166534; } +.badge.B { background: #fef9c3; color: #854d0e; } +.badge.C { background: #fee2e2; color: #991b1b; } +.badge.won { background: #dcfce7; color: #166534; } +.badge.lost { background: #fee2e2; color: #991b1b; } +.badge.not_tried { background: #eef2ff; color: #4338ca; } +.error { color: var(--red); font-size: 13px; } +.muted { color: var(--muted); } +.msg-seller { background: var(--accent); color: #fff; align-self: flex-end; border-radius: 16px 16px 4px 16px; } +.msg-customer { background: #fff; align-self: flex-start; border-radius: 16px 16px 16px 4px; border: 1px solid var(--border); } diff --git a/frontend/src/views/AdminUsers.vue b/frontend/src/views/AdminUsers.vue new file mode 100644 index 0000000..b3ee28a --- /dev/null +++ b/frontend/src/views/AdminUsers.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/views/Analytics.vue b/frontend/src/views/Analytics.vue new file mode 100644 index 0000000..cb82719 --- /dev/null +++ b/frontend/src/views/Analytics.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue new file mode 100644 index 0000000..82b1c11 --- /dev/null +++ b/frontend/src/views/Chat.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..36206c9 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/views/GenPersona.vue b/frontend/src/views/GenPersona.vue new file mode 100644 index 0000000..b718ddf --- /dev/null +++ b/frontend/src/views/GenPersona.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/views/GroupBuilder.vue b/frontend/src/views/GroupBuilder.vue new file mode 100644 index 0000000..a24e011 --- /dev/null +++ b/frontend/src/views/GroupBuilder.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/src/views/GroupEdit.vue b/frontend/src/views/GroupEdit.vue new file mode 100644 index 0000000..586bdae --- /dev/null +++ b/frontend/src/views/GroupEdit.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..2f39d19 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/views/MySessions.vue b/frontend/src/views/MySessions.vue new file mode 100644 index 0000000..42bc2ed --- /dev/null +++ b/frontend/src/views/MySessions.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/views/Personas.vue b/frontend/src/views/Personas.vue new file mode 100644 index 0000000..c91c1cf --- /dev/null +++ b/frontend/src/views/Personas.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/views/WeakAreas.vue b/frontend/src/views/WeakAreas.vue new file mode 100644 index 0000000..aca2dad --- /dev/null +++ b/frontend/src/views/WeakAreas.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d667284 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:5001', + changeOrigin: true, + }, + }, + }, +})