Security (requesting-code-review pipeline + independent reviewer): - Fix path traversal on file upload (basename sanitize + resolve-containment) - Fix IDOR: org + owner scoping on all group/chat routes (_authorize_group/_get_owned_group), hide other users' personal groups in listings - Remove XSS via v-html in Chat task (text interpolation) - Add test_security.py (traversal + cross-user denial) — all pass UX/UI (ui-ux-pro-max + frontend-dev-verification): - Global: focus rings, 44px touch targets, hover/press transitions, input focus glow, prefers-reduced-motion, skeleton loaders, empty states, back links, spinner - Login: password toggle, autocomplete, spinner, disabled-when-empty - Cards lift on hover; dashboard skeleton + empty state; analyze button spinner All backend tests pass (m0/m1/routes/security/e2e); frontend builds; served SPA verified via curl.
188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
"""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)
|
|
|
|
|
|
def _get_ready_group(s, gid: str) -> dict:
|
|
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
|
group = s["groups"].get_or_none(gid)
|
|
if not group or group.get("status") != "ready":
|
|
raise ApiError("group not ready", 404)
|
|
actor = current_user()
|
|
# super_admin can access any; otherwise owner (for personal groups) + same org.
|
|
owner = group.get("owner_user_id")
|
|
if actor.get("role") != "super_admin":
|
|
if owner and owner != actor["id"]:
|
|
raise ApiError("permission denied", 403)
|
|
if group.get("org_id") != actor.get("org_id"):
|
|
raise ApiError("permission denied", 403)
|
|
return group
|
|
|
|
|
|
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
|
|
@require_auth
|
|
@require_roles("user")
|
|
def start_session(gid: str, pid: str):
|
|
s = _stores()
|
|
group = _get_ready_group(s, gid)
|
|
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) if group else None
|
|
if not group or not persona:
|
|
raise ApiError("session context missing", 404)
|
|
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})
|