"""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("//personas//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("//personas//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("//personas//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/") @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("//personas//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")})