"""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 _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 "คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน) โทนเหมือนคุยสด" ), }, "recontact": { "label": "Re-contact (1-3 months later)" if not t else "ลูกค้ากลับมาติดต่อ (1-3 เดือน)", "init": "customer", "preamble": "⏳ 1-3 months passed... this customer previously got your info, now re-contacts, more ready to decide." if not t else "⏳ 1-3 เดือนผ่านไป... ลูกค้าคนนี้เคยได้รับข้อมูลสินค้าไปแล้ว ตอนนี้กลับมาติดต่อคุณอีกครั้ง (พร้อมตัดสินใจมากขึ้น)", "adapt": ( "You researched this seller 1-3 months ago and now re-contact, more ready to decide." if not t else "เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น" ), }, } def _scenario_config(scenario: str, persona: dict, locale: str = "th"): cfg = _scenarios(locale).get(scenario, _scenarios(locale)["social"]) return cfg, cfg["init"] def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict, locale: str = "th") -> dict: """Build a lightweight debrief from the persona's own decision + per-turn signals.""" signals = internal.get("signals", []) en = locale == "en" turning_points = [] for sig in signals: if sig.get("type") in ("annoy", "warm"): turn = sig.get("turn", "?") if sig.get("mood", 0) > 0: turning_points.append( f"Turn {turn}: customer warmed up" if en else f"รอบที่ {turn}: ลูกค้าเริ่มใจขึ้น" ) else: turning_points.append( f"Turn {turn}: customer annoyed/hesitant" if en else f"รอบที่ {turn}: ลูกค้าเริ่มหงุดหงิด/ลังเล" ) why = ( ("Customer decided to buy (pain resolved + offer accepted)" if en else "ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)") if outcome == "won" else ( "Customer decided not to buy — value not enough, or responses missed the need" if en else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ" ) ) coaching = ( ["Great job — the customer closed with you"] if en and outcome == "won" else ["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"] if outcome == "won" else ( turning_points + ["Ask deeper about the need", "Handle objections more directly"] if en else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"] ) ) return { "outcome": outcome, "score": 60 if outcome == "won" else 25, "pain": (persona.get("pains") or [{}])[0].get("description", "") if persona.get("pains") else "", "why": why, "failurePoints": [] if outcome == "won" else ( ["Failed to close the sale", "Customer walked away before deciding"] if en else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"] ), "coaching": coaching, "turning_points": turning_points, "signals": signals, "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", ""), "tolerance": persona.get("tolerance", 3), }, } 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("//personas//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() # Scenario chosen by the trainee at chat start (not baked into the persona). body = request.get_json(silent=True) or {} scenario = (body.get("scenario") or "social").strip().lower() if scenario not in ("social", "f2f_call", "recontact"): scenario = "social" locale = (body.get("locale") or "th").strip().lower() if locale not in ("en", "th"): locale = "th" scenario_meta, init_mode = _scenario_config(scenario, persona, locale) # 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": init_mode, "channel": persona.get("channel"), "scenario": scenario, "locale": locale, }, ) except ValueError as exc: raise ApiError(str(exc), 400) s["sessions"].update( session["id"], scenario=scenario, locale=locale, internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []}, ) sim = _sim(group, persona) # Seed messages: localized scenario preamble, then customer opener for customer-first scenarios. seeded = list(session.get("messages", [])) 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}) s["sessions"].update(session["id"], messages=seeded) sess = s["sessions"].get(session["id"]) return jsonify({ "session": sess, "initiation_mode": init_mode, "scenario": scenario, "scenario_meta": scenario_meta, }) @chat_bp.post("//personas//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") # 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) 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}) 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 ApiError(f"LLM error: {exc}", 500) 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", []) try: mood = int(meta.get("mood", 0)) except (TypeError, ValueError): mood = 0 # A clearly annoyed customer is a "miss" against the seller. 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"}) # Decision by the persona ends the session (one-shot lock). decision = meta.get("decision") if decision in ("buy", "walk"): outcome = "won" if decision == "buy" else "lost" debrief = _build_abbrev_debrief(outcome, persona, internal, slocale) s["sessions"].update( session["id"], status="finished", outcome=outcome, messages=messages, internal=internal, debrief=debrief, ) return jsonify({ "reply": reply, "messages": messages, "finished": True, "outcome": outcome, "debrief": debrief, "session": s["sessions"].get(session["id"]), }) 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") 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, internal=session.get("internal", {}) ) 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 def my_sessions(): s = _stores() uid = current_user()["id"] sessions = s["sessions"].list_for_user(uid) return jsonify({"sessions": sessions}) @chat_bp.get("/sessions/") @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}) @chat_bp.get("//personas//chat/resume") @require_auth @require_roles("user") 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() 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) return jsonify({"session": session, "scenario": session.get("scenario", "social")})