# app/policy.py from __future__ import annotations import json, os, re from typing import Dict, Any, List, Tuple # ---------- Loading & configuration ---------- def _root_dir() -> str: here = os.path.dirname(os.path.abspath(__file__)) return os.path.dirname(here) def _load_keywords_from_file() -> Dict[str, Any]: """Optional: data/policy_keywords.json -> {"cafe_keywords":[...], "smalltalk_regex": "..."}""" path = os.path.join(_root_dir(), "data", "policy_keywords.json") if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception: return {} def _env_list(var: str) -> List[str]: raw = (os.getenv(var) or "").strip() if not raw: return [] return [x.strip() for x in raw.split(",") if x.strip()] def _compile_regex(patterns: List[str]) -> re.Pattern: if not patterns: return re.compile(r"$^") # match nothing return re.compile(r"|".join([re.escape(k) for k in patterns]), re.I) # Defaults (used only if not overridden by env/file) _DEFAULT_CAFE_KEYWORDS = [ "menu","order","item","dish","pizza","burger","salad","pasta","vegan","gluten", "price","special","deal","offer","hours","open","close","time","location","address", "book","reserve","reservation","table","party","pickup","delivery","takeout","payment", "futurecafe","future cafe","future-cafe","café","coffee","drinks","beverage","side" ] _DEFAULT_SMALLTALK_RE = r"\b(hi|hello|hey|good\s+(morning|afternoon|evening)|thanks|thank you|bye|goodbye)\b" # Merge precedence: ENV > file > defaults _file_conf = _load_keywords_from_file() _CAFE_KEYWORDS = _env_list("CAFE_KEYWORDS") or _file_conf.get("cafe_keywords") or _DEFAULT_CAFE_KEYWORDS _SMALLTALK_RE_STR = os.getenv("SMALLTALK_REGEX") or _file_conf.get("smalltalk_regex") or _DEFAULT_SMALLTALK_RE _kw_re = _compile_regex(_CAFE_KEYWORDS) _smalltalk_re = re.compile(_SMALLTALK_RE_STR, re.I) # ---------- Limits & messages ---------- def unrelated_limit() -> int: """How many off-topic turns allowed before ending (clamped 1..5).""" try: n = int(os.getenv("CAFE_UNRELATED_LIMIT", "3")) return max(1, min(5, n)) except Exception: return 3 POLITE_REFUSAL_1 = ( "I'm here to help with FutureCafe—menu, hours, reservations, and orders. " "Could you ask something about the restaurant?" ) POLITE_REFUSAL_2 = ( "To keep things focused, I can only help with FutureCafe. " "Ask me about our menu, hours, or booking a table." ) END_MESSAGE = ( "I'm only able to help with FutureCafe topics. Let's end this chat for now. " "If you need menu, hours, or reservations, message me again anytime." ) def refusal_message(count: int) -> str: return POLITE_REFUSAL_1 if count <= 1 else POLITE_REFUSAL_2 # ---------- Public utilities ---------- def is_cafe_topic(text: str) -> bool: return bool(text and _kw_re.search(text)) def is_smalltalk(text: str) -> bool: return bool(text and _smalltalk_re.search(text)) def enforce_policy( user_text: str, guard_state: Dict[str, Any] | None ) -> Tuple[bool, str | None, Dict[str, Any], Dict[str, Any]]: """ Lightweight topic gate. Returns: allowed: bool -> if True, send to LLM; if False, use reply_if_block reply_if_block: Optional[str] new_guard: dict -> persist across turns (store in State) diag: dict -> tiny diagnostics blob for the UI guard_state schema: {"unrelated": int, "ended": bool} """ text = (user_text or "").strip() guard = dict(guard_state or {"unrelated": 0, "ended": False}) diag: Dict[str, Any] = {"limit": unrelated_limit()} if guard.get("ended"): diag["policy"] = "ended" return False, END_MESSAGE, guard, diag # Allow smalltalk to go through (LLM can handle niceties) if is_smalltalk(text) or is_cafe_topic(text): diag["policy"] = "ok" return True, None, guard, diag # Off-topic guard["unrelated"] = int(guard.get("unrelated", 0)) + 1 diag["unrelated"] = guard["unrelated"] if guard["unrelated"] >= unrelated_limit(): guard["ended"] = True diag["policy"] = "ended" return False, END_MESSAGE, guard, diag diag["policy"] = "nudge" return False, refusal_message(guard["unrelated"]), guard, diag # ---------- Introspection helpers (nice for your Insights pane) ---------- def policy_snapshot() -> Dict[str, Any]: """Expose the active config so you can show it in the Insights JSON.""" return { "cafe_keywords": _CAFE_KEYWORDS, "smalltalk_regex": _SMALLTALK_RE_STR, "unrelated_limit": unrelated_limit(), }