llm-stream-guards 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_stream_guards/__init__.py +38 -0
- llm_stream_guards/_amounts.py +344 -0
- llm_stream_guards/_code.py +114 -0
- llm_stream_guards/_leak.py +92 -0
- llm_stream_guards/_links.py +358 -0
- llm_stream_guards/_stream.py +76 -0
- llm_stream_guards/py.typed +0 -0
- llm_stream_guards-0.1.0.dist-info/METADATA +204 -0
- llm_stream_guards-0.1.0.dist-info/RECORD +11 -0
- llm_stream_guards-0.1.0.dist-info/WHEEL +4 -0
- llm_stream_guards-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Deterministic guards for a streamed LLM reply: what may be shown now, what must wait, and when to stop."""
|
|
2
|
+
|
|
3
|
+
from ._amounts import ENGLISH, AmountStream, Locale, numbers_in, unsupported_amounts
|
|
4
|
+
from ._code import CodeStream, contains_code_deliverable
|
|
5
|
+
from ._leak import LeakGuard, LeakStream
|
|
6
|
+
from ._links import (
|
|
7
|
+
LinkStream,
|
|
8
|
+
emails_in,
|
|
9
|
+
hosts_of,
|
|
10
|
+
phones_in,
|
|
11
|
+
strip_untrusted_links,
|
|
12
|
+
without_ungrounded_emails,
|
|
13
|
+
without_ungrounded_phones,
|
|
14
|
+
)
|
|
15
|
+
from ._stream import Blocked, Chain, StreamGuard
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ENGLISH",
|
|
19
|
+
"AmountStream",
|
|
20
|
+
"Blocked",
|
|
21
|
+
"Chain",
|
|
22
|
+
"CodeStream",
|
|
23
|
+
"LeakGuard",
|
|
24
|
+
"LeakStream",
|
|
25
|
+
"LinkStream",
|
|
26
|
+
"Locale",
|
|
27
|
+
"StreamGuard",
|
|
28
|
+
"contains_code_deliverable",
|
|
29
|
+
"emails_in",
|
|
30
|
+
"hosts_of",
|
|
31
|
+
"numbers_in",
|
|
32
|
+
"phones_in",
|
|
33
|
+
"strip_untrusted_links",
|
|
34
|
+
"unsupported_amounts",
|
|
35
|
+
"without_ungrounded_emails",
|
|
36
|
+
"without_ungrounded_phones",
|
|
37
|
+
]
|
|
38
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Collection, Iterable, Iterator, Mapping
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from ._stream import Blocked, next_anchor
|
|
6
|
+
|
|
7
|
+
# 1,200 or 1200.50 or 50 000 (a space or a no-break space between thousands). A number does not start after a digit, or
|
|
8
|
+
# after a digit and a comma: that is the middle of one, which would only be matched again, and failed again, from every
|
|
9
|
+
# digit of a long run. A comma after a letter ("Basic,29") does not stop one, and an empty field ("12,,45") ends one.
|
|
10
|
+
_NUMBER_TEXT = r"((?<!\d)(?<!\d,)\d{1,3}(?:[^\S\r\n]\d{3})+(?:\.\d+)?|(?<!\d)(?<!\d,)\d+(?:,\d+)*,*(?:\.\d+)?)"
|
|
11
|
+
_DIGITS = r"((?<!\d)(?<!\d,)\d+(?:,\d+)*,*(?:\.\d+)?)"
|
|
12
|
+
# A cost word this many characters before or after a bare magnitude ("60k") or a rate makes it a price.
|
|
13
|
+
_COST_WORD_WINDOW = 120
|
|
14
|
+
# What an empty list of words compiles to, so that it matches nothing rather than everything.
|
|
15
|
+
_NEVER = "(?!)"
|
|
16
|
+
# What may be the start of a magnitude: letters only, as `_TRAILING_NUMBER` holds them.
|
|
17
|
+
_WORD_ONLY = re.compile(r"[^\W\d_]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _either(parts: Iterable[str]) -> str:
|
|
21
|
+
return "|".join(part for part in parts if part) or _NEVER
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, eq=False)
|
|
25
|
+
class Locale:
|
|
26
|
+
"""
|
|
27
|
+
The words and symbols the amount guard reads. The regex fragments are matched ignoring case.
|
|
28
|
+
|
|
29
|
+
- `currency_symbols`: written before or after an amount: `$120`, `50 000 €`.
|
|
30
|
+
- `currency_codes`: written before or after an amount: `INR 50,000`, `120 usd`.
|
|
31
|
+
- `currency_prefixes`: regex fragments written before an amount, besides the codes: `Rs. 50,000`.
|
|
32
|
+
- `currency_names`: regex fragments written after an amount: `40 euros`, `40 US dollars`.
|
|
33
|
+
- `magnitudes`: a word after a number that multiplies it, so `$2M` and `$2,000,000` compare equal. A word longer
|
|
34
|
+
than two letters may take a plural `s`.
|
|
35
|
+
- `cost_words`: words that make a bare magnitude (`60k`) or a rate a price when they are near it.
|
|
36
|
+
- `effort`: a regex fragment after an amount that makes it effort in person-time: `300 engineering hours`.
|
|
37
|
+
- `rate`: a regex fragment after a number that makes it a rate: `120 per hour`, `120 an hour`.
|
|
38
|
+
- `range_words`: words that join the two ends of a range, besides a hyphen or a dash: `$40 to $65`.
|
|
39
|
+
|
|
40
|
+
Change one with `dataclasses.replace`: `replace(ENGLISH, currency_codes=(*ENGLISH.currency_codes, "nok"))`.
|
|
41
|
+
Numbers are read with `.` as the decimal point and `,` or a space between thousands, whatever the locale.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
currency_symbols: str = "$€£₹¥"
|
|
45
|
+
currency_codes: tuple[str, ...] = ("usd", "eur", "gbp", "inr", "aed", "sgd", "chf", "sar", "jpy", "aud", "cad")
|
|
46
|
+
currency_prefixes: tuple[str, ...] = (r"rs\.?", r"us\$")
|
|
47
|
+
currency_names: tuple[str, ...] = (r"(?:us\s+)?dollars?", "euros?", "pounds?", "rupees?")
|
|
48
|
+
magnitudes: Mapping[str, int] = field(
|
|
49
|
+
default_factory=lambda: {
|
|
50
|
+
"k": 1_000,
|
|
51
|
+
"m": 1_000_000,
|
|
52
|
+
"bn": 1_000_000_000,
|
|
53
|
+
"thousand": 1_000,
|
|
54
|
+
"lakh": 100_000,
|
|
55
|
+
"million": 1_000_000,
|
|
56
|
+
"crore": 10_000_000,
|
|
57
|
+
"billion": 1_000_000_000,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
cost_words: tuple[str, ...] = (
|
|
61
|
+
"cost", "costs", "costing", "price", "priced", "pricing", "budget", "budgets", "quote", "quoted", "rate",
|
|
62
|
+
"rates", "fee", "fees", "charge", "charges", "invest", "investment", "spend", "ballpark", "estimate",
|
|
63
|
+
"estimates",
|
|
64
|
+
) # fmt: skip
|
|
65
|
+
effort: str = r"(?:person|man|developer|dev|engineer|engineering)[-\s](?:hours?|days?|weeks?|months?)\b"
|
|
66
|
+
rate: str = r"\s*(?:per|/)\s*(?:hour|hr|day)\b|\s+an?\s+hour\b"
|
|
67
|
+
range_words: tuple[str, ...] = ("to", "and")
|
|
68
|
+
|
|
69
|
+
_scale: dict[str, int] = field(init=False, repr=False)
|
|
70
|
+
_number: re.Pattern[str] = field(init=False, repr=False)
|
|
71
|
+
_money: re.Pattern[str] = field(init=False, repr=False)
|
|
72
|
+
_range: re.Pattern[str] = field(init=False, repr=False)
|
|
73
|
+
_range_named: re.Pattern[str] = field(init=False, repr=False)
|
|
74
|
+
_bare_magnitude: re.Pattern[str] = field(init=False, repr=False)
|
|
75
|
+
_rate: re.Pattern[str] = field(init=False, repr=False)
|
|
76
|
+
_cost_word: re.Pattern[str] = field(init=False, repr=False)
|
|
77
|
+
_trailing_symbol: re.Pattern[str] = field(init=False, repr=False)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
scale = {word.lower(): value for word, value in self.magnitudes.items()}
|
|
81
|
+
words = _either(f"{re.escape(word)}s?" if len(word) > 2 else re.escape(word) for word in scale)
|
|
82
|
+
amount = _NUMBER_TEXT + r"\s*(?:(" + words + r")\b)?"
|
|
83
|
+
symbol = "[" + re.escape(self.currency_symbols) + "]" if self.currency_symbols else _NEVER
|
|
84
|
+
codes = [re.escape(code) for code in self.currency_codes]
|
|
85
|
+
before = _either([*self.currency_prefixes, *codes])
|
|
86
|
+
after = _either([*self.currency_names, *codes])
|
|
87
|
+
joiner = _either([r"-|\u2013|\u2014", *map(re.escape, self.range_words)])
|
|
88
|
+
effort, rate = _either([self.effort]), _either([self.rate])
|
|
89
|
+
cost_words = _either(map(re.escape, self.cost_words))
|
|
90
|
+
compiled = {
|
|
91
|
+
"_scale": scale,
|
|
92
|
+
"_number": re.compile(amount, re.IGNORECASE),
|
|
93
|
+
"_money": re.compile(
|
|
94
|
+
f"{symbol}\\s*{amount}"
|
|
95
|
+
f"|(?<![A-Za-z])(?:{before})\\s*{amount}"
|
|
96
|
+
f"|{amount}\\s*(?:{after})\\b"
|
|
97
|
+
f"|{amount}\\s*{symbol}"
|
|
98
|
+
f"|{amount}\\s*(?:{effort})",
|
|
99
|
+
re.IGNORECASE,
|
|
100
|
+
),
|
|
101
|
+
"_range": re.compile(f"({symbol})?\\s*{amount}\\s*(?:{joiner})\\s*({symbol})?\\s*{amount}", re.IGNORECASE),
|
|
102
|
+
# A range priced by a code or a name ("USD 40 to 65", "40 to 65 euros") rather than by a symbol.
|
|
103
|
+
"_range_named": re.compile(
|
|
104
|
+
f"(?<![A-Za-z])(?:{before})\\s*{amount}\\s*(?:{joiner})\\s*{amount}"
|
|
105
|
+
f"|{amount}\\s*(?:{joiner})\\s*{amount}\\s*(?:{after})\\b",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
),
|
|
108
|
+
"_bare_magnitude": re.compile(r"\b" + _DIGITS + r"\s*(" + words + r")\b", re.IGNORECASE),
|
|
109
|
+
"_rate": re.compile(r"\b" + _DIGITS + f"(?:{rate})", re.IGNORECASE),
|
|
110
|
+
"_cost_word": re.compile(r"\b(?:" + cost_words + r")\b", re.IGNORECASE),
|
|
111
|
+
"_trailing_symbol": re.compile(symbol + r"\s*\Z"),
|
|
112
|
+
}
|
|
113
|
+
for name, value in compiled.items():
|
|
114
|
+
object.__setattr__(self, name, value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
ENGLISH = Locale()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _unit(magnitude: str, scale: Mapping[str, int]) -> str:
|
|
121
|
+
"""
|
|
122
|
+
A written magnitude as `scale` names it. Folded, not lowered: ignoring case, the "s" of a plural also matches the
|
|
123
|
+
long s (U+017F), which lowercasing leaves as it is and casefolding turns into "s".
|
|
124
|
+
"""
|
|
125
|
+
unit = magnitude.casefold()
|
|
126
|
+
return unit if unit in scale else unit.removesuffix("s")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _amount_keys(number: str, magnitude: str | None, scale: Mapping[str, int]) -> set[str]:
|
|
130
|
+
"""
|
|
131
|
+
How one written amount is compared. A bare figure is its digits and nothing else, so a "5" anywhere in a source
|
|
132
|
+
never grounds a "$5k". A figure with a magnitude is its digits with the magnitude ("5k") and the value it stands for
|
|
133
|
+
("5000"), so "$2M" and "$2,000,000" compare equal and either alone is enough. A decimal is also its digits without
|
|
134
|
+
trailing zeros, so "$1,200" and "$1,200.00" do too.
|
|
135
|
+
"""
|
|
136
|
+
digits = re.sub(r"[,\s]", "", number).rstrip(".")
|
|
137
|
+
# "12.50" is "12.5": a price is written either way, and a source's and a reply's may differ.
|
|
138
|
+
written = {digits, digits.rstrip("0").rstrip(".")} if "." in digits else {digits}
|
|
139
|
+
if not magnitude:
|
|
140
|
+
return written
|
|
141
|
+
unit = _unit(magnitude, scale)
|
|
142
|
+
keys = {f"{form}{unit}" for form in written}
|
|
143
|
+
value = float(digits) * scale[unit]
|
|
144
|
+
if value.is_integer():
|
|
145
|
+
keys.add(str(int(value)))
|
|
146
|
+
return keys
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _range_key(ends: tuple[str, str | None, str, str | None], scale: Mapping[str, int]) -> str:
|
|
150
|
+
"""A range as its two values, so "$2M to $3M" and "$2,000,000-$3,000,000" compare equal."""
|
|
151
|
+
low, low_unit, high, high_unit = ends
|
|
152
|
+
values = []
|
|
153
|
+
for number, magnitude in ((low, low_unit), (high, high_unit)):
|
|
154
|
+
unit = _unit(magnitude or "", scale)
|
|
155
|
+
value = float(re.sub(r"[,\s]", "", number).rstrip(".")) * scale.get(unit, 1)
|
|
156
|
+
values.append(str(int(value)) if value.is_integer() else str(value))
|
|
157
|
+
return f"range:{values[0]}-{values[1]}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ranges(text: str, locale: Locale) -> Iterator[tuple[re.Match[str], str, bool]]:
|
|
161
|
+
"""
|
|
162
|
+
(match, key, priced) for each range in `text`; `priced` is that a currency symbol, code or name sits beside an end.
|
|
163
|
+
A range with a symbol is one match even when a code or name follows it too; one with none is still a key.
|
|
164
|
+
"""
|
|
165
|
+
spans: list[tuple[int, int]] = []
|
|
166
|
+
for match in locale._range.finditer(text):
|
|
167
|
+
symbol_before, low, low_unit, symbol_after, high, high_unit = match.groups()
|
|
168
|
+
priced = bool(symbol_before or symbol_after)
|
|
169
|
+
if priced:
|
|
170
|
+
spans.append(match.span())
|
|
171
|
+
yield match, _range_key((low, low_unit, high, high_unit), locale._scale), priced
|
|
172
|
+
for match in locale._range_named.finditer(text):
|
|
173
|
+
if any(match.start() < end and start < match.end() for start, end in spans):
|
|
174
|
+
continue
|
|
175
|
+
# The amounts are read again, so that a fragment of the locale with a group of its own cannot shift them.
|
|
176
|
+
(low, low_unit), (high, high_unit) = locale._number.findall(match.group(0))[:2]
|
|
177
|
+
yield match, _range_key((low, low_unit, high, high_unit), locale._scale), True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def numbers_in(*texts: str | None, locale: Locale = ENGLISH) -> set[str]:
|
|
181
|
+
"""
|
|
182
|
+
Every number `texts` state, in the form `unsupported_amounts` compares against, and every range as one more
|
|
183
|
+
key (`range:low-high`): the grounded set for the amount guard.
|
|
184
|
+
"""
|
|
185
|
+
keys: set[str] = set()
|
|
186
|
+
for text in texts:
|
|
187
|
+
for number, magnitude in locale._number.findall(text or ""):
|
|
188
|
+
keys |= _amount_keys(number, magnitude or None, locale._scale)
|
|
189
|
+
keys |= {key for _, key, _ in _ranges(text or "", locale)}
|
|
190
|
+
return keys
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _near_cost_word(text: str, match: re.Match[str], locale: Locale, *, complete: bool = True) -> bool:
|
|
194
|
+
start = max(0, match.start() - _COST_WORD_WINDOW)
|
|
195
|
+
window = text[start : match.end() + _COST_WORD_WINDOW]
|
|
196
|
+
# A cost word that ends a text still streaming may be the start of another word: "cost" before "umes".
|
|
197
|
+
return any(complete or start + word.end() < len(text) for word in locale._cost_word.finditer(window))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def unsupported_amounts(
|
|
201
|
+
text: str, grounded: Collection[str], *, complete: bool = True, locale: Locale = ENGLISH
|
|
202
|
+
) -> list[str]:
|
|
203
|
+
"""
|
|
204
|
+
The money and person-time figures in `text` that `grounded` (see `numbers_in`) does not state, as written.
|
|
205
|
+
|
|
206
|
+
A figure is a currency symbol, code or name beside a number (`$120`, `INR 50,000`, `40 euros`), effort in
|
|
207
|
+
person-time (`300 engineering hours`), a range with a currency (`$40 to $65`), or a bare magnitude or a rate with
|
|
208
|
+
a cost word within 120 characters (`a budget of 60k`, `the rate is 120 per hour`). A bare figure is grounded by
|
|
209
|
+
the same figure only, one with a magnitude by the same value, and a range only by a source stating that range.
|
|
210
|
+
|
|
211
|
+
With `complete=False`, for text still streaming, a figure that ends the text is not judged yet: it may still
|
|
212
|
+
grow ("$5" before "00 a month").
|
|
213
|
+
"""
|
|
214
|
+
found: list[str] = []
|
|
215
|
+
|
|
216
|
+
def settled(match: re.Match[str]) -> bool:
|
|
217
|
+
if complete:
|
|
218
|
+
return True
|
|
219
|
+
rest = text[match.end() :]
|
|
220
|
+
if not rest:
|
|
221
|
+
return False
|
|
222
|
+
# What follows may still be part of the figure: the start of "million", or the point of "12.50" when a source
|
|
223
|
+
# states a figure that goes on like that. Otherwise a full stop ends the sentence.
|
|
224
|
+
if rest == "." and match.group(0)[-1].isdecimal():
|
|
225
|
+
digits = re.sub(r"[,\s]", "", locale._number.findall(match.group(0))[-1][0])
|
|
226
|
+
if any(key.startswith(f"{digits}.") for key in grounded):
|
|
227
|
+
return False
|
|
228
|
+
return not (_WORD_ONLY.fullmatch(rest) and any(word.startswith(rest.lower()) for word in locale._scale))
|
|
229
|
+
|
|
230
|
+
for match in locale._money.finditer(text):
|
|
231
|
+
if not settled(match):
|
|
232
|
+
continue
|
|
233
|
+
number, magnitude = locale._number.findall(match.group(0))[0]
|
|
234
|
+
if _amount_keys(number, magnitude, locale._scale).isdisjoint(grounded):
|
|
235
|
+
found.append(match.group(0).strip())
|
|
236
|
+
# Each end of "$65 to $120" may be a figure a source states in different places: the range is grounded only where
|
|
237
|
+
# a source states it as one.
|
|
238
|
+
for match, key, priced in _ranges(text, locale):
|
|
239
|
+
if priced and key not in grounded and settled(match):
|
|
240
|
+
found.append(match.group(0).strip())
|
|
241
|
+
for match in locale._bare_magnitude.finditer(text):
|
|
242
|
+
if (
|
|
243
|
+
settled(match)
|
|
244
|
+
and _near_cost_word(text, match, locale, complete=complete)
|
|
245
|
+
and _amount_keys(match.group(1), match.group(2), locale._scale).isdisjoint(grounded)
|
|
246
|
+
):
|
|
247
|
+
found.append(match.group(0).strip())
|
|
248
|
+
for match in locale._rate.finditer(text):
|
|
249
|
+
if (
|
|
250
|
+
settled(match)
|
|
251
|
+
and _near_cost_word(text, match, locale, complete=complete)
|
|
252
|
+
and _amount_keys(match.group(1), None, locale._scale).isdisjoint(grounded)
|
|
253
|
+
):
|
|
254
|
+
found.append(match.group(0).strip())
|
|
255
|
+
return found
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_TRAILING_NUMBER = re.compile(r"(?<!\d)(?<!\d,)\b\d+(?:,\d+)*,*(?:\.\d*)?\s*([^\W\d_]*)\Z")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _hold_start(text: str, grounded: Collection[str], locale: Locale) -> int | None:
|
|
262
|
+
"""
|
|
263
|
+
Where a streaming text must stop being shown for now, or None.
|
|
264
|
+
|
|
265
|
+
A money figure or a currency symbol that ends the text may still grow, grounded or not ("$5" then "00"). A bare
|
|
266
|
+
magnitude no source states is a price only if a cost word comes within the window after it, so it is held until
|
|
267
|
+
that window has streamed. A number at the end may still become a magnitude ("60" before " thousand").
|
|
268
|
+
"""
|
|
269
|
+
open_figures = [match.start() for match in locale._money.finditer(text) if match.end() == len(text)]
|
|
270
|
+
if symbol := locale._trailing_symbol.search(text):
|
|
271
|
+
open_figures.append(symbol.start())
|
|
272
|
+
if open_figures:
|
|
273
|
+
return min(open_figures)
|
|
274
|
+
starts: set[int] = set()
|
|
275
|
+
for match in locale._bare_magnitude.finditer(text):
|
|
276
|
+
unsettled = len(text) < match.end() + _COST_WORD_WINDOW
|
|
277
|
+
if unsettled and _amount_keys(match.group(1), match.group(2), locale._scale).isdisjoint(grounded):
|
|
278
|
+
return match.start()
|
|
279
|
+
starts.add(match.start())
|
|
280
|
+
for match in locale._rate.finditer(text):
|
|
281
|
+
if len(text) < match.end() + _COST_WORD_WINDOW and _amount_keys(match.group(1), None, locale._scale).isdisjoint(
|
|
282
|
+
grounded
|
|
283
|
+
):
|
|
284
|
+
return match.start()
|
|
285
|
+
trailing = _TRAILING_NUMBER.search(text)
|
|
286
|
+
# A trailing magnitude already settled above ("60m", grounded) stays settled: "m" only grows into "million".
|
|
287
|
+
if (
|
|
288
|
+
trailing
|
|
289
|
+
and trailing.start() not in starts
|
|
290
|
+
and any(word.startswith(trailing.group(1).lower()) for word in locale._scale)
|
|
291
|
+
):
|
|
292
|
+
return trailing.start()
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class AmountStream:
|
|
297
|
+
"""
|
|
298
|
+
Blocks a reply as soon as it states a money or person-time figure that `grounded` (see `numbers_in`) does
|
|
299
|
+
not, before the piece completing it is shown. A figure that may still grow is held, sourced or not, until a
|
|
300
|
+
character that cannot continue it arrives; a bare magnitude no source states is held, with the text after it,
|
|
301
|
+
until the cost-word window after it has streamed or the reply ends. `finish` judges the finished text.
|
|
302
|
+
|
|
303
|
+
Each piece is judged with the last 320 to 640 characters of the reply, not the whole of it, so that the time a
|
|
304
|
+
reply takes grows with its length and not with its square; `finish` judges the whole reply once.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def __init__(self, grounded: Collection[str], *, locale: Locale = ENGLISH):
|
|
308
|
+
self._grounded = grounded
|
|
309
|
+
self._locale = locale
|
|
310
|
+
self._text = ""
|
|
311
|
+
self._anchor = 0
|
|
312
|
+
self._released = 0
|
|
313
|
+
self._blocked: Blocked | None = None
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def text(self) -> str:
|
|
317
|
+
return self._text[: self._released]
|
|
318
|
+
|
|
319
|
+
def feed(self, delta: str) -> str:
|
|
320
|
+
self._check(delta, complete=False)
|
|
321
|
+
held = _hold_start(self._text[self._anchor :], self._grounded, self._locale)
|
|
322
|
+
upto = len(self._text) if held is None else max(self._anchor + held, self._released)
|
|
323
|
+
released = self._release(upto)
|
|
324
|
+
# Never past what is still held: the hold has to see all of it.
|
|
325
|
+
self._anchor = next_anchor(self._text, self._anchor, len(self._text), self._released)
|
|
326
|
+
return released
|
|
327
|
+
|
|
328
|
+
def finish(self) -> str:
|
|
329
|
+
self._check("", complete=True)
|
|
330
|
+
return self._release(len(self._text))
|
|
331
|
+
|
|
332
|
+
def _check(self, delta: str, *, complete: bool) -> None:
|
|
333
|
+
if self._blocked:
|
|
334
|
+
raise self._blocked
|
|
335
|
+
self._text += delta
|
|
336
|
+
text = self._text if complete else self._text[self._anchor :]
|
|
337
|
+
if found := unsupported_amounts(text, self._grounded, complete=complete, locale=self._locale):
|
|
338
|
+
self._blocked = Blocked("amount", tuple(found))
|
|
339
|
+
raise self._blocked
|
|
340
|
+
|
|
341
|
+
def _release(self, upto: int) -> str:
|
|
342
|
+
released = self._text[self._released : upto]
|
|
343
|
+
self._released = upto
|
|
344
|
+
return released
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from ._stream import Blocked
|
|
4
|
+
|
|
5
|
+
# A fenced block is the surest sign that a reply hands over code rather than discussing it. The other shapes are the
|
|
6
|
+
# same content asked for in another format: tilde fences, indented blocks, SQL, shell lines, markup, declarations,
|
|
7
|
+
# shebangs, compose files and definition signatures. Every pattern prefers matching too much: a false positive costs
|
|
8
|
+
# one replaced reply, a false negative hands the code over. Inline code (single backticks) is allowed.
|
|
9
|
+
_FENCE = re.compile(r"```|^[ \t]*~{3,}", re.MULTILINE)
|
|
10
|
+
_SIGNATURE = re.compile(
|
|
11
|
+
# A definition or an import, not the word: "Import the data first" and "Function calling lets..." are prose,
|
|
12
|
+
# "import os" and "function go() {" are code.
|
|
13
|
+
r"\bpublic\s+(?:static\s+)?(?:void|class)\b|\bdef\s+\w+\s*\(|\bfunction\s+\w*\s*\([^)\n]*\)\s*(?:\{|:|=>)|"
|
|
14
|
+
r"^[^\S\n]*import\s+[\w.]+(?:\s+as\s+\w+)?\s*(?:;|$)|^[^\S\n]*import\s+[\w{},*\s]+\s+from\s+['\"]|^[^\S\n]*#include\s*[<\"]|"
|
|
15
|
+
r"^[^\S\n]*from\s+[\w.]+\s+import\b|\bclass\s+\w+\s*[{:]|\bfn\s+\w+\s*\(|\bfunc\s+\w+\s*\(|<\w+\s+on\w+=",
|
|
16
|
+
re.IGNORECASE | re.MULTILINE,
|
|
17
|
+
)
|
|
18
|
+
# The SQL forms need statement structure after the keyword (a column list, a table name plus SET or VALUES), so
|
|
19
|
+
# advice like "select the vendor from the shortlist" does not match.
|
|
20
|
+
_STATEMENT = re.compile(
|
|
21
|
+
r"^[^\S\n]*SELECT\s+(?:\*|DISTINCT\b|[\w.]+\s*[,(]|[\w.]+\s+FROM\s+\w+\s*(?:;|WHERE\b|JOIN\b|GROUP\b|ORDER\b|LIMIT\b|$))"
|
|
22
|
+
r"|^[^\S\n]*INSERT\s+INTO\s+\w+[^\n]*(?:\bVALUES\b|\bSELECT\b|\()"
|
|
23
|
+
r"|^[^\S\n]*UPDATE\s+\w+\s+SET\b"
|
|
24
|
+
r"|^[^\S\n]*DELETE\s+FROM\s+\w+\s*(?:WHERE\b|;|$)"
|
|
25
|
+
r"|^[^\S\n]*CREATE\s+TABLE\s+\w+\s*\("
|
|
26
|
+
r"|^[^\S\n]*(?:\$ |sudo\s|pip\s+install|npm\s+(?:install|run)\b|docker\s+(?:run|compose)\b|curl\s+-)"
|
|
27
|
+
r"|<(?:script|div|html|body|style)\b"
|
|
28
|
+
r"|^[^\S\n]*(?:const|let|var)\s+\w+\s*="
|
|
29
|
+
r"|#!/"
|
|
30
|
+
# A compose file's top key with a service under it; "Services:" opening a list is not one.
|
|
31
|
+
r"|^[^\S\n]*services:[ \t]*\n[ \t]+[\w-]+:"
|
|
32
|
+
# A block opener with its body indented under it; a list under a heading is not a body.
|
|
33
|
+
r"|^[ \t]*(?:for\s+\w+\s+in\s+[^\n]+|while\s+[^\n]+|if\s+[^\n]*[=<>()][^\n]*|elif\b[^\n]*|else|try)[ \t]*:[ \t]*\n"
|
|
34
|
+
r"[ \t]+(?![-*+]\s|\d+[.)]\s)\S",
|
|
35
|
+
re.IGNORECASE | re.MULTILINE,
|
|
36
|
+
)
|
|
37
|
+
# Two consecutive lines indented 4+ spaces or a tab, each with code punctuation. A prose bullet that wraps onto an
|
|
38
|
+
# indented line rarely has these characters on two lines in a row, and a line starting with a list marker never counts.
|
|
39
|
+
_INDENTED = re.compile(
|
|
40
|
+
r"^(?:(?: {4,}|\t)(?![-*+]\s|\d+[.)]\s)[^\n]*[;{}=(][^\n]*)\n"
|
|
41
|
+
r"(?:(?: {4,}|\t)(?![-*+]\s|\d+[.)]\s)[^\n]*[;{}=(][^\n]*)(?:\n|$)",
|
|
42
|
+
re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def contains_code_deliverable(text: str) -> bool:
|
|
47
|
+
"""Whether `text` hands over code rather than talking about it. Biased towards false positives."""
|
|
48
|
+
return bool(_FENCE.search(text) or _SIGNATURE.search(text) or _STATEMENT.search(text) or _INDENTED.search(text))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _opens_line(buf: str, i: int, before: str) -> bool:
|
|
52
|
+
"""Whether only indentation precedes `buf[i]` on its line; `before` is the text released ahead of `buf`."""
|
|
53
|
+
j = i - 1
|
|
54
|
+
while j >= 0 and buf[j] in " \t":
|
|
55
|
+
j -= 1
|
|
56
|
+
if j >= 0:
|
|
57
|
+
return buf[j] == "\n"
|
|
58
|
+
before = before.rstrip(" \t")
|
|
59
|
+
return not before or before.endswith("\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CodeStream:
|
|
63
|
+
"""
|
|
64
|
+
Blocks a reply at its first code fence: three backticks, or three tildes opening a line. A run of backticks
|
|
65
|
+
or tildes that ends a delta is held until the next one settles it. With `deliverable` (the default), `finish` also
|
|
66
|
+
blocks a finished reply that `contains_code_deliverable` flags.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, *, deliverable: bool = True):
|
|
70
|
+
self._deliverable = deliverable
|
|
71
|
+
self._pending = ""
|
|
72
|
+
self._text = ""
|
|
73
|
+
self._blocked: Blocked | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def text(self) -> str:
|
|
77
|
+
return self._text
|
|
78
|
+
|
|
79
|
+
def feed(self, delta: str) -> str:
|
|
80
|
+
return self._drain(delta, final=False)
|
|
81
|
+
|
|
82
|
+
def finish(self) -> str:
|
|
83
|
+
tail = self._drain("", final=True)
|
|
84
|
+
if self._deliverable and contains_code_deliverable(self._text):
|
|
85
|
+
self._blocked = Blocked("code")
|
|
86
|
+
raise self._blocked
|
|
87
|
+
return tail
|
|
88
|
+
|
|
89
|
+
def _drain(self, delta: str, *, final: bool) -> str:
|
|
90
|
+
if self._blocked:
|
|
91
|
+
raise self._blocked
|
|
92
|
+
buf = self._pending + delta
|
|
93
|
+
out: list[str] = []
|
|
94
|
+
i = 0
|
|
95
|
+
while i < len(buf):
|
|
96
|
+
char = buf[i]
|
|
97
|
+
if char == "`" or (char == "~" and _opens_line(buf, i, self._text)):
|
|
98
|
+
end = i
|
|
99
|
+
while end < len(buf) and buf[end] == char:
|
|
100
|
+
end += 1
|
|
101
|
+
if end - i >= 3:
|
|
102
|
+
self._blocked = Blocked("code", (char * 3,))
|
|
103
|
+
raise self._blocked
|
|
104
|
+
if end == len(buf) and not final:
|
|
105
|
+
break
|
|
106
|
+
out.append(buf[i:end])
|
|
107
|
+
i = end
|
|
108
|
+
continue
|
|
109
|
+
out.append(char)
|
|
110
|
+
i += 1
|
|
111
|
+
self._pending = buf[i:]
|
|
112
|
+
released = "".join(out)
|
|
113
|
+
self._text += released
|
|
114
|
+
return released
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
|
|
4
|
+
from ._stream import Blocked
|
|
5
|
+
|
|
6
|
+
_WORD = re.compile(r"\w+")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _words(text: str) -> list[str]:
|
|
10
|
+
return _WORD.findall(text.casefold())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LeakGuard:
|
|
14
|
+
"""
|
|
15
|
+
Catches a reply that quotes a protected text word for word.
|
|
16
|
+
|
|
17
|
+
Each protected text becomes the set of its n-word runs (shingles), after casefolding and dropping punctuation and
|
|
18
|
+
Markdown, and a reply leaks when any n consecutive words of it are one of them. `protected` maps a name to a text,
|
|
19
|
+
or to `(text, n)` to give that text its own n; the others use `words`. The default of 8 is long enough that an
|
|
20
|
+
ordinary reply does not repeat 8 words of a system prompt in a row, and short enough that a quoted sentence is
|
|
21
|
+
caught. A text with fewer than n words has no run to quote and is refused. A guard holds no state about a reply, so
|
|
22
|
+
one built for a system prompt can serve every request.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, protected: Mapping[str, str | tuple[str, int]], *, words: int = 8):
|
|
26
|
+
self._shingles: list[tuple[str, int, set[tuple[str, ...]]]] = []
|
|
27
|
+
for name, value in protected.items():
|
|
28
|
+
text, n = value if isinstance(value, tuple) else (value, words)
|
|
29
|
+
if n < 1:
|
|
30
|
+
raise ValueError(f"{name!r}: the run length must be at least 1, got {n}")
|
|
31
|
+
found = _words(text)
|
|
32
|
+
if len(found) < n:
|
|
33
|
+
# Such a text has no run to quote, and the guard would let a reply repeat all of it.
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"{name!r}: the text has {len(found)} word(s), fewer than the run length {n}; "
|
|
36
|
+
f"give it its own, as ({name!r}: (text, {max(len(found), 1)})), or leave it out"
|
|
37
|
+
)
|
|
38
|
+
self._shingles.append((name, n, {tuple(found[i : i + n]) for i in range(len(found) - n + 1)}))
|
|
39
|
+
self._longest = max((n for _, n, _ in self._shingles), default=0)
|
|
40
|
+
|
|
41
|
+
def match(self, text: str) -> str | None:
|
|
42
|
+
"""The name of the first protected text that `text` quotes, or None."""
|
|
43
|
+
return self._match(_words(text), 0)
|
|
44
|
+
|
|
45
|
+
def leaked(self, text: str) -> bool:
|
|
46
|
+
return self.match(text) is not None
|
|
47
|
+
|
|
48
|
+
def _match(self, found: list[str], first: int) -> str | None:
|
|
49
|
+
"""The name of the first protected text quoted by a run of `found` that ends at word `first` or later."""
|
|
50
|
+
for name, n, shingles in self._shingles:
|
|
51
|
+
if any(tuple(found[i : i + n]) in shingles for i in range(max(0, first - n + 1), len(found) - n + 1)):
|
|
52
|
+
return name
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LeakStream:
|
|
57
|
+
"""
|
|
58
|
+
Blocks a reply as soon as the text so far quotes a protected text. Holds nothing back, so up to n-1 words of a
|
|
59
|
+
quotation are shown before the run that completes it is caught.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, guard: LeakGuard):
|
|
63
|
+
self._guard = guard
|
|
64
|
+
self._text = ""
|
|
65
|
+
self._blocked: Blocked | None = None
|
|
66
|
+
# The last words of the reply that a run could still start in, and the word being written, which may still grow.
|
|
67
|
+
self._words: list[str] = []
|
|
68
|
+
self._open = ""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def text(self) -> str:
|
|
72
|
+
return self._text
|
|
73
|
+
|
|
74
|
+
def feed(self, delta: str) -> str:
|
|
75
|
+
if self._blocked:
|
|
76
|
+
raise self._blocked
|
|
77
|
+
self._text += delta
|
|
78
|
+
# Casefolding works one character at a time, so the words of the pieces joined are those of the text.
|
|
79
|
+
written = self._open + delta.casefold()
|
|
80
|
+
found = _WORD.findall(written)
|
|
81
|
+
# Only runs that end in a word that is new or has grown can be new: the others were judged as they arrived.
|
|
82
|
+
if (name := self._guard._match([*self._words, *found], len(self._words))) is not None:
|
|
83
|
+
self._blocked = Blocked("leak", (name,))
|
|
84
|
+
raise self._blocked
|
|
85
|
+
self._open = found.pop() if found and _WORD.match(written[-1]) else ""
|
|
86
|
+
self._words = [*self._words, *found][max(0, len(self._words) + len(found) - self._guard._longest + 1) :]
|
|
87
|
+
return delta
|
|
88
|
+
|
|
89
|
+
def finish(self) -> str:
|
|
90
|
+
if self._blocked:
|
|
91
|
+
raise self._blocked
|
|
92
|
+
return ""
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Collection, Iterable
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from ._stream import next_anchor
|
|
6
|
+
|
|
7
|
+
REMOVED = "[removed]"
|
|
8
|
+
|
|
9
|
+
# A URL inside inline code is a placeholder ("set the callback to `https://yourapp.example/hook`"), not a link anyone
|
|
10
|
+
# follows, and cutting it out would leave a hole mid-sentence. It is held aside while links are stripped. A span ends
|
|
11
|
+
# at a run of backticks as long as the one that opened it, as in Markdown: "`[a](https://evil.example)``" is a link.
|
|
12
|
+
# A backslash escapes a backtick or a backslash: "\`[a](https://evil.example)\`" opens no span, and is a link.
|
|
13
|
+
_CODE_SPAN = re.compile(r"\\[\\`]|(?<!`)(`+)(?!`)[^`\n]*\1(?!`)")
|
|
14
|
+
_HOLD_OPEN, _HOLD_CLOSE = chr(0xE000), chr(0xE001)
|
|
15
|
+
_HELD = re.compile(f"{_HOLD_OPEN}(\\d+){_HOLD_CLOSE}")
|
|
16
|
+
# Full-width and ideographic punctuation ends a URL, and so does a held code span: a backtick did before it was held.
|
|
17
|
+
_URL_CHARS = r"(?:[^\s<>()\[\]\"'`。、," + _HOLD_OPEN + _HOLD_CLOSE + r"]|\([^\s<>()\[\]`]*\))" # noqa: RUF001
|
|
18
|
+
# A link or an image, with a URL that may hold balanced parentheses; a <url> autolink; a reference definition line. The
|
|
19
|
+
# last two take any scheme or a `//host`: `_host_of` decides which of them is a web link.
|
|
20
|
+
_MARKDOWN_LINK = re.compile(r"(!?)\[([^\]\n]*)\]\(\s*<?((?:[^()\s>]|\([^()\s]*\))*)>?[^)]*\)")
|
|
21
|
+
_AUTOLINK = re.compile(r"<((?:[a-z][a-z0-9+.\-]{1,31}:|www\.)[^\s<>]+)>", re.IGNORECASE)
|
|
22
|
+
_LINK_DEFINITION = re.compile(r"^([ \t]*\[[^\]\n]+\]:[ \t]*)(?:<([^\s<>]+)>|(\S+))[ \t]*$", re.MULTILINE)
|
|
23
|
+
_BARE_URL = re.compile(r"[ \t]?(?:https?://|www\.)" + _URL_CHARS + "+", re.IGNORECASE)
|
|
24
|
+
_TRAILING_PUNCTUATION = ".,;:!?。、," # noqa: RUF001
|
|
25
|
+
# What Markdown reads as other characters in a link destination: an HTML character reference (`https://`) or a
|
|
26
|
+
# backslash before punctuation (`https\://`). Before the first `/`, `?` or `#` (not the `#` of a reference) they can
|
|
27
|
+
# hide the scheme or a leading `//`, so the URL reads as a relative one here and as a web link to the reader.
|
|
28
|
+
# A browser drops these from the start of a URL, so "\x01//evil.example" is a link to evil.example.
|
|
29
|
+
_C0_OR_SPACE = "".join(map(chr, range(0x21)))
|
|
30
|
+
_DESTINATION_HEAD = re.compile(r"[/?]|(?<!&)#")
|
|
31
|
+
_CHARACTER_REFERENCE = re.compile(r"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});")
|
|
32
|
+
_DISGUISED_HEAD = re.compile(_CHARACTER_REFERENCE.pattern + r"|\\[!-/:-@\[-`{-~]")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _host_of(url: str) -> str | None:
|
|
36
|
+
"""
|
|
37
|
+
The lowercase host of an absolute web URL, "" for one with no usable host or a script scheme, None for
|
|
38
|
+
anything else (a relative path, a fragment, a mailto link).
|
|
39
|
+
"""
|
|
40
|
+
# A scheme or a leading "//" written as a character reference or an escape is not one this function can read.
|
|
41
|
+
stripped = url.strip().lstrip(_C0_OR_SPACE)
|
|
42
|
+
head = _DESTINATION_HEAD.split(stripped, maxsplit=1)[0]
|
|
43
|
+
if _DISGUISED_HEAD.search(head):
|
|
44
|
+
return ""
|
|
45
|
+
# A character reference in the host can hide the "/", "?" or "#" that ends it: "https://evil.example/@acme.com".
|
|
46
|
+
if stripped.startswith("//", len(head)):
|
|
47
|
+
authority = _DESTINATION_HEAD.split(stripped[len(head) + 2 :], maxsplit=1)[0]
|
|
48
|
+
if _CHARACTER_REFERENCE.search(authority):
|
|
49
|
+
return ""
|
|
50
|
+
# Browsers read a backslash as a slash in a web URL: "https://evil.example\@acme.example" is evil.example.
|
|
51
|
+
candidate = stripped.replace("\\", "/")
|
|
52
|
+
if candidate.lower().startswith("www."):
|
|
53
|
+
candidate = "//" + candidate
|
|
54
|
+
try:
|
|
55
|
+
parsed = urlparse(candidate)
|
|
56
|
+
host = parsed.hostname
|
|
57
|
+
except ValueError:
|
|
58
|
+
return ""
|
|
59
|
+
if parsed.scheme.lower() in ("http", "https") or candidate.startswith("//"):
|
|
60
|
+
return (host or "").removeprefix("www.")
|
|
61
|
+
if parsed.scheme.lower() in ("javascript", "data", "vbscript", "file"):
|
|
62
|
+
return ""
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def hosts_of(urls: Iterable[str]) -> set[str]:
|
|
67
|
+
"""The hosts of `urls`, in the form `strip_untrusted_links` compares against: lowercase, without `www.`."""
|
|
68
|
+
return {host for url in urls if (host := _host_of(url))}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_untrusted_links(text: str, allowed_hosts: Collection[str]) -> str:
|
|
72
|
+
"""
|
|
73
|
+
`text` without the web links whose host is not in `allowed_hosts` (see `hosts_of`).
|
|
74
|
+
|
|
75
|
+
`[text](url)` and `` become their text; a bare URL, a `<url>` autolink or a reference definition
|
|
76
|
+
becomes its host in inline code, so the sentence around it still reads. A link with no usable host, or a
|
|
77
|
+
`javascript:` one, is dropped. Relative links, fragments, mailto links and URLs inside inline code stay. A host
|
|
78
|
+
matches exactly: allowing `example.com` does not allow `docs.example.com`.
|
|
79
|
+
"""
|
|
80
|
+
held: list[str] = []
|
|
81
|
+
|
|
82
|
+
def hold(match: re.Match[str]) -> str:
|
|
83
|
+
if match.group(1) is None:
|
|
84
|
+
return match.group(0)
|
|
85
|
+
held.append(match.group(0))
|
|
86
|
+
return f"{_HOLD_OPEN}{len(held) - 1}{_HOLD_CLOSE}"
|
|
87
|
+
|
|
88
|
+
def untrusted(url: str) -> str | None:
|
|
89
|
+
"""The host to show instead of `url`, "" to show nothing, None when the link may stay."""
|
|
90
|
+
host = _host_of(url)
|
|
91
|
+
# A held code span inside a URL would come back inside the host's own code span.
|
|
92
|
+
return None if host is None or host in allowed_hosts else _HELD.sub("", host)
|
|
93
|
+
|
|
94
|
+
def shown(host: str) -> str:
|
|
95
|
+
return f"`{host}`" if host else ""
|
|
96
|
+
|
|
97
|
+
def link(match: re.Match[str]) -> str:
|
|
98
|
+
_, label, url = match.groups()
|
|
99
|
+
host = untrusted(url)
|
|
100
|
+
if host is None:
|
|
101
|
+
return match.group(0)
|
|
102
|
+
return label if label.strip() else shown(host)
|
|
103
|
+
|
|
104
|
+
def autolink(match: re.Match[str]) -> str:
|
|
105
|
+
host = untrusted(match.group(1))
|
|
106
|
+
return match.group(0) if host is None else shown(host)
|
|
107
|
+
|
|
108
|
+
def definition(match: re.Match[str]) -> str:
|
|
109
|
+
host = untrusted(match.group(2) or match.group(3))
|
|
110
|
+
return match.group(0) if host is None else match.group(1) + shown(host)
|
|
111
|
+
|
|
112
|
+
def bare(match: re.Match[str]) -> str:
|
|
113
|
+
found = match.group(0)
|
|
114
|
+
lead = found[: len(found) - len(found.lstrip(" \t"))]
|
|
115
|
+
url = found.strip()
|
|
116
|
+
trailing = ""
|
|
117
|
+
while url and url[-1] in _TRAILING_PUNCTUATION:
|
|
118
|
+
trailing = url[-1] + trailing
|
|
119
|
+
url = url[:-1]
|
|
120
|
+
host = untrusted(url)
|
|
121
|
+
if host is None:
|
|
122
|
+
return found
|
|
123
|
+
return (lead + shown(host) if host else "") + trailing
|
|
124
|
+
|
|
125
|
+
# The placeholder characters are private-use ones; a reply that holds them would be handed other text's spans.
|
|
126
|
+
cleaned = _CODE_SPAN.sub(hold, text.replace(_HOLD_OPEN, "").replace(_HOLD_CLOSE, ""))
|
|
127
|
+
cleaned = _LINK_DEFINITION.sub(definition, cleaned)
|
|
128
|
+
cleaned = _AUTOLINK.sub(autolink, cleaned)
|
|
129
|
+
cleaned = _MARKDOWN_LINK.sub(link, cleaned)
|
|
130
|
+
cleaned = _BARE_URL.sub(bare, cleaned)
|
|
131
|
+
|
|
132
|
+
def restore(match: re.Match[str]) -> str:
|
|
133
|
+
# A host shown in inline code next to a span would join their backtick runs, and a longer run closes nothing:
|
|
134
|
+
# the span's text would then be read as Markdown, links included.
|
|
135
|
+
span = held[int(match.group(1))]
|
|
136
|
+
before, after = cleaned[match.start() - 1 : match.start()], cleaned[match.end() : match.end() + 1]
|
|
137
|
+
return (" " if before == "`" else "") + span + (" " if after == "`" else "")
|
|
138
|
+
|
|
139
|
+
return _HELD.sub(restore, cleaned)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# An address as it reads in prose; a trailing full stop is not part of it, because the domain must end in a label.
|
|
143
|
+
_EMAIL = r"[\w.%+\-]+@[\w\-]+(?:\.[\w\-]+)+"
|
|
144
|
+
_EMAIL_FIND = re.compile(_EMAIL)
|
|
145
|
+
_MAILTO_LINK = re.compile(r"\[([^\]]*)\]\(\s*mailto:([^)]*)\)", re.IGNORECASE)
|
|
146
|
+
_EMAIL_IN_TEXT = re.compile(r"<(?:mailto:)?(" + _EMAIL + r")>|(?:mailto:)?(" + _EMAIL + r")", re.IGNORECASE)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def emails_in(*texts: str | None) -> set[str]:
|
|
150
|
+
"""The email addresses written in `texts`, lowercased: the grounded set for `without_ungrounded_emails`."""
|
|
151
|
+
return {found.lower() for text in texts for found in _EMAIL_FIND.findall(text or "")}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def without_ungrounded_emails(text: str, grounded: Collection[str], *, replacement: str = REMOVED) -> str:
|
|
155
|
+
"""
|
|
156
|
+
`text` with every email address not in `grounded` (lowercase, see `emails_in`) replaced by `replacement`, and
|
|
157
|
+
a `mailto:` link to one replaced by its text. A model with no address to give writes a plausible one from a
|
|
158
|
+
company's name, and someone then writes to it.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def mailto(match: re.Match[str]) -> str:
|
|
162
|
+
found = _EMAIL_FIND.search(match.group(2))
|
|
163
|
+
if found is None or found.group(0).lower() in grounded:
|
|
164
|
+
return match.group(0)
|
|
165
|
+
return match.group(1).strip() or replacement
|
|
166
|
+
|
|
167
|
+
def address(match: re.Match[str]) -> str:
|
|
168
|
+
found = match.group(1) or match.group(2)
|
|
169
|
+
return match.group(0) if found.lower() in grounded else replacement
|
|
170
|
+
|
|
171
|
+
return _EMAIL_IN_TEXT.sub(address, _MAILTO_LINK.sub(mailto, text))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# A telephone number as it reads in prose, on one line: an optional "+", digits grouped by spaces, dots, hyphens or
|
|
175
|
+
# brackets.
|
|
176
|
+
# Counted by its digits, so a year, a date or a figure is not one: at least 9 digits, or 8 after a "+".
|
|
177
|
+
_PHONE = re.compile(r"(?<![\w.+])\(?\+?\d(?:[\d().-]|[^\S\n]){5,22}\d(?!\w)")
|
|
178
|
+
_DATE = re.compile(r"(?<!\d)\d{4}-\d{1,2}-\d{1,2}(?!\d)")
|
|
179
|
+
_IPV4 = re.compile(r"\d{1,3}(?:\.\d{1,3}){3}")
|
|
180
|
+
_MIN_PHONE_DIGITS = 9
|
|
181
|
+
# A number written without its country code, or with a trunk zero, is the same number when this many digits agree.
|
|
182
|
+
_MIN_SHARED_PHONE_DIGITS = 7
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _phone_digits(found: str) -> str | None:
|
|
186
|
+
"""The digits of a written number when it reads as a telephone number, else None."""
|
|
187
|
+
if _DATE.search(found) or _IPV4.fullmatch(found):
|
|
188
|
+
return None
|
|
189
|
+
digits = re.sub(r"\D", "", found)
|
|
190
|
+
return digits if len(digits) >= _MIN_PHONE_DIGITS - found.startswith("+") else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def phones_in(*texts: str | None) -> set[str]:
|
|
194
|
+
"""The telephone numbers written in `texts`, as digits only: the grounded set for `without_ungrounded_phones`."""
|
|
195
|
+
return {digits for text in texts for found in _PHONE.findall(text or "") if (digits := _phone_digits(found))}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def without_ungrounded_phones(text: str, grounded: Collection[str], *, replacement: str = REMOVED) -> str:
|
|
199
|
+
"""
|
|
200
|
+
`text` with every telephone number not in `grounded` (digits, see `phones_in`) replaced by `replacement`.
|
|
201
|
+
Numbers compare by their digits, and one that is the other without its country code, or with a trunk zero, is
|
|
202
|
+
the same number: `020 7946 0001` is grounded by `+44 20 7946 0001`.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def known(digits: str) -> bool:
|
|
206
|
+
national = digits.lstrip("0")
|
|
207
|
+
for other in grounded:
|
|
208
|
+
theirs = other.lstrip("0")
|
|
209
|
+
if digits == other or (
|
|
210
|
+
min(len(national), len(theirs)) >= _MIN_SHARED_PHONE_DIGITS
|
|
211
|
+
and (theirs.endswith(national) or national.endswith(theirs))
|
|
212
|
+
):
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def phone(match: re.Match[str]) -> str:
|
|
217
|
+
digits = _phone_digits(match.group(0))
|
|
218
|
+
return match.group(0) if digits is None or known(digits) else replacement
|
|
219
|
+
|
|
220
|
+
return _PHONE.sub(phone, text)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# A link still being written: a URL running to the end of the text, the start of a scheme, or a Markdown link whose
|
|
224
|
+
# brackets or parentheses are not closed yet. Held that long only, so a stray "[" in prose does not stall the reply.
|
|
225
|
+
_OPEN_URL = re.compile(r"[ \t]?<?(?:https?://|www\.)[^\s<>()\[\]\"'`。、,]*\Z", re.IGNORECASE) # noqa: RUF001
|
|
226
|
+
_PARTIAL_SCHEME = re.compile(r"(?<!\w)(?:<|<?(?:h(?:t(?:t(?:ps?(?::/?)?)?)?)?|ww?w?))\Z", re.IGNORECASE)
|
|
227
|
+
_MAX_HELD_LINK_CHARS = 300
|
|
228
|
+
# Link text is short: a "[" with no "]" this far on is prose.
|
|
229
|
+
_MAX_HELD_LINK_TEXT_CHARS = 80
|
|
230
|
+
# The word being written, while addresses are checked: "hello" is shown once no "@" has followed it. Longer than any
|
|
231
|
+
# address, so a long unbroken string is not held.
|
|
232
|
+
_OPEN_WORD = re.compile(r"<?[\w.%+\-@:]*\Z")
|
|
233
|
+
_MAX_HELD_WORD_CHARS = 120
|
|
234
|
+
# A number being written ("+44 7700 9"), held from its bracket or plus sign so no part of one is shown.
|
|
235
|
+
_OPEN_NUMBER = re.compile(r"\(?\+?(?:\d(?:[\d().-]|[^\S\n])*)?\Z")
|
|
236
|
+
_MAX_HELD_NUMBER_CHARS = 30
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _open_link_start(text: str) -> int:
|
|
240
|
+
"""Where a link still being written starts in `text`, or len(text) when none is."""
|
|
241
|
+
cut = len(text)
|
|
242
|
+
# A "!" that ends the text may open an image and goes with it: alone, it would stay when the image is cut down.
|
|
243
|
+
if text.endswith("!"):
|
|
244
|
+
cut -= 1
|
|
245
|
+
for pattern in (_OPEN_URL, _PARTIAL_SCHEME):
|
|
246
|
+
if (match := pattern.search(text)) is not None:
|
|
247
|
+
cut = min(cut, match.start())
|
|
248
|
+
bracket = text.rfind("[")
|
|
249
|
+
if bracket != -1:
|
|
250
|
+
rest = text[bracket:]
|
|
251
|
+
close = rest.find("]")
|
|
252
|
+
after = rest[close + 1 :] if close != -1 else ""
|
|
253
|
+
unfinished = close == -1 or after == "" or (after.startswith("(") and ")" not in after)
|
|
254
|
+
limit = _MAX_HELD_LINK_TEXT_CHARS if close == -1 else _MAX_HELD_LINK_CHARS
|
|
255
|
+
if unfinished and "\n" not in rest and len(rest) <= limit:
|
|
256
|
+
# An image's "!" goes with it: shown alone, it would stay when the image is cut down to its text.
|
|
257
|
+
cut = min(cut, bracket - 1 if text[bracket - 1 : bracket] == "!" else bracket)
|
|
258
|
+
return cut
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _held_from(pattern: re.Pattern[str], text: str, limit: int) -> int:
|
|
262
|
+
match = pattern.search(text)
|
|
263
|
+
return match.start() if match is not None and len(match.group(0)) <= limit else len(text)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class LinkStream:
|
|
267
|
+
"""
|
|
268
|
+
`strip_untrusted_links` while the reply streams, and with `emails` or `phones` (a grounded set, empty for
|
|
269
|
+
none) `without_ungrounded_emails` and `without_ungrounded_phones` too. Text is let through once no link, word or
|
|
270
|
+
number in it is still being written, already cleaned, so a URL, address or number the finished reply would lose
|
|
271
|
+
is never shown.
|
|
272
|
+
|
|
273
|
+
Cleaning a longer text can, rarely, change what an earlier piece already showed: a `[` let through as prose after
|
|
274
|
+
80 characters with no `]`, that then turns out to open a link to an untrusted host. The stream then goes on from
|
|
275
|
+
where the two agree, and `text` is the reply as it should read: after `finish`, show `text` in place of what was
|
|
276
|
+
sent if the two differ.
|
|
277
|
+
|
|
278
|
+
Each piece is cleaned together with the last 320 to 640 characters of the reply, not the whole of it, so that the
|
|
279
|
+
time a reply takes grows with its length and not with its square. `finish` cleans the whole reply once.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
allowed_hosts: Collection[str],
|
|
285
|
+
*,
|
|
286
|
+
emails: Collection[str] | None = None,
|
|
287
|
+
phones: Collection[str] | None = None,
|
|
288
|
+
replacement: str = REMOVED,
|
|
289
|
+
):
|
|
290
|
+
self._allowed = allowed_hosts
|
|
291
|
+
self._emails = emails
|
|
292
|
+
self._phones = phones
|
|
293
|
+
self._replacement = replacement
|
|
294
|
+
self._raw = ""
|
|
295
|
+
# The reply is `_prefix` (cleaned, settled) then `_window` (cleaned from `_raw[_anchor:]`, as last let through).
|
|
296
|
+
self._anchor = 0
|
|
297
|
+
self._prefix = ""
|
|
298
|
+
self._window = ""
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def text(self) -> str:
|
|
302
|
+
return self._prefix + self._window
|
|
303
|
+
|
|
304
|
+
def feed(self, delta: str) -> str:
|
|
305
|
+
self._raw += delta
|
|
306
|
+
window = self._raw[self._anchor :]
|
|
307
|
+
cut = _open_link_start(window)
|
|
308
|
+
if self._emails is not None:
|
|
309
|
+
cut = min(cut, _held_from(_OPEN_WORD, window, _MAX_HELD_WORD_CHARS))
|
|
310
|
+
if self._phones is not None:
|
|
311
|
+
cut = min(cut, _held_from(_OPEN_NUMBER, window, _MAX_HELD_NUMBER_CHARS))
|
|
312
|
+
return self._release(self._anchor + cut, final=False)
|
|
313
|
+
|
|
314
|
+
def finish(self) -> str:
|
|
315
|
+
return self._release(len(self._raw), final=True)
|
|
316
|
+
|
|
317
|
+
def _clean(self, raw: str) -> str:
|
|
318
|
+
cleaned = strip_untrusted_links(raw, self._allowed)
|
|
319
|
+
if self._emails is not None:
|
|
320
|
+
cleaned = without_ungrounded_emails(cleaned, self._emails, replacement=self._replacement)
|
|
321
|
+
if self._phones is not None:
|
|
322
|
+
cleaned = without_ungrounded_phones(cleaned, self._phones, replacement=self._replacement)
|
|
323
|
+
return cleaned
|
|
324
|
+
|
|
325
|
+
def _release(self, upto: int, *, final: bool) -> str:
|
|
326
|
+
# The window is cleaned on its own; the finished reply, once, whole.
|
|
327
|
+
cleaned, shown = (
|
|
328
|
+
(self._clean(self._raw), self.text)
|
|
329
|
+
if final
|
|
330
|
+
else (self._clean(self._raw[self._anchor : upto]), self._window)
|
|
331
|
+
)
|
|
332
|
+
if cleaned.startswith(shown):
|
|
333
|
+
shared = len(shown)
|
|
334
|
+
elif shown.startswith(cleaned):
|
|
335
|
+
# A link that opened in text already let through is being held: nothing more goes out until it closes.
|
|
336
|
+
# Once the reply has ended there is nothing left to wait for, and `text` is the cleaned reply.
|
|
337
|
+
if final:
|
|
338
|
+
self._prefix, self._window = "", cleaned
|
|
339
|
+
return ""
|
|
340
|
+
else:
|
|
341
|
+
shared = next(i for i, (new, old) in enumerate(zip(cleaned, shown, strict=False)) if new != old)
|
|
342
|
+
if final:
|
|
343
|
+
self._prefix, self._window = "", cleaned
|
|
344
|
+
else:
|
|
345
|
+
self._window = cleaned
|
|
346
|
+
self._advance(upto)
|
|
347
|
+
return cleaned[shared:]
|
|
348
|
+
|
|
349
|
+
def _advance(self, upto: int) -> None:
|
|
350
|
+
"""Move the window on once it is long, if no link or address straddles where it would start."""
|
|
351
|
+
anchor = next_anchor(self._raw, self._anchor, upto, upto)
|
|
352
|
+
if anchor == self._anchor:
|
|
353
|
+
return
|
|
354
|
+
head = self._clean(self._raw[self._anchor : anchor])
|
|
355
|
+
if self._window.startswith(head):
|
|
356
|
+
self._prefix += head
|
|
357
|
+
self._anchor = anchor
|
|
358
|
+
self._window = self._clean(self._raw[anchor:upto])
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
# How far behind the end of a streaming reply a guard looks. What it has already let through is settled, so each piece
|
|
4
|
+
# is judged with this many characters of context, not the whole reply, and the whole reply is judged once, at `finish`.
|
|
5
|
+
WINDOW = 320
|
|
6
|
+
# Where a window may start: after a line break, a space or a stop no URL runs across. Never inside a word or a URL.
|
|
7
|
+
_BREAKS = "\n \t。、," # noqa: RUF001
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def next_anchor(text: str, anchor: int, end: int, ceiling: int) -> int:
|
|
11
|
+
"""
|
|
12
|
+
Where the window over `text[:end]` starts, given where it started (`anchor`). It moves only once the window is twice
|
|
13
|
+
`WINDOW` long, and then to a break at least `WINDOW` behind `end`, and never past `ceiling` (what is still held).
|
|
14
|
+
"""
|
|
15
|
+
if end - anchor <= 2 * WINDOW:
|
|
16
|
+
return anchor
|
|
17
|
+
stop = min(end - WINDOW, ceiling)
|
|
18
|
+
after = max((text.rfind(char, anchor, stop) for char in _BREAKS), default=-1)
|
|
19
|
+
return after + 1 if after >= anchor else anchor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Blocked(Exception):
|
|
23
|
+
"""
|
|
24
|
+
A streaming guard refused the reply: stop reading the model and replace what was shown.
|
|
25
|
+
|
|
26
|
+
`reason` names the guard (`"code"`, `"leak"`, `"amount"`) and `found` says what it caught: the protected text's
|
|
27
|
+
name for a leak, the figures for an amount. A guard that has raised raises the same exception on every later call.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, reason: str, found: tuple[str, ...] = ()):
|
|
31
|
+
super().__init__(f"{reason}: {', '.join(found)}" if found else reason)
|
|
32
|
+
self.reason = reason
|
|
33
|
+
self.found = found
|
|
34
|
+
|
|
35
|
+
def __reduce__(self) -> tuple[type["Blocked"], tuple[str, tuple[str, ...]]]:
|
|
36
|
+
return type(self), (self.reason, self.found)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StreamGuard(Protocol):
|
|
40
|
+
"""
|
|
41
|
+
What every streaming guard does: `feed` takes the next piece of the reply and returns what may be shown now,
|
|
42
|
+
`finish` returns what was held back once the reply has ended, and either may raise `Blocked`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def feed(self, delta: str) -> str: ...
|
|
46
|
+
|
|
47
|
+
def finish(self) -> str: ...
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def text(self) -> str:
|
|
51
|
+
"""Everything let through so far, as it should finally read."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Chain:
|
|
56
|
+
"""Guards run in order, each fed what the one before it let through. A chain is a guard itself."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *guards: StreamGuard):
|
|
59
|
+
if not guards:
|
|
60
|
+
raise ValueError("a chain needs at least one guard")
|
|
61
|
+
self._guards = guards
|
|
62
|
+
|
|
63
|
+
def feed(self, delta: str) -> str:
|
|
64
|
+
for guard in self._guards:
|
|
65
|
+
delta = guard.feed(delta)
|
|
66
|
+
return delta
|
|
67
|
+
|
|
68
|
+
def finish(self) -> str:
|
|
69
|
+
tail = ""
|
|
70
|
+
for guard in self._guards:
|
|
71
|
+
tail = guard.feed(tail) + guard.finish()
|
|
72
|
+
return tail
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def text(self) -> str:
|
|
76
|
+
return self._guards[-1].text
|
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-stream-guards
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic output guards for a streamed LLM reply: untrusted links, made-up emails, phone numbers and prices, quoted system prompts and code, stopped before they reach the screen.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sansanth/llm-stream-guards
|
|
6
|
+
Project-URL: Issues, https://github.com/sansanth/llm-stream-guards/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/sansanth/llm-stream-guards/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Sandhu Santhakumar <code@sandhu.sh>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chatbot,guardrails,llm,output-filter,prompt-injection,security,streaming
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# llm-stream-guards
|
|
26
|
+
|
|
27
|
+
[](https://github.com/sansanth/llm-stream-guards/actions/workflows/python.yml)
|
|
28
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
29
|
+
[](https://pypi.org/project/llm-stream-guards/)
|
|
30
|
+
[](https://github.com/sansanth/llm-stream-guards/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
Output guards for an LLM reply that streams to a user. Prompt injection can make a model print a phishing link, a made-up price or phone number, or quote its system prompt, and by the time you check the finished reply the user has read it. These guards work on the reply as it streams: each piece is either shown now, held back until it can be judged (a half-written link, a number that may still grow), or the reply is stopped before the piece that breaks a rule reaches the screen.
|
|
33
|
+
|
|
34
|
+
Plain string logic: no model call, no ML, no dependencies. Every guard can only make a reply more restrictive. The
|
|
35
|
+
same guards are on npm as [`llm-stream-guards`](https://www.npmjs.com/package/llm-stream-guards), and the two packages
|
|
36
|
+
pass the same test cases.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install llm-stream-guards
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What it guards
|
|
43
|
+
|
|
44
|
+
| Guard | Stops | While streaming |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `LinkStream`, `strip_untrusted_links` | A link to a host you did not supply. `[text](url)` becomes its text; a bare URL becomes its host in inline code (`` `evil.example` ``), so the sentence still reads. | Holds a link from its first character (`h`, `www`, an unclosed `[...](`) until it can be checked. |
|
|
47
|
+
| `LinkStream(emails=...)`, `without_ungrounded_emails` | An email address that none of your sources holds. | Holds the word being written until it ends. |
|
|
48
|
+
| `LinkStream(phones=...)`, `without_ungrounded_phones` | A telephone number that none of your sources holds. `020 7946 0001` and `+44 20 7946 0001` are the same number. | Holds a number from its `+`, `(` or first digit. |
|
|
49
|
+
| `AmountStream`, `unsupported_amounts` | A price or an effort estimate none of your sources states: `$120`, `INR 50,000`, `40 euros`, `300 engineering hours`, `$40 to $65`, and `60k` or `120 per hour` near a cost word. `$2M` and `$2,000,000` compare equal, and so do `$1,200` and `$1,200.00`. A range is judged as a range, whether it is priced by a symbol (`$40 to $65`), a code (`USD 40 to 65`) or a name (`40 to 65 euros`). | Blocks before the piece that completes the figure. Holds a figure that may still grow (`$5` before `00`), and an unsourced `60k` or `120 per hour` until the 120 characters after it show no cost word. |
|
|
50
|
+
| `LeakStream`, `LeakGuard` | A reply that quotes a protected text (your system prompt) 8 words in a row, ignoring case, punctuation and Markdown. | Blocks as soon as the run completes. |
|
|
51
|
+
| `CodeStream`, `contains_code_deliverable` | A reply that hands over code: a fence, an indented block, SQL, a shell line, markup, a definition or an import. Prose that uses the words ("Import the data first") is not code. | Blocks at the first fence, so you stop paying for the rest. |
|
|
52
|
+
|
|
53
|
+
Links, emails and phone numbers are rewritten and the reply goes on. A code fence, a quotation or an unsourced figure means the reply as a whole is wrong: the guard raises `Blocked`, and you replace what was shown.
|
|
54
|
+
|
|
55
|
+
## Use it
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from llm_stream_guards import (
|
|
59
|
+
AmountStream,
|
|
60
|
+
Blocked,
|
|
61
|
+
Chain,
|
|
62
|
+
CodeStream,
|
|
63
|
+
LeakGuard,
|
|
64
|
+
LeakStream,
|
|
65
|
+
LinkStream,
|
|
66
|
+
emails_in,
|
|
67
|
+
hosts_of,
|
|
68
|
+
numbers_in,
|
|
69
|
+
phones_in,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Built once: it holds nothing about a reply.
|
|
73
|
+
leaks = LeakGuard({"system prompt": SYSTEM_PROMPT})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def guarded(deltas, sources, user_messages):
|
|
77
|
+
texts = [source.text for source in sources]
|
|
78
|
+
guards = Chain(
|
|
79
|
+
CodeStream(),
|
|
80
|
+
LeakStream(leaks),
|
|
81
|
+
AmountStream(numbers_in(*texts)),
|
|
82
|
+
LinkStream(
|
|
83
|
+
hosts_of(source.url for source in sources),
|
|
84
|
+
emails=emails_in(*texts, *user_messages),
|
|
85
|
+
phones=phones_in(*texts, *user_messages),
|
|
86
|
+
replacement="our team",
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
for delta in deltas:
|
|
90
|
+
if shown := guards.feed(delta):
|
|
91
|
+
yield shown
|
|
92
|
+
if tail := guards.finish():
|
|
93
|
+
yield tail
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
stream = client.chat.completions.create(..., stream=True)
|
|
97
|
+
deltas = (chunk.choices[0].delta.content or "" for chunk in stream)
|
|
98
|
+
try:
|
|
99
|
+
for piece in guarded(deltas, sources, user_messages):
|
|
100
|
+
send(piece)
|
|
101
|
+
except Blocked as blocked:
|
|
102
|
+
stream.close() # stop paying for the rest
|
|
103
|
+
log.warning("reply blocked: %s", blocked) # e.g. "amount: $80,000"
|
|
104
|
+
replace_shown_reply("Our team will follow up with a quote.")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A grounded set is what the reply may repeat. For links, emails and phone numbers that is the sources you retrieved for this reply, the user's own messages and your own contact details. Never the model's earlier replies: that would ground what it made up. For amounts, leave the user's messages out, or "you charge 5k, right?" lets the reply confirm it.
|
|
108
|
+
|
|
109
|
+
Order the chain as above: the guards that block judge the model's own text, and `LinkStream` rewrites what they let through.
|
|
110
|
+
|
|
111
|
+
Not streaming? Call the functions on the finished text:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
reply = strip_untrusted_links(reply, hosts_of(urls))
|
|
115
|
+
reply = without_ungrounded_emails(reply, emails_in(*texts), replacement="our team")
|
|
116
|
+
if unsupported_amounts(reply, numbers_in(*texts)) or leaks.leaked(reply) or contains_code_deliverable(reply):
|
|
117
|
+
reply = fallback
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
### Streaming guards
|
|
123
|
+
|
|
124
|
+
Every streaming guard has the same three members, and a `Chain` of them is one too (`StreamGuard` is the protocol):
|
|
125
|
+
|
|
126
|
+
- `feed(delta) -> str`: takes the next piece of the reply and returns what may be shown now, possibly `""`.
|
|
127
|
+
- `finish() -> str`: returns what was held back, once the model has stopped.
|
|
128
|
+
- `text`: everything let through so far, as it should finally read. After `finish`, the reply to store.
|
|
129
|
+
|
|
130
|
+
`feed` and `finish` raise `Blocked` when the reply must not go on. A guard that has raised raises the same exception on every later call.
|
|
131
|
+
|
|
132
|
+
| Name | Signature |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `Chain` | `Chain(*guards)`: each guard is fed what the one before it let through. |
|
|
135
|
+
| `CodeStream` | `CodeStream(*, deliverable=True)`: blocks at the first fence (three backticks, or three tildes opening a line). With `deliverable`, `finish` also blocks a finished reply that `contains_code_deliverable` flags. |
|
|
136
|
+
| `LeakStream` | `LeakStream(guard: LeakGuard)`: blocks once the text so far quotes a protected text. Holds nothing back. |
|
|
137
|
+
| `AmountStream` | `AmountStream(grounded, *, locale=ENGLISH)`: blocks an unsourced figure before it is shown; holds what may still grow. `finish` judges the finished text. |
|
|
138
|
+
| `LinkStream` | `LinkStream(allowed_hosts, *, emails=None, phones=None, replacement="[removed]")`: removes untrusted links, and with `emails` or `phones` (a grounded set, empty for none) ungrounded addresses and numbers. |
|
|
139
|
+
| `Blocked` | The exception. `reason` is `"code"`, `"leak"` or `"amount"`; `found` is what was caught: the fence, the protected text's name, or the figures. |
|
|
140
|
+
|
|
141
|
+
### Finished text
|
|
142
|
+
|
|
143
|
+
| Name | Signature |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `strip_untrusted_links` | `(text, allowed_hosts) -> str` |
|
|
146
|
+
| `hosts_of` | `(urls) -> set[str]`: the hosts `strip_untrusted_links` compares against: lowercase, without `www.`. Matched exactly: `example.com` does not allow `docs.example.com`. |
|
|
147
|
+
| `without_ungrounded_emails` | `(text, grounded, *, replacement="[removed]") -> str`: a `mailto:` link to an ungrounded address becomes its text. |
|
|
148
|
+
| `emails_in` | `(*texts) -> set[str]`: the addresses in `texts`, lowercased. |
|
|
149
|
+
| `without_ungrounded_phones` | `(text, grounded, *, replacement="[removed]") -> str` |
|
|
150
|
+
| `phones_in` | `(*texts) -> set[str]`: the telephone numbers in `texts`, as digits. |
|
|
151
|
+
| `unsupported_amounts` | `(text, grounded, *, complete=True, locale=ENGLISH) -> list[str]`: the unsourced figures, as written. With `complete=False` a figure that ends the text is not judged yet. |
|
|
152
|
+
| `numbers_in` | `(*texts, locale=ENGLISH) -> set[str]`: every number and range in `texts`, in the form `unsupported_amounts` compares against. |
|
|
153
|
+
| `LeakGuard` | `LeakGuard(protected, *, words=8)`: `protected` maps a name to a text, or to `(text, n)` for its own run length. A text with fewer than `n` words is refused with a `ValueError`: it has no run to quote. `match(text)` returns the name of the first text quoted, or `None`; `leaked(text)` is `match(text) is not None`. |
|
|
154
|
+
| `contains_code_deliverable` | `(text) -> bool` |
|
|
155
|
+
|
|
156
|
+
### `Locale`
|
|
157
|
+
|
|
158
|
+
The amount guard's words and symbols: currency symbols, codes, prefixes (`Rs.`) and names, magnitudes (`k`, `million`, `lakh`, `crore`), cost words, and the regex fragments that make a number effort (`person-days`) or a rate (`per hour`). `ENGLISH` is the default. Change part of it with `dataclasses.replace`, or build your own:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from dataclasses import replace
|
|
162
|
+
from llm_stream_guards import ENGLISH, Locale
|
|
163
|
+
|
|
164
|
+
nordic = replace(ENGLISH, currency_codes=(*ENGLISH.currency_codes, "nok", "sek", "dkk"))
|
|
165
|
+
german = Locale(
|
|
166
|
+
currency_names=("euro",),
|
|
167
|
+
magnitudes={"tsd": 1_000, "mio": 1_000_000, "mrd": 1_000_000_000},
|
|
168
|
+
cost_words=("kosten", "preis", "budget", "angebot"),
|
|
169
|
+
effort=r"personentage\b",
|
|
170
|
+
rate=r"\s*(?:pro|je)\s*stunde\b",
|
|
171
|
+
range_words=("bis",),
|
|
172
|
+
)
|
|
173
|
+
AmountStream(grounded, locale=german)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
An empty list matches nothing. The fields are documented on the class.
|
|
177
|
+
|
|
178
|
+
## Limits
|
|
179
|
+
|
|
180
|
+
- **Quotation, not paraphrase.** The leak guard catches a protected text repeated word for word, not one retold. Up to 7 words of a quotation are shown before the eighth completes the run. A protected text shorter than the run length is refused, not ignored: give it its own, `(text, 3)`. Leave out of the protected text any phrase the model is told to say, or saying it counts as a leak.
|
|
181
|
+
- **Text without word boundaries is not covered.** Chinese, Japanese or Thai reduces to very few words, so the leak guard sees almost nothing to compare.
|
|
182
|
+
- **The default patterns read English.** A price written in another language passes `ENGLISH`; give it a `Locale`. A figure in words ("forty thousand dollars") is not caught in any locale. Numbers are read with `.` as the decimal point.
|
|
183
|
+
- **Telephone numbers are counted by their digits**: 9 or more, or 8 after a `+`, on one line. A shorter number, or one written in words, is not checked. A date such as `2026-10-07` is never a number, but a run of other numbers that adds up to that many digits (`2019 2020 2021`) is, and so is an identifier of that length (`Order 12345678901`). A dotted address such as `192.168.100.100` is not a number.
|
|
184
|
+
- **Only web links are checked.** A link is one to `http`, `https`, `www.` or `//host`, and one with a `javascript:`, `data:`, `vbscript:` or `file:` scheme goes too. An `ftp:` link, or one to an app scheme (`intent:`, `slack:`), passes.
|
|
185
|
+
- **Holding back costs latency.** An unsourced `60k` or `120 per hour` waits for the next 120 characters; an unclosed `[` waits up to 80 characters and an unclosed link up to 300. The rest of the reply waits behind it.
|
|
186
|
+
- **Time is spent on the end of the reply.** Each piece is checked together with the last 320 to 640 characters, not the whole reply, so the time a reply takes grows with its length, about 0.3 milliseconds a piece for the chain above (a few times that if your pieces are single characters). The whole reply is checked once more when it ends. A link whose text runs past 300 characters, or a figure with more than 300 characters of white space inside it, is therefore judged only then.
|
|
187
|
+
- **What was shown can differ from `text`.** In a rare case (a `[` let through as prose that then opens a link to an untrusted host) cleaning the longer text changes text already shown. The stream goes on, and after `finish` you show `text` in its place.
|
|
188
|
+
- **The code detector prefers false positives.** A reply with a SQL statement or a block opener with an indented body counts as code. Pass `deliverable=False` to `CodeStream` to block on fences only.
|
|
189
|
+
- **A backstop, not a judge.** These checks see shapes, not meaning: they are what still holds when the prompt, and any model-based check, did not.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
See [CONTRIBUTING.md](https://github.com/sansanth/llm-stream-guards/blob/main/CONTRIBUTING.md). In short:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cd python
|
|
197
|
+
uv sync
|
|
198
|
+
uv run pytest --cov
|
|
199
|
+
uv run ruff check . && uv run mypy
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
llm_stream_guards/__init__.py,sha256=n718TYSdKrPe-b0P6TE9YWEPgAVtKO86vHfWofjw-Wc,943
|
|
2
|
+
llm_stream_guards/_amounts.py,sha256=Q_cKJ2wF-dQn7dSgKlASpbKVam6o4bYulRcDKt5bTZY,17358
|
|
3
|
+
llm_stream_guards/_code.py,sha256=z3BaIKie9i6HpYDC49eC09h458NHPoUTRr5K87FMxsM,5183
|
|
4
|
+
llm_stream_guards/_leak.py,sha256=jlOgIBzgqI4CCC9q2aQeXLxBJ7UGUumIpxLp7I0KGpA,4124
|
|
5
|
+
llm_stream_guards/_links.py,sha256=0SKBGCTjBIsBpC_KyKlC6H6dbhb-UeDVnDFX5mLBG2M,17458
|
|
6
|
+
llm_stream_guards/_stream.py,sha256=psfSTxuq7bH2XAYwH0CfoK6mDtSw32NUGI4wreY3uQQ,2821
|
|
7
|
+
llm_stream_guards/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
llm_stream_guards-0.1.0.dist-info/METADATA,sha256=WPM6gemrRwnI1ohbxwXVwcpxE9FFvCVB2NvPs51PsDo,13650
|
|
9
|
+
llm_stream_guards-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
llm_stream_guards-0.1.0.dist-info/licenses/LICENSE,sha256=Ml3YR3qGMwvxt9lCtrt3Gr00WiNPwOYWPRC4kaU82ao,1075
|
|
11
|
+
llm_stream_guards-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandhu Santhakumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|