"""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) WALK_FRAGMENTS = [ "no thanks", "not interested", "forget it", "never mind", "no longer", "out of budget", "too expensive", "can't afford", "cannot afford", "ซื้อไม่ไหว", "ไม่ซื้อ", "ไม่เอาแล้ว", "ไม่สนใจ", "พอแค่นี้", "ไม่ไหว", "แพงไป", "ลืมไปเถอะ", "ตัดสินใจไม่ซื้อ", "ขอไม่ซื้อ", "ยังไม่ซื้อ", "ไว้ก่อน", "ไปก่อนนะ", "ขอบคุณแต่ไม่เอา", "ไม่เอาครับ", "ไม่เอาค่ะ", "ไม่เอา", ] BUY_FRAGMENTS = [ "i'll take it", "i'll go with it", "i'll buy", "deal,", "let's do it", "count me in", "how do i pay", "where do i sign", "ok i'll buy", "ซื้อเลย", "ตกลงซื้อ", "เอาครับ", "เอาค่ะ", "ซื้อครับ", "ซื้อค่ะ", "ตัดสินใจซื้อ", "เอาเลย", "ตกลง", "ซื้อ", "รับไว้", "โอเคค่ะ รับ", "โอเคครับ รับ", ] def _detect_customer_decision(text: str, locale: str = "th") -> str | None: """Detect whether the customer clearly walked away or decided to buy, from their message. Returns 'walk' | 'buy' | None. Used as a fallback because real LLMs rarely emit a structured meta.decision — they just state it in the conversation. """ if not text: return None low = text.lower() # Walk-away signals can appear anywhere; be conservative to avoid false ends on # phrases like "I can't afford that, what's the discount?" (question) vs "too expensive, no." for frag in WALK_FRAGMENTS: if frag in low: return "walk" for frag in BUY_FRAGMENTS: if frag in low: return "buy" return None 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"]) 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"): 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) # RESUME: if this user already has an ACTIVE (unfinished) session for this persona, keep # chatting it — do NOT create a new one and do NOT force re-picking the scenario. existing = s["sessions"].active_for_persona(actor["id"], pid) if existing and existing.get("group_id") == gid: return jsonify({ "session": existing, "initiation_mode": existing.get("persona_meta", {}).get("initiation_mode") or "customer", "scenario": existing.get("scenario", scenario), "scenario_meta": _scenario_config(existing.get("scenario", 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"}) # 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) # Decision by the persona ends the session (one-shot lock). decision = meta.get("decision") # Real LLMs usually DON'T emit a structured meta.decision — they just say it in the # customer's reply. Fall back to a lightweight text detector so "ซื้อไม่ไหว"/"no thanks" # actually ENDS the chat (otherwise it never reports win/loss). if decision not in ("buy", "walk"): decision = _detect_customer_decision(reply, locale=slocale) 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")})