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.
920 lines
37 KiB
Python
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})
|