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:
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})
|
||||
Reference in New Issue
Block a user