Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)

- Auth/roles (no self-reg), admin user provision, JWT
- Analyze: sales kit + initial pain-fit from form/upload
- Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special
- Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching
- Trainee loop: win/lose board, weak-areas, user-generated personas
- Admin analytics; EN+TH Vue SPA served by Flask
- Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF
- Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
Macky
2026-08-07 15:31:06 +07:00
commit c3d31c06e2
70 changed files with 6135 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
**/.venv/
venv/
**/__pycache__/
*.pyc
**/node_modules/
frontend/dist/
backend/data/
data/
.env
**/*.log
.DS_Store
.git/
.tmp/

21
.env.example Normal file
View File

@@ -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

30
.gitignore vendored Normal file
View File

@@ -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/

31
Dockerfile Normal file
View File

@@ -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"]

1
IDEA.md Normal file
View File

@@ -0,0 +1 @@
App platform for Sales Training by develop persona with pain point. Sales trainee will try to chat for sell a product.

119
README.md Normal file
View File

@@ -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).

16
backend/.env.example Normal file
View File

@@ -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

6
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Backend entry point."""
from __future__ import annotations
from .factory import create_app
__all__ = ["create_app"]

View File

@@ -0,0 +1 @@
"""API package."""

View File

@@ -0,0 +1,88 @@
"""Admin routes: user provisioning + role management (no self-registration)."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from ..config import Config
from .helpers import ApiError, current_user, require_auth, require_roles
admin_bp = Blueprint("admin", __name__)
def _store():
from flask import current_app
return current_app.extensions["user_store"]
@admin_bp.post("/users")
@require_auth
@require_roles("admin")
def create_user():
"""Create a user + provision a password (invite). Admin or super-admin only."""
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
role = (data.get("role") or "user").strip()
org_id = (data.get("org_id") or current_user().get("org_id") or "org-default").strip()
if not email or not password:
raise ApiError("email and password are required")
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
# Only super_admin can create another admin/super_admin
actor_role = current_user().get("role")
if role in ("admin", "super_admin") and actor_role != "super_admin":
raise ApiError("only super_admin can grant admin roles", 403)
try:
user = _store().create_user(
org_id=org_id, email=email, password=password, name=name, role=role
)
except AuthError as exc:
raise ApiError(str(exc))
return jsonify({"user": _store().public_user(user)}), 201
@admin_bp.get("/users")
@require_auth
@require_roles("admin")
def list_users():
actor = current_user()
if actor.get("role") == "super_admin":
users = _store().list_users()
else:
users = _store().list_users(org_id=actor.get("org_id"))
return jsonify({"users": users})
@admin_bp.put("/users/<email>")
@require_auth
@require_roles("admin")
def update_user(email: str):
data = request.get_json(silent=True) or {}
email = email.strip().lower()
actor = current_user()
target = _store().get_user_or_none(email)
if not target:
raise ApiError("user not found", 404)
# Role changes / admin-modification restricted to super_admin
if "role" in data:
role = (data.get("role") or "").strip()
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
if actor.get("role") != "super_admin":
raise ApiError("only super_admin can change roles")
_store().set_role(email, role)
if "active" in data:
if actor.get("role") != "super_admin":
raise ApiError("only super_admin can activate/deactivate users")
_store().set_active(email, bool(data.get("active")))
if "password" in data and data.get("password"):
_store().set_password(email, data.get("password"))
return jsonify({"user": _store().public_user(_store().get_user(email))})

View File

@@ -0,0 +1,83 @@
"""Admin analytics: aggregate trainee results."""
from __future__ import annotations
from flask import Blueprint, jsonify
from .helpers import ApiError, current_user, require_auth, require_roles
analytics_bp = Blueprint("analytics", __name__)
def _stores():
from flask import current_app
return {
"sessions": current_app.extensions["session_store"],
"groups": current_app.extensions["group_store"],
"users": current_app.extensions["user_store"],
}
@analytics_bp.get("")
@require_auth
@require_roles("admin")
def analytics():
s = _stores()
actor = current_user()
if actor.get("role") == "super_admin":
sessions = s["sessions"].sessions.all()
users = s["users"].list_users()
else:
org_id = actor.get("org_id")
# users in this org
users = s["users"].list_users(org_id=org_id)
user_ids = {u["id"] for u in users}
sessions = [
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
]
overall = {
"total_sessions": len(sessions),
"wins": sum(1 for x in sessions if x.get("outcome") == "won"),
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
}
overall["close_rate"] = round(
overall["wins"] / overall["total_sessions"] * 100, 1
) if overall["total_sessions"] else 0
# average score
scores = [ (x.get("debrief") or {}).get("score", 0) for x in sessions if x.get("outcome") ]
overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
# hardest personas = personas with most losses (lowest avg score)
by_persona: dict = {}
for x in sessions:
key = (x.get("group_id"), x.get("persona_id"), x.get("persona_name", "?"))
if key not in by_persona:
by_persona[key] = {"plays": 0, "losses": 0, "wins": 0, "scores": []}
rec = by_persona[key]
rec["plays"] += 1
rec["scores"].append((x.get("debrief") or {}).get("score", 0))
if x.get("outcome") == "won":
rec["wins"] += 1
elif x.get("outcome") == "lost":
rec["losses"] += 1
hardest = sorted(
(
{
"persona_name": k[2],
"plays": v["plays"],
"wins": v["wins"],
"losses": v["losses"],
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1) if v["scores"] else 0,
}
for k, v in by_persona.items()
),
key=lambda r: (r["losses"], -r["avg_score"]),
)[:10]
return jsonify({
"overall": overall,
"trainee_count": len(users),
"hardest_personas": hardest,
})

View File

@@ -0,0 +1,36 @@
"""Auth routes: login + current user. No self-registration."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from .helpers import ApiError, current_user, require_auth
auth_bp = Blueprint("auth", __name__)
def _store():
from flask import current_app
return current_app.extensions["user_store"]
@auth_bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
raise ApiError("email and password are required")
try:
user = _store().verify(email, password)
token = _store().issue_token(user)
except AuthError as exc:
raise ApiError(str(exc), 401)
return jsonify({"token": token, "user": _store().public_user(user)})
@auth_bp.get("/me")
@require_auth
def me():
return jsonify({"user": _store().public_user(current_user())})

View File

@@ -0,0 +1,171 @@
"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..llm import LLMError
from ..services.simulator import Simulator
from .helpers import ApiError, current_user, require_auth, require_roles
chat_bp = Blueprint("chat", __name__)
def _stores():
from flask import current_app
return {
"groups": current_app.extensions["group_store"],
"sessions": current_app.extensions["session_store"],
"llm": current_app.extensions["llm"],
}
def _sim(group, persona):
llm = _stores()["llm"]
if not llm:
raise ApiError("LLM not configured", 500)
return Simulator(llm)
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
@require_auth
@require_roles("user")
def start_session(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group or group.get("status") != "ready":
raise ApiError("group not ready", 404)
persona = s["groups"].get_persona(gid, pid)
if not persona:
raise ApiError("persona not found", 404)
actor = current_user()
# One-shot: reject if already finished this persona
try:
session = s["sessions"].create(
user_id=actor["id"], group_id=gid, persona_id=pid,
persona_name=persona.get("name", "?"),
persona_meta={
"tier": persona.get("tier"),
"initiation_mode": persona.get("initiation_mode"),
"channel": persona.get("channel"),
},
)
except ValueError as exc:
raise ApiError(str(exc), 400)
sim = _sim(group, persona)
# Seller-initiated: give the trainee an opening task (no persona message yet).
init_mode = persona.get("initiation_mode", "customer")
if init_mode == "customer":
# Customer opens: inject the persona's opener as the first message.
opener = persona.get("opener") or "Hi, I saw your product and had a question."
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
else:
s["sessions"].update(
session["id"],
task="The customer did NOT message first. You must open the sale — start the "
"conversation with this lead (e.g. introduce yourself and engage with interest).",
)
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
@require_auth
@require_roles("user")
def send_message(gid: str, pid: str):
s = _stores()
actor = current_user()
session = s["sessions"].active_for_persona(actor["id"], pid)
if not session or session.get("group_id") != gid:
raise ApiError("no active session for this persona", 404)
data = request.get_json(silent=True) or {}
text = (data.get("text") or "").strip()
if not text:
raise ApiError("message is empty")
if len(text) > 2000:
raise ApiError("message too long")
group = s["groups"].get_or_none(gid)
persona = s["groups"].get_persona(gid, pid)
messages = list(session.get("messages", []))
messages.append({"role": "seller", "text": text})
sim = _sim(group, persona)
try:
reply = sim.persona_reply(
persona=persona,
sales_kit=group.get("sales_kit") or {},
messages=messages,
internal=session.get("internal", {}),
)
except LLMError as exc:
raise ApiError(f"LLM error: {exc}", 500)
messages.append({"role": "customer", "text": reply})
s["sessions"].update(session["id"], messages=messages)
return jsonify({"reply": reply, "messages": messages})
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
@require_auth
@require_roles("user")
def finish_session(gid: str, pid: str):
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
s = _stores()
actor = current_user()
session = s["sessions"].active_for_persona(actor["id"], pid)
if not session or session.get("group_id") != gid:
raise ApiError("no active session for this persona", 404)
group = s["groups"].get_or_none(gid)
persona = s["groups"].get_persona(gid, pid)
sim = _sim(group, persona)
messages = session.get("messages", [])
try:
verdict = sim.judge(persona=persona, messages=messages)
except LLMError as exc:
raise ApiError(f"LLM error: {exc}", 500)
outcome = "won" if verdict.get("outcome") == "won" else "lost"
debrief = {
**verdict,
"revealed_persona": {
"pains": persona.get("pains", []),
"income": persona.get("income", ""),
"personality": persona.get("personality", ""),
"budget": persona.get("budget", ""),
"negotiation_levers": persona.get("negotiation_levers", []),
"opener": persona.get("opener", ""),
"background": persona.get("background", ""),
},
}
s["sessions"].update(
session["id"],
status="finished",
outcome=outcome,
debrief=debrief,
internal=session.get("internal", {}),
)
return jsonify({"session": s["sessions"].get(session["id"]), "debrief": debrief})
@chat_bp.get("/sessions")
@require_auth
@require_roles("user")
def my_sessions():
s = _stores()
uid = current_user()["id"]
sessions = s["sessions"].list_for_user(uid)
return jsonify({"sessions": sessions})
@chat_bp.get("/sessions/<sid>")
@require_auth
@require_roles("user")
def get_session(sid: str):
s = _stores()
session = s["sessions"].get_or_none(sid)
if not session or session.get("user_id") != current_user()["id"]:
raise ApiError("session not found", 404)
return jsonify({"session": session})

View File

@@ -0,0 +1,235 @@
"""Group API: create, analyze (sales kit + personas), read, edit, report."""
from __future__ import annotations
import threading
from pathlib import Path
from flask import Blueprint, jsonify, request
from ..config import Config
from ..llm import LLMClient, LLMError
from ..services.groups import GroupStore
from ..services.store import ensure_persona_shape, revealable_view
from .helpers import ApiError, current_user, require_auth, require_roles
groups_bp = Blueprint("groups", __name__)
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
_ANALYZE_GUARD = threading.Lock()
def _stores():
from flask import current_app
return {
"groups": current_app.extensions.get("group_store"),
"users": current_app.extensions["user_store"],
"session_store": current_app.extensions.get("session_store"),
"llm": current_app.extensions["llm"],
}
def _upload_dir():
d = Config.DATA_DIR / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
@groups_bp.post("")
@require_auth
@require_roles("admin")
def create_group():
"""Create a persona group from a setup form + optional files."""
s = _stores()
file_text = ""
saved_files = []
if request.files:
for file in request.files.getlist("files"):
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
if ext not in Config.ALLOWED_UPLOAD_EXTS:
raise ApiError(f"unsupported file type: {ext}")
dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{file.filename}"
file.save(dest)
saved_files.append(dest.name)
data = request.form.to_dict() if request.files else (request.get_json(silent=True) or {})
from ..services.file_parser import parse_document
for name in saved_files:
try:
file_text += "\n\n" + parse_document(_upload_dir() / name)
except Exception as exc:
raise ApiError(f"could not parse file {name}: {exc}")
product = (data.get("product") or "").strip()
if product == "" and not file_text.strip():
raise ApiError("provide product info in the form or via file upload")
group = s["groups"].create(
org_id=current_user().get("org_id") or "org-default",
creator_id=current_user()["id"],
title=(product or file_text[:80] or "Untitled group").strip()[:200],
)
s["groups"].update(
group["id"],
input={
"product": product,
"segment": (data.get("segment") or ""),
"description": (data.get("description") or ""),
"channel": (data.get("channel") or "facebook"),
"language": (data.get("language") or "th"),
"files": saved_files,
"file_text": file_text[:60000],
},
)
return jsonify({"group": s["groups"].get(group["id"])}), 201
@groups_bp.get("")
@require_auth
def list_groups():
s = _stores()
actor = current_user()
visible = s["groups"].list_visible_to(
role=actor.get("role"), org_id=actor.get("org_id")
)
return jsonify({"groups": visible})
@groups_bp.post("/<gid>/analyze")
@require_auth
@require_roles("admin")
def analyze_group(gid: str):
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen)."""
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
if group.get("org_id") != (current_user().get("org_id") or "org-default"):
raise ApiError("permission denied", 403)
inp = group.get("input", {})
if not s["llm"]:
raise ApiError("LLM not configured", 500)
from ..services.analyzer import Analyzer
from ..services.persona_generator import PersonaGenerator
s["groups"].update(gid, status="analyzing", error=None)
try:
sales_kit = Analyzer(s["llm"]).analyze(
product=inp.get("product", ""),
segment=inp.get("segment", ""),
description=inp.get("description", ""),
file_text=inp.get("file_text", ""),
channel=inp.get("channel", "facebook"),
)
personas = PersonaGenerator(s["llm"]).generate(
sales_kit=sales_kit,
language=inp.get("language", "th"),
channel=inp.get("channel", "facebook"),
)
except Exception as exc:
s["groups"].update(gid, status="failed", error=str(exc))
raise ApiError(f"analysis failed: {exc}", 500)
from ..services.report import build_report
report = build_report(sales_kit=sales_kit, personas=personas, language=inp.get("language", "th"))
s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None)
s["groups"].set_personas(gid, personas)
s["groups"].update(gid, report=report)
return jsonify({
"group": s["groups"].get(gid),
"sales_kit": sales_kit,
"personas": s["groups"].get(gid)["personas"],
})
@groups_bp.get("/<gid>")
@require_auth
def get_group(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
actor = current_user()
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
view = dict(group)
if actor.get("role") == "user":
# Trainee: hide latent persona fields + sales kit details they shouldn't see
view["personas"] = [
revealable_view(p) for p in group.get("personas", [])
]
return jsonify({"group": view})
@groups_bp.get("/<gid>/personas")
@require_auth
def list_personas(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
actor = current_user()
if actor.get("role") == "user":
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
personas = [revealable_view(p) for p in group.get("personas", [])]
else:
personas = group.get("personas", [])
# attach per-user status (won/lost/not-tried) for trainees
if actor.get("role") == "user":
sess = _stores().get("session_store")
store = sess.sessions if sess else None
mine = store.where(lambda r: r.get("user_id") == actor["id"] and r.get("group_id") == gid) if store else []
outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine}
for p in personas:
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
return jsonify({"personas": personas, "tiers": ["A", "B", "C"]})
@groups_bp.get("/<gid>/personas/<pid>")
@require_auth
def get_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
p = s["groups"].get_persona(gid, pid)
if not p:
raise ApiError("persona not found", 404)
actor = current_user()
ensure = ensure_persona_shape(p)
if actor.get("role") == "user":
return jsonify({"persona": revealable_view(ensure)})
return jsonify({"persona": ensure})
@groups_bp.put("/<gid>/personas/<pid>")
@require_auth
@require_roles("admin")
def update_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
data = request.get_json(silent=True) or {}
try:
updated = s["groups"].update_persona(gid, pid, data)
except ValueError as exc:
raise ApiError(str(exc), 404)
return jsonify({"persona": ensure_persona_shape(updated["personas"][
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
])})
@groups_bp.post("/<gid>/reanalyze")
@require_auth
@require_roles("admin")
def reanalyze_group(gid: str):
return analyze_group(gid)

View File

@@ -0,0 +1,73 @@
"""JWT auth decorators + role guards + shared API helpers."""
from __future__ import annotations
import functools
from typing import Any, Callable
from flask import g, jsonify, request
from ..auth.users import AuthError
from ..config import Config
class ApiError(Exception):
def __init__(self, message: str, status: int = 400):
super().__init__(message)
self.message = message
self.status = status
def _get_store():
from flask import current_app
return current_app.extensions["user_store"]
def current_user() -> dict[str, Any]:
return g.user
def require_auth(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
header = request.headers.get("Authorization", "")
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token:
raise ApiError("authentication required", 401)
try:
payload = _get_store().decode_token(token)
except AuthError as exc:
raise ApiError(str(exc), 401)
user = _get_store().get_user_or_none(payload.get("sub", ""))
if not user or not user.get("active", True):
raise ApiError("account is inactive", 401)
g.user = user
g.token_payload = payload
return fn(*args, **kwargs)
return wrapper
def require_roles(*roles: str) -> Callable:
def deco(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
role = g.user.get("role")
# super_admin passes any role gate
allowed = {"super_admin", *roles}
if role not in allowed:
raise ApiError("permission denied", 403)
return fn(*args, **kwargs)
return wrapper
return deco
def api_error_handler(err: ApiError):
return jsonify({"error": err.message}), err.status
def register_error_handlers(app) -> None:
app.register_error_handler(ApiError, api_error_handler)
app.register_error_handler(ValueError, lambda e: (jsonify({"error": str(e)}), 400))

View File

@@ -0,0 +1,121 @@
"""Trainee routes: win/lose board, weak-areas, generate own persona."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..llm import LLMError
from ..services.trainee import MyPersonaStore, analyze_weak_areas
from .helpers import ApiError, current_user, require_auth, require_roles
me_bp = Blueprint("me", __name__)
def _stores():
from flask import current_app
return {
"groups": current_app.extensions["group_store"],
"sessions": current_app.extensions["session_store"],
"my_personas": current_app.extensions.get("my_persona_store"),
"llm": current_app.extensions["llm"],
}
@me_bp.get("/board")
@require_auth
@require_roles("user")
def win_lose_board():
"""Per-persona won/lost/not-tried across all groups the user sees."""
s = _stores()
uid = current_user()["id"]
my_sessions = s["sessions"].list_for_user(uid)
outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions}
groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id"))
items = []
for g in groups:
for p in g.get("personas", []):
key = (g["id"], p["id"])
items.append({
"group_id": g["id"],
"group_title": g.get("title"),
"persona_id": p["id"],
"persona_name": p.get("name"),
"tier": p.get("tier"),
"my_outcome": outcome_by.get(key, "not_tried"),
})
return jsonify({"board": items})
@me_bp.get("/weak-areas")
@require_auth
@require_roles("user")
def weak_areas():
s = _stores()
uid = current_user()["id"]
sessions = s["sessions"].list_for_user(uid)
insight = analyze_weak_areas(sessions)
return jsonify({"insight": insight})
def _personal_group(s, actor) -> dict:
"""Return (or create) the user's private group holding their own personas."""
groups = s["groups"].list_for_org(org_id=actor.get("org_id"))
for g in groups:
if g.get("owner_user_id") == actor["id"]:
return g
g = s["groups"].create(
org_id=actor.get("org_id") or "org-default",
creator_id=actor["id"],
title=f"{actor.get('name','User')}'s private personas",
)
s["groups"].update(
g["id"],
status="ready",
owner_user_id=actor["id"],
input={"channel": "facebook", "language": "th"},
sales_kit={"productName": "personal practice", "valueProps": [], "features": []},
)
return s["groups"].get(g["id"])
@me_bp.get("/personas")
@require_auth
@require_roles("user")
def my_personas():
s = _stores()
uid = current_user()["id"]
group = _personal_group(s, current_user())
return jsonify({"group": group, "personas": group.get("personas", [])})
@me_bp.post("/personas/generate")
@require_auth
@require_roles("user")
def generate_persona():
s = _stores()
actor = current_user()
data = request.get_json(silent=True) or {}
mode = data.get("mode", "manual") # "weak-area" | "manual"
spec = data.get("spec") or {}
llm = s["llm"]
if not llm:
raise ApiError("LLM not configured", 500)
if mode == "weak-area" and not spec:
# auto-detect weak areas from this user's losses if no spec given
sessions = s["sessions"].list_for_user(actor["id"])
spec = analyze_weak_areas(sessions)
from ..services.own_persona import generate_own_persona
try:
persona = generate_own_persona(llm, mode=mode, spec=spec)
except (LLMError, ValueError) as exc:
raise ApiError(f"generation failed: {exc}", 500)
group = _personal_group(s, actor)
group = s["groups"].get(group["id"])
existing = group.get("personas", [])
persona["id"] = f"myp-{len(existing)+1:02d}"
existing.append(persona)
s["groups"].set_personas(group["id"], existing)
return jsonify({"persona": persona, "group": s["groups"].get(group["id"])}), 201

View File

@@ -0,0 +1 @@
"""Auth package."""

128
backend/app/auth/users.py Normal file
View File

@@ -0,0 +1,128 @@
"""User + organization store and auth logic (JWT, password hashing, roles)."""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
import jwt
from werkzeug.security import check_password_hash, generate_password_hash
from ..config import Config
from ..storage.store import JsonStore, StoreError, new_id
class AuthError(Exception):
pass
class UserStore:
def __init__(self, data_dir: Path) -> None:
self.users = JsonStore(data_dir / "users")
self.orgs = JsonStore(data_dir / "orgs")
# ── org ────────────────────────────────────────────────────────────
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
return self.orgs.create(
{"name": name, "id": org_id or new_id("org")},
key=org_id or new_id("org"),
)
def get_org(self, org_id: str) -> dict[str, Any]:
return self.orgs.get(org_id)
# ── users ──────────────────────────────────────────────────────────
def create_user(
self,
*,
org_id: str,
email: str,
password: str,
name: str,
role: str = "user",
) -> dict[str, Any]:
if role not in Config.ROLES:
raise AuthError(f"invalid role: {role}")
org = self.orgs.get(org_id)
email = email.strip().lower()
if not email or not password:
raise AuthError("email and password are required")
if self.users.get_or_none(email) is not None:
raise AuthError("a user with this email already exists")
user = {
"id": email, # email = unique id/username
"email": email,
"org_id": org_id,
"org_name": org.get("name", ""),
"name": name.strip() or email,
"password_hash": generate_password_hash(password),
"role": role,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"active": True,
}
return self.users.create(user, key=email)
def get_user(self, email: str) -> dict[str, Any]:
email = email.strip().lower()
return self.users.get(email)
def get_user_or_none(self, email: str) -> dict[str, Any] | None:
return self.users.get_or_none(email.strip().lower())
def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]:
users = self.users.all()
if org_id:
users = [u for u in users if u.get("org_id") == org_id]
# Redact password hash
for u in users:
u.pop("password_hash", None)
return users
def set_active(self, email: str, active: bool) -> dict[str, Any]:
return self.users.update(email.strip().lower(), active=active)
def set_role(self, email: str, role: str) -> dict[str, Any]:
if role not in Config.ROLES:
raise AuthError(f"invalid role: {role}")
return self.users.update(email.strip().lower(), role=role)
def set_password(self, email: str, new_password: str) -> dict[str, Any]:
if not new_password:
raise AuthError("password is required")
return self.users.update(
email.strip().lower(),
password_hash=generate_password_hash(new_password),
)
# ── auth ───────────────────────────────────────────────────────────
def verify(self, email: str, password: str) -> dict[str, Any]:
user = self.get_user_or_none(email)
if not user or not user.get("active", True):
raise AuthError("invalid credentials")
if not check_password_hash(user["password_hash"], password):
raise AuthError("invalid credentials")
return user
def issue_token(self, user: dict[str, Any]) -> str:
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": user["email"],
"org_id": user["org_id"],
"role": user["role"],
"iat": now,
"exp": now + datetime.timedelta(hours=Config.JWT_EXPIRES_HOURS),
}
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
def decode_token(self, token: str) -> dict[str, Any]:
try:
return jwt.decode(
token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO]
)
except jwt.PyJWTError as exc:
raise AuthError("invalid or expired token") from exc
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
u = dict(user)
u.pop("password_hash", None)
return u

73
backend/app/config.py Normal file
View File

@@ -0,0 +1,73 @@
"""Configuration from environment / .env."""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from backend/ (project root for this app)
_BACKEND_DIR = Path(__file__).resolve().parent.parent
load_dotenv(_BACKEND_DIR / ".env", override=True)
def _get_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def resolve_llm() -> tuple[str, str, str, str | None]:
"""Return (base_url, model, api_key, provider_name)."""
provider = os.environ.get("LLM_PROVIDER", "").strip()
explicit_base = os.environ.get("LLM_BASE_URL", "").strip()
explicit_model = os.environ.get("LLM_MODEL_NAME", "").strip()
api_key = os.environ.get("LLM_API_KEY", "").strip() or None
presets = {
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
"custom": ("", ""),
}
if provider and provider in presets:
base_url, model = presets[provider]
if explicit_base:
base_url = explicit_base
if explicit_model:
model = explicit_model
return base_url, model, api_key or "", provider
# No/unknown provider: fall back to explicit config
return (
explicit_base or "https://api.openai.com/v1",
explicit_model or "gpt-4o-mini",
api_key or "",
provider or None,
)
class Config:
APP_NAME = "Sales Trainer"
SECRET_KEY = os.environ.get("JWT_SECRET", "dev-secret-change-me")
JWT_ALGO = "HS256"
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
DATA_DIR = Path(
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
).resolve()
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.environ.get("FLASK_PORT", "5001"))
FLASK_DEBUG = _get_bool("FLASK_DEBUG", True)
UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15"))
ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"}
# LLM
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm()
ROLES = ("super_admin", "admin", "user")
@classmethod
def ensure_dirs(cls) -> None:
for name in ("users", "orgs", "groups", "sessions"):
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)

103
backend/app/factory.py Normal file
View File

@@ -0,0 +1,103 @@
"""Flask application factory."""
from __future__ import annotations
from pathlib import Path
from flask import Flask
from flask_cors import CORS
from .auth.users import AuthError, UserStore
from .config import Config
def bootstrap_admin(users: UserStore) -> None:
"""Ensure a default org + super-admin exists on first run (no self-registration)."""
email = "admin@salestrainer.local"
org = users.orgs.get_or_none("org-default")
if org is None:
org = users.create_org("Default Organization", org_id="org-default")
if users.get_user_or_none(email) is None:
users.create_user(
org_id=org["id"],
email=email,
password="admin123",
name="Super Admin",
role="super_admin",
)
print("[bootstrap] created default super-admin:", email, "/ admin123")
def create_app() -> Flask:
Config.ensure_dirs()
app = Flask(__name__)
app.config["SECRET_KEY"] = Config.SECRET_KEY
CORS(app, resources={r"/api/*": {"origins": "*"}})
from .api.auth_routes import auth_bp
from .api.admin_routes import admin_bp
from .api.group_routes import groups_bp
from .api.chat_routes import chat_bp
from .api.me_routes import me_bp
from .api.analytics_routes import analytics_bp
from .api.helpers import register_error_handlers
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(admin_bp, url_prefix="/api/admin")
app.register_blueprint(groups_bp, url_prefix="/api/groups")
app.register_blueprint(chat_bp, url_prefix="/api/chat")
app.register_blueprint(me_bp, url_prefix="/api/me")
app.register_blueprint(analytics_bp, url_prefix="/api/analytics")
register_error_handlers(app)
from .auth.users import UserStore
from .llm import LLMClient
from .llm import LLMError
from .services.groups import GroupStore
from .services.sessions import SessionStore
from .services.trainee import MyPersonaStore
app.extensions["user_store"] = UserStore(Config.DATA_DIR)
app.extensions["group_store"] = GroupStore(Config.DATA_DIR)
app.extensions["session_store"] = SessionStore(Config.DATA_DIR)
app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR)
try:
app.extensions["llm"] = LLMClient()
except LLMError as exc:
print(f"[warn] LLM not configured yet: {exc}")
app.extensions["llm"] = None
bootstrap_admin(app.extensions["user_store"])
@app.get("/health")
def health():
return {"status": "ok", "service": Config.APP_NAME}
# Serve built Vue frontend if present (production single-app mode).
_register_frontend(app)
return app
def _register_frontend(app: Flask) -> None:
from flask import send_from_directory
# repo-root frontend/dist (factory.py -> app/ -> backend/ -> repo root)
dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if not (dist / "index.html").exists():
print(f"[info] frontend build not found at {dist}; API-only mode")
return
@app.route("/")
def index():
return send_from_directory(dist, "index.html")
@app.route("/<path:path>", methods=["GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"])
def assets(path: str):
# Never let the SPA fallback shadow API/auth routes: return 404 for them.
if path.startswith("api/") or path.startswith("health"):
return ("not found", 404)
candidate = dist / path
if candidate.is_file():
return send_from_directory(dist, path)
# SPA fallback for client-side routes
return send_from_directory(dist, "index.html")

131
backend/app/llm.py Normal file
View File

@@ -0,0 +1,131 @@
"""OpenAI-compatible LLM client (OpenAI / DeepSeek / custom base URL).
Mirrors the MiroFish provider-agnostic pattern. Credentials live in .env only.
"""
from __future__ import annotations
import json
import re
from typing import Any
from openai import OpenAI
from .config import Config
class LLMError(Exception):
pass
def _strip_thinking_trace(text: str) -> str:
"""Remove ReACT-style chain-of-thought / fences, keep the final JSON text."""
for fence in ("```json", "```"):
idx = text.rfind(fence)
if idx != -1:
after = text[idx:].lstrip()
lang_len = after.find("\n")
body = after[lang_len:] if lang_len != -1 else after
end = body.rfind("```")
if end != -1:
body = body[:end]
body = body.strip()
if body:
return body
for marker in ("\n\n[", "\n\n{"):
idx = text.rfind(marker)
if idx != -1:
candidate = text[idx:].strip()
if candidate and candidate[0] in "{[":
return candidate
return text
class LLMClient:
def __init__(
self,
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
) -> None:
self.base_url = base_url or Config.LLM_BASE_URL
self.api_key = api_key or Config.LLM_API_KEY
self.model = model or Config.LLM_MODEL
if not self.api_key:
raise LLMError("LLM_API_KEY is not configured in .env")
if not self.base_url:
raise LLMError("LLM_BASE_URL is not configured (unknown provider)")
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
def complete(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.5,
max_tokens: int = 3000,
) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
temperature=temperature,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
except Exception as exc: # network/auth/provider
raise LLMError(f"LLM call failed: {exc}") from exc
text = (resp.choices[0].message.content or "").strip()
if not text:
raise LLMError("LLM returned empty response")
return text
def complete_json(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.2,
max_tokens: int = 6000,
) -> dict[str, Any]:
text = self.complete(
system_prompt,
user_prompt,
temperature=temperature,
max_tokens=max_tokens,
)
text = _strip_thinking_trace(text)
try:
return json.loads(text)
except json.JSONDecodeError as exc:
# Last-ditch: strip leading text before the first { or [
match = re.search(r"[{\[].*[}\]]", text, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise LLMError(f"LLM returned invalid JSON: {exc}") from exc
def complete_conversation(
self,
messages: list[dict[str, str]],
*,
temperature: float = 0.6,
max_tokens: int = 1200,
) -> str:
try:
resp = self.client.chat.completions.create(
model=self.model,
temperature=temperature,
max_tokens=max_tokens,
messages=messages,
)
except Exception as exc:
raise LLMError(f"LLM call failed: {exc}") from exc
text = (resp.choices[0].message.content or "").strip()
if not text:
raise LLMError("LLM returned empty response")
return text

View File

@@ -0,0 +1 @@
"""Service layer."""

View File

@@ -0,0 +1,96 @@
"""Analyzer: extracts a Sales Kit (product facts) + initial pain-fit from inputs.
Product data is used primarily to derive pains that persona generation can build
against. The result also carries a `scenario` prompt that frames persona creation.
"""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
SALES_KIT_SYSTEM = """You are an expert ecommerce/B2B analyst. Given product information
(typed in a form and/or extracted from uploaded files), produce a structured Sales Kit.
Rules:
- Output ONLY valid JSON with the exact keys requested.
- Pain-fit: judge which pains / customer pain categories the product can PLAUSIBLY solve,
and clearly distinguish "strong fit" from "partial / weak fit".
- The product info is only initial grounding; personas may be reused across similar products.
- If some fields are unknown, leave them as empty lists / empty strings (never invent specifics).
Output schema:
{
"productName": string,
"category": string,
"valueProps": [string],
"features": [string],
"pricingAnchors": [string],
"targetAudience": { "segment": string, "demographics": string, "useCases": [string] },
"objectionHandlers": [string],
"initialPainFit": [
{ "pain": string, "fit": "strong"|"partial"|"weak", "evidence": string }
],
"scenarioFrame": string
}
The scenarioFrame is a one-paragraph description of the selling situation (who the seller,
what channel, target segment) that will frame persona creation.
"""
class Analyzer:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def analyze(
self,
*,
product: str = "",
segment: str = "",
description: str = "",
file_text: str = "",
channel: str = "facebook",
) -> dict[str, Any]:
# Build the merged product context (form wins over file text)
product_src = product.strip() or file_text.strip() or ""
context = (
f"PRODUCT (form/typed):\n{product}\n\n" if product.strip() else ""
)
if segment.strip():
context += f"INITIAL CUSTOMER SEGMENT:\n{segment}\n\n"
if description.strip():
context += f"ADDITIONAL DESCRIPTION / SCENARIO:\n{description}\n\n"
if file_text.strip():
context += f"UPLOADED FILE CONTENT:\n{file_text[:12000]}\n"
if not context.strip():
raise ValueError("no product information provided (form or file)")
user_prompt = (
f"Channel: {channel}\n\n"
f"Analyze the following and return the Sales Kit JSON:\n\n{context}"
)
result = self.llm.complete_json(
SALES_KIT_SYSTEM, user_prompt, temperature=0.2, max_tokens=5000
)
# Normalize shape defensively
result.setdefault("productName", product_src[:200] or "Untitled product")
result.setdefault("category", "")
result.setdefault("valueProps", [])
result.setdefault("features", [])
result.setdefault("pricingAnchors", [])
result.setdefault("targetAudience", {
"segment": segment or "",
"demographics": "",
"useCases": [],
})
result.setdefault("objectionHandlers", [])
result.setdefault("initialPainFit", [])
result.setdefault("scenarioFrame", description or "")
for k in ("valueProps", "features", "pricingAnchors", "objectionHandlers"):
if not isinstance(result[k], list):
result[k] = []
if not isinstance(result.get("initialPainFit"), list):
result["initialPainFit"] = []
return result

View File

@@ -0,0 +1,46 @@
"""File parsing for uploaded documents (pdf / markdown / txt)."""
from __future__ import annotations
from pathlib import Path
class ParseError(Exception):
pass
def parse_pdf(path: Path) -> str:
import fitz # PyMuPDF
try:
doc = fitz.open(path)
except Exception as exc:
raise ParseError(f"cannot open PDF: {exc}") from exc
parts = []
for page in doc:
parts.append(page.get_text())
doc.close()
return "\n".join(parts)
def parse_text(path: Path) -> str:
import chardet
raw = path.read_bytes()
# Try utf-8 first, else detect encoding
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
guess = chardet.detect(raw)
enc = guess.get("encoding") or "utf-8"
try:
return raw.decode(enc, errors="replace")
except Exception:
return raw.decode("utf-8", errors="replace")
def parse_document(path: Path) -> str:
ext = path.suffix.lower().lstrip(".")
if ext == "pdf":
return parse_pdf(path)
return parse_text(path)

View File

@@ -0,0 +1,97 @@
"""Persona group store: groups hold a sale kit + personas + report.
A group is created by an admin from a setup form + optional files. After analyze,
it contains `personas` (15 by default = 5 per tier) and a `report`. Groups are
editable/re-analyzeable by admins. Trainees only read revealable views of personas
and run one-shot sessions (sessions are stored separately).
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
DEFAULT_TIERS = ["A", "B", "C"]
PERSONAS_PER_TIER = 5
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class GroupStore:
def __init__(self, data_dir: Path) -> None:
self.groups = JsonStore(data_dir / "groups")
def create(self, *, org_id: str, creator_id: str, title: str) -> dict[str, Any]:
gid = new_id("group")
group = {
"id": gid,
"org_id": org_id,
"creator_id": creator_id,
"title": title or "Untitled group",
"status": "draft", # draft -> analyzing -> ready | failed
"created_at": _now(),
"updated_at": _now(),
"input": {}, # form fields
"sales_kit": None,
"personas": [], # full persona dicts
"report": None,
"error": None,
}
return self.groups.create(group, key=gid)
def get(self, gid: str) -> dict[str, Any]:
return self.groups.get(gid)
def get_or_none(self, gid: str) -> dict[str, Any] | None:
return self.groups.get_or_none(gid)
def update(self, gid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.groups.update(gid, **fields)
def list_for_org(self, org_id: str | None = None) -> list[dict[str, Any]]:
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
return groups
def list_visible_to(
self, *, role: str, org_id: str | None = None
) -> list[dict[str, Any]]:
"""List groups a given role/user can see. Trainees see only ready groups."""
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
if role == "user":
groups = [g for g in groups if g.get("status") == "ready"]
return groups
# ── personas ────────────────────────────────────────────────────────
def set_personas(self, gid: str, personas: list[dict[str, Any]]) -> dict[str, Any]:
personas = [ensure_persona_shape(p) for p in personas]
return self.groups.update(gid, personas=personas)
def get_persona(self, gid: str, pid: str) -> dict[str, Any] | None:
group = self.get(gid)
for p in group.get("personas", []):
if p.get("id") == pid:
return p
return None
def update_persona(self, gid: str, pid: str, patch: dict[str, Any]) -> dict[str, Any]:
group = self.get(gid)
found = False
for i, p in enumerate(group.get("personas", [])):
if p.get("id") == pid:
merged = {**p, **patch, "id": pid}
group["personas"][i] = ensure_persona_shape(merged)
found = True
break
if not found:
raise ValueError("persona not found")
return self.groups.replace(gid, group)

View File

@@ -0,0 +1,42 @@
"""Generate a user's own persona (private) from weak-area spec or a manual form."""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
OWN_PERSONA_SYSTEM = """You generate ONE customer persona for a sales-training simulator,
PRIVATE to a specific trainee. You produce valid JSON only: {"persona": { ... }}.
The persona dict must contain: name, tier, channel, initiation_mode, profession, age_group,
location, product_context (revealable), plus background, income, lifestyle, personality,
communication_style, budget, decision_timeline, goal, objections[], pains[] (with fit + rootCause
+ resolutionConditions), negotiation_levers[], opener, difficulty, special, notes.
The trainee wants to specifically practice against the described weakness/profile, so make this
persona HARD in exactly that dimension (e.g. heavy price negotiation, seller-initiated cold lead,
skeptical). Keep pains partially product-solvable for realism.
"""
def build_own_persona_user_prompt(*, mode: str, spec: dict[str, Any]) -> str:
if mode == "weak-area":
return (
"Mode: WEAK-AREA 'lock' persona. Generate a persona specifically targeting the "
"trainee's reported weaknesses:\n" + str(spec)
)
return "Mode: MANUAL. Generate a persona matching the trainee's description:\n" + str(spec)
def generate_own_persona(llm: LLMClient, *, mode: str, spec: dict[str, Any]) -> dict[str, Any]:
user_prompt = build_own_persona_user_prompt(mode=mode, spec=spec)
result = llm.complete_json(OWN_PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=7000)
persona = result.get("persona") or result
if not isinstance(persona, dict):
raise ValueError("own-persona generator returned invalid data")
persona.setdefault("tier", "B")
persona.setdefault("channel", "facebook")
persona.setdefault("initiation_mode", "customer")
persona.setdefault("pains", [])
persona.setdefault("negotiation_levers", [])
return persona

View File

@@ -0,0 +1,74 @@
"""Persona generator: builds 15 personas (5 per tier) from a Sales Kit + scenario."""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient
from .persona_prompts import PERSONA_SYSTEM
TIERS = ["A", "B", "C"]
PER_TIER = 5
class PersonaGenerator:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def generate(
self,
*,
sales_kit: dict[str, Any],
language: str = "en",
channel: str = "facebook",
) -> list[dict[str, Any]]:
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
lang_name = "Thai" if language == "th" else "English"
scenario = (sales_kit.get("scenarioFrame") or "").strip() or "a general product sale"
user_prompt = (
f"Platform/channel preference: {channel}\n"
f"Language: {lang_name} (all persona text in {lang_name})\n"
f"Sales Kit:\n{kit_json}\n\n"
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
)
result = self.llm.complete_json(
PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=14000
)
personas = result.get("personas") or []
if not isinstance(personas, list) or not personas:
raise ValueError("persona generator returned no personas")
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
for idx, p in enumerate(personas, start=1):
if not isinstance(p, dict):
continue
tier = p.get("tier", p.get("intent_tier"))
if tier not in TIERS:
tier = "B"
if counts[tier] >= PER_TIER:
continue # skip overflow per tier
counts[tier] += 1
p["id"] = f"persona-{idx:02d}"
p["tier"] = tier
p["channel"] = p.get("channel", channel)
p.setdefault("initiation_mode", "customer")
p.setdefault("special", "")
p.setdefault("difficulty", 1)
p.setdefault("pains", [])
p.setdefault("negotiation_levers", [])
p.setdefault("objections", [])
normalized.append(p)
# Wrap tier-C: ensure at least one wrong_text persona
if "C" in counts and not any(
p.get("special") == "wrong_text" for p in normalized
):
# find first tier-C and mark it
for p in normalized:
if p["tier"] == "C":
p["special"] = "wrong_text"
break
if len(normalized) < 15:
raise ValueError(f"expected 15 personas, generated {len(normalized)}")
return normalized

View File

@@ -0,0 +1,43 @@
"""Persona generation prompts (system + output schema instructions)."""
from __future__ import annotations
PERSONA_SYSTEM = """You are a world-class market-research persona designer for a sales-training
simulator. Given a Sales Kit (product facts + initial pain-fit) and a scenario frame, you generate
REALISTIC customer personas that a trainee will chat with to practice closing a sale.
Generate exactly 15 personas = 5 in tier A + 5 in tier B + 5 in tier C.
TIER MEANING:
- A = Ready to buy (has budget+authority+urgency, but still expects fit confirmation & handles 1-2
objections; can still WALK AWAY if the seller is rude or clearly wrong).
- B = Unsure / educating (researching; needs discovery, trust, proof, reason-to-act-now; stalls easily).
- C = Not interested but has pain (resistant, unaware/skeptical/budget-constrained, BUT has a real
unresolved pain; the ONLY path to close is surfacing and resolving it).
EACH persona MUST include ALL of these fields:
- name, tier, channel, initiation_mode
- profession, age_group, location, product_context (REVEALABLE - what a real seller could know)
- background, income, lifestyle, personality, communication_style (LATENT)
- budget, decision_timeline, goal, objections[] (LATENT)
- pains[] (LATENT)
- negotiation_levers[] (LATENT)
- opener, special, difficulty, notes
RULES:
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
personalities. Consistent with the product's target audience + scenario frame.
2. PAIN VARIETY: most pains do NOT map 1:1 to the product. Include pains the product solves
DIRECTLY (fit=strong), some only PARTIALLY solve (fit=partial), and some UNRELATED (fit=weak /
red herring). For each pain give: id, name, fit, description, rootCause, and resolutionConditions[]
(what the seller must satisfy to resolve it).
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
5. CHANNEL: "facebook" or "line".
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
7. difficulty 1-5. special="" unless wrong_text.
8. Language: output all human text in the requested language.
Only output valid JSON: {"personas": [ ... ]}
"""

View File

@@ -0,0 +1,92 @@
"""Report builder: assemble a human-readable analysis report from sales kit + personas."""
from __future__ import annotations
from typing import Any
TIER_NAMES = {
"A": ("Ready to buy", "ตั้งใจซื้อ"),
"B": ("Unsure / educating", "ไม่แน่ใจ"),
"C": ("Not interested but has pain", "ไม่สนใจแต่มี pain"),
}
def build_report(*, sales_kit: dict[str, Any], personas: list[dict[str, Any]], language: str = "th") -> dict[str, Any]:
thai = language == "th"
tiers: dict[str, list[dict[str, Any]]] = {"A": [], "B": [], "C": []}
for p in personas:
tiers.get(p.get("tier", "B"), []).append(p)
sections = []
sections.append({
"title": "Sales Kit / ข้อมูลสินค้า" if thai else "Sales Kit",
"content": _render_sales_kit(sales_kit, thai),
})
for tier, personas_list in tiers.items():
label = TIER_NAMES[tier][1 if thai else 0]
sections.append({
"title": f"Tier {tier}{label}",
"content": _render_tier(personas_list, thai),
})
return {
"title": f"{sales_kit.get('productName', 'Product')} — Sales Training Analysis",
"summary": "Customer personas + pain analysis for sales training.",
"language": language,
"sections": sections,
"raw_personas": personas,
}
def _render_sales_kit(kit: dict[str, Any], thai: bool) -> str:
lines = []
lines.append(f"**{'สินค้า' if thai else 'Product'}:** {kit.get('productName', '-')}")
if kit.get("category"):
lines.append(f"**{'หมวดหมู่' if thai else 'Category'}:** {kit['category']}")
if kit.get("valueProps"):
lines.append(f"**{'คุณค่า' if thai else 'Value props'}:** " + "; ".join(kit["valueProps"]))
if kit.get("features"):
lines.append(f"**{'ฟีเจอร์' if thai else 'Features'}:** " + "; ".join(kit["features"]))
if kit.get("pricingAnchors"):
lines.append(f"**{'ราคา' if thai else 'Pricing'}:** " + "; ".join(kit["pricingAnchors"]))
ta = kit.get("targetAudience") or {}
if ta.get("segment"):
lines.append(f"**{'กลุ่มเป้าหมาย' if thai else 'Target segment'}:** {ta['segment']}")
if kit.get("initialPainFit"):
lines.append(f"**{'Pain ที่สินค้าแก้ได้เบื้องต้น' if thai else 'Initial pain-fit'}:**")
for p in kit["initialPainFit"]:
lines.append(f"- ({p.get('fit', '?')}) {p.get('pain', '')}")
return "\n".join(lines)
def _render_tier(personas: list[dict[str, Any]], thai: bool) -> str:
if not personas:
return "_" + ("ไม่มี" if thai else "none") + "_"
blocks = []
for p in personas:
blocks.append(_render_persona(p, thai))
return "\n\n---\n\n".join(blocks)
def _render_persona(p: dict[str, Any], thai: bool) -> str:
lines = [f"### {p.get('name', '-')} (difficulty {p.get('difficulty', 1)})"]
lines.append(f"- {'อาชีพ' if thai else 'Profession'}: {p.get('profession', '-')} | "
f"{'อายุ' if thai else 'Age'}: {p.get('age_group', '-')} | "
f"{'ช่องทาง' if thai else 'Channel'}: {p.get('channel', 'facebook')} | "
f"{'เปิดบท' if thai else 'Initiation'}: {p.get('initiation_mode', 'customer')}")
if p.get("special"):
lines.append(f"- SPECIAL: {p['special']}")
lines.append(f"- {'พื้นหลัง' if thai else 'Background'}: {p.get('background', '-')}")
lines.append(f"- {'รายได้' if thai else 'Income'}: {p.get('income', '-')} | "
f"{'ไลฟ์สไตล์' if thai else 'Lifestyle'}: {p.get('lifestyle', '-')}")
lines.append(f"- {'นิสัย' if thai else 'Personality'}: {p.get('personality', '-')}")
if p.get("pains"):
lines.append(f"- {'Pain points (latent)' if thai else 'Pains (latent)'}:")
for pain in p.get("pains", []):
conds = "; ".join(pain.get("resolutionConditions", [])) if isinstance(pain, dict) else ""
lines.append(f" - [{pain.get('fit', '?') if isinstance(pain, dict) else '?'}] "
f"{pain.get('name', pain) if isinstance(pain, dict) else pain}"
f"{' — resolve: ' + conds if conds else ''}")
if p.get("negotiation_levers"):
levers = p.get("negotiation_levers") or []
lines.append(f"- {'ต่อรอง' if thai else 'Negotiation levers'}: " + ", ".join(str(x) for x in levers))
return "\n".join(lines)

View File

@@ -0,0 +1,81 @@
"""Training session store.
A session = one trainee's one-shot chat attempt against one persona. It records the
full transcript + internal state + outcome + debrief. One user may have at most one
session per persona (one-shot rule), enforced here.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class SessionStore:
def __init__(self, data_dir: Path) -> None:
self.sessions = JsonStore(data_dir / "sessions")
def create(
self,
*,
user_id: str,
group_id: str,
persona_id: str,
persona_name: str,
persona_meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
# One-shot: reject if the user already has a finished session on this persona
existing = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("outcome") in ("won", "lost")
)
if existing:
raise ValueError("you have already trained on this persona (one-shot)")
sid = new_id("session")
session = {
"id": sid,
"user_id": user_id,
"group_id": group_id,
"persona_id": persona_id,
"persona_name": persona_name,
"persona_meta": persona_meta or {},
"status": "active", # active | finished
"outcome": None, # won | lost | abandoned
"messages": [], # [{role, text, ts}]
"internal": {"trust": 50, "pain_progress": {}, "buying_signals": [], "tier": None},
"debrief": None,
"created_at": _now(),
"updated_at": _now(),
}
return self.sessions.create(session, key=sid)
def get(self, sid: str) -> dict[str, Any]:
return self.sessions.get(sid)
def get_or_none(self, sid: str) -> dict[str, Any] | None:
return self.sessions.get_or_none(sid)
def update(self, sid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.sessions.update(sid, **fields)
def active_for_persona(self, user_id: str, persona_id: str) -> dict[str, Any] | None:
hits = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("status") == "active"
)
return hits[0] if hits else None
def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
return sorted(
self.sessions.where(lambda r: r.get("user_id") == user_id),
key=lambda r: r.get("created_at", ""),
)

View File

@@ -0,0 +1,186 @@
"""Sales chat simulator: the trainee's chat engine against one persona.
Reuses the persona card + sales kit + chat history + internal state. A separate
judge-LLM decides outcome (won/lost) + scoring + coaching. Hidden/latent data is
never exposed mid-chat. Initiation is per-persona (customer or seller).
"""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient, LLMError
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
Stay perfectly in character at ALL times. Use {tone}.
CONTEXT ABOUT YOU (USE THIS — it is your truth, but DO NOT reveal latent details unless asked
naturally and it makes sense for a real customer to reveal them):
- Profession: {profession} | Age: {age_group} | Channel: {channel}
- Background: {background}
- Personality: {personality}
- Lifestyle: {lifestyle} | Income: {income}
- Budget: {budget} | Decision timeline: {decision_timeline}
- Your pains (some may be product-solvable, some NOT): {pains}
- Your negotiation levers: {levers}
- Your goal/mood: {goal}
Initiation mode: {init_mode}. {special_instr}
BEHAVIOR RULES:
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
delivery time, scope, payment).
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
and you may refuse to continue / walk away — even if you wanted the product.
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
pains unprompted.
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
5. Stay in character; never mention that you are a simulation or an AI persona.
Reply with a JSON object: {{"reply": "<your message>"}}
Only output that JSON.
"""
JUDGE_SYSTEM = """You are the JUDGE of a sales-training chat. Decide the outcome and score it.
A sale is CLOSED only if BOTH:
1. The seller resolved the customer's real pain(s) (the conditions that matter to this persona),
AND
2. The customer verbally accepts the offer/price (in the final exchange).
Otherwise it is LOST (or abandoned if the user ended early).
Scoring (0-100): painResolution + trust + objectionHandling are the only factors.
Return JSON:
{
"outcome": "won" | "lost",
"score": 0-100,
"pain": "the persona's key pain",
"why": "brief reason for won/lost",
"failurePoints": ["what went wrong, or []"],
"coaching": ["for each weak point, a concrete 'you should have said/asked this instead']",
"painProgress": {"painName": 0-100}
}
"""
class Simulator:
def __init__(self, llm: LLMClient, judge_llm: LLMClient | None = None) -> None:
self.llm = llm
self.judge_llm = judge_llm or llm
# ── persona reply ──────────────────────────────────────────────────
def persona_reply(
self,
*,
persona: dict[str, Any],
sales_kit: dict[str, Any],
messages: list[dict[str, str]],
internal: dict[str, Any],
) -> str:
pains_txt = self._describe_pains(persona.get("pains", []))
system = CHAT_SYSTEM.format(
name=persona.get("name", "Customer"),
tone=persona.get("communication_style", "natural, casual"),
profession=persona.get("profession", "customer"),
age_group=persona.get("age_group", "adult"),
channel=persona.get("channel", "facebook"),
background=persona.get("background", ""),
personality=persona.get("personality", ""),
lifestyle=persona.get("lifestyle", ""),
income=persona.get("income", ""),
budget=persona.get("budget", ""),
decision_timeline=persona.get("decision_timeline", ""),
pains=pains_txt,
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
goal=persona.get("goal", ""),
init_mode="you contacted the seller first (customer-initiated)"
if persona.get("initiation_mode") == "customer"
else "the seller opened the sale to you (you are a lead)",
special_instr=self._special_instr(persona),
)
msgs = [{"role": "system", "content": system}]
# send a compact recap of internal state to the persona ad
# (doesn't leak to trainee)
msgs.append({
"role": "system",
"content": "Internal state (for your role-play only): "
+ json.dumps(internal, ensure_ascii=False),
})
msgs.extend(messages[-30:]) # context window
try:
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
except LLMError as exc:
raise
# extract {reply: ...}
try:
data = json.loads(self._extract_json(resp))
reply = data.get("reply") or data.get("response") or str(resp)
except Exception:
reply = resp
return reply.strip()
# ── judge ──────────────────────────────────────────────────────────
def judge(
self,
*,
persona: dict[str, Any],
messages: list[dict[str, str]],
) -> dict[str, Any]:
persona_summary = json.dumps({
"name": persona.get("name"),
"pains": persona.get("pains", []),
"budget": persona.get("budget"),
"negotiation_levers": persona.get("negotiation_levers"),
"special": persona.get("special"),
}, ensure_ascii=False)
transcript = "\n".join(
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
)
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}"
try:
result = self.judge_llm.complete_json(
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
)
except LLMError as exc:
raise
result.setdefault("outcome", "lost")
result.setdefault("score", 0)
result.setdefault("pain", "")
result.setdefault("why", "")
result.setdefault("failurePoints", [])
result.setdefault("coaching", [])
result.setdefault("painProgress", {})
return result
# ── helpers ────────────────────────────────────────────────────────
def _describe_pains(self, pains: list[Any]) -> str:
if not pains:
return "(you have some personal frustrations, but the seller must find out)"
out = []
for p in pains:
if isinstance(p, dict):
out.append(
f"{p.get('name','pain')} (fit={p.get('fit','?')}): {p.get('description','')} "
f"root={p.get('rootCause','')}"
)
else:
out.append(str(p))
return "; ".join(out)
def _special_instr(self, persona: dict[str, Any]) -> str:
if persona.get("special") == "wrong_text":
return (
"SPECIAL: You opened as if ready to buy, but the moment the seller replies you act "
"disinterested and try to end the chat (e.g. 'never mind, forget it'). Deep down your "
"pain is still real. A seller who gently re-engages without pushing may earn a second "
"chance; a pushy seller drives you away for good."
)
return ""
def _extract_json(self, text: str) -> str:
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return text

View File

@@ -0,0 +1,75 @@
"""Persona data model + shape normalization.
A persona has a canonical schema. Fields are split into:
- revealable: shown to trainees up front (what a real seller could plausibly know)
- latent: hidden until the conversation ends (pain, income, personality, budget,
negotiation levers, hidden opener, etc.)
Every persona also carries an `intent_tier` (A/B/C), an `initiation_mode`
(customer/seller), a `channel` (facebook/line), a set of `pains` with resolution
conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text).
"""
from __future__ import annotations
from typing import Any
DEFAULT_TIERS = ["A", "B", "C"]
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
"""Fill defaults so a persona dict is always structurally complete."""
pid = p.get("id") or p.get("name", "persona")
base = {
"id": pid,
"name": p.get("name", ""),
"tier": p.get("tier", p.get("intent_tier", "B")),
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
"channel": p.get("channel", "facebook"), # facebook | line
# revealable
"profession": p.get("profession", ""),
"age_group": p.get("age_group", ""),
"location": p.get("location", ""),
"product_context": p.get("product_context", ""),
# latent (hidden until end)
"background": p.get("background", ""),
"income": p.get("income", ""),
"lifestyle": p.get("lifestyle", ""),
"personality": p.get("personality", ""),
"communication_style": p.get("communication_style", ""),
"budget": p.get("budget", ""),
"decision_timeline": p.get("decision_timeline", ""),
"goal": p.get("goal", ""),
"objections": p.get("objections", []),
"pains": p.get("pains", []),
"negotiation_levers": p.get("negotiation_levers", []),
"opener": p.get("opener", ""),
"special": p.get("special", ""), # e.g. "wrong_text" | ""
"difficulty": p.get("difficulty", 1), # 1..5
"notes": p.get("notes", ""),
}
# validate
if base["tier"] not in DEFAULT_TIERS:
base["tier"] = "B"
if base["initiation_mode"] not in ("customer", "seller"):
base["initiation_mode"] = "customer"
if base["channel"] not in ("facebook", "line"):
base["channel"] = "facebook"
return base
def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
"""Return ONLY the fields a trainee may see before/while chatting."""
return {
"id": p.get("id"),
"name": p.get("name"),
"tier": p.get("tier"),
"channel": p.get("channel"),
"initiation_mode": p.get("initiation_mode"),
"profession": p.get("profession"),
"age_group": p.get("age_group"),
"location": p.get("location"),
"product_context": p.get("product_context"),
}
def full_view(p: dict[str, Any]) -> dict[str, Any]:
return ensure_persona_shape(p)

View File

@@ -0,0 +1,81 @@
"""Trainee loop: win/lose board, weak-area analysis, user-generated personas.
A user never re-chats a persona. To keep training, they generate new personas —
either auto from their weak areas ("lock") or from a manual form. Generated
personas are private to the user.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
class MyPersonaStore:
def __init__(self, data_dir: Path) -> None:
self.personas = JsonStore(data_dir / "my_personas")
def _path_key(self, user_id: str, pid: str) -> str:
return f"{user_id}__{pid}"
def create(self, *, user_id: str, persona: dict[str, Any]) -> dict[str, Any]:
p = ensure_persona_shape(persona)
if "id" not in p or not p["id"]:
p["id"] = new_id("myp")
record = {
"key": self._path_key(user_id, p["id"]),
"user_id": user_id,
"persona": p,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
return self.personas.create(record, key=record["key"])
def list_for(self, user_id: str) -> list[dict[str, Any]]:
return [
r.get("persona")
for r in self.personas.where(lambda x: x.get("user_id") == user_id)
]
def analyze_weak_areas(sessions: list[dict[str, Any]]) -> dict[str, Any]:
"""Summarize which persona attributes a user tends to lose against."""
losses, wins = [], []
for ses in sessions:
if ses.get("outcome") == "won":
wins.append(ses)
elif ses.get("outcome") == "lost":
losses.append(ses)
def tally(key: str, label: str) -> list[dict[str, Any]]:
from collections import Counter
c = Counter()
for l in losses:
meta = l.get("persona_meta") or {}
v = meta.get(key)
if v is not None:
c[v] += 1
return [{"value": k, "losses": v} for k, v in c.most_common(3)]
return {
"total_sessions": len(sessions),
"wins": len(wins),
"losses": len(losses),
"by_tier": tally("tier", "tier"),
"by_initiation": tally("initiation_mode", "initiation"),
"by_channel": tally("channel", "channel"),
"top_loss_personas": [
{
"persona_id": l.get("persona_id"),
"persona_name": l.get("persona_name"),
"score": (l.get("debrief") or {}).get("score", 0),
"why": (l.get("debrief") or {}).get("why", ""),
}
for l in sorted(
losses, key=lambda x: (x.get("debrief") or {}).get("score", 0)
)[:5]
],
}

View File

@@ -0,0 +1 @@
"""Storage layer."""

View File

@@ -0,0 +1,138 @@
"""Durable filesystem JSON store.
Each entity is stored as its own JSON file under a per-type directory. Writes are
atomic (temp file + os.replace + fsync). Thread-safe via a per-path lock.
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from pathlib import Path
from typing import Any, Callable
from ..config import Config
class StoreError(Exception):
pass
class _Locks:
def __init__(self) -> None:
self._locks: dict[str, threading.RLock] = {}
self._guard = threading.Lock()
def get(self, key: str) -> threading.RLock:
with self._guard:
if key not in self._locks:
self._locks[key] = threading.RLock()
return self._locks[key]
_locks = _Locks()
def new_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:12]}"
def _atomic_write(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with tmp.open("w", encoding="utf-8") as fh:
json.dump(value, fh, ensure_ascii=False, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
finally:
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
def _read_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
class JsonStore:
"""Simple JSON-file collection with CRUD + locking."""
def __init__(self, root: Path, *, key_attr: str = "id") -> None:
self.root = root
self.key_attr = key_attr
self.root.mkdir(parents=True, exist_ok=True)
def _path(self, key: str) -> Path:
if not key or "/" in key or ".." in key:
raise StoreError("invalid id")
return self.root / f"{key}.json"
def create(self, value: dict[str, Any], *, key: str | None = None) -> dict[str, Any]:
key = key or value.get(self.key_attr) or new_id(self.key_attr)
if self.key_attr not in value:
value = dict(value)
value[self.key_attr] = key
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if path.exists():
raise StoreError(f"already exists: {key}")
_atomic_write(path, value)
return value
def get(self, key: str) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if not path.exists():
raise StoreError(f"not found: {key}")
return _read_json(path)
def get_or_none(self, key: str) -> dict[str, Any] | None:
try:
return self.get(key)
except StoreError:
return None
def update(self, key: str, **fields: Any) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if not path.exists():
raise StoreError(f"not found: {key}")
cur = _read_json(path)
cur.update(fields)
_atomic_write(path, cur)
return cur
def replace(self, key: str, value: dict[str, Any]) -> dict[str, Any]:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
_atomic_write(path, value)
return value
def delete(self, key: str) -> None:
path = self._path(key)
lock = _locks.get(str(path))
with lock:
if path.exists():
path.unlink()
def all(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for path in sorted(self.root.glob("*.json")):
try:
out.append(_read_json(path))
except (OSError, ValueError):
continue
return out
def where(self, pred: Callable[[dict[str, Any]], bool]) -> list[dict[str, Any]]:
return [row for row in self.all() if pred(row)]

9
backend/requirements.txt Normal file
View File

@@ -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

24
backend/run.py Normal file
View File

@@ -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()

111
backend/scripts/mock_llm.py Normal file
View File

@@ -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)

141
backend/scripts/test_e2e.py Normal file
View File

@@ -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()

107
backend/scripts/test_m0.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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/<email>",
"/api/groups", "/api/groups/<gid>",
"/api/groups/<gid>/analyze", "/api/groups/<gid>/personas",
"/api/groups/<gid>/personas/<pid>", "/api/groups/<gid>/personas/<pid>",
"/api/groups/<gid>/reanalyze",
"/api/chat/<gid>/personas/<pid>/chat/start",
"/api/chat/<gid>/personas/<pid>/chat/send",
"/api/chat/<gid>/personas/<pid>/chat/finish",
"/api/chat/sessions", "/api/chat/sessions/<sid>",
"/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()

View File

@@ -0,0 +1,4 @@
import flask, jwt, dotenv, openai, fitz, pydantic, werkzeug
print("flask", flask.__version__)
print("openai", openai.__version__)
print("all imports ok")

15
docker-compose.yml Normal file
View File

@@ -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

62
docs/HANDOFF.md Normal file
View File

@@ -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 (M0M7), 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 <script>` directly** — the tool lifecycle guard crashes
("embedded null byte"). Always: `uv run python scripts/<name>.py`.
2. LLM creds in `.env` (backend/.env for local; root `.env` for compose). `LLM_API_KEY=replace_me`
is a placeholder → LLM is None → analyze/chat return 500 "LLM not configured".
3. SPA fallback in `app/factory._register_frontend` accepts all HTTP methods and 404s `/api/*`
so no-self-registration holds.
## Blockers / open items
- **Real-LLM E2E not yet run** (needs a live API key). This is the #1 item.
- Docker image not built locally (no Docker on this Mac). Validate on EasyPanel.
- No git remote set (Gitea).
## Exact next actions
1. Set real `LLM_PROVIDER` + `LLM_API_KEY` (and optionally base/model) in `backend/.env`.
2. Run a live smoke test: login → create group → analyze → pick persona → chat a few turns → finish → read debrief; confirm judge produces sane output (this exercises real analyzer/persona/chat/judge).
3. Fix any real-model issues surfaced (prompt drift, JSON parsing).
4. Add Gitea remote + push. Optionally wire Gitea Actions / EasyPanel deploy.
5. If EasyPanel: build from root `Dockerfile`, set env vars, map port 5001.
## Docs
- `docs/PLAN.md` — full design + all confirmed decisions & open questions.
- `docs/engineering-log.md` + `docs/engineering-log/2026-08-07-build-out.md` — milestone record.
- `README.md` — quick start, accounts, tests, LLM config.

483
docs/PLAN.md Normal file
View File

@@ -0,0 +1,483 @@
# Sales Trainer — Plan & Architecture
App platform for Sales Training. Users develop **customer personas with pain points**
from uploaded files + a natural-language description, then **chat with simulated
customers** to practice closing a sale. Informed by two codebases:
- **MiroFish** (`~/Gitea/MiroFish`) — original full-stack CrowdSight engine (Flask + Vue, OASIS, Zep, persona/report/chat).
- **hermes-brain-and-tools** (`~/Gitea/hermes-brain-and-tools`) — clean-room CrowdSight plugin (seed → ontology → graph → environment; profile generator; durable workflow; report contract; social simulation).
This app is a **standalone web app** (not a Hermes plugin) with its own **login/auth**,
deployable via Dockerfile/EasyPanel (the user's established pattern).
---
## 1. Product goals
1. **Input**: setup form (product / customer segment / description) AND/OR upload files
(`.pdf/.md/.txt`). Product info is used **primarily to extract pains** as the raw material
for persona generation — it is just initial grounding.
2. **Analyze**: extract value propositions, features, pricing anchors, target profile, and an
**initial pain-fit**. A generated persona is **reusable across products in the same/similar
category** — it need not be locked to the exact uploaded product.
3. **Create persona pool**: generate 15 personas (5 × intent tier A/B/C), each marked with a
buying tier, a set of pains (varied, not all product-solvable), negotiation levers, and an
initiation mode (customer-initiated vs seller-initiated). Channel: Facebook / LINE.
4. **Standard report**: human-readable analysis report (per persona: background, pains,
objections, price sensitivity, buying signals, revealable/latent fields) — downloadable.
5. **Sales Simulation (chat)**: trainee chats 1:1 with a persona (one-shot). The persona
reacts realistically: negotiates, stalls, and **refuses to buy** unless the trainee actually
resolves the persona's specific pain(s). On a result, the app reveals the pain and
**why the close succeeded/failed** (short debrief + coaching).
6. **Training loop**: track each user's wins/losses; analyze the personas a user tends to lose
against and **generate new harder/variant personas** (or manual form) to keep training.
---
## 2. Persona design (core requirement)
### 2.1 Intent tiers (23 levels) — REQUIRED
| Tier | Name | Behavior |
|------|------|----------|
| **A** | **Ready-to-buy** (ตั้งใจซื้อ) | Has budget + authority + urgency. Short close window, but still expects the seller to confirm fit & handle 12 objections. Won't buy if the offer clearly misses their need. |
| **B** | **Unsure / educating** (ไม่แน่ใจ) | Researching/considering. Needs discovery, trust-building, proof, comparison, and a clear reason to act now. High chance to stall or go silent. |
| **C** | **Not interested but has pain** (ไม่สนใจแต่มี pain) | Unaware of the category/value, budget-constrained, or skeptical. Strong resistance, but has a real, unresolved pain — the ONLY path to a close is surfacing and resolving that pain. |
### 2.1b Tier counts
- **5 personas per tier** → **15 personas minimum** per project (3 tiers × 5).
- Each persona is a distinct, realistic individual — no two share the same background/income/personality combination.
### 2.2 Persona variety (REQUIRED) — every persona gets:
- **Background** (life story / situation that justifies their behavior)
- **Income & occupation** (varied across personas: salary, e.g. employee / freelancer / SME owner / executive / student / homemaker)
- **Age group & lifestyle** (varied: age ranges, family stage, living situation, habits)
- **Personality & temperament** (e.g. skeptical, analytical, impulsive, cautious, price-haggler, relationship-driven, impatient, distrustful)
- **Communication style** (tone, vocabulary, formality, emoji usage, sentence length)
- **Goal** when entering the chat + **objections** + **budget** + **decision timeline**
- **Pains** (14), each with: description, root need, and **the specific condition(s)** the seller must satisfy to resolve it.
**Consistency rule:** persona demographics (age band, occupation, lifestyle, income) must be
**consistent with the product's target user** (from the sales kit / product data), and the
**additional description / scenario field acts as a framing constraint** for persona creation
(e.g. "product targets SME restaurants in Bangkok" → all 15 personas fit that world, with
sensible income/lifestyle spread).
### 2.3 Realism rules (from user)
- Customers **never buy easily**. Every close requires "earning" it.
- **All tiers can lose — including Ready-to-buy (A).** A customer walks away (no sale) if the
conversation is genuinely bad — e.g. rude/aggressive language, ignoring their needs, pushy
pitching. Even a customer who desperately wants the product will refuse if the seller
violates basic decency or trust. "Wanting it" never overrides "bad experience."
- They **negotiate** (price, timeline, scope, add-ons).
- They **refuse / end the chat** if the seller fails to address their pain.
- Each persona has hidden pains the seller must **discover** (ask questions), not just pitch.
### 2.3a Negotiation — ALL tiers (REQUIRED)
Every persona bargains over **concessions/benefits**, at every tier:
- **Price reduction** (discount ask),
- **Freebies / add-ons / bundling**,
- **Delivery / fulfillment timeline** (especially for made-to-order / production goods),
- Scope adjustments, payment terms, guarantees, etc.
The seller must respond to negotiation realistically — give value in exchange, not just give in.
The specific negotiable leverage each persona will push on is defined in the persona card.
### 2.3b Pain variety (REQUIRED)
- **NOT every pain aligns with the product.** Persona pains may be:
- **Directly solvable** by the product (the clear win),
- **Partially solvable / nearby** (the product helps but doesn't fully close it — seller must
manage expectation / bundle / reframe), or
- **Unrelated/unsolvable** by this product (a genuine red herring — seller must recognize it
and redirect rather than force a fit).
- This mirrors reality: real customers rarely have exactly the pain a product solves. The
seller must **diagnose which pain is which** and only claim what the product truly delivers.
### 2.3c Chat initiation — TWO modes (REQUIRED)
The simulation supports **two initiation modes**, set per persona (a persona is either one or the other):
- **Customer-initiated** (ลูกค้าทักก่อน): for products/brands marketed to drive engagement
(e.g. cars, food, retail). The customer opens the chat; the seller responds.
- **Seller-initiated** (ฝ่ายเราเปิดการขายก่อน): for products/services sold proactively /
outbound (e.g. insurance, B2B services). **The customer does NOT message first — the seller
must open the sale.** The simulator gives the seller an opening task to start the conversation
with a cold/warm lead, and the persona reacts accordingly.
Each persona card declares which mode applies (and, for customer-initiated, includes the
persona's opener; for seller-initiated, the persona's initial mood/receptiveness).
> Note: this supersedes any earlier "customer always messages first" rule. Initiation is
> per-case: some personas are customer-initiated, some are seller-initiated.
### 2.3d Special tier-3 case — "wrong text / lost interest" (REQUIRED, ≥1 in tier C)
- At least **one tier-C persona** is designed as a **false lead**: their opening message makes
them *appear* ready to buy (e.g. "I need this, tell me the price"). But once the seller
responds, the persona immediately reveals lost interest and wants to **end the conversation**
("never mind, forget it") — yet **deep down the pain still exists**.
- To close: the seller must not take the "forget it" at face value; they must gently re-engage,
rebuild a moment of connection, and surface the still-live pain without being pushy. High
difficulty, frequent refusal — it tests resilience + empathy + non-pushy discovery.
### 2.3e Channel context (REQUIRED)
- The simulated chat is presented as a real messaging thread. Primary channels: **Facebook** and
**LINE**. A persona/case declares which channel it happens on (affects the look, and can color
tone — e.g. LINE more casual, FB page vs Messenger).
- Other channels optional later; v1 ships Facebook + LINE.
### 2.4 Persona lifecycle & one-shot rule (REQUIRED)
- **1 persona ↔ many users**: a persona is a shared playable asset; any user who has not yet
chatted it may practice on it.
- **1 user ↔ 1 chat per persona max (one-shot)**: a user can chat a given persona **only once**
the outcome is final and that persona is "used" for that user. It cannot be re-chatted/replayed
by the same user. (Reels/retry against it is not allowed.)
- To practice again on a similar customer, the user must **generate a new persona** (2.5).
### 2.5 Persona sources & generation (REQUIRED)
- **Admin-created baseline pool**: admin/upload-generated personas form the shared pool that
**new users** can pick from and practice on.
- **User-generated personas**: after practicing, a user can generate **their own persona** to
train on, in two ways:
1. **Weak-area generation** (4 in feature list): auto-analyze the personas this user tends to
lose against → generate a new, harder/variant persona targeting that weakness, as a "lock"
to overcome it; **or**
2. **Manual form**: the user describes the persona they want to practice (target profile,
situation, difficulty level) and the system generates it.
- User-generated personas are private to that user (unlike the admin pool).
### 2.6 Data exposure by view (REQUIRED)
- **Approve/edit view (admin)**: full persona data — pains, income, personality, negotiation
levers, hidden details. Admin sees everything to review/edit/approve.
- **Select/chat view (trainee)**: **only** the persona's name + basic info one would plausibly
know up front (profession, age group, channel, initiation mode, product context). **Hidden**:
pain, income, personality, budget, negotiation levers — anything you couldn't know without
talking. Revealed only **after** the conversation ends (win/lose + debrief).
- **Win/lose status**: the user can always see which personas they've **won** vs **lost** vs
**not yet tried**.
---
## 3. App architecture
```
┌────────────────────────────────────────────────────────────┐
│ Vue 3 Frontend (port 3000) │
│ Login · Dashboard · Project setup · Report · Chat view │
└───────────────▲────────────────────────────┬───────────────┘
│ HTTP/JSON (JWT) │
┌───────────────┴────────────────────────────▼───────────────┐
│ Flask Backend (port 5001) │
│ │
│ auth/ → register, login, JWT, per-user isolation, ROLES │
│ api/ → projects, uploads, personas, report, chat, groups │
│ services/ → analyzer · persona generator │
│ → sales_kit (product facts + initial pain-fit) │
│ → report builder │
│ → sales_simulator (chat engine) │
│ storage/ → filesystem JSON per user (no SQL DB) │
│ llm_client/ → OpenAI/DeepSeek/custom OpenAI-compatible calls │
└────────────────────────────────────────────────────────────────────┘
```
### 3.0 Roles & permissions (multi-user corporate)
| Role | Can do |
|------|--------|
| **Super Admin** | Manage all users, assign roles, manage org-level persona groups, view all data + analytics. |
| **Admin** (Manager) | Create/edit/delete **persona groups** (define a product/offer + its personas), **manually edit any persona**, manage users in their scope, view analytics. |
| **User** (trainee) | **Cannot create groups.** Only **selects an existing persona group** and practices (chat) on its personas; sees own results. |
- Org model: `organization``users``persona_groups``projects`/`sessions`.
- **Persona group** = a reusable packaged scenario: product definition (sales kit) + the 15
personas generated for it. Admins build groups **and may edit/re-analyze them later**
(groups are editable; regenerate is allowed; **admin can hand-edit any persona field**).
- A trainee's "project" is a **training session** bound to a group + a chosen persona.
### 3.0b Registration (confirmed)
- **No self-registration.** Admin creates users and sends invites (email/account-creation).
Only Super Admin and Admin can provision accounts. Roles: Super Admin / Admin / User (no Trainer).
- **Repo root**: `~/Gitea/Sales Trainer/`
- **Stack**: Flask 3 + Vue 3 (Vite) + JWT auth + filesystem JSON persistence + OpenAI-compatible LLM.
- **LLM**: reuse the provider-agnostic pattern from MiroFish (`llm_client.py`): configurable
`LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_MODEL_NAME` / `LLM_API_KEY` via `.env`. Supports
**OpenAI, DeepSeek, or any OpenAI-compatible custom model** (base URL + model name overridable).
- **Persistence**: per-user `data/<user_id>/projects/<project_id>/…` JSON (mirrors
MiroFish/Hermes durable file approach). No external DB needed for v1.
### 3.1 Data flow
1. **Setup (admin)** → create a persona group: form (product/segment/description) + upload files
(+ channel & initiation-mode preferences).
2. **Analyze** (background, async) →
- **Sales Kit** extracted from input (what we sell, features, pricing, value prop, target, use cases)
**mainly for pain extraction** (reusable across same/similar category);
- **Personas** generated (15 personas w/ tier, pains, background, negotiation levers, initiation mode, channel, latent/revealable fields).
3. **Approve/Edit (admin)** → review full persona data; edit any field; approve pool.
4. **Report** (background, async) → structured report assembled from personas (downloadable; full data).
5. **Chat** → a trainee picks a persona (one-shot) → real-time conversational close attempt,
per the persona's initiation mode.
- Each message: persona responds; the simulator also returns an internal **state update**
(tier, pain-resolution progress, trust, buying-signal flags) — kept hidden.
- On **close** or **refusal**, the chat terminates with a debrief that reveals latent fields.
6. **Training loop** → record win/lose; weak-area analysis; generate-new-persona (lock or manual).
---
## 4. Services detail
### 4.1 `analyzer` (Sales Kit extraction + initial pain-fit)
Input: **setup form** with:
- **Product** (what it is) — required,
- **Initial customer segment** (optional),
- **Additional description / scenario** (optional framing for persona creation),
- **OR upload files** (`.pdf/.md/.txt`) carrying all of this — user may skip the form entirely.
**Decision logic (clear/simple):** if files are uploaded, parse them for product + target + pains;
if the form is also filled, the **form's explicit fields win** and file text fills the gaps
(and is still analyzed for extra context / pain-fit). If only files → derive everything from files.
If only the form → use the form.
Output (JSON): `productName`, `category`, `valueProps[]`, `features[]`, `pricing`
(budget anchors), `targetAudience` (incl. segment from form), `useCases[]`, `competitors[]`,
`objectionHandlers[]`, and — critically — **`initialPainFit[]`**: the analyzer's first-pass
judgement of **which pains the product can plausibly solve** (with evidence/claims from the
inputs), so persona pains can be built partly against and partly away from this baseline.
`scenario`/description is preserved as a **framing constraint** passed to persona generation.
This grounds all persona + chat generation so the simulation stays on-product.
### 4.2 `persona_generator`
Builds the **15 personas (5 × tier A/B/C)** for a given persona group. Prompt engineered to enforce:
- exactly 5 personas per tier;
- the required variety fields (background, income/occupation, personality,
communication style, goals, budget, timeline, objections);
- **pain variety** (2.3b): not every pain is product-solvable — include directly-solvable,
partially-solvable, and unrelated pains; use `initialPainFit` as the baseline;
- **the tier-C "wrong text / lost interest" special persona** (2.3d) — at least 1;
- **chat opener** for every persona + whether they initiate friendly/blunt/indifferent;
- **initiation mode** (customer-initiated vs seller-initiated) and **channel** (facebook/line);
- **latent vs revealable fields** (2.6) so the UI can hide what a real seller wouldn't know;
- JSON output schema (strict), language follows project `language` (en/th).
### 4.2b `weak_area_analyzer` (win/loss insight → new persona)
- Tracks each user's per-persona outcomes (won/lost) and their scores.
- On request, analyzes the user's **losses**: which tier / initiation mode / channel / pain-type
/ objection-type / negotiation-style they tend to lose against.
- Produces (a) a **summary insight** ("you lose most against seller-initiated, price-hardball
persona; you rarely handle discount + delivery-time pressure together"), and (b) a **spec** to
**generate a new persona** targeting that weakness (as a 'lock' to overcome) — or the user can
use the manual persona form instead.
- A user can also see their **win/lose status board** (which personas won/lost/not-tried).
### 4.3 `sales_simulator` (chat engine) — the heart
State machine per chat session:
```
READY → (customer or seller opens per mode) TALKING ⇄ (negotiating/objecting/thinking) → CLOSED | REFUSED | TIMEOUT
```
- **Initiation depends on the persona's mode (2.3c)**:
- **customer-initiated**: the customer sends the first message; the seller responds.
- **seller-initiated**: the customer does NOT message first — the simulator gives the seller
an **opening task** ("open the sale"), and the seller must start the conversation; the
persona then reacts as a cold/warm lead.
- **Channels**: Facebook or LINE (2.3e) — affects presentation + some tone.
- **One-shot (2.4)**: a user may not start a second session on a persona they've already
finished (won or lost). Backend enforces it.
- **Hidden/latent data (2.6)**: the trainee only ever sees revealable fields during the chat;
pain / income / personality / budget / negotiation levers / opener are latent and hidden until
the end.
- **Hidden pain state**: each persona has hidden unresolved pains. The simulator tracks
per-pain resolution. Pitching without discovery does NOT resolve pain.
- **Pain-fit realism (2.3b)**: since not all pains are product-solvable, the simulator must
let sellers mis-diagnose — claiming to solve an unrelated pain must backfire (trust down)
or lead down a dead end, while the real (product-solvable) pain stays unresolved.
- **Per message**: LLM plays the persona in-character (using persona card + sales kit +
chat history + current internal state). Returns:
- `reply` (the persona's in-character message),
- `internal`: updated `{ trust, painProgress, buyingSignals, tier, mayRefuse }`.
- **Close trigger**: the judge-LM decides if the seller has satisfied the pain-resolution
conditions (visible+hidden) AND the persona verbally accepts the offer/price. Only then `CLOSED`.
- **Refuse trigger**: if trust collapses, the seller pushes a hard pitch without
addressing pain, or after N failed attempts → `REFUSED`.
- **Negotiation**: personas actively counter (price, scope, timeline, freebies, delivery). Seller
must handle these on top of resolving pain.
- **Timeout/abandon**: persona goes silent if seller is repetitive/low-value.
**Internal signals are NEVER shown live.** Trust/pain/buying meters stay hidden during the
conversation (default) — the trainee reads the customer's words only.
**Debrief (on CLOSED or REFUSED) — REQUIRED, shown only at conversation end:** reveal the
latent fields (pain, income, personality, budget, negotiation levers, hidden opener), then:
- Keep the summary **short**, then give **coaching**: for each message that hurt the score,
suggest **how the seller should have responded** so the trainee understands and can improve.
- **CLOSED**: brief note on the persona's `pain` (what it was) + why it closed.
- **REFUSED**: the unaddressed pain(s), where trust was lost, which pain was mis-diagnosed (if
any) — each with **a concrete "better reply" suggestion**.
- A judge-LM (separate from the persona LLM) produces the score + coaching.
- Toggle: admin can choose to show live meters **per persona group** if desired; default hidden.
---
## 5. API surface (v1)
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/auth/login` | JWT login |
| GET | `/api/auth/me` | current user + role |
| POST/PUT | `/api/admin/users` | admin: create users + invite, assign roles (super-admin/admin/user) |
| GET | `/api/admin/users` | admin: list users |
| POST | `/api/groups` | admin: create persona group (product files/description) |
| GET | `/api/groups` | list persona groups visible to role (user: selectable only) |
| GET | `/api/groups/<id>` | get group + personas (admin: full; user: practice view) |
| POST | `/api/groups/<id>/analyze` | admin: trigger analyze (sales kit + 15 personas) |
| GET | `/api/groups/<id>/report` | get/download report |
| GET | `/api/groups/<id>/personas` | list personas (grouped by tier; user sees revealable fields only) |
| GET | `/api/groups/<id>/personas/<pid>` | persona card (admin: full; user: revealable only) |
| PUT | `/api/groups/<id>/personas/<pid>` | admin: hand-edit a persona |
| POST | `/api/groups/<id>/reanalyze` | admin: re-run analyze / regenerate personas |
| POST | `/api/groups/<id>/personas/<pid>/chat` | user: start/send message → persona reply + hidden state (one-shot enforced) |
| GET | `/api/groups/<id>/personas/<pid>/session` | user: session state/history |
| GET/POST | `/api/sessions` | user: my training sessions + debriefs (own results) |
| GET | `/api/sessions/<id>/debrief` | user: end-of-chat debrief (pain + reason + coaching + latent reveal) |
| GET | `/api/me/board` | user: win/lose status board per persona (won/lost/not-tried) |
| GET | `/api/me/weak-areas` | user: analyze which personas I tend to lose against (insight) |
| POST | `/api/me/personas/generate` | user: generate own persona — body: {mode: "weak-area" \| "manual", ...spec} |
| GET | `/api/me/personas` | user: list my generated (private) personas + status |
| GET | `/api/analytics` | admin: aggregate trainee analytics (close rate, avg score, hardest personas) |
All `/api/*` except login require `Authorization: Bearer <jwt>`; data scoped by role + org.
Admins call analyze/persona-edit endpoints; trainees read revealable fields + run one-shot sessions +
generate their own personas.
---
## 6. Frontend views (Vue 3 + Vite)
1. **Login** (no self-registration — accounts created by admin)
2. **Dashboard** — (admin) persona groups + user mgmt; (user) selectable groups + my sessions
3. **Admin: Group Builder** — setup form (product/segment/description) + upload files; pick
channel (Facebook/LINE) + initiation-mode mix; trigger analyze → approve/edit the 15 personas
4. **Admin: User Management** — no self-registration; create + invite users, set roles
5. **Personas (approve/edit, admin only)****full data** for all 15 personas; edit any field, approve
6. **Personas (select, user)** — win/lose status board + list of available personas; each card shows
**only revealable info** (name, profession, age group, channel, initiation mode, product context)
7. **Report** — rendered analysis report + download (admin; full persona data)
8. **Simulation (chat)** — Facebook/LINE-style thread. **Internal signals + latent fields hidden**
during chat. Initiation per mode: customer opens OR seller gets an "open the sale" task.
**One-shot enforced.** On end → **debrief overlay**: reveal latent fields + short summary +
coaching (how to improve weak-score replies) + pain + reason + score. Result saved; persona
marked won/lost for this user.
9. **Gen persona (user)** — generate own persona: **weak-area** (from my loss analysis) or
**manual form** (describe target persona). Private to the user.
10. **Weak-areas (user)** — insight: which personas I tend to lose against + generate-a-lock CTA
11. **Admin Analytics** — aggregate trainee results (close rate, avg score, hardest personas).
i18n: en + th (mirrors MiroFish pattern). Role-based navigation (admin vs trainee).
---
## 7. Security, config, deploy
- **Auth**: JWT (HS256) with password hashing (werkzeug `generate_password_hash`).
Secrets in `.env`. No raw tokens/keys in UI. **No self-registration** — only admin-provisioned accounts.
- **LLM credentials**: `.env` only, never shipped/logged.
- **File safety**: upload allowed types + size caps; parse text server-side; strip anything
executable; keep raw uploads out of any served path.
- **LLM route discipline**: the chat + persona gen + judge are the only LLM-touching callers.
- **Deploy**: single `Dockerfile` (python:3.11 + Node 18, build Vue → serve static via
Flask or nginx) + `docker-compose.yml` with `.env`, per the user's EasyPanel pattern.
Local tests via `http.server` / Flask dev (no Docker on local Mac).
---
## 8. Milestones (build order) — ALL COMPLETE ✅
- **M0 — Scaffold & auth**: repo, Flask app factory, JWT auth, roles (super-admin/admin/user),
user + org store, **admin-user creation/invite (no self-registration)**. ✅
- **M1 — Input & analyze**: setup form (product/segment/description) + file upload/parse with
precedence rule, sales-kit extraction + **initial pain-fit**, JSON storage. ✅
- **M2 — Persona groups & persona generation**: group model (editable/re-analyzeable); 15 personas
(5 × tier) with variety, pain variety, negotiation levers, chat openers, **initiation mode
(customer/seller)**, **channel (facebook/line)**, **latent vs revealable fields**, and the
tier-C "wrong text" special case; **admin hand-edit persona endpoints**. ✅
- **M3 — Report**: assemble + render + download report. ✅
- **M4 — Chat simulation**: stateful persona chat (per-mode initiation: customer opens OR seller
"open the sale" task), Facebook/LINE thread, negotiation, all-tiers-can-lose, close/refuse logic,
**hidden signals + latent fields**, **one-shot enforcement**, **separate judge LLM** for scoring,
**short debrief with coaching** (latent reveal + pain + reason + how to improve). ✅
- **M5 — Trainee loop**: win/lose status board, weak-area analysis, **user-generated personas**
(weak-area "lock" + manual form), my-sessions. ✅
- **M6 — Frontend polish**: login, role-based dashboard, group builder (+ persona approve/edit UI),
user mgmt, personas (select view), report, chat UI, debrief overlay, gen-persona, weak-areas,
**admin analytics dashboard**, EN+TH i18n. ✅ (SPA served by Flask; verified live)
- **M7 — Deploy & docs**: Dockerfile, docker-compose, README, engineering-handoff docs,
E2E verification with honest status reporting. ✅ (mock-LLM E2E; real-key + Docker pending)
> Status: prototype complete + verified with mock LLM. **Pending: real-LLM live smoke test
> and remote Docker/EasyPanel validation** (see docs/HANDOFF.md).
---
## 9. Acceptance criteria
1. Multi-user corporate, **no self-registration**: super-admin/admin create + invite users; roles
Super Admin / Admin / User (no Trainer). **User (trainee) cannot create** — selects a group + practices.
2. Setup accepts **form (product / segment / description) AND/OR uploaded files**, with a clear
decision rule (form-wins, file-fills-gaps; files-only → derive all).
3. Analyzer produces sales kit **+ initial pain-fit**; persona demographics (age, occupation,
lifestyle, income) are **varied AND consistent with product + scenario framing**.
4. → 15 personas (5 × tier A/B/C); **admin can edit any persona and re-analyze the group**.
5. Persona group has **≥1 tier-C "wrong text / lost interest" special persona**.
6. Report is human-readable + downloadable.
7. **All tiers can lose** — bad conversation (e.g. rude language) = no sale, even for ready-to-buy.
8. **All tiers negotiate** concessions (price / freebies / delivery timeline / scope / payment).
9. Initiation is **per-persona mode**: customer-initiated (customer opens) OR seller-initiated
(seller gets an "open the sale" task). Channels **Facebook + LINE**. Internal signals + latent
fields are **hidden** during chat; only revealable fields shown.
10. **One-shot rule**: a user can chat a persona only once (won/lost = final); the same persona
stays playable for other users.
11. **Two persona views**: admin sees full data (approve/edit); trainee sees only revealable info
(name, profession, age group, channel, initiation mode) — latent fields revealed only after result.
12. **Win/lose status board**; **weak-area analysis**; **user-generated personas** (weak-area "lock"
OR manual form), private to the user.
13. Debrief is **short + coaches**: suggests how to improve on weak-score messages; reveals pain +
reason. Scored by a **separate judge LLM**; **no speed factor** in scoring.
14. Product data is used primarily for **pain extraction**; personas are reusable across the
same/similar product category.
15. **Admin analytics dashboard** aggregates trainee results (close rate, avg score, hardest personas).
16. Single `docker compose up` runs the whole app (or EasyPanel build); LLM pickable as
OpenAI / DeepSeek / any OpenAI-compatible custom model via `.env`.
17. E2E tests pass; honest report of any blocked stages.
---
## 10. Confirmed decisions (all from user)
- Standalone web app (Flask + Vue + JWT), Docker/EasyPanel deploy.
- Multi-user corporate, **no self-registration** — Super Admin / Admin / User (no Trainer).
Admin creates + invites users.
- **Admin** builds/edits persona groups, **hand-edits any persona**, re-analyzes groups, sees analytics.
- LLM: **OpenAI, DeepSeek, or custom OpenAI-compatible** (configurable via `.env`).
- **5 personas per tier → 15 min per group.**
- Persona variety: **age-group, occupation, lifestyle, income** — consistent with product + scenario framing.
- Internal chat signals **hidden**, revealed at end; **short debrief + coaching** (how to answer better on weak-score messages).
- **All tiers can lose**; **all tiers negotiate** concessions.
- **Separate judge LLM** for close/refuse + scoring; **no speed factor** in scoring.
- **Admin analytics dashboard** (aggregate trainee results).
- UI **EN + TH**.
- Product **user-input via form AND/OR uploaded file** with clear precedence; analyzer computes
initial pain-fit. Product data mainly for **pain extraction** — personas reusable across same/similar category.
- Personas have **pain variety** (not only product-solvable), demographically consistent with product.
- **Initiation per persona**: customer-initiated OR seller-initiated (open-the-sale task).
- **Channels**: Facebook + LINE.
- **Two views**: admin full data; trainee revealable-only (latent hidden until result).
- **One-shot rule**: 1 user = 1 chat per persona; persona shared across users.
- **User-generated personas**: weak-area "lock" OR manual form (private).
- **Win/lose board** + **weak-area analysis**.
- **≥1 tier-C "wrong text / lost interest" persona.**
## 11. Remaining open questions (low-risk; defaults noted)
- **Invite delivery**: email link to set password, or admin pre-sets a temporary password?
(Default: admin sets temporary password on account creation; optional email later.)
- **Analytics granularity**: just group-level aggregates, or drill-down per persona/trainee?
(Default: group + per-persona close rate + avg score; per-trainee detail on request.)
- **Scoring weights** (beyond dropping speed): pain 40 / trust 30 / objection-handling 30 ok?
- **Persona edit UI**: full form for all fields, or JSON editor for power users?
(Default: structured form for common fields + JSON for advanced.)
_Plan is ready for build. Confirm the low-risk defaults above if you disagree, otherwise I begin M0._

35
docs/engineering-log.md Normal file
View File

@@ -0,0 +1,35 @@
# Engineering Log — Sales Trainer
Program status table + dated entries. Append-only entries under `docs/engineering-log/`.
## What this is
A corporate, multi-user **sales-training simulator**. Admins upload/describe a product → app
analyzes it + generates 15 realistic customer personas (5 per intent tier A/B/C) with varied,
partially product-aligned pains, negotiation levers, initiation modes (customer/seller),
channels (Facebook/LINE), and latent-vs-revealable data. Trainees chat 1:1 (one-shot) to close
a sale; customers resist/negotiate/refuse; a separate judge-LLM scores + coaches the result.
Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight plugin.
## Status table
| Milestone | Status | Last verified | Evidence | Next action |
|-----------|--------|---------------|----------|-------------|
| M0 Scaffold + auth/roles | complete | 2026-08-07 | `test_m0.py` | — |
| M1 Input & analyze (+pain-fit) | complete | 2026-08-07 | `test_e2e.py` | — |
| M2 Persona groups + generation (15, wrong_text) | complete | 2026-08-07 | `test_e2e.py` | — |
| M3 Report | complete | 2026-08-07 | `test_e2e.py` | — |
| M4 Chat simulator (init modes, one-shot, judge, debrief) | complete | 2026-08-07 | `test_e2e.py` | — |
| M5 Trainee loop (board, weak-areas, gen-persona) | complete | 2026-08-07 | `test_e2e.py` | — |
| M6 Frontend (Vue SPA) + static serving fix | complete | 2026-08-07 | build + live HTTP 200 | — |
| M7 Docker/deploy/docs | complete | 2026-08-07 | Dockerfile/compose/README | live-key E2E |
## Guardrails
- No self-registration; admin provisions users. (Verified: register => 404.)
- One persona = one chat per user (one-shot). Enforced in SessionStore + chat start.
- Latent persona fields never leak to trainees pre-result.
- LLM credentials live in `.env` only; never logged.
## Entry index
- `2026-08-07-build-out.md` — M0M7 build-out, decisions, verification, current state.

View File

@@ -0,0 +1,69 @@
# 2026-08-07 — Sales Trainer build-out (M0M7)
## Summary
Built the first complete version of the Sales Trainer app — corporate multi-user sales-training
simulator — in `~/Gitea/Sales Trainer` from the detailed plan in `docs/PLAN.md` (which records all
user decisions from the planning discussion).
## Plan status
- M0M7 all complete (see `engineering-log.md` status table).
## What was built
Backend (Flask + JWT + filesystem JSON):
- `app/config.py` (env/.env, LLM resolve), `app/llm.py` (OpenAI-compatible client + JSON/conv helpers)
- `app/storage/store.py` (durable JSON store, lock + atomic write)
- `app/auth/users.py` (UserStore: password hash, JWT, roles, no self-reg bootstrap)
- `app/services/`: `file_parser` (pdf/txt/md), `analyzer` (sales kit + initial pain-fit),
`persona_prompts` + `persona_generator` (15 personas, 5/tier, wrong_text special),
`store` (persona shape + revealable_view), `report`, `groups`, `sessions`, `simulator`
(chat + judge), `trainee` (weak-areas, MyPersonaStore), `own_persona`
- `app/api/`: auth, admin, groups, chat, me, analytics routes + JWT/RBAC helpers
- `app/factory.py`: app factory, bootstrap admin, serves built Vue frontend (SPA fallback)
- `run.py` entry
Frontend (Vue 3 + Vite): login, dashboard (role-based), group builder, group edit (admin) ,
personas (trainee revealable-only + win/lose), chat (FB/LINE thread + seller-task + debrief
overlay), my-sessions, weak-areas, gen-persona, admin users, analytics. EN+TH i18n.
## Verified commands / results
```
backend: uv venv --python 3.11 .venv
uv pip install -r requirements.txt --python .venv/bin/python
uv run python scripts/test_m0.py -> ALL M0 TESTS PASSED
uv run python scripts/test_m1.py -> ALL M1/M2-IMPORT TESTS PASSED
uv run python scripts/test_routes.py -> ALL ROUTE REGISTRATION TESTS PASSED
uv run python scripts/test_e2e.py -> ALL E2E TESTS PASSED
frontend: npm install && npm run build -> builds 11 route-split chunks (423ms)
live HTTP (Flask dev on :5001):
GET / -> 200 (SPA)
POST /api/auth/register -> 404 (no self-reg)
POST /api/auth/login -> 200 (JWT)
POST /api/groups -> 201 (draft group)
```
Mock-LLM E2E covers: analyze→15 personas (+wrong_text)→revealable-only→customer/seller-initiated
sessions→debrief(latent reveal+coaching)→one-shot→board→weak-areas→gen-persona→analytics.
## Engineering notes / issues
1. **Tooling**: the Hermes terminal life-cycle guard crashes ("embedded null byte") on direct
`.venv/bin/python <script>` invocation. Workaround: run scripts via `uv run python scripts/x.py`.
2. **Static serving path**: initially pointed at `backend/frontend/dist` (wrong) → `GET /` 404.
Fixed to repo-root `frontend/dist`, and made the SPA fallback accept all HTTP methods so
`/api/*` returns 404 (not 405), preserving no-self-registration.
3. **Mock vs real LLM**: tests use `scripts/mock_llm.py` (deterministic). Real model path
requires a live `LLM_API_KEY` in `.env` — NOT yet exercised live.
## Current state / runtime
- Backend runs via `cd backend && uv run python run.py`; frontend dev via `cd frontend && npm run dev` (proxies /api -> :5001).
- Default super-admin: `admin@salestrainer.local` / `admin123` (bootstrap; change in prod).
- Deploy files: root `Dockerfile`, `docker-compose.yml`, `.env.example`; repo-root `frontend/dist` build.
## Risks / remaining
- **Real-LLM end-to-end not verified** (needs a live key). Next: run analyze + persona + chat +
judge against the configured provider (DeepSeek/OpenAI/custom).
- Docker build not run locally (no Docker on this Mac — per environment note). Dockerfile follows
the EasyPanel single-container pattern; remote build+run should be validated on EasyPanel.
## Next action
1. Set a real `LLM_API_KEY` (and provider) in `.env`, run a live smoke test of analyze→personas→chat→debrief.
2. Push to Gitea remote (repo currently local git, no remote yet).
3. Validate Docker image build on EasyPanel.

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sales Trainer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1287
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -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
}
}

53
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<template>
<div class="app">
<nav v-if="auth.user" class="topnav">
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
<div class="nav-right">
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
<span class="muted">{{ auth.user.name }} ({{ auth.role }})</span>
<button @click="logout"> {{ i18n.t('logout') }}</button>
</div>
</nav>
<main class="main">
<router-view />
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { auth } from './store/auth'
import { i18n } from './i18n'
const router = useRouter()
function toggleLang() {
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
}
function logout() {
auth.logout()
router.push('/login')
}
onMounted(() => {
if (auth.token && !auth.user) auth.load()
})
</script>
<style scoped>
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-weight: 800; text-decoration: none; color: var(--ink); }
.nav-right { display: flex; align-items: center; gap: 12px; }
.lang { padding: 6px 10px; }
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
</style>

57
frontend/src/api/index.js Normal file
View File

@@ -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'),
}

122
frontend/src/i18n/index.js Normal file
View File

@@ -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)
}

6
frontend/src/main.js Normal file
View File

@@ -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')

View File

@@ -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

View File

@@ -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)
},
})

73
frontend/src/style.css Normal file
View File

@@ -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); }

View File

@@ -0,0 +1,46 @@
<template>
<div>
<h2>{{ i18n.t('users') }}</h2>
<div class="card" style="margin-bottom:16px">
<h4>+ {{ i18n.t('create') }} user</h4>
<div class="row">
<input v-model="form.name" placeholder="Name" style="flex:1" />
<input v-model="form.email" placeholder="Email" style="flex:1" />
<input v-model="form.password" type="password" placeholder="Temp password" style="flex:1" />
<select v-model="form.role" style="flex:1">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button class="primary" @click="create">{{ i18n.t('create') }}</button>
</div>
<div class="error" v-if="error">{{ error }}</div>
</div>
<div class="card" v-for="u in users" :key="u.id" style="margin-bottom:8px;display:flex;align-items:center;gap:12px">
<strong style="flex:1">{{ u.name }} ({{ u.email }})</strong>
<span class="badge">{{ u.role }}</span>
<span class="badge" :class="u.active ? 'won' : 'lost'">{{ u.active ? 'active' : 'inactive' }}</span>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { i18n } from '../i18n'
const users = ref([])
const error = ref('')
const form = ref({ name: '', email: '', password: '', role: 'user' })
async function load() { users.value = (await api.adminListUsers()).users }
async function create() {
error.value = ''
try {
await api.adminCreateUser({ ...form.value })
form.value = { name: '', email: '', password: '', role: 'user' }
await load()
} catch (e) { error.value = e.message }
}
onMounted(load)
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div>
<h2>{{ i18n.t('analytics') }}</h2>
<div class="row" style="gap:16px;margin:16px 0">
<div class="card stat"><div>Sessions</div><strong>{{ a.overall.total_sessions }}</strong></div>
<div class="card stat"><div>Wins</div><strong style="color:var(--green)">{{ a.overall.wins }}</strong></div>
<div class="card stat"><div>Losses</div><strong style="color:var(--red)">{{ a.overall.losses }}</strong></div>
<div class="card stat"><div>Close rate</div><strong>{{ a.overall.close_rate }}%</strong></div>
<div class="card stat"><div>Avg score</div><strong>{{ a.overall.avg_score }}</strong></div>
</div>
<h3>Trainees: {{ a.trainee_count }}</h3>
<h3>Hardest personas</h3>
<div class="card" v-for="(p, i) in a.hardest_personas" :key="i" style="margin-bottom:8px">
<strong>{{ p.persona_name }}</strong>
<span class="badge lost">{{ p.losses }}L</span>
<span class="badge won">{{ p.wins }}W</span>
<span class="muted">· avg {{ p.avg_score }}</span>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { i18n } from '../i18n'
const a = ref({ overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, trainee_count: 0, hardest_personas: [] })
onMounted(async () => { a.value = await api.analytics() })
</script>
<style scoped>
.stat { text-align: center; min-width: 110px; }
.stat div { color: var(--muted); font-size: 12px; }
.stat strong { font-size: 20px; }
</style>

136
frontend/src/views/Chat.vue Normal file
View File

@@ -0,0 +1,136 @@
<template>
<div>
<div class="row" style="align-items:center;margin-bottom:12px">
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0">
{{ i18n.t('finish') }}
</button>
</div>
<!-- Seller-initiated task -->
<div v-if="!started" class="card task" v-html="taskText"></div>
<!-- Chat thread -->
<div class="thread" v-if="started" ref="thread">
<div v-for="(m, i) in messages" :key="i" class="bubble" :class="m.role === 'seller' ? 'msg-seller' : 'msg-customer'">
{{ m.text }}
</div>
<div v-if="sending" class="bubble msg-customer muted">...</div>
</div>
<!-- Input -->
<div v-if="started && !debrief" class="composer">
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button>
</div>
<!-- Debrief overlay -->
<div v-if="debrief" class="card debrief">
<h3>{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
{{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
<div v-if="debrief.coaching && debrief.coaching.length">
<strong>Coaching:</strong>
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
</div>
<details>
<summary>{{ i18n.t('reveal') }}</summary>
<pre class="json">{{ JSON.stringify(debrief.revealed_persona, null, 2) }}</pre>
</details>
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
</div>
</div>
</template>
<script setup>
import { onMounted, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
const route = useRoute()
const gid = route.params.gid
const pid = route.params.pid
const persona = ref(null)
const started = ref(false)
const messages = ref([])
const text = ref('')
const sending = ref(false)
const debrief = ref(null)
const taskText = ref('')
const sessionId = ref(null)
function scrollDown() {
nextTick(() => {
if (thread.value) thread.value.scrollTop = thread.value.scrollHeight
})
}
const thread = ref(null)
onMounted(async () => {
persona.value = (await api.getPersona(gid, pid)).persona
const res = await api.chatStart(gid, pid)
sessionId.value = res.session.id
if (res.session.task) {
taskText.value = `📣 <strong>${i18n.t('sellerInitiated')}</strong><br/>${res.session.task}`
}
messages.value = res.session.messages || []
started.value = true
if (messages.value.length) scrollDown()
})
async function send() {
if (!text.value.trim()) return
sending.value = true
try {
const res = await api.chatSend(gid, pid, text.value.trim())
messages.value = res.messages
text.value = ''
scrollDown()
} catch (e) {
alert(e.message)
} finally {
sending.value = false
}
}
async function finish() {
if (!confirm(i18n.t('finish') + '?')) return
sending.value = true
try {
const res = await api.chatFinish(gid, pid)
debrief.value = res.debrief
messages.value = res.session.messages
} catch (e) {
alert(e.message)
} finally {
sending.value = false
}
}
</script>
<style scoped>
.thread {
background: #eceff4;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
min-height: 320px;
max-height: 52vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.bubble { max-width: 72%; padding: 10px 14px; white-space: pre-wrap; word-break: break-word; }
.composer { display: flex; gap: 8px; margin-top: 12px; }
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
.debrief { margin-top: 16px; }
.json { background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px; font-size: 11px; overflow: auto; max-height: 260px; }
button.danger { background: var(--red); color: #fff; border: none; }
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div>
<div class="row" style="margin-bottom:16px">
<h2 style="margin:0">{{ i18n.t('dashboard') }}</h2>
<div style="margin-left:auto" v-if="auth.isAdmin">
<router-link to="/admin/new-group"><button class="primary">{{ i18n.t('groupBuilder') }} +</button></router-link>
</div>
</div>
<div class="row" v-if="auth.isAdmin" style="gap:16px;margin-bottom:20px">
<router-link to="/admin/users" style="text-decoration:none"><div class="card link-card">👥 {{ i18n.t('users') }}</div></router-link>
<router-link to="/admin/analytics" style="text-decoration:none"><div class="card link-card">📊 {{ i18n.t('analytics') }}</div></router-link>
</div>
<div v-if="auth.role === 'user'" class="row" style="gap:16px;margin-bottom:20px">
<router-link to="/my/sessions" style="text-decoration:none"><div class="card link-card">🎯 {{ i18n.t('myTraining') }}</div></router-link>
<router-link to="/my/weak-areas" style="text-decoration:none"><div class="card link-card"> {{ i18n.t('weakAreas') }}</div></router-link>
<router-link to="/my/generate" style="text-decoration:none"><div class="card link-card"> {{ i18n.t('generatePersona') }}</div></router-link>
</div>
<h3>{{ i18n.t('groups') }}</h3>
<div v-if="loading">...</div>
<div v-else-if="groups.length === 0" class="card muted"></div>
<div class="grid">
<div v-for="g in groups" :key="g.id" class="card group-card">
<div class="row" style="justify-content:space-between">
<strong>{{ g.title }}</strong>
<span class="badge" :class="g.status">{{ g.status }}</span>
</div>
<div class="muted" style="margin:6px 0 12px">{{ (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || '' }}</div>
<router-link v-if="auth.isAdmin" :to="`/admin/groups/${g.id}/edit`">
<button>{{ i18n.t('personas') }} / {{ i18n.t('create') }}</button>
</router-link>
<router-link v-else-if="g.status === 'ready'" :to="`/groups/${g.id}/personas`">
<button class="primary">{{ i18n.t('selectPersona') }}</button>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { auth } from '../store/auth'
import { i18n } from '../i18n'
const groups = ref([])
const loading = ref(true)
onMounted(async () => {
try {
groups.value = (await api.listGroups()).groups
} finally {
loading.value = false
}
})
</script>
<style scoped>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.group-card { display: flex; flex-direction: column; }
.group-card a { margin-top: auto; }
.link-card { text-align: center; min-width: 150px; }
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<h2>{{ i18n.t('generatePersona') }}</h2>
<div class="card" style="margin-bottom:16px">
<label>Mode</label>
<div class="row">
<button :class="{ active: mode === 'manual' }" @click="mode = 'manual'"> {{ i18n.t('manual') }}</button>
<button :class="{ active: mode === 'weak-area' }" @click="mode = 'weak-area'">🔒 Weak-area lock</button>
</div>
<template v-if="mode === 'manual'">
<label>Describe the persona you want to practice against</label>
<textarea v-model="spec" rows="4" placeholder="e.g. a price-hardball restaurant owner on LINE who stalls when I bring up costs"></textarea>
</template>
<template v-else>
<p class="muted">The system will analyze your losses and auto-generate a harder persona targeting your weak points.</p>
</template>
<button class="primary" style="margin-top:16px" :disabled="busy || (mode === 'manual' && !spec.trim())" @click="gen">
{{ busy ? '...' : i18n.t('generatePersona') }}
</button>
<div class="error" v-if="error">{{ error }}</div>
</div>
<h3>My personas</h3>
<div class="grid">
<div v-for="p in mine" :key="p.id" class="card pcard">
<strong>{{ p.name }}</strong>
<span class="badge" :class="p.tier">Tier {{ p.tier }}</span>
<div class="muted">{{ p.profession }} · {{ p.age_group }}</div>
<router-link :to="`/groups/${myGid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
const route = useRoute()
const mode = ref(route.query.mode === 'weak' ? 'weak-area' : 'manual')
const spec = ref('')
const busy = ref(false)
const error = ref('')
const mine = ref([])
const myGid = ref(null)
async function loadMine() {
const d = await api.myPersonas()
myGid.value = d.group.id
mine.value = d.personas
}
onMounted(loadMine)
async function gen() {
busy.value = true
error.value = ''
try {
const body = mode.value === 'weak-area'
? { mode: 'weak-area', spec: {} }
: { mode: 'manual', spec: { description: spec.value } }
await api.generatePersona(body)
await loadMine()
} catch (e) { error.value = e.message }
finally { busy.value = false }
}
</script>
<style scoped>
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.pcard { display: flex; flex-direction: column; min-height: 140px; }
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="card">
<h2>{{ i18n.t('groupBuilder') }}</h2>
<label>{{ i18n.t('product') }}</label>
<textarea v-model="form.product" rows="3" placeholder="e.g. Cloud POS for small restaurants"></textarea>
<label>{{ i18n.t('segment') }}</label>
<input v-model="form.segment" />
<label>{{ i18n.t('description') }}</label>
<textarea v-model="form.description" rows="3"></textarea>
<div class="row">
<div style="flex:1">
<label>{{ i18n.t('channel') }}</label>
<select v-model="form.channel">
<option value="facebook">{{ i18n.t('facebook') }}</option>
<option value="line">{{ i18n.t('line') }}</option>
</select>
</div>
<div style="flex:1">
<label>{{ i18n.t('language') }}</label>
<select v-model="form.language">
<option value="th">{{ i18n.t('thai') }}</option>
<option value="en">{{ i18n.t('english') }}</option>
</select>
</div>
</div>
<label>📎 Files (.pdf/.md/.txt) {{ i18n.t('product') }} can come from here</label>
<input type="file" multiple accept=".pdf,.md,.txt" @change="onFiles" />
<div class="error" v-if="error">{{ error }}</div>
<button class="primary" style="margin-top:16px" :disabled="busy || (!form.product && !files.length)" @click="create">
{{ busy ? '...' : i18n.t('create') }}
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
const router = useRouter()
const form = ref({ product: '', segment: '', description: '', channel: 'facebook', language: 'th' })
const files = ref([])
const error = ref('')
const busy = ref(false)
function onFiles(e) {
files.value = Array.from(e.target.files || [])
}
async function create() {
busy.value = true
error.value = ''
try {
const fd = new FormData()
fd.append('product', form.value.product)
fd.append('segment', form.value.segment)
fd.append('description', form.value.description)
fd.append('channel', form.value.channel)
fd.append('language', form.value.language)
files.value.forEach((f) => fd.append('files', f))
const data = await api.createGroup(fd)
const gid = data.group.id
router.push(`/admin/groups/${gid}/edit`)
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div>
<div class="row" style="align-items:center;margin-bottom:16px">
<h2 style="margin:0">{{ i18n.t('groupBuilder') }} {{ group && group.title }}</h2>
<button class="primary" style="margin-left:auto" @click="analyze" :disabled="busy">
{{ busy ? '...' : i18n.t('analyze') }}
</button>
</div>
<div class="error" v-if="error">{{ error }}</div>
<div v-for="tier in ['A','B','C']" :key="tier" style="margin-bottom:20px">
<h4>{{ tierLabel(tier) }}</h4>
<div class="grid">
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard">
<div class="row">
<strong>{{ p.name }}</strong>
<span class="badge" v-if="p.special === 'wrong_text'"> wrong_text</span>
</div>
<div class="muted">{{ p.profession }} · {{ p.age_group }} · {{ p.channel }} · {{ p.initiation_mode }}</div>
<div class="muted" style="margin-top:4px">diff {{ p.difficulty }} · {{ p.income }} · {{ p.personality }}</div>
<details style="margin-top:8px" open>
<summary>{{ i18n.t('reveal') }}</summary>
<pre class="json">{{ JSON.stringify(p, null, 2) }}</pre>
</details>
<button @click="editPersona(p)"> Edit</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
const route = useRoute()
const gid = route.params.gid
const group = ref(null)
const personas = ref([])
const busy = ref(false)
const error = ref('')
async function load() {
const data = await api.getGroup(gid)
group.value = data.group
personas.value = (await api.listPersonas(gid)).personas
}
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
async function analyze() {
busy.value = true
error.value = ''
try {
const data = await api.analyzeGroup(gid)
personas.value = data.personas
await load()
} catch (e) { error.value = e.message }
finally { busy.value = false }
}
function editPersona(p) {
const json = prompt('Edit persona JSON (full fields):', JSON.stringify(p, null, 2))
if (!json) return
try {
const parsed = JSON.parse(json)
api.updatePersona(gid, p.id, parsed).then(load)
} catch (e) { error.value = 'Invalid JSON: ' + e.message }
}
onMounted(load)
</script>
<style scoped>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.pcard { display: flex; flex-direction: column; }
.pcard button { margin-top: auto; }
.json {
background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px;
font-size: 11px; overflow: auto; max-height: 220px;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="login-wrap">
<div class="card login-card">
<h1>{{ i18n.t('app') }}</h1>
<label>{{ i18n.t('email') }}</label>
<input v-model="email" type="email" @keyup.enter="submit" />
<label>{{ i18n.t('password') }}</label>
<input v-model="password" type="password" @keyup.enter="submit" />
<div class="error" v-if="error">{{ error }}</div>
<button class="primary" style="width:100%;margin-top:16px" :disabled="loading" @click="submit">
{{ loading ? '...' : i18n.t('login') }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { auth } from '../store/auth'
import { i18n } from '../i18n'
const route = useRoute()
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(email.value, password.value)
router.push(route.query.redirect || '/')
} catch (e) {
error.value = i18n.t('loginError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrap { display: flex; justify-content: center; padding-top: 10vh; }
.login-card { width: 360px; }
h1 { margin-top: 0; }
</style>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<h2>{{ i18n.t('myTraining') }}</h2>
<div class="card" v-for="s in sessions" :key="s.id" style="margin-bottom:10px">
<div class="row" style="justify-content:space-between">
<strong>{{ s.persona_name }}</strong>
<span class="badge" :class="s.outcome || 'not_tried'">{{ s.outcome || '—' }}</span>
</div>
<div class="muted">{{ s.persona_id }} · {{ (new Date(s.created_at)).toLocaleString() }}</div>
<div v-if="s.debrief" class="muted" style="margin-top:4px">
Score {{ s.debrief.score }} {{ s.debrief.why }}
</div>
</div>
<div v-if="sessions.length === 0" class="card muted"></div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { i18n } from '../i18n'
const sessions = ref([])
onMounted(async () => { sessions.value = (await api.mySessions()).sessions })
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div>
<div class="row" style="align-items:center">
<h2 style="margin:0">{{ i18n.t('personas') }}</h2>
<span class="muted" style="margin-left:auto">Levels: choose one to practice (one-shot)</span>
</div>
<div v-for="tier in ['A','B','C']" :key="tier" style="margin:20px 0">
<h4>{{ tierLabel(tier) }}</h4>
<div class="grid">
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard">
<div class="row">
<strong>{{ p.name }}</strong>
<span class="badge" :class="p.my_outcome">{{ outcomeLabel(p.my_outcome) }}</span>
</div>
<div class="muted">
{{ p.profession }} · {{ p.age_group }} · {{ p.location }}<br />
<span class="badge" :class="p.channel">{{ p.channel }}</span>
<span class="muted"> · {{ p.initiation_mode === 'seller' ? i18n.t('sellerInitiated') : i18n.t('customerInitiated') }}</span>
</div>
<div class="muted" style="margin-top:6px">{{ p.product_context }}</div>
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
<div v-else class="muted" style="margin-top:auto;font-size:12px"> Trained ({{ outcomeLabel(p.my_outcome) }})</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
const route = useRoute()
const gid = route.params.gid
const personas = ref([])
const loading = ref(true)
async function load() {
try { personas.value = (await api.listPersonas(gid)).personas }
finally { loading.value = false }
}
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
function outcomeLabel(o) {
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : i18n.t('notTried')
}
onMounted(load)
</script>
<style scoped>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.pcard { display: flex; flex-direction: column; min-height: 170px; }
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div>
<div class="row" style="align-items:center">
<h2 style="margin:0">{{ i18n.t('weakAreas') }}</h2>
<router-link :to="`/my/generate?mode=weak`" style="margin-left:auto"><button class="primary">🔒 Generate a lock persona</button></router-link>
</div>
<div class="row" style="gap:16px;margin:16px 0">
<div class="card stat"><div>Wins</div><strong>{{ insight.wins }}</strong></div>
<div class="card stat"><div>Losses</div><strong>{{ insight.losses }}</strong></div>
<div class="card stat"><div>Total</div><strong>{{ insight.total_sessions }}</strong></div>
</div>
<div v-for="g in insight.by_tier" :key="g.value" class="card" style="margin-bottom:8px">
<span class="badge" :class="g.value">Tier {{ g.value }}</span> {{ g.losses }} losses
</div>
<h3 style="margin-top:20px">Top loss personas</h3>
<div v-if="!insight.top_loss_personas || !insight.top_loss_personas.length" class="card muted">No losses yet 🎉</div>
<div class="card" v-for="(p, i) in insight.top_loss_personas" :key="i" style="margin-bottom:8px">
<strong>{{ p.persona_name }}</strong> score {{ p.score }}<br />
<span class="muted">{{ p.why }}</span>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { i18n } from '../i18n'
const insight = ref({ wins: 0, losses: 0, total_sessions: 0, by_tier: [], top_loss_personas: [] })
onMounted(async () => { insight.value = (await api.weakAreas()).insight })
</script>
<style scoped>
.stat { text-align: center; min-width: 100px; }
.stat div { color: var(--muted); font-size: 12px; }
.stat strong { font-size: 22px; }
</style>

19
frontend/vite.config.js Normal file
View File

@@ -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,
},
},
},
})