- start_session now RESUMES an existing ACTIVE session for the persona instead of creating a new one / forcing re-pick of the scenario (keep original scenario+messages). - send_message falls back to a text-based decision detector (_detect_customer_decision) because real LLMs rarely emit structured meta.decision — so a customer who says 'ซื้อไม่ไหว'/'no thanks' now actually ENDS the chat as lost (was stuck active forever). Fragments handled in TH + EN; buy + walk. All 11 backend suites pass. Rebuilt dist.
426 lines
19 KiB
Python
426 lines
19 KiB
Python
"""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("/<gid>/personas/<pid>/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("/<gid>/personas/<pid>/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("/<gid>/personas/<pid>/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/<sid>")
|
|
@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("/<gid>/personas/<pid>/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")})
|