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 +16 -0
- napier/auto.py +443 -0
- napier/cli.py +986 -0
- napier/evaluate.py +64 -0
- napier/facts.py +165 -0
- napier/htmlmd.py +112 -0
- napier/mcp_server.py +232 -0
- napier/parse.py +156 -0
- napier/paths.py +158 -0
- napier/rack.py +707 -0
- napier/rack_sources.py +124 -0
- napier/refresh.py +267 -0
- napier/scenarios.py +306 -0
- napier/search.py +113 -0
- napier/sources.py +564 -0
- napier/specs.py +183 -0
- napier/store.py +202 -0
- napier/text.py +274 -0
- napier/viewer.py +507 -0
- napier-0.1.0.dist-info/METADATA +348 -0
- napier-0.1.0.dist-info/RECORD +26 -0
- napier-0.1.0.dist-info/WHEEL +4 -0
- napier-0.1.0.dist-info/entry_points.txt +2 -0
- napier-0.1.0.dist-info/licenses/LICENSE +202 -0
- napier-0.1.0.dist-info/licenses/NOTICE +11 -0
- napier-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +47 -0
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)
|