File size: 4,774 Bytes
74bb5fe
 
ac1f51b
 
74bb5fe
ac1f51b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74bb5fe
 
 
 
 
ac1f51b
74bb5fe
ac1f51b
 
 
 
74bb5fe
ac1f51b
 
74bb5fe
ac1f51b
74bb5fe
 
ac1f51b
74bb5fe
 
 
 
 
 
ac1f51b
 
74bb5fe
 
 
 
 
 
 
 
ac1f51b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# 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(),
    }