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