Files
sales-trainer/backend/app/api/chat_routes.py
Macky 6a2e6a326f fix: restore legacy training data hidden by new visibility schema (migrate-on-read)
Records written before the visibility field existed carry none; the new
fail-closed authorization treated missing visibility as invalid, making every
legacy group unlistable and unreadable. Add resolved_visibility(group) that
derives effective visibility for legacy records only (owner present => private,
absent => public), leaves explicit-malformed visibility fail-closed (None), and
never derives demo/hidden. Apply it at every list, authorization, chat, and
analytics boundary while keeping demo and hidden-preview paths raw and
owner_user_id-based private isolation intact. No persisted data is rewritten.

Backend full suite passes 517; frontend 26/26; production build passes.
2026-08-25 08:59:22 +07:00

920 lines
37 KiB
Python

"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
from __future__ import annotations
from collections.abc import Collection
import functools
import math
from flask import Blueprint, jsonify, request
from ..config import Config
from ..llm import LLMError
from ..services.groups import (
group_visibility,
is_canonical_private_owner,
is_ready_group,
is_valid_owner_visibility,
resolved_visibility,
)
from ..services.simulator import Simulator, _safe_roleplay_internal
from ..services.store import PERSONA_CHANNELS
from ..storage.store import StoreNotFoundError
from .helpers import (
ApiError,
current_user,
internal_error,
is_valid_tenant_id,
request_json_object,
require_auth,
require_roles,
)
chat_bp = Blueprint("chat", __name__)
# Only high-level fields already visible before/while training may appear in a
# debrief. Latent persona context and the sales formula stay server-side.
REVEALED_PERSONA_FIELDS = (
"name",
"tier",
"initiation_mode",
"channel",
"profession",
"age_group",
"location",
"product_context",
)
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()
session = _active_session_for_actor(s, actor, gid=gid, pid=pid)
if session:
# Group deletion takes the group lock before cascading session locks.
# Keep the same order here so revocation cannot deadlock with send/finish.
with s["groups"].record_lock(gid):
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 _public_scenario_meta(value: object) -> dict[str, str]:
"""Return only scenario labels safe for the client surface."""
if not isinstance(value, dict):
return {}
return {
field: value[field].strip()[:200]
for field in ("label", "init")
if isinstance(value.get(field), str) and value[field].strip()
}
def _safe_scenario(value: object) -> str:
return value if isinstance(value, str) and value in {"social", "f2f_call"} else "social"
def _safe_locale(value: object) -> str:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"en", "th"}:
return normalized
return "th"
def _safe_enum(value: object, allowed: Collection[str]) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized if normalized in allowed else None
def _safe_persona_meta(value: object) -> dict[str, str]:
raw = value if isinstance(value, dict) else {}
safe: dict[str, str] = {}
for field, allowed in (
("tier", {"A", "B", "C"}),
("initiation_mode", {"customer", "seller"}),
("channel", set(PERSONA_CHANNELS)),
):
normalized = _safe_enum(raw.get(field), allowed)
if normalized is not None:
safe[field] = normalized
safe["scenario"] = _safe_scenario(raw.get("scenario"))
safe["locale"] = _safe_locale(raw.get("locale"))
return safe
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 _bounded_int(
value: object,
*,
default: int,
minimum: int,
maximum: int,
) -> int:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
return default
if isinstance(value, float) and not math.isfinite(value):
return default
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return default
return max(minimum, min(maximum, parsed))
def _safe_revealed_persona(persona: dict) -> dict:
"""Return only bounded scalar values from the public persona surface."""
safe = {}
for field in REVEALED_PERSONA_FIELDS:
if field not in persona:
continue
value = persona[field]
if isinstance(value, str) and value.strip():
safe[field] = value.strip()[:500]
return safe
def _safe_judge_debrief(
verdict: object,
outcome: str,
persona: dict,
public_debrief: object | None = None,
) -> dict:
"""Return score plus public coaching, never hidden-judge prose.
The final judge sees latent persona data, so its narrative fields are
untrusted even when their keys are allowlisted. Public prose must come from
the separate transcript-only coaching call (or an explicitly marked safe
persisted record), never from the hidden judge response.
"""
raw = verdict if isinstance(verdict, dict) else {}
if public_debrief is None:
public_debrief = raw.get("_public_debrief")
public = public_debrief if isinstance(public_debrief, dict) else {}
score = _bounded_int(raw.get("score", 0), default=0, minimum=0, maximum=100)
return {
"outcome": outcome,
"score": score,
# IP protection: do NOT surface the customer's pain (prose or raw list) to any
# user-facing role. Pain drives the judge/training internally but is the core
# "formula" of the coaching product, so it must not leak through the debrief.
"why": _bounded_text(public.get("why")),
"failurePoints": _string_list(public.get("failurePoints")),
"coaching": _string_list(public.get("coaching")),
"revealed_persona": _safe_revealed_persona(persona),
}
def _public_debrief(sim: object, messages: list[dict], outcome: str, score: int) -> dict:
"""Ask for coaching using seller-only transcript data; fail closed."""
generate = getattr(sim, "public_debrief", None)
if not callable(generate):
return {}
safe_messages = [
message
for message in _safe_session_messages(messages)
if message.get("role") == "seller"
]
try:
result = generate(messages=safe_messages, outcome=outcome, score=score)
except LLMError:
return {}
return result if isinstance(result, dict) else {}
def _public_customer_opener(locale: object) -> str:
return (
"Hi, a customer has started the conversation."
if locale == "en"
else "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ"
)
_SAFE_SYSTEM_MESSAGES = frozenset({
"⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)",
"⏳ 2-3 weeks later ... the customer who asked earlier went quiet; now they re-contact, more ready to decide.",
"⏳ ลูกค้าตัดสินใจจะลองใช้สินค้า/บริการก่อน แล้วจะกลับมาติดต่ออีกครั้งเมื่อลองใช้แล้ว — ถือเป็นการสรุปการตัดสินใจจุดนี้ และปิด session อัตโนมัติ",
"⏳ 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.",
})
def _safe_session_messages(
value: object,
*,
redact_initial_customer: bool = False,
locale: object = "th",
) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
messages = []
seller_seen = False
for item in value[-100:]:
if not isinstance(item, dict):
continue
role = item.get("role")
text = item.get("text")
if not isinstance(text, str):
continue
if role == "system" and text not in _SAFE_SYSTEM_MESSAGES:
continue
if role in {"system", "seller", "customer"}:
if role == "customer" and redact_initial_customer and not seller_seen:
text = _public_customer_opener(locale)
if role == "seller":
seller_seen = True
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."""
safe_meta = _safe_persona_meta(session.get("persona_meta"))
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
raw_mode = session.get("mode")
if raw_mode is None:
mode = "trainee"
elif raw_mode in ("trainee", "preview"):
mode = raw_mode
else:
mode = None
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,
"scenario": _safe_scenario(session.get("scenario")),
"mode": mode,
"status": session.get("status") if session.get("status") in ("active", "finished") else "active",
"outcome": outcome,
"messages": _safe_session_messages(
session.get("messages"),
redact_initial_customer=True,
locale=safe_meta["locale"],
),
"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 = _safe_session_messages(session.get("messages"))
internal = _safe_roleplay_internal(session.get("internal"))
# 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)
if not isinstance(verdict, dict):
raise LLMError("judge returned an invalid response")
outcome = "won" if verdict.get("outcome") == "won" else "lost"
score = (
_bounded_int(verdict.get("score", 0), default=0, minimum=0, maximum=100)
if isinstance(verdict, dict)
else 0
)
public_debrief = _public_debrief(sim, messages, outcome, score)
debrief = _safe_judge_debrief(verdict, outcome, persona, public_debrief)
stored_debrief = {**debrief, "_public_debrief": public_debrief}
updated = s["sessions"].update(
session["id"],
status="finished",
outcome=outcome,
messages=messages,
internal=internal,
debrief=stored_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:
raise ApiError("group not found", 404)
actor = current_user()
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
raise ApiError("group not found", 404)
if not is_valid_owner_visibility(group):
raise ApiError("permission denied", 403)
owner = group.get("owner_user_id")
actor_is_owner = is_canonical_private_owner(
group,
user_id=actor.get("id"),
org_id=actor.get("org_id"),
)
if actor.get("role") == "super_admin" and "owner_user_id" in group and not actor_is_owner:
raise ApiError("group not found", 404)
if not is_ready_group(group):
raise ApiError("group not ready", 404)
# super_admin can access any shared group, never owner-private data. Demo
# accounts are restricted to the dedicated
# demo tenant and demo-visible shared groups. Tenant admins retain the
# historical hidden-group preview path, while ordinary users can access
# only public shared groups (or their own private group).
visibility = (
group_visibility(group.get("visibility"))
if actor.get("role") == "demo"
else resolved_visibility(group)
)
if actor.get("role") != "super_admin" and visibility is None:
raise ApiError("permission denied", 403)
if actor.get("role") == "demo":
if (
actor.get("org_id") != Config.DEMO_ORG_ID
or group.get("org_id") != Config.DEMO_ORG_ID
or visibility != "demo"
or "owner_user_id" in group
):
raise ApiError("permission denied", 403)
elif actor.get("role") == "admin":
if (
actor.get("org_id") != group.get("org_id")
or ("owner_user_id" in group and not actor_is_owner)
or (
"owner_user_id" not in group
and visibility not in {"public", "hidden"}
)
):
raise ApiError("permission denied", 403)
elif 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 "owner_user_id" not in group and visibility != "public":
raise ApiError("permission denied", 403)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
return group
def _active_session_for_actor(s, actor: dict, *, gid: str, pid: str) -> dict | None:
"""Return the actor's active session, preferring a real attempt.
Admin preview sessions are a separate scope from trainee attempts. Prefer a
trainee session when both exist, then fall back to the admin-only preview
scope so the hidden-group preview can actually be continued and finished.
"""
org_id = actor.get("org_id")
user_id = actor.get("id")
if not isinstance(org_id, str) or not isinstance(user_id, str):
return None
modes = ("trainee", "preview") if actor.get("role") == "admin" else ("trainee",)
for mode in modes:
session = s["sessions"].active_for_scope(
org_id=org_id,
user_id=user_id,
group_id=gid,
persona_id=pid,
mode=mode,
)
if session:
return session
return None
def _latest_session_for_actor(s, actor: dict, *, gid: str, pid: str) -> dict | None:
"""Return the newest session in the actor's allowed modes."""
org_id = actor.get("org_id")
user_id = actor.get("id")
if not isinstance(org_id, str) or not isinstance(user_id, str):
return None
modes = ("trainee", "preview") if actor.get("role") == "admin" else ("trainee",)
candidates = []
for mode in modes:
session = s["sessions"].latest_for_scope(
org_id=org_id,
user_id=user_id,
group_id=gid,
persona_id=pid,
mode=mode,
)
if session:
candidates.append(session)
return max(candidates, key=lambda row: row.get("updated_at", ""), default=None)
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")
# Both trainee (one-shot) and admin preview sessions are authorized here;
# the group context re-check below still locks out non-owners/foreign orgs.
expected_modes = (
("trainee", "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 not in expected_modes
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:
with s["groups"].record_lock(session_gid):
group = _get_ready_group(s, session_gid)
persona = s["groups"].get_persona(session_gid, session_pid)
except ApiError as exc:
if exc.status in (403, 404):
raise ApiError("session not found", 404) from exc
raise
except StoreNotFoundError as exc:
raise ApiError("session not found", 404) from exc
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", "demo")
def start_session(gid: str, pid: str):
s = _stores()
group = _get_ready_group(s, gid)
persona = s["groups"].get_persona(gid, pid)
if not isinstance(persona, dict):
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 actor.get("role") == "demo" and requested_mode == "preview":
raise ApiError("demo accounts may only use trainee sessions", 403)
# Preview mode exists so an admin can safely try a HIDDEN (draft) shared
# product without consuming a trainee attempt or polluting trainee
# analytics. For any other group/role, requesting 'preview' is coerced to a
# real one-shot trainee session (previous behavior).
group_visibility_value = group_visibility(group.get("visibility"))
if requested_mode == "preview" and not (
actor.get("role") == "admin"
and group_visibility_value == "hidden"
and "owner_user_id" not in group
):
requested_mode = "trainee"
scenario_raw = body.get("scenario", "social")
scenario = _safe_scenario(
scenario_raw.strip().lower() if isinstance(scenario_raw, str) else scenario_raw
)
locale_raw = body.get("locale", "th")
locale = _safe_locale(locale_raw)
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":
seeded.append({"role": "customer", "text": _public_customer_opener(locale)})
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":
seeded.append({"role": "customer", "text": _public_customer_opener(locale)})
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 = _safe_scenario(
session.get("scenario")
or (session.get("persona_meta") or {}).get("scenario")
)
existing_locale = _safe_locale(
session.get("locale")
or (session.get("persona_meta") or {}).get("locale")
)
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": _public_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": _public_scenario_meta(scenario_meta),
})
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
@require_auth
@require_roles("user", "admin", "demo")
@_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 = _active_session_for_actor(s, actor, gid=gid, pid=pid)
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 = _safe_session_messages(session.get("messages"))
messages.append({"role": "seller", "text": text})
scenario = _safe_scenario(session.get("scenario"))
slocale = _safe_locale(session.get("locale"))
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=_safe_roleplay_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 = _safe_roleplay_internal(session.get("internal"))
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.
# Save the reply before the second provider call so an evaluation failure
# leaves an active, retryable session rather than losing the turn.
s["sessions"].update(session["id"], messages=messages, internal=internal)
try:
turn_eval = sim.evaluate_turn(
persona=persona, messages=messages, internal=internal
)
except LLMError as exc:
raise internal_error("LLM service unavailable", exc)
if not isinstance(turn_eval, dict):
raise internal_error("LLM service unavailable", ValueError("invalid turn evaluation"))
decision = turn_eval.get("decision", "pending")
mood = _bounded_int(turn_eval.get("mood", 0), default=0, minimum=-100, maximum=100)
# Apply the judge's score delta to internal score trend.
sd = _bounded_int(turn_eval.get("score_delta", 0), default=0, minimum=-100, maximum=100)
current_score = _bounded_int(internal.get("score", 50), default=50, minimum=0, maximum=100)
internal["score"] = max(0, min(100, current_score + 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})
try:
finalized, debrief = _finalize_session(
s,
{**session, "messages": messages, "internal": internal},
group,
persona,
)
except LLMError as exc:
raise internal_error("LLM service unavailable", exc)
outcome = finalized.get("outcome")
return jsonify({
"reply": reply,
"messages": _safe_session_messages(
messages, redact_initial_customer=True, locale=slocale
),
"finished": True,
"outcome": outcome,
"debrief": debrief,
"session": serialize_session(finalized),
})
s["sessions"].update(session["id"], messages=messages, internal=internal)
return jsonify({
"reply": reply,
"messages": _safe_session_messages(
messages, redact_initial_customer=True, locale=slocale
),
})
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
@require_auth
@require_roles("user", "admin", "demo")
@_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 = _active_session_for_actor(s, actor, gid=gid, pid=pid)
if not session:
finished = _latest_session_for_actor(s, actor, gid=gid, pid=pid)
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", "demo")
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", "demo")
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 = _active_session_for_actor(s, actor, gid=gid, pid=pid)
if not session:
raise ApiError("no active session for this persona", 404)
_authorize_session_context(
s, session, gid=gid, pid=pid, required_status="active"
)
safe_scenario = _safe_scenario(session.get("scenario"))
safe_locale = _safe_locale(session.get("locale"))
if session.get("scenario") != safe_scenario or session.get("locale") != safe_locale:
session = s["sessions"].update(
session["id"], scenario=safe_scenario, locale=safe_locale
)
return jsonify({"session": serialize_session(session), "scenario": safe_scenario})