Skip to content

Commit 6d7a8a8

Browse files
authored
Package the report generators as rulechef-report and rulechef-savings (#18)
Fixes #6. - rulechef/report.py: generic rule report (generate() + CLI); gold data via JSONL only, no benchmarks imports - rulechef/savings.py: the branded savings report, moved from benchmarks - benchmarks/rule_report.py becomes a thin TAB-convenience wrapper - console entries in [project.scripts]; docs/README updated to the commands - fixes an out-parameter shadowing bug found while extracting generate() - version 0.2.1 Signed-off-by: Kovács Ádám <adaam.ko@gmail.com>
1 parent 9039365 commit 6d7a8a8

7 files changed

Lines changed: 280 additions & 213 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ chef.delete_rule("rule_id")
258258
The rules are the model — so you can read them. Generate a browsable report of any ruleset against labeled data: per-rule precision, and every true/false positive highlighted in context.
259259

260260
```bash
261-
python benchmarks/rule_report.py --rules my_rules.json --data gold.jsonl --out report.html
261+
rulechef-report --rules my_rules.json --data gold.jsonl --out report.html
262262
```
263263

264264
<p align="center">

benchmarks/INSPECTING_RULES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Replay a ruleset over observed LLM traffic and get a print-ready, KR Labs-brande
7171
report of how many calls the rules could take over:
7272

7373
```bash
74-
python benchmarks/savings_report.py \
74+
rulechef-savings \
7575
--rules benchmarks/results/results_banking77_new.json \
7676
--traffic benchmarks/results/sample_traffic_banking77.jsonl \
7777
--cost-per-call 0.002 --out savings.html

benchmarks/rule_report.py

Lines changed: 16 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -1,226 +1,38 @@
11
#!/usr/bin/env python3
2-
"""Generate a browsable HTML report of a learned ruleset against gold data.
3-
4-
Loads any ruleset (checkpoint / comparison result / dataset JSON) with
5-
RuleChef.load_rules, runs each rule over labeled data, classifies every match as
6-
a true or false positive against the gold entities, and writes a self-contained
7-
HTML file. Per rule you get its pattern, precision, TP/FP counts, and collapsible
8-
lists of every true positive and false positive with the matched span
9-
highlighted in its surrounding context (nothing is truncated). Open the file in
10-
any browser; a search box filters rules by name.
11-
12-
Dataset-agnostic. Provide gold data either as a JSONL file (one
13-
{"text": ..., "entities": [{"text","start","end","type"}]} per line) or use the
14-
built-in --dataset tab convenience loader.
15-
16-
Usage:
17-
python benchmarks/rule_report.py \
18-
--rules benchmarks/results/results_extract_tab.ckpt_rulechef.json \
19-
--dataset tab --test 600 --out rules_tab.html
20-
21-
python benchmarks/rule_report.py --rules my_rules.json --data my_gold.jsonl
2+
"""TAB-convenience wrapper around ``rulechef.report`` (the installed
3+
``rulechef-report`` command). Adds the --dataset tab loader used in the paper;
4+
for your own data use ``rulechef-report --rules ... --data gold.jsonl``.
225
"""
236

247
import argparse
25-
import html
26-
import json
278
import sys
28-
import tempfile
299
from pathlib import Path
30-
from unittest.mock import MagicMock
3110

3211
sys.path.insert(0, str(Path(__file__).parent))
3312

34-
CONTEXT = 90 # chars of context shown on each side of a matched span
35-
36-
37-
def load_gold(args):
38-
"""Return (list of {text, entities}, list of entity types)."""
39-
if args.data:
40-
rows = []
41-
for line in Path(args.data).read_text().splitlines():
42-
line = line.strip()
43-
if line:
44-
rows.append(json.loads(line))
45-
types = sorted({e["type"] for r in rows for e in r.get("entities", [])})
46-
return rows, types
47-
if args.dataset == "tab":
48-
from benchmark_extract import TAB_FORMAT, TAB_SEMANTIC, load_tab_ds
49-
50-
types = TAB_FORMAT + TAB_SEMANTIC
51-
_, test = load_tab_ds(args.train, args.test, args.seed, types)
52-
return test, types
53-
raise SystemExit("Provide --data <jsonl> or --dataset tab")
54-
55-
56-
def is_tp(pred, gold_entities):
57-
"""True positive: a gold entity of the same type whose span overlaps."""
58-
ps, pe = pred.get("start"), pred.get("end")
59-
for g in gold_entities:
60-
if g.get("type") != pred.get("type"):
61-
continue
62-
gs, ge = g.get("start"), g.get("end")
63-
if ps is not None and gs is not None:
64-
if max(ps, gs) < min(pe, ge): # overlap
65-
return True
66-
elif pred.get("text") and pred["text"].strip() == (g.get("text") or "").strip():
67-
return True
68-
return False
69-
70-
71-
def context_html(text, pred):
72-
"""Matched span highlighted in its surrounding context (span never trimmed)."""
73-
s, e = pred.get("start"), pred.get("end")
74-
span = pred.get("text", "")
75-
if s is None or e is None:
76-
idx = text.find(span)
77-
if idx < 0:
78-
return html.escape(span)
79-
s, e = idx, idx + len(span)
80-
left = text[max(0, s - CONTEXT) : s]
81-
right = text[e : e + CONTEXT]
82-
pre = "…" if s - CONTEXT > 0 else ""
83-
post = "…" if e + CONTEXT < len(text) else ""
84-
return (
85-
pre
86-
+ html.escape(left)
87-
+ "<mark>"
88-
+ html.escape(text[s:e])
89-
+ "</mark>"
90-
+ html.escape(right)
91-
+ post
92-
)
13+
from rulechef.report import generate, load_jsonl # noqa: E402
9314

9415

9516
def main():
96-
from rulechef import RuleChef
97-
from rulechef.core import Task, TaskType
98-
99-
p = argparse.ArgumentParser(description="Browsable HTML report of rules vs gold data")
100-
p.add_argument("--rules", required=True, help="rules file (checkpoint / result / dataset JSON)")
101-
p.add_argument("--data", default=None, help="gold data as JSONL: {text, entities:[...]}")
102-
p.add_argument("--dataset", default=None, choices=["tab"], help="built-in loader")
17+
p = argparse.ArgumentParser(description="Rule report (TAB convenience wrapper)")
18+
p.add_argument("--rules", required=True)
19+
p.add_argument("--data", default=None, help="gold JSONL; alternative to --dataset")
20+
p.add_argument("--dataset", default=None, choices=["tab"])
10321
p.add_argument("--train", type=int, default=1000)
10422
p.add_argument("--test", type=int, default=600)
10523
p.add_argument("--seed", type=int, default=42)
10624
p.add_argument("--out", default="rule_report.html")
10725
args = p.parse_args()
10826

109-
gold, types = load_gold(args)
110-
111-
task = Task(
112-
name="inspect",
113-
description="Extract entity spans.",
114-
input_schema={"text": "str"},
115-
output_schema={"entities": "List[{text,start,end,type}]"},
116-
type=TaskType.NER,
117-
text_field="text",
118-
)
119-
chef = RuleChef(
120-
task=task, client=MagicMock(), dataset_name="inspect", storage_path=tempfile.mkdtemp()
121-
)
122-
rules = chef.load_rules(args.rules)
123-
print(f"Loaded {len(rules)} rules from {args.rules}; scoring on {len(gold)} docs")
124-
125-
# Per rule, collect every match classified as TP or FP.
126-
per_rule = {r.id: {"rule": r, "tp": [], "fp": []} for r in rules}
127-
for doc in gold:
128-
text = doc["text"]
129-
gold_entities = doc.get("entities", [])
130-
for r in rules:
131-
out = chef.learner._apply_rules([r], {"text": text}, TaskType.NER, "text")
132-
for pred in out.get("entities", []) or []:
133-
bucket = "tp" if is_tp(pred, gold_entities) else "fp"
134-
per_rule[r.id][bucket].append(context_html(text, pred))
135-
136-
def stats(info):
137-
tp, fp = len(info["tp"]), len(info["fp"])
138-
n = tp + fp
139-
return tp, fp, n, (tp / n if n else None)
140-
141-
# Sort by precision (accuracy), highest first; rules that never fired go last.
142-
def sort_key(info):
143-
tp, fp, n, p = stats(info)
144-
return (0 if n else 1, -(p if p is not None else 0), -n)
145-
146-
cards = sorted(per_rule.values(), key=sort_key)
147-
148-
def items(matches):
149-
return "".join(f"<li>{m}</li>" for m in matches) or '<li class="none">none</li>'
150-
151-
def badge(p):
152-
if p is None:
153-
return "na"
154-
return "hi" if p >= 0.8 else "mid" if p >= 0.5 else "lo"
155-
156-
parts = []
157-
for info in cards:
158-
r = info["rule"]
159-
tp, fp, n, p = stats(info)
160-
pr = f"{p:.0%}" if p is not None else "n/a"
161-
out_type = (r.output_template or {}).get("type", "?")
162-
parts.append(
163-
f"""
164-
<div class="rule" data-name="{html.escape(r.name.lower())}">
165-
<div class="head">
166-
<span class="rname">{html.escape(r.name)}</span>
167-
<span class="type">{html.escape(str(out_type))}</span>
168-
<span class="prec {badge(p)}">{pr}</span>
169-
<span class="pills"><span class="tp">{tp} TP</span><span class="fp">{fp} FP</span></span>
170-
</div>
171-
<details class="pat"><summary>pattern</summary><code>{html.escape(r.pattern)}</code></details>
172-
<details open><summary>False positives <b>({fp})</b></summary><ul class="fp">{items(info["fp"])}</ul></details>
173-
<details><summary>True positives <b>({tp})</b></summary><ul class="tp">{items(info["tp"])}</ul></details>
174-
</div>"""
175-
)
27+
if args.data:
28+
rows = load_jsonl(args.data)
29+
elif args.dataset == "tab":
30+
from benchmark_extract import TAB_FORMAT, TAB_SEMANTIC, load_tab_ds
17631

177-
css = """
178-
:root{--bg:#f6f7f9;--card:#fff;--bd:#e3e6ea;--fg:#1f2328;--mut:#6b7280}
179-
*{box-sizing:border-box}
180-
body{font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
181-
background:var(--bg);color:var(--fg);max-width:920px;margin:0 auto;padding:1.5rem 1rem 4rem}
182-
h1{font-size:18px;font-weight:650;margin:.2rem 0 1rem}
183-
.bar{position:sticky;top:0;background:var(--bg);padding:.6rem 0;z-index:5}
184-
#q{width:100%;padding:.6rem .8rem;font-size:14px;border:1px solid var(--bd);border-radius:8px;background:var(--card)}
185-
.rule{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:.7rem .9rem;margin:.7rem 0;
186-
box-shadow:0 1px 2px rgba(0,0,0,.03)}
187-
.head{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
188-
.rname{font-weight:650;font-size:14px}
189-
.type{font-size:10.5px;letter-spacing:.03em;color:#475569;background:#eef2f6;border:1px solid #e0e6ec;
190-
padding:1px 7px;border-radius:999px;text-transform:uppercase}
191-
.prec{font-weight:650;font-size:12px;padding:1px 8px;border-radius:999px;color:#fff}
192-
.prec.hi{background:#16a34a}.prec.mid{background:#d97706}.prec.lo{background:#dc2626}.prec.na{background:#9aa3ad}
193-
.pills{margin-left:auto;display:flex;gap:.4rem;font-size:11.5px}
194-
.pills span{padding:1px 7px;border-radius:999px;border:1px solid var(--bd);color:var(--mut)}
195-
.pills .tp{color:#15803d;border-color:#bbf7d0;background:#f0fdf4}
196-
.pills .fp{color:#b91c1c;border-color:#fecaca;background:#fef2f2}
197-
details{margin-top:.5rem}
198-
summary{cursor:pointer;color:#475569;font-size:12px;user-select:none}
199-
summary:hover{color:#1f2328}
200-
.pat code{display:block;margin-top:.4rem;padding:.5rem .6rem;background:#0f172a;color:#e2e8f0;border-radius:6px;
201-
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
202-
ul{margin:.4rem 0 .2rem;padding:0;list-style:none}
203-
li{padding:.3rem .5rem;border-left:2px solid #eef0f3;margin:.2rem 0;font-size:12.5px;
204-
white-space:pre-wrap;overflow-wrap:anywhere;color:#374151}
205-
li.none{color:var(--mut);font-style:italic;border-left-color:transparent}
206-
.fp li{border-left-color:#fca5a5}.tp li{border-left-color:#86efac}
207-
mark{padding:0 2px;border-radius:3px;font-weight:600}
208-
.fp mark{background:#fecaca}.tp mark{background:#bbf7d0}
209-
"""
210-
js = """
211-
const q=document.getElementById('q');
212-
q.addEventListener('input',()=>{const v=q.value.toLowerCase();
213-
document.querySelectorAll('.rule').forEach(r=>r.style.display=r.dataset.name.includes(v)?'':'none');});
214-
"""
215-
title = f"Rule report &mdash; {len(rules)} rules, {len(gold)} docs (sorted by precision)"
216-
doc = f"""<!doctype html><html lang=en><meta charset=utf-8>
217-
<meta name=viewport content="width=device-width,initial-scale=1">
218-
<title>Rule report</title><style>{css}</style>
219-
<h1>{title}</h1>
220-
<div class="bar"><input id=q placeholder="filter rules by name…"></div>
221-
{''.join(parts)}<script>{js}</script>"""
222-
Path(args.out).write_text(doc)
223-
print(f"Wrote {args.out} ({len(doc) // 1024} KB) — open it in a browser")
32+
_, rows = load_tab_ds(args.train, args.test, args.seed, TAB_FORMAT + TAB_SEMANTIC)
33+
else:
34+
raise SystemExit("Provide --data <gold.jsonl> or --dataset tab")
35+
generate(args.rules, rows, args.out)
22436

22537

22638
if __name__ == "__main__":

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "rulechef"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Learn rule-based models from examples and LLM interactions"
99
requires-python = ">=3.10"
1010
readme = {file = "README.md", content-type = "text/markdown"}
@@ -81,6 +81,8 @@ Repository = "https://github.com/KRLabsOrg/rulechef"
8181

8282
[project.scripts]
8383
rulechef = "rulechef.cli:main"
84+
rulechef-report = "rulechef.report:main"
85+
rulechef-savings = "rulechef.savings:main"
8486

8587
[tool.hatch.build.targets.wheel]
8688
packages = ["rulechef"]

0 commit comments

Comments
 (0)