Files
sales-trainer/backend/app/api/chat_routes.py

667 lines
27 KiB
Python

"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
from __future__ import annotations
import functools
from flask import Blueprint, jsonify, request
from ..llm import LLMError
from ..services.simulator import Simulator
from .helpers import (
ApiError,
current_user,
internal_error,
request_json_object,
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 _session_mutation(fn):
"""Serialize chat read/LLM/write transactions per session."""
@functools.wraps(fn)
def wrapped(gid: str, pid: str, *args, **kwargs):
s = _stores()
actor = current_user()
mode = "preview" if actor.get("role") in ("admin", "super_admin") else "trainee"
session = s["sessions"].active_for_scope(
org_id=actor.get("org_id"), user_id=actor["id"],
group_id=gid, persona_id=pid, mode=mode,
)
if session:
with s["sessions"].mutation_lock(session["id"]):
return fn(gid, pid, *args, **kwargs)
return fn(gid, pid, *args, **kwargs)
return wrapped
def _sim(group, persona):
llm = _stores()["llm"]
if not llm:
raise ApiError("LLM not configured", 500)
return Simulator(llm)
def _scenarios(locale: str = "th"):
"""Scenario presets, localized. Returns {id: {label, init, adapt}}."""
t = locale != "en"
return {
"social": {
"label": "Social Media" if not t else "Social Media (แชท)",
"init": "customer",
"preamble": "💬 Social messaging — the customer messaged you first." if not t else "💬 ช่องทางข้อความโซเชียล — ลูกค้าทักมาหาคุณก่อน (โทนสั้น ทักๆ)",
"adapt": (
"Chat style: short, casual, quick social-messaging replies. The customer opened."
if not t
else "ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชทโซเชียล"
),
},
"f2f_call": {
"label": "Face-to-face / Phone" if not t else "พบหน้า / โทรศัพท์",
"init": "seller",
"preamble": "📞 Face-to-face / phone — you must proactively open with this lead." if not t else "📞 สถานการณ์ พบหน้าหรือโทรศัพท์ — คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด",
"adapt": (
"Natural, conversational like a live face-to-face or phone sales talk. The seller opens."
if not t
else "คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน) โทนเหมือนคุยสด"
),
},
}
def _scenario_config(scenario: str, persona: dict, locale: str = "th"):
cfg = _scenarios(locale).get(scenario, _scenarios(locale)["social"])
# Initiation belongs to the persona. Scenario changes channel/tone, but a
# customer-first persona must not silently become seller-first because the
# trainee selected the phone/f2f preset.
persona_mode = persona.get("initiation_mode")
init_mode = persona_mode if persona_mode in ("customer", "seller") else cfg["init"]
return cfg, init_mode
def _bounded_text(value: object, limit: int = 2000) -> str:
return value.strip()[:limit] if isinstance(value, str) else ""
def _string_list(value: object, *, max_items: int = 20, item_limit: int = 1000) -> list[str]:
if not isinstance(value, list):
return []
return [item.strip()[:item_limit] for item in value[:max_items] if isinstance(item, str) and item.strip()]
def _safe_judge_debrief(verdict: object, outcome: str, persona: dict) -> dict:
"""Keep provider-controlled judge output inside a closed response envelope."""
raw = verdict if isinstance(verdict, dict) else {}
try:
score = max(0, min(100, int(raw.get("score", 0))))
except (TypeError, ValueError):
score = 0
progress = {}
raw_progress = raw.get("painProgress")
if isinstance(raw_progress, dict):
for key, value in list(raw_progress.items())[:20]:
if not isinstance(key, str):
continue
try:
progress[key[:100]] = max(0, min(100, int(value)))
except (TypeError, ValueError):
continue
return {
"outcome": outcome,
"score": score,
"pain": _bounded_text(raw.get("pain")),
"why": _bounded_text(raw.get("why")),
"failurePoints": _string_list(raw.get("failurePoints")),
"coaching": _string_list(raw.get("coaching")),
"painProgress": progress,
"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", ""),
},
}
def _safe_session_messages(value: object) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
allowed_roles = {"system", "seller", "customer", "assistant"}
messages = []
for item in value[-100:]:
if not isinstance(item, dict):
continue
role = item.get("role")
text = item.get("text")
if isinstance(role, str) and role in allowed_roles and isinstance(text, str):
messages.append({"role": role, "text": text[:4000]})
return messages
def serialize_session(session: dict) -> dict:
"""Return only the trainee-visible session envelope; omit hidden judge state."""
meta = session.get("persona_meta")
meta = meta if isinstance(meta, dict) else {}
safe_meta = {
key: meta[key]
for key in ("tier", "initiation_mode", "channel", "scenario", "locale")
if key in meta and isinstance(meta[key], (str, int, float, type(None)))
}
outcome = session.get("outcome")
if outcome not in (None, "won", "lost", "abandoned"):
outcome = None
raw_debrief = session.get("debrief")
if isinstance(raw_debrief, dict):
revealed = raw_debrief.get("revealed_persona")
revealed = revealed if isinstance(revealed, dict) else {}
debrief = _safe_judge_debrief(raw_debrief, outcome or "lost", revealed)
else:
debrief = None
mode = session.get("mode") or "trainee"
if mode not in ("trainee", "preview"):
mode = "trainee"
return {
key: session.get(key)
for key in ("id", "group_id", "persona_id", "persona_name", "created_at", "updated_at")
if isinstance(session.get(key), str)
} | {
"persona_meta": safe_meta,
"mode": mode,
"status": session.get("status") if session.get("status") in ("active", "finished") else "active",
"outcome": outcome,
"messages": _safe_session_messages(session.get("messages")),
"debrief": debrief,
}
def _finalize_session(s, session: dict, group: dict, persona: dict) -> tuple[dict, dict]:
"""Run the single final-judge path for automatic and manual completion."""
if session.get("status") == "finished":
existing = session.get("debrief")
existing_outcome = session.get("outcome")
if existing_outcome not in ("won", "lost"):
existing_outcome = "lost"
debrief = _safe_judge_debrief(
existing if isinstance(existing, dict) else {},
existing_outcome,
persona,
)
return session, debrief
messages = list(session.get("messages", []))
internal = session.get("internal", {})
internal = dict(internal) if isinstance(internal, dict) else {}
# Persist the latest transcript before the provider call. A failed judge
# therefore leaves an active, retryable session rather than losing turns.
s["sessions"].update(session["id"], messages=messages, internal=internal)
sim = _sim(group, persona)
verdict = sim.judge(persona=persona, messages=messages, internal=internal)
outcome = "won" if isinstance(verdict, dict) and verdict.get("outcome") == "won" else "lost"
debrief = _safe_judge_debrief(verdict, outcome, persona)
updated = s["sessions"].update(
session["id"],
status="finished",
outcome=outcome,
messages=messages,
internal=internal,
debrief=debrief,
)
return updated, debrief
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 isinstance(group, dict) or group.get("id") != gid 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_user_id" in group:
if not isinstance(owner, str) or not owner or owner != actor["id"]:
raise ApiError("permission denied", 403)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
return group
def _authorize_session_context(
s,
session: object,
*,
gid: str | None = None,
pid: str | None = None,
required_status: str | None = None,
) -> tuple[dict, dict]:
"""Re-check the group context before exposing or using a session.
Session rows are scoped by actor identifiers, but persisted rows can outlive
group ownership changes or deletion. Never trust those identifiers alone:
the referenced group must still be ready, tenant-authorized, and contain the
referenced persona. Mismatches intentionally look like a missing session.
"""
if not isinstance(session, dict):
raise ApiError("session not found", 404)
actor = current_user()
actor_id = actor.get("id")
org_id = actor.get("org_id")
expected_mode = "preview" if actor.get("role") in ("admin", "super_admin") else "trainee"
session_mode = session.get("mode")
if session_mode is None:
session_mode = "trainee" # legacy rows before explicit mode was added
session_status = session.get("status")
session_gid = session.get("group_id")
session_pid = session.get("persona_id")
if (
not isinstance(actor_id, str)
or not actor_id
or not isinstance(org_id, str)
or not org_id
or session.get("user_id") != actor_id
or session.get("org_id") != org_id
or session_mode != expected_mode
or not isinstance(session_gid, str)
or not session_gid
or not isinstance(session_pid, str)
or not session_pid
or (gid is not None and session_gid != gid)
or (pid is not None and session_pid != pid)
or session_status not in ("active", "finished")
or (required_status is not None and session_status != required_status)
):
raise ApiError("session not found", 404)
try:
group = _get_ready_group(s, session_gid)
except ApiError as exc:
if exc.status in (403, 404):
raise ApiError("session not found", 404) from exc
raise
persona = s["groups"].get_persona(session_gid, session_pid)
if not isinstance(persona, dict):
raise ApiError("session context missing", 404)
return group, persona
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
@require_auth
@require_roles("user", "admin")
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()
org_id = actor.get("org_id")
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
# Scenario chosen by the trainee at chat start (not baked into the persona).
body = request_json_object(allow_empty=True)
requested_mode = body.get("mode", "trainee")
if requested_mode not in ("trainee", "preview"):
raise ApiError("session mode is invalid", 400)
if requested_mode == "preview" and actor.get("role") not in ("admin", "super_admin"):
raise ApiError("permission denied", 403)
if actor.get("role") in ("admin", "super_admin") and requested_mode == "trainee":
requested_mode = "preview"
scenario_raw = body.get("scenario", "social")
scenario = scenario_raw.strip().lower() if isinstance(scenario_raw, str) else "social"
if scenario not in ("social", "f2f_call"):
scenario = "social"
locale_raw = body.get("locale", "th")
locale = locale_raw.strip().lower() if isinstance(locale_raw, str) else "th"
if locale not in ("en", "th"):
locale = "th"
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
# Build the complete initial record before publishing it. A concurrent send
# must never observe an active session with an empty/partially seeded transcript.
seeded = []
if scenario_meta.get("preamble"):
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
if init_mode == "customer":
opener = persona.get("opener") or "Hi, I saw your product and had a question."
seeded.append({"role": "customer", "text": opener})
initial_internal = {"turns": 0, "score": 50, "signals": []}
# RESUME/create is one atomic operation over the complete tenant scope. A
# second worker gets the same active record instead of creating a duplicate.
resumed_payload = None
try:
# Group deletion holds the same lock while cascading sessions. Recheck
# the ready group and persona inside that lock immediately before
# publishing the session, so deletion cannot leave a new orphan.
with s["groups"].record_lock(gid):
group = _get_ready_group(s, gid)
persona = s["groups"].get_persona(gid, pid)
if not persona:
raise ApiError("persona not found", 404)
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
seeded = []
if scenario_meta.get("preamble"):
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
if init_mode == "customer":
opener = persona.get("opener") or "Hi, I saw your product and had a question."
seeded.append({"role": "customer", "text": opener})
session, resumed = s["sessions"].start(
org_id=org_id,
user_id=actor["id"],
group_id=gid,
persona_id=pid,
persona_name=persona.get("name", "?"),
mode=requested_mode,
persona_meta={
"tier": persona.get("tier"),
"initiation_mode": init_mode,
"channel": persona.get("channel"),
"scenario": scenario,
"locale": locale,
},
messages=seeded,
internal=initial_internal,
scenario=scenario,
locale=locale,
)
if resumed:
_authorize_session_context(
s, session, gid=gid, pid=pid, required_status="active"
)
existing_scenario = session.get("scenario") or (
session.get("persona_meta") or {}
).get("scenario") or "social"
existing_locale = session.get("locale") or (
session.get("persona_meta") or {}
).get("locale") or "th"
existing_meta, existing_init = _scenario_config(
existing_scenario, persona, existing_locale
)
resumed_payload = {
"session": serialize_session(session),
"initiation_mode": existing_init,
"scenario": existing_scenario,
"scenario_meta": existing_meta,
}
except ValueError as exc:
raise internal_error("could not start session", exc, 400)
if resumed:
return jsonify(resumed_payload)
sess = s["sessions"].get(session["id"])
return jsonify({
"session": serialize_session(sess),
"initiation_mode": init_mode,
"scenario": scenario,
"scenario_meta": scenario_meta,
})
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
@require_auth
@require_roles("user", "admin")
@_session_mutation
def send_message(gid: str, pid: str):
s = _stores()
actor = current_user()
org_id = actor.get("org_id")
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
session = s["sessions"].active_for_scope(
org_id=org_id,
user_id=actor["id"],
group_id=gid,
persona_id=pid,
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
)
if not session:
raise ApiError("no active session for this persona", 404)
group, persona = _authorize_session_context(
s, session, gid=gid, pid=pid, required_status="active"
)
data = request_json_object()
text_raw = data.get("text")
if not isinstance(text_raw, str):
raise ApiError("message is invalid", 400)
text = text_raw.strip()
if not text:
raise ApiError("message is empty")
if len(text) > 2000:
raise ApiError("message too long")
# Protect LLM cost: per-user chat-send window.
from ..services.rate_limit import check as ratelimit
actor_rl = current_user()
if not ratelimit("chat:user", actor_rl.get("id") or actor_rl.get("username") or "?", limit=30, window=60):
raise ApiError("slow down — too many messages", 429)
messages = list(session.get("messages", []))
messages.append({"role": "seller", "text": text})
scenario = session.get("scenario", "social") or "social"
slocale = session.get("locale", "th") or "th"
adapt = _scenarios(slocale).get(scenario, _scenarios(slocale)["social"]).get("adapt", "")
sim = _sim(group, persona)
try:
reply, meta = sim.persona_reply(
persona=persona,
sales_kit=group.get("sales_kit") or {},
messages=messages,
internal=session.get("internal", {}),
scenario=scenario,
scenario_adapt=adapt,
)
except LLMError as exc:
raise internal_error("LLM service unavailable", exc)
messages.append({"role": "customer", "text": reply})
# Update internal state: track misses (poor answers) and mood trend.
internal = session.get("internal", {}) or {}
internal.setdefault("turns", 0)
internal["turns"] = internal.get("turns", 0) + 1
internal["signals"] = internal.get("signals", [])
# Re-contact persona behavior: after enough info is exchanged (turn 2), the customer
# goes quiet, a time-lapse system note is shown, and the customer re-engages warmer.
if persona.get("recontact") and not internal.get("recontact_done") and internal["turns"] >= 2:
unit = "สัปดาห์" if slocale != "en" else "weeks"
sys_txt = (
f"⏳ ผ่านไป 2-3 {unit} ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)"
if slocale != "en"
else "⏳ 2-3 weeks later ... the customer who asked earlier went quiet; now they re-contact, more ready to decide."
)
messages.append({"role": "system", "text": sys_txt})
internal["recontact_done"] = True
# Save the time-lapse note immediately so the UI shows it even if send ends here.
s["sessions"].update(session["id"], messages=messages, internal=internal)
# Evaluate this turn via the (judge) LLM: how the persona feels + whether it has decided.
# This is context-based (NOT fixed keywords), so e.g. "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays
# pending until the customer truly commits to (or abandons) the decision.
turn_eval = sim.evaluate_turn(
persona=persona, messages=messages, internal=internal
)
decision = turn_eval.get("decision", "pending")
try:
mood = int(turn_eval.get("mood", 0))
except (TypeError, ValueError):
mood = 0
# Apply the judge's score delta to internal score trend.
try:
sd = int(turn_eval.get("score_delta", 0))
except (TypeError, ValueError):
sd = 0
internal["score"] = max(0, min(100, int(internal.get("score", 50)) + sd))
internal["last_reason"] = turn_eval.get("reason", "")
# Track mood trend for debrief.
if mood <= -1:
internal["misses"] = internal.get("misses", 0) + 1
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "annoy"})
elif mood >= 1:
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "warm"})
if decision in ("buy", "walk", "try"):
if decision == "try":
# The customer decided to trial/test the product first and will come
# back later. Surface that as a system note, then close + summarize
# (the decision point is reached; the final judge scores the close).
try_note = (
"⏳ ลูกค้าตัดสินใจจะลองใช้สินค้า/บริการก่อน แล้วจะกลับมาติดต่ออีกครั้งเมื่อลองใช้แล้ว — "
"ถือเป็นการสรุปการตัดสินใจจุดนี้ และปิด session อัตโนมัติ"
if slocale != "en"
else "⏳ The customer decided to trial the product/service first and will come back "
"once they have tried it — this decision point is closed and the session summarizes "
"automatically."
)
messages.append({"role": "system", "text": try_note})
finalized, debrief = _finalize_session(
s,
{**session, "messages": messages, "internal": internal},
group,
persona,
)
outcome = finalized.get("outcome")
return jsonify({
"reply": reply,
"messages": messages,
"finished": True,
"outcome": outcome,
"debrief": debrief,
"session": serialize_session(finalized),
})
s["sessions"].update(session["id"], messages=messages, internal=internal)
return jsonify({"reply": reply, "messages": messages})
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
@require_auth
@require_roles("user", "admin")
@_session_mutation
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()
org_id = actor.get("org_id")
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
session = s["sessions"].active_for_scope(
org_id=org_id,
user_id=actor["id"],
group_id=gid,
persona_id=pid,
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
)
if not session:
finished = s["sessions"].latest_for_scope(
org_id=org_id,
user_id=actor["id"],
group_id=gid,
persona_id=pid,
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
)
if finished and finished.get("status") == "finished":
_group, persona = _authorize_session_context(
s, finished, gid=gid, pid=pid, required_status="finished"
)
finished_outcome = finished.get("outcome")
if finished_outcome not in ("won", "lost"):
finished_outcome = "lost"
return jsonify({
"session": serialize_session(finished),
"debrief": _safe_judge_debrief(
finished.get("debrief") or {},
finished_outcome,
persona,
),
})
raise ApiError("no active session for this persona", 404)
group, persona = _authorize_session_context(
s, session, gid=gid, pid=pid, required_status="active"
)
try:
finalized, debrief = _finalize_session(s, session, group, persona)
except LLMError as exc:
raise internal_error("LLM service unavailable", exc)
return jsonify({"session": serialize_session(finalized), "debrief": debrief})
@chat_bp.get("/sessions")
@require_auth
def my_sessions():
s = _stores()
actor = current_user()
org_id = actor.get("org_id")
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
sessions = s["sessions"].list_for_user(actor["id"], org_id=org_id)
visible_sessions = []
for session in sessions:
try:
_authorize_session_context(s, session)
except ApiError:
continue
visible_sessions.append(serialize_session(session))
return jsonify({"sessions": visible_sessions})
@chat_bp.get("/sessions/<sid>")
@require_auth
@require_roles("user", "admin")
def get_session(sid: str):
s = _stores()
session = s["sessions"].get_or_none(sid)
actor = current_user()
if (
not isinstance(session, dict)
or session.get("user_id") != actor["id"]
or session.get("org_id") != actor.get("org_id")
):
raise ApiError("session not found", 404)
_authorize_session_context(s, session)
return jsonify({"session": serialize_session(session)})
@chat_bp.get("/<gid>/personas/<pid>/chat/resume")
@require_auth
@require_roles("user", "admin")
def resume_session(gid: str, pid: str):
"""Resume an active (unfinished) session for this persona so the trainee can continue."""
s = _stores()
actor = current_user()
org_id = actor.get("org_id")
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
session = s["sessions"].active_for_scope(
org_id=org_id,
user_id=actor["id"],
group_id=gid,
persona_id=pid,
mode="preview" if actor.get("role") in ("admin", "super_admin") else "trainee",
)
if not session:
raise ApiError("no active session for this persona", 404)
_authorize_session_context(
s, session, gid=gid, pid=pid, required_status="active"
)
return jsonify({"session": serialize_session(session), "scenario": session.get("scenario", "social")})