factguard 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.
- factguard/__init__.py +29 -0
- factguard/__main__.py +5 -0
- factguard/cli.py +73 -0
- factguard/facts.py +211 -0
- factguard/guard.py +143 -0
- factguard/py.typed +0 -0
- factguard/sentences.py +56 -0
- factguard/testing.py +16 -0
- factguard/validate.py +252 -0
- factguard-0.1.0.dist-info/METADATA +175 -0
- factguard-0.1.0.dist-info/RECORD +14 -0
- factguard-0.1.0.dist-info/WHEEL +4 -0
- factguard-0.1.0.dist-info/entry_points.txt +2 -0
- factguard-0.1.0.dist-info/licenses/LICENSE +21 -0
factguard/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Code computes every number, the LLM only interprets.
|
|
2
|
+
|
|
3
|
+
factguard keeps LLM-written text free of numbers your code didn't compute.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .facts import RULES, Fact, Facts, Formatter
|
|
7
|
+
from .guard import Result, Retry, StreamGuard, aguard, guard
|
|
8
|
+
from .sentences import drop_sentences, split_sentences
|
|
9
|
+
from .validate import Report, Violation, Window, validate
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RULES",
|
|
15
|
+
"Fact",
|
|
16
|
+
"Facts",
|
|
17
|
+
"Formatter",
|
|
18
|
+
"Report",
|
|
19
|
+
"Result",
|
|
20
|
+
"Retry",
|
|
21
|
+
"StreamGuard",
|
|
22
|
+
"Violation",
|
|
23
|
+
"Window",
|
|
24
|
+
"aguard",
|
|
25
|
+
"drop_sentences",
|
|
26
|
+
"guard",
|
|
27
|
+
"split_sentences",
|
|
28
|
+
"validate",
|
|
29
|
+
]
|
factguard/__main__.py
ADDED
factguard/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""factguard check / render, for checking recorded answers from the shell or CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .facts import Facts
|
|
12
|
+
from .validate import Window, validate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _window(value: str) -> Window:
|
|
16
|
+
try:
|
|
17
|
+
start, end = value.split(":")
|
|
18
|
+
return date.fromisoformat(start), date.fromisoformat(end)
|
|
19
|
+
except ValueError:
|
|
20
|
+
raise argparse.ArgumentTypeError(f"expected START:END as ISO dates, got {value!r}") from None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read(path: str) -> str:
|
|
24
|
+
return sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> int:
|
|
28
|
+
parser = argparse.ArgumentParser(prog="factguard", description="Keep LLM text free of numbers code didn't compute.")
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
check = sub.add_parser("check", help="list ungrounded numbers; exit 1 if there are any")
|
|
32
|
+
check.add_argument("draft", help="text file with {{fact:key}} placeholders, or - for stdin")
|
|
33
|
+
check.add_argument("--facts", required=True, help="JSON file with the facts")
|
|
34
|
+
check.add_argument(
|
|
35
|
+
"--window",
|
|
36
|
+
action="append",
|
|
37
|
+
type=_window,
|
|
38
|
+
default=[],
|
|
39
|
+
metavar="START:END",
|
|
40
|
+
help="a date range the answer used (repeatable)",
|
|
41
|
+
)
|
|
42
|
+
check.add_argument("--user", default="", help="the user's question; numbers in it may be repeated")
|
|
43
|
+
check.add_argument("--number-words", action="store_true", help="also flag words like 'double' or 'twenty'")
|
|
44
|
+
check.add_argument("--json", action="store_true", help="print violations as JSON")
|
|
45
|
+
|
|
46
|
+
render = sub.add_parser("render", help="replace placeholders with formatted values")
|
|
47
|
+
render.add_argument("draft", help="text file, or - for stdin")
|
|
48
|
+
render.add_argument("--facts", required=True, help="JSON file with the facts")
|
|
49
|
+
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
facts = Facts.from_records(json.loads(_read(args.facts)))
|
|
52
|
+
text = _read(args.draft)
|
|
53
|
+
|
|
54
|
+
if args.command == "render":
|
|
55
|
+
try:
|
|
56
|
+
sys.stdout.write(facts.render(text))
|
|
57
|
+
except KeyError as e:
|
|
58
|
+
print(f"factguard: {e.args[0]}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
report = validate(text, facts, windows=args.window, user_text=args.user, number_words=args.number_words)
|
|
63
|
+
if args.json:
|
|
64
|
+
print(json.dumps([v.__dict__ for v in report.violations], indent=2))
|
|
65
|
+
else:
|
|
66
|
+
for v in report.violations:
|
|
67
|
+
line = text.count("\n", 0, v.start) + 1
|
|
68
|
+
print(f"{args.draft}:{line}: {v.kind}: `{v.token}`: {v.detail}")
|
|
69
|
+
return 0 if report.ok else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
factguard/facts.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Facts are numbers computed in code. The LLM cites them by placeholder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
Kind = Literal["money", "percent", "count", "number", "ratio", "text"]
|
|
12
|
+
KINDS: tuple[str, ...] = ("money", "percent", "count", "number", "ratio", "text")
|
|
13
|
+
|
|
14
|
+
KEY = r"[A-Za-z0-9_.:\-]+"
|
|
15
|
+
PLACEHOLDER = re.compile(r"\{\{\s*fact:(" + KEY + r")\s*\}\}")
|
|
16
|
+
|
|
17
|
+
# Codes and symbols the model tends to type in front of a money placeholder.
|
|
18
|
+
# Kept to real currencies so "CPA {{fact:cpa}}" is never touched.
|
|
19
|
+
CURRENCIES = frozenset(
|
|
20
|
+
"USD EUR GBP INR JPY CNY AUD CAD NZD SGD HKD AED SAR CHF SEK NOK DKK ZAR BRL MXN IDR THB MYR PHP KRW".split()
|
|
21
|
+
)
|
|
22
|
+
_TYPED_CURRENCY = re.compile(
|
|
23
|
+
r"(?:(?<![A-Za-z])(?P<code>[A-Z]{3}|Rs\.?)\s?|(?P<symbol>[$€£₹¥])\s?)"
|
|
24
|
+
r"(?P<placeholder>\{\{\s*fact:(?P<key>" + KEY + r")\s*\}\})"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
RULES = """\
|
|
28
|
+
Every number in your answer must come from the facts listed below.
|
|
29
|
+
- Cite a fact only as its placeholder, exactly as written, for example {{fact:spend}}. \
|
|
30
|
+
Never type its value: code fills it in, already formatted with any currency or % sign.
|
|
31
|
+
- Do not type any other digits: no totals, differences, percentages, dates, \
|
|
32
|
+
durations or thresholds you worked out yourself.
|
|
33
|
+
- If a number you want is not in the list, describe the change in words without a figure."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Fact:
|
|
38
|
+
"""One computed value, plus where it came from."""
|
|
39
|
+
|
|
40
|
+
key: str
|
|
41
|
+
value: float | int | str
|
|
42
|
+
kind: Kind = "number"
|
|
43
|
+
label: str = ""
|
|
44
|
+
currency: str | None = None
|
|
45
|
+
decimals: int | None = None
|
|
46
|
+
source: str | None = None
|
|
47
|
+
step: str | None = None
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not re.fullmatch(KEY, self.key):
|
|
51
|
+
raise ValueError(f"fact key {self.key!r} may only use letters, digits and _ . : -")
|
|
52
|
+
if self.kind not in KINDS:
|
|
53
|
+
raise ValueError(f"fact kind must be one of {', '.join(KINDS)}, not {self.kind!r}")
|
|
54
|
+
numeric = isinstance(self.value, (int, float)) and not isinstance(self.value, bool)
|
|
55
|
+
if self.kind != "text" and not numeric:
|
|
56
|
+
raise TypeError(f"fact {self.key!r} is {self.kind}, so its value must be a number")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def placeholder(self) -> str:
|
|
60
|
+
return "{{fact:" + self.key + "}}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _trim(s: str) -> str:
|
|
64
|
+
return s.rstrip("0").rstrip(".") if "." in s else s
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _compact(x: float, decimals: int) -> str | None:
|
|
68
|
+
"""60_240 -> "60.2K". Returns None below a thousand."""
|
|
69
|
+
for size, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
70
|
+
# Check the rounded value so 999_960 becomes "1M", not "1000K".
|
|
71
|
+
if abs(x) >= 1000 and round(abs(x) / size, decimals) >= 1:
|
|
72
|
+
return _trim(f"{x / size:.{decimals}f}") + suffix
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Formatter:
|
|
78
|
+
"""Turns a fact into the text that replaces its placeholder.
|
|
79
|
+
|
|
80
|
+
Percent facts are fractions: 0.034 renders as "3.4%".
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
currency: str | None = None
|
|
84
|
+
compact: bool = True
|
|
85
|
+
|
|
86
|
+
def __call__(self, fact: Fact) -> str:
|
|
87
|
+
if fact.kind == "text":
|
|
88
|
+
return str(fact.value)
|
|
89
|
+
x = float(fact.value)
|
|
90
|
+
if not math.isfinite(x):
|
|
91
|
+
return "n/a"
|
|
92
|
+
if fact.kind == "money":
|
|
93
|
+
return self._prefix(fact.currency or self.currency) + self._money(x, fact.decimals)
|
|
94
|
+
if fact.kind == "percent":
|
|
95
|
+
return _trim(f"{x * 100:.{1 if fact.decimals is None else fact.decimals}f}") + "%"
|
|
96
|
+
if fact.kind == "count":
|
|
97
|
+
return f"{round(x):,}"
|
|
98
|
+
if fact.kind == "ratio":
|
|
99
|
+
return _trim(f"{x:.{2 if fact.decimals is None else fact.decimals}f}") + "x"
|
|
100
|
+
if x.is_integer() and fact.decimals is None:
|
|
101
|
+
return f"{int(x):,}"
|
|
102
|
+
return _trim(f"{x:,.{2 if fact.decimals is None else fact.decimals}f}")
|
|
103
|
+
|
|
104
|
+
def _money(self, x: float, decimals: int | None) -> str:
|
|
105
|
+
if self.compact and (short := _compact(x, 1 if decimals is None else decimals)):
|
|
106
|
+
return short
|
|
107
|
+
if decimals is not None:
|
|
108
|
+
return f"{x:,.{decimals}f}"
|
|
109
|
+
if x.is_integer() or abs(x) >= 100:
|
|
110
|
+
return f"{x:,.0f}"
|
|
111
|
+
return f"{x:,.2f}"
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _prefix(currency: str | None) -> str:
|
|
115
|
+
if not currency:
|
|
116
|
+
return ""
|
|
117
|
+
return currency + " " if currency.isalpha() else currency
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Facts:
|
|
121
|
+
"""The facts for one answer. Renders placeholders and describes facts to the LLM."""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
facts: Iterable[Fact] = (),
|
|
126
|
+
*,
|
|
127
|
+
currency: str | None = None,
|
|
128
|
+
formatter: Callable[[Fact], str] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._facts: dict[str, Fact] = {}
|
|
131
|
+
self.formatter = formatter or Formatter(currency=currency)
|
|
132
|
+
for fact in facts:
|
|
133
|
+
self.put(fact)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_records(cls, data: Mapping[str, Any] | Iterable[Mapping[str, Any]]) -> Facts:
|
|
137
|
+
"""Load {"currency": ..., "facts": [...]}, a list of fact dicts, or {key: value}."""
|
|
138
|
+
currency = None
|
|
139
|
+
if isinstance(data, Mapping):
|
|
140
|
+
if "facts" in data:
|
|
141
|
+
currency = data.get("currency")
|
|
142
|
+
data = data["facts"]
|
|
143
|
+
else:
|
|
144
|
+
data = [{"key": k, "value": v} for k, v in data.items()]
|
|
145
|
+
facts = cls(currency=currency)
|
|
146
|
+
for record in data:
|
|
147
|
+
record = dict(record)
|
|
148
|
+
if "kind" not in record:
|
|
149
|
+
record["kind"] = "number" if isinstance(record.get("value"), (int, float)) else "text"
|
|
150
|
+
facts.put(Fact(**record))
|
|
151
|
+
return facts
|
|
152
|
+
|
|
153
|
+
def add(self, key: str, value: float | int | str, kind: Kind = "number", **fields: Any) -> Fact:
|
|
154
|
+
return self.put(Fact(key, value, kind, **fields))
|
|
155
|
+
|
|
156
|
+
def put(self, fact: Fact) -> Fact:
|
|
157
|
+
if fact.key in self._facts:
|
|
158
|
+
raise ValueError(f"fact {fact.key!r} is already defined")
|
|
159
|
+
self._facts[fact.key] = fact
|
|
160
|
+
return fact
|
|
161
|
+
|
|
162
|
+
def get(self, key: str) -> Fact | None:
|
|
163
|
+
return self._facts.get(key)
|
|
164
|
+
|
|
165
|
+
def __getitem__(self, key: str) -> Fact:
|
|
166
|
+
return self._facts[key]
|
|
167
|
+
|
|
168
|
+
def __contains__(self, key: object) -> bool:
|
|
169
|
+
return key in self._facts
|
|
170
|
+
|
|
171
|
+
def __iter__(self) -> Iterator[Fact]:
|
|
172
|
+
return iter(self._facts.values())
|
|
173
|
+
|
|
174
|
+
def __len__(self) -> int:
|
|
175
|
+
return len(self._facts)
|
|
176
|
+
|
|
177
|
+
def format(self, fact: Fact | str) -> str:
|
|
178
|
+
return self.formatter(self[fact] if isinstance(fact, str) else fact)
|
|
179
|
+
|
|
180
|
+
def strip_currency(self, text: str) -> str:
|
|
181
|
+
"""Drop a currency the model typed before a money placeholder: "INR {{fact:spend}}"."""
|
|
182
|
+
|
|
183
|
+
def fix(m: re.Match[str]) -> str:
|
|
184
|
+
fact = self.get(m["key"])
|
|
185
|
+
typed = m["code"] or m["symbol"]
|
|
186
|
+
known = typed in CURRENCIES or typed.startswith("Rs") or m["symbol"] is not None
|
|
187
|
+
if fact is not None and fact.kind == "money" and known:
|
|
188
|
+
return m["placeholder"]
|
|
189
|
+
return m[0]
|
|
190
|
+
|
|
191
|
+
return _TYPED_CURRENCY.sub(fix, text)
|
|
192
|
+
|
|
193
|
+
def render(self, text: str) -> str:
|
|
194
|
+
"""Replace every placeholder with its formatted value. Unknown keys raise KeyError,
|
|
195
|
+
so validate first."""
|
|
196
|
+
|
|
197
|
+
def fill(m: re.Match[str]) -> str:
|
|
198
|
+
if m[1] not in self._facts:
|
|
199
|
+
raise KeyError(f"unknown fact {m[1]!r}")
|
|
200
|
+
return self.format(m[1])
|
|
201
|
+
|
|
202
|
+
return PLACEHOLDER.sub(fill, self.strip_currency(text))
|
|
203
|
+
|
|
204
|
+
def prompt(self, *, rules: bool = True) -> str:
|
|
205
|
+
"""The fact list for the system prompt, optionally preceded by the citation rules."""
|
|
206
|
+
lines = [RULES, ""] if rules else []
|
|
207
|
+
lines.append("Facts:")
|
|
208
|
+
for fact in self:
|
|
209
|
+
about = ", ".join(x for x in (fact.label, fact.kind, fact.source) if x)
|
|
210
|
+
lines.append(f"- {fact.placeholder} = {self.format(fact)} ({about})")
|
|
211
|
+
return "\n".join(lines)
|
factguard/guard.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The validate, retry once, then drop loop around any LLM call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .facts import Facts
|
|
10
|
+
from .sentences import drop_sentences, split_sentences
|
|
11
|
+
from .validate import Report, Violation, validate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Retry:
|
|
16
|
+
"""Handed to your generate function when the previous draft failed."""
|
|
17
|
+
|
|
18
|
+
draft: str
|
|
19
|
+
report: Report
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def feedback(self) -> str:
|
|
23
|
+
return self.report.feedback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Result:
|
|
28
|
+
text: str # rendered: placeholders replaced with formatted values
|
|
29
|
+
raw: str # the accepted text, still with placeholders
|
|
30
|
+
attempts: int
|
|
31
|
+
dropped: tuple[str, ...] # sentences removed because they still broke the rules
|
|
32
|
+
violations: tuple[Violation, ...] # what the last draft got wrong
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def clean(self) -> bool:
|
|
36
|
+
"""True when nothing had to be dropped."""
|
|
37
|
+
return not self.dropped
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _finish(draft: str, report: Report, facts: Facts, attempts: int, rules: dict[str, Any]) -> Result:
|
|
41
|
+
if report.ok:
|
|
42
|
+
return Result(facts.render(draft), draft, attempts, (), ())
|
|
43
|
+
kept, dropped = draft, []
|
|
44
|
+
current = report
|
|
45
|
+
# One pass is enough for local violations; loop in case a drop changes what a
|
|
46
|
+
# later sentence means (a list marker that lost its line, say).
|
|
47
|
+
for _ in range(3):
|
|
48
|
+
kept, more = drop_sentences(kept, [(v.start, v.end) for v in current.violations])
|
|
49
|
+
dropped += more
|
|
50
|
+
current = validate(kept, facts, **rules)
|
|
51
|
+
if current.ok:
|
|
52
|
+
break
|
|
53
|
+
else:
|
|
54
|
+
kept, dropped = "", dropped + [kept]
|
|
55
|
+
return Result(facts.render(kept), kept, attempts, tuple(dropped), report.violations)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def guard(
|
|
59
|
+
generate: Callable[[Retry | None], str],
|
|
60
|
+
facts: Facts,
|
|
61
|
+
*,
|
|
62
|
+
retries: int = 1,
|
|
63
|
+
**rules: Any,
|
|
64
|
+
) -> Result:
|
|
65
|
+
"""Call generate(None) for a draft. If it breaks the rules, call generate(Retry)
|
|
66
|
+
up to `retries` times. If the last draft still fails, drop the offending sentences.
|
|
67
|
+
|
|
68
|
+
rules are passed to validate(): windows, user_text, allow, number_words, ...
|
|
69
|
+
"""
|
|
70
|
+
retry = None
|
|
71
|
+
attempts = 0
|
|
72
|
+
while True:
|
|
73
|
+
attempts += 1
|
|
74
|
+
draft = generate(retry)
|
|
75
|
+
report = validate(draft, facts, **rules)
|
|
76
|
+
if report.ok or attempts > retries:
|
|
77
|
+
return _finish(draft, report, facts, attempts, rules)
|
|
78
|
+
retry = Retry(draft, report)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def aguard(
|
|
82
|
+
generate: Callable[[Retry | None], Awaitable[str]],
|
|
83
|
+
facts: Facts,
|
|
84
|
+
*,
|
|
85
|
+
retries: int = 1,
|
|
86
|
+
**rules: Any,
|
|
87
|
+
) -> Result:
|
|
88
|
+
"""guard() for an async generate function."""
|
|
89
|
+
retry = None
|
|
90
|
+
attempts = 0
|
|
91
|
+
while True:
|
|
92
|
+
attempts += 1
|
|
93
|
+
draft = await generate(retry)
|
|
94
|
+
report = validate(draft, facts, **rules)
|
|
95
|
+
if report.ok or attempts > retries:
|
|
96
|
+
return _finish(draft, report, facts, attempts, rules)
|
|
97
|
+
retry = Retry(draft, report)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class StreamGuard:
|
|
101
|
+
"""Validates streamed text sentence by sentence.
|
|
102
|
+
|
|
103
|
+
A sentence already on screen can't be rewritten, so a failing sentence is
|
|
104
|
+
dropped rather than retried.
|
|
105
|
+
|
|
106
|
+
sg = StreamGuard(facts, windows=[window])
|
|
107
|
+
for chunk in stream:
|
|
108
|
+
for sentence in sg.feed(chunk):
|
|
109
|
+
show(sentence)
|
|
110
|
+
for sentence in sg.close():
|
|
111
|
+
show(sentence)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, facts: Facts, **rules: Any) -> None:
|
|
115
|
+
self.facts = facts
|
|
116
|
+
self.rules = rules
|
|
117
|
+
self.dropped: list[str] = []
|
|
118
|
+
self.violations: list[Violation] = []
|
|
119
|
+
self._buffer = ""
|
|
120
|
+
self._line_closed = True
|
|
121
|
+
|
|
122
|
+
def feed(self, chunk: str) -> list[str]:
|
|
123
|
+
self._buffer += chunk
|
|
124
|
+
pieces = split_sentences(self._buffer)
|
|
125
|
+
# The last piece may still be growing.
|
|
126
|
+
self._buffer = pieces.pop() if pieces else ""
|
|
127
|
+
return [out for piece in pieces if (out := self._check(piece))]
|
|
128
|
+
|
|
129
|
+
def close(self) -> list[str]:
|
|
130
|
+
rest, self._buffer = self._buffer, ""
|
|
131
|
+
out = self._check(rest) if rest else ""
|
|
132
|
+
return [out] if out else []
|
|
133
|
+
|
|
134
|
+
def _check(self, piece: str) -> str:
|
|
135
|
+
report = validate(piece, self.facts, **self.rules)
|
|
136
|
+
if report.ok:
|
|
137
|
+
self._line_closed = piece.endswith("\n")
|
|
138
|
+
return self.facts.render(piece)
|
|
139
|
+
self.dropped.append(piece.strip())
|
|
140
|
+
self.violations.extend(report.violations)
|
|
141
|
+
# Same rule as drop_sentences: close the line only if it isn't closed already.
|
|
142
|
+
ends_line = "\n" in piece[len(piece.rstrip()) :]
|
|
143
|
+
return "\n" if ends_line and not self._line_closed else ""
|
factguard/py.typed
ADDED
|
File without changes
|
factguard/sentences.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Sentence splitting that keeps whitespace, so dropped sentences leave tidy text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
_END = re.compile(r"[.!?]+[\"')\]]*[ \t]*(?:\n\s*|[ \t]+)|\n\s*")
|
|
9
|
+
_ABBREVIATION = re.compile(r"(?:\b(?:e\.g|i\.e|vs|etc|approx|incl|Mr|Ms|Dr)|^\s*\d{1,3})\.$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def split_sentences(text: str) -> list[str]:
|
|
13
|
+
"""Split text into sentences and lines. Each piece keeps its trailing whitespace,
|
|
14
|
+
so "".join(pieces) == text."""
|
|
15
|
+
pieces: list[str] = []
|
|
16
|
+
start = 0
|
|
17
|
+
for m in _END.finditer(text):
|
|
18
|
+
line_start = text.rfind("\n", 0, m.start()) + 1
|
|
19
|
+
# "e.g. " and the "1. " of a numbered list are not sentence ends.
|
|
20
|
+
if text[m.start()] == "." and _ABBREVIATION.search(text[line_start : m.start() + 1]):
|
|
21
|
+
continue
|
|
22
|
+
pieces.append(text[start : m.end()])
|
|
23
|
+
start = m.end()
|
|
24
|
+
if start < len(text):
|
|
25
|
+
pieces.append(text[start:])
|
|
26
|
+
return pieces
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_EMPTY_BULLET = re.compile(r"(?m)^[ \t]*(?:[-*•]|\d{1,3}[.)])[ \t]*$\n?")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def drop_sentences(text: str, spans: Iterable[tuple[int, int]]) -> tuple[str, list[str]]:
|
|
33
|
+
"""Remove every sentence that overlaps one of the (start, end) spans.
|
|
34
|
+
|
|
35
|
+
Returns the remaining text and the removed sentences."""
|
|
36
|
+
spans = list(spans)
|
|
37
|
+
kept: list[str] = []
|
|
38
|
+
dropped: list[str] = []
|
|
39
|
+
pos = 0
|
|
40
|
+
for piece in split_sentences(text):
|
|
41
|
+
start, end = pos, pos + len(piece)
|
|
42
|
+
pos = end
|
|
43
|
+
if any(s < end and e > start for s, e in spans):
|
|
44
|
+
body = piece.rstrip()
|
|
45
|
+
dropped.append(body.strip())
|
|
46
|
+
# A sentence that ended its line takes the line break with it; keep the
|
|
47
|
+
# break unless the line is already closed, so lists keep their shape.
|
|
48
|
+
if "\n" in piece[len(body) :] and kept and not kept[-1].endswith("\n"):
|
|
49
|
+
kept.append("\n")
|
|
50
|
+
else:
|
|
51
|
+
kept.append(piece)
|
|
52
|
+
out = "".join(kept)
|
|
53
|
+
out = _EMPTY_BULLET.sub("", out)
|
|
54
|
+
out = re.sub(r"[ \t]+\n", "\n", out)
|
|
55
|
+
out = re.sub(r"\n{3,}", "\n\n", out)
|
|
56
|
+
return out.strip(), dropped
|
factguard/testing.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Helpers for eval suites: assert that recorded answers contain zero ungrounded numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .facts import Facts
|
|
8
|
+
from .validate import Report, validate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assert_grounded(text: str, facts: Facts, **rules: Any) -> Report:
|
|
12
|
+
"""Raise AssertionError listing every ungrounded number in text."""
|
|
13
|
+
report = validate(text, facts, **rules)
|
|
14
|
+
if not report.ok:
|
|
15
|
+
raise AssertionError(report.feedback)
|
|
16
|
+
return report
|
factguard/validate.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Checks LLM text for numbers that did not come from a fact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date
|
|
10
|
+
|
|
11
|
+
from .facts import PLACEHOLDER, Facts
|
|
12
|
+
|
|
13
|
+
Window = tuple[date, date]
|
|
14
|
+
|
|
15
|
+
_MALFORMED = re.compile(r"\{\{[^{}]*\}\}|\{fact:[^{}]*\}")
|
|
16
|
+
_LIST_MARKER = re.compile(r"(?m)^[ \t]*(?:[-*•][ \t]+)?\d{1,3}[.)](?=\s)")
|
|
17
|
+
# Names and words that contain digits but state no quantity.
|
|
18
|
+
_TERMS = re.compile(r"(?i)\b(?:GA4|GA360|B2B|B2C|D2C|C2C|P2P|24/7|[123](?:st|nd|rd)[- ]party|H\.26[45])\b")
|
|
19
|
+
_ATTRIBUTION = re.compile(r"(?i)\b\d{1,2}[- ]?(?:days?|d)[- ](?:click|view)s?\b")
|
|
20
|
+
|
|
21
|
+
_MONTH = (
|
|
22
|
+
r"(?P<mon>Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?"
|
|
23
|
+
r"|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?"
|
|
24
|
+
)
|
|
25
|
+
_DAY = r"(?P<day>\d{1,2})(?:st|nd|rd|th)?"
|
|
26
|
+
_YEAR = r"(?:,?\s+(?P<year>(?:19|20)\d{2})\b)?"
|
|
27
|
+
_DATES = [
|
|
28
|
+
re.compile(r"\b(?P<year>\d{4})-(?P<m>\d{2})-(?P<day>\d{2})\b"),
|
|
29
|
+
re.compile(r"\b" + _MONTH + r"\s+" + _DAY + r"\b" + _YEAR),
|
|
30
|
+
re.compile(r"\b" + _DAY + r"\s+" + _MONTH + r"(?![A-Za-z])" + _YEAR),
|
|
31
|
+
]
|
|
32
|
+
_MONTH_YEAR = re.compile(r"\b" + _MONTH + r",?\s+(?P<year>(?:19|20)\d{2})\b")
|
|
33
|
+
_YEAR_ONLY = re.compile(r"\b(?:19|20)\d{2}\b")
|
|
34
|
+
_DURATION = re.compile(r"(?i)\b(?P<n>\d+)[- ]?(?P<unit>day|week|month)s?\b")
|
|
35
|
+
|
|
36
|
+
_TOKEN = re.compile(r"\S*\d\S*")
|
|
37
|
+
_NUMBER = re.compile(r"(?i)^[^\d\-+]*?(?P<sign>[-+])?(?P<num>\d[\d,]*(?:\.\d+)?)\s*(?P<suffix>%|x|k|m|mn|bn|b)?$")
|
|
38
|
+
_MULTIPLIER = {"k": 1e3, "m": 1e6, "mn": 1e6, "b": 1e9, "bn": 1e9}
|
|
39
|
+
|
|
40
|
+
_NUMBER_WORDS = re.compile(
|
|
41
|
+
r"(?i)\b(?:two|three|four|five|six|seven|eight|nine|ten|eleven|twelve"
|
|
42
|
+
r"|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|million|billion"
|
|
43
|
+
r"|dozen|double[sd]?|doubling|triple[sd]?|tripling|twice|half|halved|\w+fold)\b"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Violation:
|
|
49
|
+
"""One broken rule: what kind, the offending text and where it is."""
|
|
50
|
+
|
|
51
|
+
kind: str # unknown_fact | malformed_placeholder | literal_number | date | duration | number_word
|
|
52
|
+
token: str
|
|
53
|
+
start: int
|
|
54
|
+
end: int
|
|
55
|
+
detail: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Report:
|
|
60
|
+
text: str
|
|
61
|
+
violations: tuple[Violation, ...]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def ok(self) -> bool:
|
|
65
|
+
return not self.violations
|
|
66
|
+
|
|
67
|
+
def __bool__(self) -> bool:
|
|
68
|
+
return self.ok
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def feedback(self) -> str:
|
|
72
|
+
"""A retry message that names every offending key or token."""
|
|
73
|
+
if self.ok:
|
|
74
|
+
return ""
|
|
75
|
+
lines = [
|
|
76
|
+
"Your draft broke the rule that every number must come from a fact placeholder. "
|
|
77
|
+
"Fix only these and keep the rest of the draft as it is:"
|
|
78
|
+
]
|
|
79
|
+
seen = set()
|
|
80
|
+
for v in self.violations:
|
|
81
|
+
if (v.kind, v.token) not in seen:
|
|
82
|
+
seen.add((v.kind, v.token))
|
|
83
|
+
lines.append(f"- `{v.token}`: {v.detail}")
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fmt_windows(windows: Sequence[Window]) -> str:
|
|
88
|
+
return ", ".join(f"{a.isoformat()} to {b.isoformat()}" for a, b in windows)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _month_number(name: str) -> int:
|
|
92
|
+
return [m.lower() for m in calendar.month_abbr].index(name[:3].lower())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _value(token: str) -> float | None:
|
|
96
|
+
m = _NUMBER.match(token)
|
|
97
|
+
if not m:
|
|
98
|
+
return None
|
|
99
|
+
x = float(m["num"].replace(",", ""))
|
|
100
|
+
x *= _MULTIPLIER.get((m["suffix"] or "").lower(), 1)
|
|
101
|
+
return -x if m["sign"] == "-" else x
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _strip(token: str) -> tuple[str, int]:
|
|
105
|
+
"""Trim surrounding punctuation. Returns the token and how much was cut from the left."""
|
|
106
|
+
left = len(token) - len(token.lstrip("(\"'[“‘"))
|
|
107
|
+
return token[left:].rstrip(".,;:!?)\"']”’"), left
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Text:
|
|
111
|
+
"""The text being checked, with spans blanked out once they are accounted for."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, text: str) -> None:
|
|
114
|
+
self.text = text
|
|
115
|
+
self.masked = text
|
|
116
|
+
|
|
117
|
+
def blank(self, start: int, end: int) -> None:
|
|
118
|
+
self.masked = self.masked[:start] + " " * (end - start) + self.masked[end:]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate(
|
|
122
|
+
text: str,
|
|
123
|
+
facts: Facts,
|
|
124
|
+
*,
|
|
125
|
+
windows: Iterable[Window] = (),
|
|
126
|
+
user_text: str = "",
|
|
127
|
+
allow: Iterable[str | re.Pattern[str]] = (),
|
|
128
|
+
list_markers: bool = True,
|
|
129
|
+
attribution_labels: bool = True,
|
|
130
|
+
terms: bool = True,
|
|
131
|
+
number_words: bool = False,
|
|
132
|
+
) -> Report:
|
|
133
|
+
"""Check LLM text against the rule: no number the code did not compute.
|
|
134
|
+
|
|
135
|
+
windows: the date ranges this answer actually used. Dates, years and
|
|
136
|
+
durations ("last 28 days") are allowed only when they match one.
|
|
137
|
+
user_text: what the user wrote. Numbers they typed may be repeated.
|
|
138
|
+
allow: extra regexes for text that may contain digits.
|
|
139
|
+
terms: allow names like GA4, B2B and 3rd-party.
|
|
140
|
+
number_words: also flag quantities in words ("doubled", "twenty").
|
|
141
|
+
"""
|
|
142
|
+
windows = tuple(windows)
|
|
143
|
+
t = _Text(text)
|
|
144
|
+
found: list[Violation] = []
|
|
145
|
+
|
|
146
|
+
def flag(kind: str, start: int, end: int, detail: str) -> None:
|
|
147
|
+
found.append(Violation(kind, text[start:end], start, end, detail))
|
|
148
|
+
t.blank(start, end)
|
|
149
|
+
|
|
150
|
+
for m in PLACEHOLDER.finditer(text):
|
|
151
|
+
if m[1] in facts:
|
|
152
|
+
t.blank(*m.span())
|
|
153
|
+
else:
|
|
154
|
+
flag("unknown_fact", *m.span(), f"`{m[1]}` is not a known fact key. Use only keys from the fact list.")
|
|
155
|
+
for m in _MALFORMED.finditer(t.masked):
|
|
156
|
+
flag("malformed_placeholder", *m.span(), "write placeholders exactly as {{fact:key}}.")
|
|
157
|
+
|
|
158
|
+
patterns = [re.compile(p) if isinstance(p, str) else p for p in allow]
|
|
159
|
+
if list_markers:
|
|
160
|
+
patterns.append(_LIST_MARKER)
|
|
161
|
+
if attribution_labels:
|
|
162
|
+
patterns.append(_ATTRIBUTION)
|
|
163
|
+
if terms:
|
|
164
|
+
patterns.append(_TERMS)
|
|
165
|
+
for pattern in patterns:
|
|
166
|
+
for m in pattern.finditer(t.masked):
|
|
167
|
+
t.blank(*m.span())
|
|
168
|
+
|
|
169
|
+
_check_dates(t, windows, flag)
|
|
170
|
+
|
|
171
|
+
allowed = _numbers(user_text)
|
|
172
|
+
for m in _TOKEN.finditer(t.masked):
|
|
173
|
+
token, cut = _strip(m[0])
|
|
174
|
+
if not token:
|
|
175
|
+
continue
|
|
176
|
+
start = m.start() + cut
|
|
177
|
+
value = _value(token)
|
|
178
|
+
if token in allowed or (value is not None and value in allowed):
|
|
179
|
+
continue
|
|
180
|
+
flag(
|
|
181
|
+
"literal_number",
|
|
182
|
+
start,
|
|
183
|
+
start + len(token),
|
|
184
|
+
"this is a number you typed. Cite it with a {{fact:...}} placeholder, or say it without the figure.",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if number_words:
|
|
188
|
+
for m in _NUMBER_WORDS.finditer(t.masked):
|
|
189
|
+
flag("number_word", *m.span(), "this states a quantity in words. Cite a fact or drop the comparison.")
|
|
190
|
+
|
|
191
|
+
return Report(text, tuple(sorted(found, key=lambda v: v.start)))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _numbers(user_text: str) -> set[str | float]:
|
|
195
|
+
out: set[str | float] = set()
|
|
196
|
+
for m in _TOKEN.finditer(user_text):
|
|
197
|
+
token, _ = _strip(m[0])
|
|
198
|
+
out.add(token)
|
|
199
|
+
if (value := _value(token)) is not None:
|
|
200
|
+
out.add(value)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_dates(t: _Text, windows: tuple[Window, ...], flag) -> None:
|
|
205
|
+
where = f"the analysis window ({_fmt_windows(windows)})" if windows else "an analysis window, and none was given"
|
|
206
|
+
|
|
207
|
+
def judge(m: re.Match[str], inside: bool, kind: str = "date") -> None:
|
|
208
|
+
if inside:
|
|
209
|
+
t.blank(*m.span())
|
|
210
|
+
else:
|
|
211
|
+
flag(kind, *m.span(), f"dates and periods must come from {where}.")
|
|
212
|
+
|
|
213
|
+
def contains(d: date) -> bool:
|
|
214
|
+
return any(a <= d <= b for a, b in windows)
|
|
215
|
+
|
|
216
|
+
for i, pattern in enumerate(_DATES):
|
|
217
|
+
for m in pattern.finditer(t.masked):
|
|
218
|
+
month = int(m["m"]) if i == 0 else _month_number(m["mon"])
|
|
219
|
+
day = int(m["day"])
|
|
220
|
+
years = (
|
|
221
|
+
[int(m["year"])]
|
|
222
|
+
if m["year"]
|
|
223
|
+
else range(
|
|
224
|
+
min((a.year for a, _ in windows), default=0), max((b.year for _, b in windows), default=-1) + 1
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
inside = False
|
|
228
|
+
for year in years:
|
|
229
|
+
try:
|
|
230
|
+
inside = inside or contains(date(year, month, day))
|
|
231
|
+
except ValueError:
|
|
232
|
+
pass
|
|
233
|
+
judge(m, inside)
|
|
234
|
+
|
|
235
|
+
for m in _MONTH_YEAR.finditer(t.masked):
|
|
236
|
+
year, month = int(m["year"]), _month_number(m["mon"])
|
|
237
|
+
first, last = date(year, month, 1), date(year, month, calendar.monthrange(year, month)[1])
|
|
238
|
+
judge(m, any(a <= last and b >= first for a, b in windows))
|
|
239
|
+
|
|
240
|
+
for m in _YEAR_ONLY.finditer(t.masked):
|
|
241
|
+
judge(m, any(a.year <= int(m[0]) <= b.year for a, b in windows))
|
|
242
|
+
|
|
243
|
+
lengths = [(b - a).days + 1 for a, b in windows]
|
|
244
|
+
for m in _DURATION.finditer(t.masked):
|
|
245
|
+
n, unit = int(m["n"]), m["unit"].lower()
|
|
246
|
+
if unit == "day":
|
|
247
|
+
inside = n in lengths
|
|
248
|
+
elif unit == "week":
|
|
249
|
+
inside = n * 7 in lengths
|
|
250
|
+
else:
|
|
251
|
+
inside = any(abs(length - n * 30.44) <= 3 for length in lengths)
|
|
252
|
+
judge(m, inside, "duration")
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: factguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keep LLM-written text free of numbers your code didn't compute: fact placeholders, a strict validator, one retry, then drop the sentence
|
|
5
|
+
Project-URL: Homepage, https://github.com/shivamsingh262/factguard
|
|
6
|
+
Project-URL: Issues, https://github.com/shivamsingh262/factguard/issues
|
|
7
|
+
Project-URL: Design notes, https://shivamsingh262.github.io/notes/code-computes-llm-interprets/
|
|
8
|
+
Author: Shivam Singh
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,analytics,grounding,guardrails,hallucination,llm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# factguard
|
|
24
|
+
|
|
25
|
+
[](https://github.com/shivamsingh262/factguard/actions/workflows/ci.yml)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+
[](CONTRIBUTING.md)
|
|
30
|
+
|
|
31
|
+
Keep LLM-written text free of numbers your code didn't compute.
|
|
32
|
+
|
|
33
|
+
If an LLM writes a wrong number confidently, users stop trusting every other
|
|
34
|
+
number on the screen. factguard enforces one rule: **code computes every
|
|
35
|
+
number, the LLM only interprets.**
|
|
36
|
+
|
|
37
|
+
- Your code registers each number as a **fact**: a key, a value, a format
|
|
38
|
+
(money, percent, count, ratio...) and where it came from.
|
|
39
|
+
- The LLM cites facts as placeholders like `{{fact:spend}}` and never types the
|
|
40
|
+
digits itself.
|
|
41
|
+
- A **validator** rejects any literal number, unknown fact key, or date or
|
|
42
|
+
duration outside the analysis window.
|
|
43
|
+
- On failure the model gets **one retry** with a message naming exactly what
|
|
44
|
+
broke. If the retry fails too, the offending **sentences are dropped**. A
|
|
45
|
+
shorter answer is better than a wrong one.
|
|
46
|
+
|
|
47
|
+
It works with any LLM and any SDK, and has no dependencies. The design comes
|
|
48
|
+
from a production AI analyst for marketing teams; the write-up is
|
|
49
|
+
[Stopping an LLM from making up numbers in an AI analyst](https://shivamsingh262.github.io/notes/code-computes-llm-interprets/).
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install git+https://github.com/shivamsingh262/factguard
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from datetime import date
|
|
61
|
+
from factguard import Facts, guard
|
|
62
|
+
|
|
63
|
+
facts = Facts(currency="INR")
|
|
64
|
+
facts.add("spend", 61_240, "money", label="Ad spend this week")
|
|
65
|
+
facts.add("roas", 1.8, "ratio", label="ROAS this week")
|
|
66
|
+
facts.add("roas_prev", 2.4, "ratio", label="ROAS last week")
|
|
67
|
+
|
|
68
|
+
system_prompt = facts.prompt() # the rules, plus "- {{fact:spend}} = INR 61.2K (...)"
|
|
69
|
+
|
|
70
|
+
def generate(retry):
|
|
71
|
+
# Call your LLM here. On a retry, show it retry.draft and retry.feedback.
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
week = (date(2026, 9, 22), date(2026, 9, 28))
|
|
75
|
+
result = guard(generate, facts, windows=[week], user_text="Why did ROAS drop?")
|
|
76
|
+
|
|
77
|
+
result.text # "ROAS fell from 2.4x to 1.8x while spend held at INR 61.2K."
|
|
78
|
+
result.dropped # sentences removed because they still broke the rules
|
|
79
|
+
result.attempts # 1 or 2
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
[`examples/anthropic_analyst.py`](examples/anthropic_analyst.py) is a complete
|
|
83
|
+
example using Claude.
|
|
84
|
+
|
|
85
|
+
## What counts as a violation
|
|
86
|
+
|
|
87
|
+
| Kind | Example | Why it fails |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `literal_number` | `CPA is up 40%` | The model typed a number. Cite a fact or say it without the figure. |
|
|
90
|
+
| `unknown_fact` | `{{fact:f_9999.roas}}` | No such fact was computed. |
|
|
91
|
+
| `malformed_placeholder` | `{{spend}}` | Placeholders must be exactly `{{fact:key}}`. |
|
|
92
|
+
| `date` | `since Oct 3` | Dates, months and years must fall inside a window you passed. |
|
|
93
|
+
| `duration` | `over the last 45 days` | Durations must match the length of a window you passed. |
|
|
94
|
+
| `number_word` | `spend nearly doubled` | Only with `number_words=True`. |
|
|
95
|
+
|
|
96
|
+
Allowed without a fact:
|
|
97
|
+
|
|
98
|
+
- numbered list markers (`1.`, `2)`)
|
|
99
|
+
- attribution labels (`7-day click`, `1-day view`)
|
|
100
|
+
- names like `GA4`, `B2B`, `3rd-party` and `24/7`
|
|
101
|
+
- numbers the user typed (`user_text`), so "campaigns with ROAS below 2" can
|
|
102
|
+
repeat the 2
|
|
103
|
+
- your own patterns: `allow=[r"SKU-\d+"]`
|
|
104
|
+
|
|
105
|
+
Without `windows`, every date and duration fails. That is deliberate: the
|
|
106
|
+
model shouldn't do date arithmetic. Resolve "last month" in code and pass the
|
|
107
|
+
window you actually queried. Pass both windows when you compare two periods.
|
|
108
|
+
|
|
109
|
+
## The pieces
|
|
110
|
+
|
|
111
|
+
**`Facts`** holds the facts for one answer.
|
|
112
|
+
|
|
113
|
+
- `facts.add(key, value, kind, label=..., currency=..., decimals=..., source=..., step=...)`
|
|
114
|
+
- `facts.prompt()` returns the citation rules and fact list for your system prompt.
|
|
115
|
+
- `facts.render(text)` replaces placeholders with formatted values. It also strips a currency the model typed in
|
|
116
|
+
front of a money placeholder (`INR {{fact:spend}}`), since the fact brings its own.
|
|
117
|
+
- `Facts.from_records(json_data)` loads facts from JSON.
|
|
118
|
+
|
|
119
|
+
Formats: `money` renders as `INR 61.2K` or `$1.2M` (pass `Formatter(compact=False)` for `61,240`). `percent` takes
|
|
120
|
+
fractions, so `0.034` renders as `3.4%`. `count` renders as `12,480`, `ratio` as `2.3x`, and `text` as-is. To use
|
|
121
|
+
your own formatting, pass any `Callable[[Fact], str]` as `formatter=`.
|
|
122
|
+
|
|
123
|
+
**`validate(text, facts, **rules)`** returns a `Report` with `.ok`, `.violations` (kind, token, start, end) and
|
|
124
|
+
`.feedback`, a retry message that names each offending token.
|
|
125
|
+
|
|
126
|
+
**`guard(generate, facts, retries=1, **rules)`** runs the whole loop. `generate(None)` should return a first draft,
|
|
127
|
+
and `generate(retry)` a corrected one. Use `aguard` for async code.
|
|
128
|
+
|
|
129
|
+
**`StreamGuard(facts, **rules)`** checks streamed text one sentence at a time. A sentence already on screen can't
|
|
130
|
+
be rewritten, so a failing sentence is dropped instead of retried:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
sg = StreamGuard(facts, windows=[week])
|
|
134
|
+
for chunk in llm_stream:
|
|
135
|
+
for sentence in sg.feed(chunk):
|
|
136
|
+
ui.append(sentence)
|
|
137
|
+
for sentence in sg.close():
|
|
138
|
+
ui.append(sentence)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Evals.** Check every recorded answer for zero ungrounded numbers, not just for the right answer:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from factguard.testing import assert_grounded
|
|
145
|
+
|
|
146
|
+
def test_recorded_answer():
|
|
147
|
+
assert_grounded(answer, facts, windows=[week])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Or from the shell or CI:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
factguard check answer.txt --facts facts.json --window 2026-09-22:2026-09-28
|
|
154
|
+
factguard render answer.txt --facts facts.json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Trade-offs
|
|
158
|
+
|
|
159
|
+
The prose gets stiffer. Without "nearly double" or "about a third", the model
|
|
160
|
+
writes more literal sentences. Some sentences get dropped, and a retry adds
|
|
161
|
+
latency. If users have to trust the numbers before the interpretation is worth
|
|
162
|
+
anything, that trade is worth it.
|
|
163
|
+
|
|
164
|
+
Placeholders don't stop the model from citing the *wrong* fact, so keep fact
|
|
165
|
+
labels clear and test with recorded questions. And `number_words` is a
|
|
166
|
+
heuristic: it is off by default because words like "half" are often harmless.
|
|
167
|
+
|
|
168
|
+
## Contributing
|
|
169
|
+
|
|
170
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports with a sentence that was
|
|
171
|
+
wrongly flagged or wrongly let through are especially useful.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
factguard/__init__.py,sha256=4ZF4pMjZLxUGTHTl1LI5QC1hIbMLB9TI2mlVHZ6vycQ,632
|
|
2
|
+
factguard/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
factguard/cli.py,sha256=e-2uySQpnHycvGwp904kZCpU3utG7E7tBxThdNhl8iE,2739
|
|
4
|
+
factguard/facts.py,sha256=3qFffUzKHAQYizjwOTikhHcNQFFKDCNeuPFlpAGFAsU,7949
|
|
5
|
+
factguard/guard.py,sha256=IAoY6iKgpy54M9-wKdMbbVy2emOqqBdF9_YiBp8UkLI,4669
|
|
6
|
+
factguard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
factguard/sentences.py,sha256=MQTu2vWh38FGOkncm0Pjt8inU4IcNgG1AARwDyUM30s,2100
|
|
8
|
+
factguard/testing.py,sha256=wyT8JyE8wcuCz5Qk74lEvd1JteAYHgWUyq6hviOi9lQ,492
|
|
9
|
+
factguard/validate.py,sha256=MIf2hUhIeNlCtijKzx5jAdqGkr9i_MOWJ3bGMVRJrQc,8892
|
|
10
|
+
factguard-0.1.0.dist-info/METADATA,sha256=JosUWYZmwStaRADEPhj1U-_TJEe3wf5rsMSlXkLC6cw,7372
|
|
11
|
+
factguard-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
factguard-0.1.0.dist-info/entry_points.txt,sha256=W44S9PmOZh5qN5N1JlAxVDVhGiKQuimJLq6ZJ7l75c0,49
|
|
13
|
+
factguard-0.1.0.dist-info/licenses/LICENSE,sha256=7si93GIH3wzi1-ttSl--KR1PnU3Wvc4bJD4GXETM_Ys,1069
|
|
14
|
+
factguard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shivam Singh
|
|
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.
|