napier 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.
napier/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Napier: a device knowledge store.
2
+
3
+ Sources (manuals, firmware changelogs, community docs) are listed in sources.yaml, fetched into
4
+ ~/.napier, and turned into a text layer (one unit per PDF page or per document section) that
5
+ `grep` and `read` use directly. `ingest` also builds a hybrid vector + BM25 index for `search`.
6
+ Checked facts live in facts/<device>.yaml with where each one came from.
7
+
8
+ Citations use the page number printed in the manual, so you can turn straight to it.
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ try:
14
+ __version__ = version("napier") # pyproject.toml is the one place the version is written
15
+ except PackageNotFoundError: # running from a checkout that isn't installed
16
+ __version__ = "0.0.0+unknown"
napier/auto.py ADDED
@@ -0,0 +1,443 @@
1
+ """`napier refresh`: the unattended update pass, with a local model for the two checked steps.
2
+
3
+ 1. Check every source upstream. Nothing changed: write a one-line report and stop.
4
+ 2. Apply changed sources (new file, pin, offset; the local model reads the new version label off
5
+ the first page), re-ingest them, recheck their facts and fix moved pages.
6
+ 3. Eval the affected devices. A fact that now misses AND whose source changed gets its question
7
+ reworded by the local model, judged by search (top 3), up to 3 tries.
8
+ 4. Anything it can't settle goes on the "needs review" list: sources gone or erroring, anchors
9
+ that vanished (the claim may have changed), misses it couldn't fix.
10
+ 5. Full eval and tests, then a branch, commit and PR. With --merge it merges when the eval gate and
11
+ tests pass; the review list rides along in the PR and the report.
12
+
13
+ Keeps the Mac awake (not the display) while it runs. Reports land in ~/.napier/reports/.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as dt
18
+ import json
19
+ import os
20
+ import platform
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import urllib.request
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import yaml
29
+
30
+ from napier import facts as factsmod
31
+ from napier import paths, refresh
32
+ from napier.parse import norm
33
+ from napier.sources import load_sources
34
+ from napier.text import load_units
35
+
36
+ OLLAMA = os.environ.get("NAPIER_OLLAMA", "http://localhost:11434")
37
+ MODEL = os.environ.get("NAPIER_LOCAL_MODEL", "qwen3.5:9b")
38
+ TRIES = 3
39
+ RACK_NOTE = "notes/rack.md" # the generated rack note source (see napier.rack)
40
+
41
+ REWORD_SYSTEM = (
42
+ "You write the question a musician would type into a search box over hardware manuals. "
43
+ "You get a fact, the manual passage that states it, and passages a previous question wrongly "
44
+ "retrieved. Write ONE natural question (max 20 words) whose answer is the target passage. Name the "
45
+ "device by its real name, and use the passage's own terms for the control, jack, mode or setting. "
46
+ "Don't copy the passage's sentences. Reply with JSON only: {\"q\": \"...\"}"
47
+ )
48
+ VERSION_SYSTEM = (
49
+ "You read the first pages of a hardware manual or release notes and return its version label: "
50
+ "firmware or OS version and/or revision and date as printed, short, like 'firmware 1.5 (2026.11.02)' "
51
+ "or 'Rev C (2027-01-10)'. If none is printed, return null. Reply with JSON only: {\"version\": ...}"
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------- small helpers
56
+
57
+ def keep_awake() -> subprocess.Popen | None:
58
+ """Stop idle and system sleep for as long as this process lives; the display may still sleep."""
59
+ if platform.system() != "Darwin":
60
+ return None
61
+ return subprocess.Popen(["caffeinate", "-ims", "-w", str(os.getpid())])
62
+
63
+
64
+ def notify(title: str, msg: str) -> None:
65
+ if platform.system() == "Darwin":
66
+ subprocess.run(["osascript", "-e", f"display notification {json.dumps(msg)} with title {json.dumps(title)}"],
67
+ check=False)
68
+
69
+
70
+ def git(*args, check=True, cwd=None) -> str:
71
+ r = subprocess.run(["git", *args], cwd=cwd or paths.REPO_ROOT, capture_output=True, text=True)
72
+ if check and r.returncode:
73
+ raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
74
+ return r.stdout.strip()
75
+
76
+
77
+ def local_repo():
78
+ """The private overlay folder when it is its own git repo (rack and note changes are committed there)."""
79
+ lr = paths.local_root()
80
+ return lr if lr and (lr / ".git").exists() else None
81
+
82
+
83
+ def ollama_ok() -> bool:
84
+ try:
85
+ with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as r:
86
+ return any(m.get("name") == MODEL for m in json.loads(r.read()).get("models", []))
87
+ except Exception: # noqa: BLE001
88
+ return False
89
+
90
+
91
+ def chat_json(system: str, messages: list[dict]) -> dict:
92
+ body = json.dumps({"model": MODEL, "stream": False, "format": "json", "think": False,
93
+ "options": {"temperature": 0.3},
94
+ "messages": [{"role": "system", "content": system}, *messages]}).encode()
95
+ req = urllib.request.Request(f"{OLLAMA}/api/chat", data=body, headers={"Content-Type": "application/json"})
96
+ with urllib.request.urlopen(req, timeout=300) as r:
97
+ return json.loads(json.loads(r.read())["message"]["content"])
98
+
99
+
100
+ def device_name(device: str) -> str:
101
+ """A readable device name for prompts: its manual's title without 'manual', 'user guide' etc."""
102
+ import re
103
+ titles = [s.title for s in load_sources() if s.device == device and s.kind == "manual"]
104
+ t = titles[0] if titles else device.replace("_", " ")
105
+ t = re.sub(r"\s*\(.*?\)", "", t)
106
+ t = re.sub(r"\b(official\s+)?(user\s+|operation\s+)?(manual|guide|product page|cheat sheet)\b.*$", "", t,
107
+ flags=re.I)
108
+ return t.strip() or device.replace("_", " ")
109
+
110
+
111
+ def set_fact_fields(fid: str, **fields) -> None:
112
+ """Rewrite one fact's block with new field values; the rest of the file is untouched."""
113
+ device = factsmod.find(fid)["device"]
114
+ file = factsmod.facts_file(device)
115
+ text = file.read_text()
116
+ a, b = refresh._block_span(text, "id", fid)
117
+ fact = yaml.safe_load(text[a:b])[0]
118
+ fact.update(fields)
119
+ order = factsmod.ORDER
120
+ fact = {k: fact[k] for k in order if k in fact} | {k: v for k, v in fact.items() if k not in order}
121
+ file.write_text(refresh._replace_block(text, a, b, yaml.safe_dump([fact], sort_keys=False,
122
+ allow_unicode=True, width=110)))
123
+ yaml.safe_load(file.read_text())
124
+
125
+
126
+ # ---------------------------------------------------------------- local-model steps
127
+
128
+ VERSION_RE = re.compile(r"^[\w .,:()/+-]{1,60}$")
129
+
130
+
131
+ def new_version_label(sid: str, old: str) -> str | None:
132
+ units = load_units(sid)[:2]
133
+ head = "\n".join((u.get("md") or u.get("raw") or "") for u in units)[:3000]
134
+ if not head.strip():
135
+ return None
136
+ try:
137
+ v = chat_json(VERSION_SYSTEM, [{"role": "user", "content": f"Previous label: {old}\n\n{head}"}]).get("version")
138
+ except Exception: # noqa: BLE001
139
+ return None
140
+ if not v or not isinstance(v, str) or v.strip() == old or not VERSION_RE.match(v.strip()):
141
+ return None # anything that isn't a plain version label (URLs, sentences, markup) is dropped
142
+ return v.strip()
143
+
144
+
145
+ def passage_for(fact: dict) -> str:
146
+ want = [norm(a) for a in fact["anchors"]]
147
+ for u in load_units(fact["source"]):
148
+ t = (u.get("md") or "") + " " + (u.get("raw") or "")
149
+ if all(w in norm(t) for w in want):
150
+ return (u.get("md") or u.get("raw") or "")[:2500]
151
+ return ""
152
+
153
+
154
+ def reword(fact: dict, searcher, kind: str | None, hits) -> tuple[str | None, int | None, list]:
155
+ """Up to TRIES questions from the local model; returns (q, rank, attempts) for the first that
156
+ puts the anchored passage in the top 3, else (None, best_rank, attempts)."""
157
+ from napier import evaluate as ev
158
+ name = device_name(fact["device"])
159
+ wrong = "\n".join(f"- {h.text[:200]}" for h in hits[:3])
160
+ msgs = [{"role": "user", "content": f"Device: {name}\nFact: {fact['claim']}\nOld question (failed): {fact['q']}\n\n"
161
+ f"Target passage:\n{passage_for(fact)}\n\nReturned instead:\n{wrong}"}]
162
+ attempts, best = [], None
163
+ for _ in range(TRIES):
164
+ try:
165
+ q = (chat_json(REWORD_SYSTEM, msgs).get("q") or "").strip()
166
+ except Exception as e: # noqa: BLE001
167
+ attempts.append(dict(error=str(e)))
168
+ continue
169
+ if not q or any(norm(a) in norm(q) for a in fact["anchors"]):
170
+ attempts.append(dict(q=q, rejected="empty or copied the anchor"))
171
+ msgs += [{"role": "assistant", "content": json.dumps({"q": q})},
172
+ {"role": "user", "content": "Rejected: write a different natural question."}]
173
+ continue
174
+ res = searcher.search(q, device=fact["device"], kind=kind, top=10)
175
+ rank = next((i + 1 for i, h in enumerate(res) if ev.holds(h.text, fact, primary_only=True)), None)
176
+ attempts.append(dict(q=q, rank=rank)) # type: ignore[dict-item]
177
+ if rank and rank <= 3:
178
+ return q, rank, attempts
179
+ if rank and (best is None or rank < best):
180
+ best = rank
181
+ got = "\n".join(f"- {h.text[:200]}" for h in res[:3])
182
+ msgs += [{"role": "assistant", "content": json.dumps({"q": q})},
183
+ {"role": "user", "content": f"The target ranked {rank or 'below 10'}. It returned:\n{got}\nTry again."}]
184
+ return None, best, attempts
185
+
186
+
187
+ # ---------------------------------------------------------------- the pass
188
+
189
+ def overlay_tests(lrepo) -> str:
190
+ """Run the overlay's own tests (local/tests) before public changes are committed and pushed, so a
191
+ private guard there (say, a check that nothing private reaches the public tree) can stop the push.
192
+ Returns "" when they pass or there are none, else the tail of pytest's output."""
193
+ if not lrepo or not (Path(lrepo) / "tests").is_dir():
194
+ return ""
195
+ r = subprocess.run([sys.executable, "-m", "pytest", "-q", "tests"], cwd=lrepo, capture_output=True, text=True)
196
+ return "" if r.returncode == 0 else "\n".join((r.stdout + r.stderr).strip().splitlines()[-15:])
197
+
198
+
199
+ def run(merge: bool = False, push: bool = True, log=print) -> dict:
200
+ from napier import evaluate as ev
201
+ from napier import search as sm
202
+ from napier.store import EMBED_MODEL, Store
203
+
204
+ awake = keep_awake()
205
+ today = dt.date.today().isoformat()
206
+ rep: dict[str, Any] = dict(date=today, model=MODEL, updated=[], review=[], reworded=[], pages_fixed=[], eval=None,
207
+ outcome="")
208
+ reports = paths.home() / "reports"
209
+ reports.mkdir(parents=True, exist_ok=True)
210
+
211
+ def finish(outcome: str) -> dict:
212
+ rep["outcome"] = outcome
213
+ (reports / f"refresh-{today}.json").write_text(json.dumps(rep, indent=1, ensure_ascii=False))
214
+ (reports / f"refresh-{today}.md").write_text(render(rep))
215
+ (reports / "latest.md").write_text(render(rep))
216
+ n = len(rep["review"])
217
+ notify("napier refresh", outcome + (f" · {n} for review" if n else ""))
218
+ log(outcome)
219
+ if awake:
220
+ awake.terminate()
221
+ return rep
222
+
223
+ try:
224
+ # 0. Don't touch work in progress.
225
+ if git("status", "--porcelain"):
226
+ return finish("skipped: napier has uncommitted changes")
227
+ if git("branch", "--show-current") != "main":
228
+ return finish("skipped: napier is not on main")
229
+ if push: # --no-push touches no remote: no pull either
230
+ git("pull", "--ff-only", "-q", check=False)
231
+ lrepo = local_repo()
232
+ if lrepo:
233
+ if git("status", "--porcelain", cwd=lrepo):
234
+ return finish("skipped: the local overlay has uncommitted changes")
235
+ if push:
236
+ git("pull", "--ff-only", "-q", check=False, cwd=lrepo)
237
+
238
+ # 1. What changed upstream?
239
+ srcs = load_sources()
240
+ checks = refresh.check(srcs, log=lambda c: log(f"{c.status:8s} {c.id} {c.detail}"))
241
+ changed = [c for c in checks if c.status == "changed"]
242
+ for c in checks:
243
+ if c.status in ("gone", "error"):
244
+ rep["review"].append(dict(kind=f"source-{c.status}", id=c.id, detail=c.detail))
245
+
246
+ # 1b. The rack from its rack source (a plug-in may fetch once): a change re-ingests notes/rack.md and gets committed.
247
+ rack_changed = False
248
+ try:
249
+ from napier import rack as rackmod
250
+ rr = rackmod.sync(log=lambda m: log(f"rack {m}"))
251
+ rack_changed = rr["changed"]
252
+ rep["rack"] = {k: [f"{m['vendor']} {m['name']}" for m in rr[k]]
253
+ for k in ("added", "removed", "recount", "unmapped",
254
+ "wishlist_added", "wishlist_removed", "wishlist_unmapped")}
255
+ rep["rack"]["changed"] = rack_changed
256
+ from napier import specs as specmod
257
+ snap = rackmod.load_snapshot() or {"modules": []}
258
+ rep["rack"]["power_mismatches"] = [f"{m['name']} ({', '.join(m['diffs'])})"
259
+ for m in specmod.compare(snap["modules"])]
260
+ for m in rr["unmapped"]:
261
+ rep["review"].append(dict(kind="rack-unmapped", id=m["slug"],
262
+ detail=f"{m['vendor']} {m['name']}: add its manual, then map it in racks/devices.yaml"))
263
+ for m in rr.get("wishlist_unmapped", []):
264
+ rep["review"].append(dict(kind="wishlist-unmapped", id=m["slug"],
265
+ detail=f"{m['vendor']} {m['name']} (planning rack, not owned): add its manual, "
266
+ "then map it in racks/devices.yaml"))
267
+ if rr.get("wishlist_error"):
268
+ rep["review"].append(dict(kind="wishlist-rack-error", id="wishlist", detail=rr["wishlist_error"]))
269
+ except Exception as e: # noqa: BLE001
270
+ rep["review"].append(dict(kind="rack-error", id="rack", detail=f"{type(e).__name__}: {e}"))
271
+ if not changed and not rack_changed:
272
+ return finish(f"no changes upstream ({len(checks)} sources checked, rack unchanged)")
273
+ llm = ollama_ok()
274
+ if not llm:
275
+ rep["review"].append(dict(kind="local-model", id=MODEL, detail=f"{MODEL} not reachable at {OLLAMA}"))
276
+
277
+ # 2. Apply, ingest, recheck.
278
+ byid = {s.id: s for s in srcs}
279
+ for c in changed:
280
+ a = refresh.apply(c.id)
281
+ entry = dict(id=c.id, device=c.device, old_version=c.version, new_version=None,
282
+ offset=a.get("offset"), offset_note=a.get("offset_note"))
283
+ rep["updated"].append(entry)
284
+ fresh = load_sources()
285
+ rack_ids = {s.id for s in fresh if rack_changed and s.path and s.path.replace("\\", "/") == RACK_NOTE}
286
+ Store().ingest([s for s in fresh if s.id in {c.id for c in changed} | rack_ids], embed_model=EMBED_MODEL,
287
+ all_sources=fresh, log=lambda m: None)
288
+ for e in rep["updated"]: # type: ignore[misc]
289
+ v = new_version_label(e["id"], e["old_version"]) if llm else None
290
+ base = re.sub(r"\s*\(changed upstream [\d-]+\)$", "", e["old_version"])
291
+ e["new_version"] = v or f"{base} (changed upstream {today})"
292
+ refresh.update_source_entry(e["id"], {"version": e["new_version"]})
293
+ if e.get("offset_note"):
294
+ rep["review"].append(dict(kind="page-offset", id=e["id"], detail=e["offset_note"]))
295
+ changed_ids = {c.id for c in changed} | rack_ids
296
+ for r in refresh.recheck():
297
+ if r.source not in changed_ids:
298
+ continue
299
+ if r.problem == "page-moved" and r.found_pages:
300
+ refresh.set_fact_pages(r.id, r.found_pages)
301
+ rep["pages_fixed"].append(dict(id=r.id, detail=r.detail))
302
+ elif r.problem in ("anchor-missing", "alt-only"):
303
+ rep["review"].append(dict(kind=r.problem, id=r.id, detail=r.detail))
304
+
305
+ # 3. Eval affected devices; reword misses on changed sources with the local model.
306
+ searcher = sm.Searcher(rerank_model=sm.RERANK_MODEL)
307
+ devices = sorted({e["device"] for e in rep["updated"]} | {s.device for s in fresh if s.id in rack_ids})
308
+ missing = {x["id"] for x in rep["review"] if x["kind"] == "anchor-missing"}
309
+ for d in devices:
310
+ cases = factsmod.eval_cases(d)
311
+ res = ev.evaluate(searcher, cases, k=10, rerank=True)
312
+ for f in cases:
313
+ r = res.ranks[f["id"]] # type: ignore[assignment]
314
+ if (r and r <= 3) or f["id"] in missing: # type: ignore[operator]
315
+ continue
316
+ kind = byid[f["source"]].kind if f.get("source") in byid else None
317
+ if f.get("source") in changed_ids and llm:
318
+ hits = searcher.search(f["q"], device=d, kind=kind, top=10)
319
+ q, rank, attempts = reword(f, searcher, kind, hits)
320
+ if q:
321
+ set_fact_fields(f["id"], q=q)
322
+ rep["reworded"].append(dict(id=f["id"], old=f["q"], new=q, rank=rank))
323
+ continue
324
+ rep["review"].append(dict(kind="eval-miss", id=f["id"], detail=f"local model couldn't fix (best rank {rank})",
325
+ attempts=attempts))
326
+ else:
327
+ rep["review"].append(dict(kind="eval-miss", id=f["id"],
328
+ detail=f"rank {r or '>10'}; source unchanged, so not reworded"))
329
+
330
+ # 3b. Specs: power anchors and jack names must still be on their cited pages.
331
+ from napier import specs as specmod
332
+ sc = specmod.check()
333
+ for pr in sc.problems:
334
+ rep["review"].append(dict(kind="spec", id=pr.spec, detail=f"{pr.what}: {pr.detail}"))
335
+
336
+ # 4. Gate: full eval + tests.
337
+ allres = ev.evaluate(searcher, factsmod.eval_cases(), k=10, rerank=True)
338
+ rep["eval"] = dict(hit1=round(allres.hit(1), 3), hit3=round(allres.hit(3), 3), facts=len(allres.ranks))
339
+ tests = subprocess.run([sys.executable, "-m", "pytest", "-q"], cwd=paths.REPO_ROOT, capture_output=True, text=True)
340
+ rep["tests"] = tests.stdout.strip().splitlines()[-1] if tests.stdout.strip() else "no output"
341
+ passing = allres.hit(3) >= ev.MIN_HIT3 and tests.returncode == 0
342
+
343
+ # 5. Branch, commit, PR, maybe merge.
344
+ if not push:
345
+ return finish(f"applied {len(rep['updated'])} updates locally (no push)")
346
+ names = ", ".join([e["id"] for e in rep["updated"]] + (["rack"] if rack_changed else [])) # local commit only
347
+ if lrepo and git("status", "--porcelain", cwd=lrepo):
348
+ # private: rack snapshot, generated notes, overlay sources and facts. Straight to its main.
349
+ git("add", "-A", cwd=lrepo)
350
+ git("commit", "-q", "-m", f"Auto refresh {today}: {names}", cwd=lrepo)
351
+ git("push", "-q", cwd=lrepo, check=False)
352
+ rep["local_commit"] = git("rev-parse", "--short", "HEAD", cwd=lrepo)
353
+ if not git("status", "--porcelain"):
354
+ return finish(f"updated the private overlay only ({names})")
355
+ failed = overlay_tests(lrepo)
356
+ if failed:
357
+ rep["review"].append(dict(kind="push-blocked", id="local/tests", detail=failed))
358
+ return finish("public changes left uncommitted: the overlay's tests failed (see review)")
359
+ pub = public_report(rep)
360
+ pub_names = ", ".join(e["id"] for e in pub["updated"]) or "facts"
361
+ branch = f"auto-refresh-{today}"
362
+ git("checkout", "-q", "-B", branch) # -B: a second run the same day reuses the name
363
+ git("add", *[d for d in ("sources.yaml", "facts", "specs") if (paths.REPO_ROOT / d).exists()])
364
+ git("commit", "-q", "-m", f"Auto refresh {today}: {pub_names}\n\n{render(pub, short=True)}")
365
+ git("push", "-q", "-u", "origin", branch)
366
+ body = render(pub)
367
+ pr = subprocess.run(["gh", "pr", "create", "--base", "main", "--head", branch, # type: ignore[assignment]
368
+ "--title", f"Auto refresh {today}", "--body", body],
369
+ cwd=paths.REPO_ROOT, capture_output=True, text=True)
370
+ rep["pr"] = pr.stdout.strip() or pr.stderr.strip() # type: ignore[attr-defined]
371
+ if pr.returncode: # type: ignore[attr-defined]
372
+ git("checkout", "-q", "main")
373
+ return finish(f"updated {len(rep['updated'])} sources, branch {branch} pushed but no PR ({rep['pr'][:200]})")
374
+ model_text = bool(rep["reworded"]) # questions the local model wrote: a person reviews them
375
+ if merge and passing and not model_text:
376
+ m = subprocess.run(["gh", "pr", "merge", branch, "--merge", "--delete-branch"], cwd=paths.REPO_ROOT,
377
+ capture_output=True, text=True)
378
+ git("checkout", "-q", "main")
379
+ git("pull", "-q", "--ff-only", check=False)
380
+ if m.returncode:
381
+ return finish(f"updated {len(rep['updated'])} sources, PR open (merge failed: "
382
+ f"{(m.stderr or m.stdout).strip()[:200]})")
383
+ return finish(f"updated {len(rep['updated'])} sources, merged (hit@3 {rep['eval']['hit3']:.0%})")
384
+ git("checkout", "-q", "main")
385
+ why = ("eval or tests below the gate" if not passing else
386
+ "questions reworded by the local model need review" if model_text else "left for review")
387
+ return finish(f"updated {len(rep['updated'])} sources, PR open ({why})")
388
+ except Exception as e: # noqa: BLE001 always leave a report, and leave the checkout on main
389
+ if git("branch", "--show-current", check=False) not in ("main", ""):
390
+ git("checkout", "-q", "main", check=False)
391
+ rep["review"].append(dict(kind="refresh-failed", id="napier refresh", detail=f"{type(e).__name__}: {e}"))
392
+ return finish(f"failed: {type(e).__name__}: {e}")
393
+
394
+ def public_report(rep: dict) -> dict:
395
+ """The parts of a refresh report that may go into the public repository's commit and PR: public
396
+ sources and facts only. Overlay sources, private facts and the rack stay in the local report."""
397
+ from napier.sources import load_sources as _load
398
+ pub_ids = {s.id for s in _load(paths.sources_file())}
399
+ pub_facts = {f["id"] for f in factsmod.load(root=paths.facts_dir())}
400
+ from napier import specs as specmod
401
+ pub_specs = set(specmod.load_all())
402
+ keep = pub_ids | pub_facts | pub_specs | {MODEL, "napier refresh", "local/tests"}
403
+ out = dict(rep)
404
+ out["updated"] = [e for e in rep["updated"] if e["id"] in pub_ids]
405
+ out["reworded"] = [r for r in rep["reworded"] if r["id"] in pub_facts]
406
+ out["pages_fixed"] = [r for r in rep["pages_fixed"] if r["id"] in pub_facts]
407
+ out["review"] = [x for x in rep["review"] if x.get("id") in keep and not x["kind"].startswith(("rack", "wishlist"))
408
+ and x["kind"] != "push-blocked"]
409
+ out.pop("rack", None)
410
+ out.pop("local_commit", None)
411
+ return out
412
+
413
+
414
+ def render(rep: dict, short: bool = False) -> str:
415
+ L = [f"# napier refresh {rep['date']}", "", f"**{rep.get('outcome', '')}**", ""]
416
+ if rep["updated"]:
417
+ L += ["## Updated sources", "", "| source | was | now |", "|---|---|---|"]
418
+ L += [f"| {e['id']} | {e['old_version']} | {e['new_version']} |" for e in rep["updated"]]
419
+ L.append("")
420
+ if rep["pages_fixed"]:
421
+ L += [f"Pages fixed automatically: {len(rep['pages_fixed'])}", ""]
422
+ if rep["reworded"]:
423
+ L += ["## Questions reworded by the local model", ""]
424
+ L += [f"- `{r['id']}`: {r['old']} -> {r['new']} (rank {r['rank']})" for r in rep["reworded"]]
425
+ L.append("")
426
+ rk = rep.get("rack") or {}
427
+ if any(rk.get(k) for k in ("added", "removed", "recount")):
428
+ L += ["## Rack changes", ""]
429
+ L += [f"- {k}: {', '.join(rk[k])}" for k in ("added", "removed", "recount") if rk.get(k)]
430
+ L.append("")
431
+ if rk.get("power_mismatches"):
432
+ L += [f"Manual vs rack-source power differs for {len(rk['power_mismatches'])} modules "
433
+ f"(`napier rack check`): {', '.join(rk['power_mismatches'])}", ""]
434
+ if rep["review"]:
435
+ L += ["## Needs review", ""]
436
+ L += [f"- {x['kind']}: `{x['id']}` {x.get('detail', '')}" for x in rep["review"]]
437
+ L.append("")
438
+ if rep.get("eval"):
439
+ L += [f"Eval: hit@1 {rep['eval']['hit1']:.0%}, hit@3 {rep['eval']['hit3']:.0%} over {rep['eval']['facts']} facts. "
440
+ f"Tests: {rep.get('tests', '')}", ""]
441
+ if not short:
442
+ L += [f"Local model: {rep['model']}"]
443
+ return "\n".join(L)