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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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
+ [![CI](https://github.com/shivamsingh262/factguard/actions/workflows/ci.yml/badge.svg)](https://github.com/shivamsingh262/factguard/actions/workflows/ci.yml)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
27
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)
28
+ ![No dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)
29
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ factguard = factguard.cli:main
@@ -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.