Spaces:
Sleeping
Sleeping
| # 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(), | |
| } |