667 lines
27 KiB
Python
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")})
|