esbi-cli 0.2.1__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.
Files changed (72) hide show
  1. esbi_cli/__init__.py +8 -0
  2. esbi_cli/ask/__init__.py +0 -0
  3. esbi_cli/ask/answer.py +256 -0
  4. esbi_cli/bench/__init__.py +0 -0
  5. esbi_cli/bench/cases.py +57 -0
  6. esbi_cli/bench/metrics.py +23 -0
  7. esbi_cli/bench/report.py +117 -0
  8. esbi_cli/bench/runner.py +114 -0
  9. esbi_cli/capture/__init__.py +0 -0
  10. esbi_cli/capture/inbox.py +63 -0
  11. esbi_cli/capture/legacy.py +49 -0
  12. esbi_cli/cli.py +1387 -0
  13. esbi_cli/config.py +344 -0
  14. esbi_cli/doctor.py +391 -0
  15. esbi_cli/evaluate.py +91 -0
  16. esbi_cli/export.py +137 -0
  17. esbi_cli/extract/__init__.py +107 -0
  18. esbi_cli/extract/clip.py +30 -0
  19. esbi_cli/extract/html.py +60 -0
  20. esbi_cli/extract/image.py +58 -0
  21. esbi_cli/extract/pdf.py +109 -0
  22. esbi_cli/gitops.py +101 -0
  23. esbi_cli/index.py +303 -0
  24. esbi_cli/ingest/__init__.py +0 -0
  25. esbi_cli/ingest/apply.py +480 -0
  26. esbi_cli/ingest/chunks.py +49 -0
  27. esbi_cli/ingest/connect.py +87 -0
  28. esbi_cli/ingest/digest.py +91 -0
  29. esbi_cli/ingest/pipeline.py +176 -0
  30. esbi_cli/ingest/plan.py +231 -0
  31. esbi_cli/ingest/read.py +105 -0
  32. esbi_cli/ingest/retrieve.py +59 -0
  33. esbi_cli/init.py +176 -0
  34. esbi_cli/interrupts.py +90 -0
  35. esbi_cli/lang.py +341 -0
  36. esbi_cli/links.py +10 -0
  37. esbi_cli/lint/__init__.py +0 -0
  38. esbi_cli/lint/checks.py +178 -0
  39. esbi_cli/lint/report.py +60 -0
  40. esbi_cli/llm/__init__.py +0 -0
  41. esbi_cli/llm/adapter.py +393 -0
  42. esbi_cli/llm/schemas.py +146 -0
  43. esbi_cli/mail/__init__.py +0 -0
  44. esbi_cli/mail/convert.py +194 -0
  45. esbi_cli/mail/credentials.py +65 -0
  46. esbi_cli/mail/fetch.py +154 -0
  47. esbi_cli/mail/imap.py +92 -0
  48. esbi_cli/netguard.py +127 -0
  49. esbi_cli/privacy.py +81 -0
  50. esbi_cli/queue.py +179 -0
  51. esbi_cli/reingest.py +165 -0
  52. esbi_cli/report/__init__.py +0 -0
  53. esbi_cli/report/daily_index.py +235 -0
  54. esbi_cli/report/index_md.py +21 -0
  55. esbi_cli/report/readstate.py +26 -0
  56. esbi_cli/run.py +100 -0
  57. esbi_cli/runlock.py +31 -0
  58. esbi_cli/runlog.py +80 -0
  59. esbi_cli/schedule.py +106 -0
  60. esbi_cli/templates/SCHEMA.md +52 -0
  61. esbi_cli/templates/clipper-template.json +17 -0
  62. esbi_cli/templates/clipper-youtube-template.json +18 -0
  63. esbi_cli/templates/config.example.toml +108 -0
  64. esbi_cli/update.py +247 -0
  65. esbi_cli/vault.py +188 -0
  66. esbi_cli/wizards/clipper.sh +271 -0
  67. esbi_cli/wizards/email.sh +265 -0
  68. esbi_cli-0.2.1.dist-info/METADATA +167 -0
  69. esbi_cli-0.2.1.dist-info/RECORD +72 -0
  70. esbi_cli-0.2.1.dist-info/WHEEL +4 -0
  71. esbi_cli-0.2.1.dist-info/entry_points.txt +3 -0
  72. esbi_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
esbi_cli/netguard.py ADDED
@@ -0,0 +1,127 @@
1
+ """Refuse to fetch URLs that could reach internal services (SSRF): email links are untrusted."""
2
+
3
+ import ipaddress
4
+ import socket
5
+ import time
6
+ from collections.abc import Callable
7
+ from urllib.parse import urljoin, urlparse
8
+
9
+ import httpx
10
+
11
+
12
+ class UnsafeURL(ValueError):
13
+ pass
14
+
15
+
16
+ class CannotResolve(UnsafeURL):
17
+ """The host does not resolve at all (a dead domain): unreachable, not "refused"."""
18
+
19
+
20
+ NAT64 = ipaddress.ip_network("64:ff9b::/96")
21
+
22
+
23
+ def _is_public(address: str) -> bool:
24
+ ip = ipaddress.ip_address(address.split("%")[0])
25
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
26
+ ip = ip.ipv4_mapped
27
+ if isinstance(ip, ipaddress.IPv6Address) and ip in NAT64: # judge the IPv4 address it wraps
28
+ ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
29
+ return ip.is_global and not ip.is_multicast
30
+
31
+
32
+ def check_public_url(url: str, resolver: Callable = socket.getaddrinfo) -> list[str]:
33
+ """Raise UnsafeURL unless url is http(s) and its host resolves only to public addresses.
34
+ Returns those addresses: connect to one of them, never to the name (see safe_get)."""
35
+ parsed = urlparse(url)
36
+ if parsed.scheme not in ("http", "https"):
37
+ raise UnsafeURL(f"Only http(s) URLs are fetched, not {parsed.scheme or 'this'!r}")
38
+ host = parsed.hostname
39
+ if not host:
40
+ raise UnsafeURL("The URL has no host")
41
+ try:
42
+ addresses = [str(ipaddress.ip_address(host))] # an IP literal needs no DNS
43
+ except ValueError:
44
+ try:
45
+ infos = resolver(host, parsed.port or 443, type=socket.SOCK_STREAM)
46
+ except OSError as exc:
47
+ raise CannotResolve(f"Cannot resolve {host}: {exc}") from None
48
+ addresses = list(dict.fromkeys(info[4][0] for info in infos))
49
+ for address in addresses:
50
+ if not _is_public(address):
51
+ raise UnsafeURL(f"{host} resolves to a non-public address ({address})")
52
+ return addresses
53
+
54
+
55
+ # Behind a proxy it is the proxy that resolves and connects, so the address check below would no
56
+ # longer describe where the request goes. Environment proxies (and the other environment settings
57
+ # httpx reads) are therefore ignored unless the user opts in with [network] use_environment_proxy.
58
+ use_environment_proxy = False
59
+
60
+ MAX_REDIRECTS = 5
61
+ MAX_BYTES = 20_000_000 # a page or PDF bigger than this is not a note
62
+ DEADLINE_SECONDS = 120 # one whole download: a server that trickles bytes cannot hold a run
63
+
64
+
65
+ def _read_capped(response: httpx.Response, url: str) -> httpx.Response:
66
+ body, started = bytearray(), time.monotonic()
67
+ for chunk in response.iter_bytes():
68
+ body += chunk
69
+ if len(body) > MAX_BYTES:
70
+ raise UnsafeURL(f"The response is larger than {MAX_BYTES // 1_000_000} MB")
71
+ if time.monotonic() - started > DEADLINE_SECONDS:
72
+ raise UnsafeURL(f"The download took more than {DEADLINE_SECONDS} s")
73
+ return httpx.Response(
74
+ response.status_code,
75
+ headers={k: v for k, v in response.headers.items() if k.lower() != "content-encoding"},
76
+ content=bytes(body),
77
+ request=httpx.Request("GET", url), # the URL the caller asked for, not the pinned IP
78
+ )
79
+
80
+
81
+ def _fetch_pinned(client: httpx.Client, url: str, addresses: list[str]) -> httpx.Response:
82
+ target = httpx.URL(url)
83
+ for i, address in enumerate(addresses):
84
+ try:
85
+ with client.stream(
86
+ "GET",
87
+ target.copy_with(host=address),
88
+ headers={"Host": target.netloc.decode()},
89
+ extensions={"sni_hostname": target.host},
90
+ follow_redirects=False,
91
+ ) as response:
92
+ if response.is_redirect:
93
+ return httpx.Response(response.status_code, headers=response.headers)
94
+ return _read_capped(response, url)
95
+ except httpx.ConnectError:
96
+ if i == len(addresses) - 1: # tried them all
97
+ raise
98
+
99
+
100
+ def safe_get(
101
+ url: str,
102
+ *,
103
+ client: httpx.Client | None = None,
104
+ resolver: Callable = socket.getaddrinfo,
105
+ headers: dict | None = None,
106
+ timeout_seconds: float = 30,
107
+ ) -> httpx.Response:
108
+ """GET a URL, following redirects manually so every hop passes check_public_url.
109
+
110
+ The name is resolved once per hop, and the request goes to one of the addresses that passed
111
+ the check (the URL carries the IP; Host and the TLS server name stay the original host, so
112
+ certificates are still verified for it). A second lookup by the HTTP client, which a hostile
113
+ DNS server could answer with a private address, never happens."""
114
+ own_client = client is None
115
+ client = client or httpx.Client(
116
+ headers=headers, timeout=timeout_seconds, trust_env=use_environment_proxy
117
+ )
118
+ try:
119
+ for _ in range(MAX_REDIRECTS + 1):
120
+ response = _fetch_pinned(client, url, check_public_url(url, resolver=resolver))
121
+ if not response.is_redirect:
122
+ return response
123
+ url = urljoin(url, response.headers["location"])
124
+ raise UnsafeURL(f"Too many redirects (more than {MAX_REDIRECTS})")
125
+ finally:
126
+ if own_client:
127
+ client.close()
esbi_cli/privacy.py ADDED
@@ -0,0 +1,81 @@
1
+ """Email stays on the machine: it is never sent to a model that sends text away."""
2
+
3
+ import re
4
+ from urllib.parse import urlparse
5
+
6
+ from esbi_cli import lang
7
+ from esbi_cli.llm.adapter import is_loopback
8
+ from esbi_cli.vault import Vault
9
+
10
+
11
+ class PrivacyError(ValueError):
12
+ pass
13
+
14
+
15
+ def sends_text_out(llm) -> bool:
16
+ """True for models run by someone else (API, subscription); a local model, and any fake, is not."""
17
+ return bool(getattr(llm, "sends_text_out", False))
18
+
19
+
20
+ def remote_host(model: str, base_url: str | None) -> str | None:
21
+ """The host serving an Ollama or LM Studio model when that is not this machine, else None."""
22
+ if model.partition("/")[0] not in ("ollama", "lmstudio") or not base_url:
23
+ return None
24
+ return None if is_loopback(base_url) else urlparse(base_url).hostname
25
+
26
+
27
+ def remote_warning(model: str, host: str) -> str:
28
+ return (
29
+ f"{model} is served by {host}, not this machine: the text of your notes goes to that "
30
+ "host, and email is kept off it."
31
+ )
32
+
33
+
34
+ def section_pattern(source_title: str) -> re.Pattern:
35
+ """The `## From [[source]]` section a source adds to a concept page, in any catalogued language
36
+ (a vault may hold sections written before the language setting changed)."""
37
+ link = re.escape(f"[[{source_title}]]")
38
+ heads = "|".join(re.escape(h) for h in lang.every("from_source"))
39
+ return re.compile(rf"\n*## (?:{heads}) {link}\n.*?(?=\n## |\Z)", re.DOTALL)
40
+
41
+
42
+ def private_sources(vault: Vault) -> set[str]:
43
+ return {p.title for p in vault.iter_pages(("sources",)) if p.meta.get("kind") == "email"}
44
+
45
+
46
+ def _email_links(vault: Vault) -> set[str]:
47
+ return {f"[[{t}]]" for t in private_sources(vault)}
48
+
49
+
50
+ def private_titles(vault: Vault) -> set[str]:
51
+ """Pages to hide from a cloud model: email sources, pages built only from them, and every
52
+ synthesis that used any email page (its text is an answer written from that page)."""
53
+ emails = private_sources(vault)
54
+ linked = _email_links(vault)
55
+ hidden = set(emails)
56
+ for page in vault.iter_pages(("concepts", "entities", "syntheses")):
57
+ sources = page.meta.get("sources") or []
58
+ if page.kind == "syntheses":
59
+ if any(s in linked for s in sources):
60
+ hidden.add(page.title)
61
+ elif sources and all(s in linked for s in sources):
62
+ hidden.add(page.title)
63
+ return hidden
64
+
65
+
66
+ def email_touched(vault: Vault) -> set[str]:
67
+ """Pages email helped write (some source of theirs is an email): their one-line summaries may
68
+ carry email text, so a cloud model is offered the title only."""
69
+ linked = _email_links(vault)
70
+ return {
71
+ p.title
72
+ for p in vault.iter_pages(("concepts", "entities", "syntheses"))
73
+ if any(s in linked for s in (p.meta.get("sources") or []))
74
+ }
75
+
76
+
77
+ def public_body(body: str, vault_private_sources: set[str]) -> str:
78
+ """A page's text without the sections that came from email."""
79
+ for title in vault_private_sources:
80
+ body = section_pattern(title).sub("", body)
81
+ return body.strip()
esbi_cli/queue.py ADDED
@@ -0,0 +1,179 @@
1
+ """Persistent work queue of sources waiting to be ingested (SQLite)."""
2
+
3
+ import sqlite3
4
+ from collections.abc import Collection
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
9
+
10
+ _TRACKING_PARAMS = frozenset({"fbclid", "gclid", "mc_cid", "mc_eid", "trk", "trkemail"})
11
+
12
+
13
+ def normalize_target(target: str) -> str:
14
+ """Canonical form used to detect duplicates: URLs lose tracking params and fragments."""
15
+ parsed = urlparse(target)
16
+ if parsed.scheme not in ("http", "https"):
17
+ return str(Path(target).expanduser().resolve())
18
+ query = [
19
+ (k, v)
20
+ for k, v in parse_qsl(parsed.query, keep_blank_values=True)
21
+ if not k.lower().startswith("utm_") and k.lower() not in _TRACKING_PARAMS
22
+ ]
23
+ path = parsed.path.rstrip("/") or "/"
24
+ return urlunparse(
25
+ (parsed.scheme, parsed.netloc.lower(), path, "", urlencode(sorted(query)), "")
26
+ )
27
+
28
+
29
+ _COLUMNS = "id, target, origin, attempts, error, captured, label"
30
+
31
+
32
+ def _item(row: tuple) -> "Item":
33
+ id_, target, origin, attempts, error, captured, label = row
34
+ return Item(
35
+ id_,
36
+ target,
37
+ origin,
38
+ attempts,
39
+ error,
40
+ date.fromisoformat(captured) if captured else None,
41
+ label,
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Item:
47
+ id: int
48
+ target: str
49
+ origin: str
50
+ attempts: int = 0
51
+ error: str | None = None
52
+ captured: date | None = None # when the source was saved, if known (legacy import)
53
+ label: str | None = None # human-readable name (clip title, file name)
54
+
55
+
56
+ class Queue:
57
+ def __init__(self, path: Path, max_attempts: int = 3):
58
+ self.max_attempts = max_attempts
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ self._db = sqlite3.connect(path)
61
+ self._db.execute(
62
+ """CREATE TABLE IF NOT EXISTS items (
63
+ id INTEGER PRIMARY KEY,
64
+ target TEXT NOT NULL UNIQUE,
65
+ origin TEXT NOT NULL,
66
+ status TEXT NOT NULL DEFAULT 'queued',
67
+ attempts INTEGER NOT NULL DEFAULT 0,
68
+ error TEXT,
69
+ added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
70
+ captured TEXT,
71
+ label TEXT
72
+ )"""
73
+ )
74
+ # databases created before these columns existed keep working
75
+ existing = {row[1] for row in self._db.execute("PRAGMA table_info(items)")}
76
+ for column in ("captured", "label"):
77
+ if column not in existing:
78
+ self._db.execute(f"ALTER TABLE items ADD COLUMN {column} TEXT")
79
+ self._db.commit()
80
+
81
+ def add(
82
+ self,
83
+ target: str,
84
+ origin: str,
85
+ captured: date | None = None,
86
+ label: str | None = None,
87
+ ) -> bool:
88
+ """Queue a source. Returns False if an equivalent one is already known."""
89
+ cur = self._db.execute(
90
+ "INSERT OR IGNORE INTO items (target, origin, captured, label) VALUES (?, ?, ?, ?)",
91
+ (normalize_target(target), origin, captured.isoformat() if captured else None, label),
92
+ )
93
+ self._db.commit()
94
+ return cur.rowcount == 1
95
+
96
+ def counts(self) -> dict[str, int]:
97
+ rows = self._db.execute("SELECT status, COUNT(*) FROM items GROUP BY status").fetchall()
98
+ return dict(rows)
99
+
100
+ def claim(self, n: int, exclude: Collection[int] = ()) -> list[Item]:
101
+ """Take up to n of the oldest queued items (skipping `exclude`), marking them processing."""
102
+ skip = ",".join("?" for _ in exclude)
103
+ rows = self._db.execute(
104
+ f"SELECT {_COLUMNS} FROM items WHERE status = 'queued'"
105
+ + (f" AND id NOT IN ({skip})" if skip else "")
106
+ + " ORDER BY id LIMIT ?",
107
+ (*exclude, n),
108
+ ).fetchall()
109
+ self._db.executemany(
110
+ "UPDATE items SET status = 'processing' WHERE id = ?", [(r[0],) for r in rows]
111
+ )
112
+ self._db.commit()
113
+ return [_item(r) for r in rows]
114
+
115
+ def complete(self, item_id: int) -> None:
116
+ self._db.execute("UPDATE items SET status = 'done' WHERE id = ?", (item_id,))
117
+ self._db.commit()
118
+
119
+ def fail(self, item_id: int, error: str) -> None:
120
+ """Record a failed attempt: retry next run, or park as 'failed' after max_attempts."""
121
+ self._db.execute(
122
+ """UPDATE items SET attempts = attempts + 1, error = ?,
123
+ status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'queued' END
124
+ WHERE id = ?""",
125
+ (error, self.max_attempts, item_id),
126
+ )
127
+ self._db.commit()
128
+
129
+ def items(self, status: str) -> list[Item]:
130
+ rows = self._db.execute(
131
+ f"SELECT {_COLUMNS} FROM items WHERE status = ? ORDER BY id", (status,)
132
+ ).fetchall()
133
+ return [_item(r) for r in rows]
134
+
135
+ def get(self, target: str) -> Item | None:
136
+ row = self._db.execute(
137
+ f"SELECT {_COLUMNS} FROM items WHERE target = ?", (normalize_target(target),)
138
+ ).fetchone()
139
+ return _item(row) if row else None
140
+
141
+ def requeue_failed(self, target: str | None = None) -> int:
142
+ """Put parked failures back in the queue with a fresh attempt count. Returns how many."""
143
+ sql = (
144
+ "UPDATE items SET status = 'queued', attempts = 0, error = NULL WHERE status = 'failed'"
145
+ )
146
+ args: tuple = ()
147
+ if target is not None:
148
+ sql, args = sql + " AND target = ?", (normalize_target(target),)
149
+ cur = self._db.execute(sql, args)
150
+ self._db.commit()
151
+ return cur.rowcount
152
+
153
+ def remove(self, target: str) -> bool:
154
+ """Delete an item whatever its state. The original file, if any, is not touched."""
155
+ cur = self._db.execute("DELETE FROM items WHERE target = ?", (normalize_target(target),))
156
+ self._db.commit()
157
+ return cur.rowcount == 1
158
+
159
+ def recover(self) -> int:
160
+ """Requeue items a previous run claimed but never finished. Returns how many. The lost run
161
+ counts as an attempt: an item that kills the process (a PDF that exhausts memory) is parked
162
+ after max_attempts instead of killing every run."""
163
+ cur = self._db.execute(
164
+ """UPDATE items SET attempts = attempts + 1,
165
+ status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'queued' END,
166
+ error = CASE WHEN attempts + 1 >= ? THEN 'the run died while reading this' ELSE error END
167
+ WHERE status = 'processing'""",
168
+ (self.max_attempts, self.max_attempts),
169
+ )
170
+ self._db.commit()
171
+ return cur.rowcount
172
+
173
+ def release(self, item_id: int) -> bool:
174
+ """Give back a claimed item untouched (no attempt counted). False if it was not claimed."""
175
+ cur = self._db.execute(
176
+ "UPDATE items SET status = 'queued' WHERE id = ? AND status = 'processing'", (item_id,)
177
+ )
178
+ self._db.commit()
179
+ return cur.rowcount == 1
esbi_cli/reingest.py ADDED
@@ -0,0 +1,165 @@
1
+ """`sb reingest`: rebuild source notes from the saved raw files, keeping what the user did to them."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import time
6
+ from collections.abc import Callable
7
+ from contextlib import ExitStack
8
+ from dataclasses import dataclass, field
9
+ from datetime import date
10
+
11
+ from esbi_cli.config import Config
12
+ from esbi_cli.extract import ExtractedDoc, ExtractError
13
+ from esbi_cli.extract.image import IMAGE_SUFFIXES, image_figure, to_png
14
+ from esbi_cli.extract.pdf import extract_pdf_bytes
15
+ from esbi_cli.gitops import commit_vault
16
+ from esbi_cli.ingest.apply import NOTE_FORMAT
17
+ from esbi_cli.ingest.pipeline import ingest
18
+ from esbi_cli.interrupts import deferred
19
+ from esbi_cli.llm.adapter import LLM, LLMError, LLMTimeout
20
+ from esbi_cli.privacy import section_pattern
21
+ from esbi_cli.vault import Page, Vault, fold, slugify
22
+
23
+ KEPT = ("status", "read", "content_hash") # what the user (or dedupe) owns: never rebuilt
24
+
25
+
26
+ @dataclass
27
+ class ReingestResult:
28
+ done: list[str] = field(default_factory=list)
29
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (title, why)
30
+ failed: list[tuple[str, str]] = field(default_factory=list)
31
+ warnings: list[tuple[str, str]] = field(default_factory=list) # (title, what went wrong)
32
+ stopped: bool = False # the model went away; run again to resume
33
+ tag: str | None = None
34
+
35
+
36
+ def _raw_doc(vault: Vault, note: Page) -> ExtractedDoc:
37
+ """The source as it was saved: the PDF (re-extracted, so figures come back) or the snapshot."""
38
+ raw = vault.root / str(note.meta.get("raw") or "")
39
+ if not note.meta.get("raw") or not raw.is_file():
40
+ raise ExtractError(f"the file is missing from raw/ ({note.meta.get('raw')})")
41
+ snap = vault.read_page(raw)
42
+ doc = ExtractedDoc(
43
+ note.title, snap.body, note.meta.get("kind") or "article", note.meta.get("url")
44
+ )
45
+ pdf = raw.with_suffix(".pdf")
46
+ if pdf.is_file():
47
+ data = pdf.read_bytes()
48
+ try:
49
+ doc = extract_pdf_bytes(data, note.title)
50
+ doc.url = note.meta.get("url")
51
+ except ExtractError: # a scanned PDF: the snapshot holds the text the OCR model read
52
+ doc.pdf_bytes = data # still tells the privacy rules whether it came from a mail
53
+ for suffix in IMAGE_SUFFIXES: # a picture note: show the original again, no new OCR
54
+ if (image := raw.with_suffix(suffix)).is_file():
55
+ doc.figures = [image_figure(to_png(image.read_bytes(), image.name))]
56
+ return doc
57
+
58
+
59
+ def _forget(vault: Vault, note: Page) -> None:
60
+ """Remove what this source added elsewhere; concept pages nobody else cites are deleted."""
61
+ link = f"[[{note.title}]]"
62
+ section = section_pattern(note.title)
63
+ for page in vault.iter_pages(("concepts", "entities", "syntheses")):
64
+ sources = list(page.meta.get("sources") or [])
65
+ if link not in sources:
66
+ continue
67
+ sources.remove(link)
68
+ if not sources and page.meta.get("type") in ("concept", "entity"):
69
+ page.path.unlink()
70
+ continue
71
+ page.meta["sources"] = sources
72
+ page.body = section.sub("", page.body).strip()
73
+ vault.write_page(page)
74
+ note.path.unlink()
75
+ shutil.rmtree(vault.root / "attachments" / slugify(note.title), ignore_errors=True)
76
+
77
+
78
+ def _tag_before(vault: Vault, today: date) -> str:
79
+ """One command to undo the whole rebuild: `git reset --hard <tag>` in the vault."""
80
+ commit_vault(vault.root, "chore: state before reingest")
81
+ tag = f"pre-reingest-{today.isoformat()}"
82
+ # an existing tag means this is a resumed run: the first tag is the true "before"
83
+ subprocess.run(["git", "tag", tag], cwd=vault.root, capture_output=True)
84
+ return tag
85
+
86
+
87
+ def reingest_all(
88
+ vault: Vault,
89
+ llm: LLM,
90
+ synth_llm: LLM | None,
91
+ cfg: Config,
92
+ *,
93
+ today: date | None = None,
94
+ all_sources: bool = False,
95
+ private_llm: LLM | None = None,
96
+ only: list[str] | None = None,
97
+ on_progress: Callable[[str], None] = lambda _: None,
98
+ ) -> ReingestResult:
99
+ """Rebuild every note that is not yet in the current format (or all of them), one commit each.
100
+
101
+ Notes carry `format: 2` once rebuilt, so an interrupted run resumes where it stopped.
102
+ """
103
+ today = today or date.today()
104
+ result = ReingestResult()
105
+ wanted = [fold(t) for t in only or []] # naming notes means "these, whatever their format"
106
+
107
+ def selected(note: Page) -> bool:
108
+ if wanted:
109
+ return any(w in fold(note.title) for w in wanted)
110
+ return all_sources or note.meta.get("format") != NOTE_FORMAT
111
+
112
+ todo = [n for n in vault.iter_pages(("sources",)) if selected(n)]
113
+ if not todo:
114
+ return result
115
+ result.tag = _tag_before(vault, today)
116
+ started = time.monotonic()
117
+ for n, note in enumerate(todo, 1):
118
+ left = ""
119
+ if n > 1: # the pace of the notes done so far is the best guess for the rest
120
+ minutes = (time.monotonic() - started) / (n - 1) * (len(todo) - n + 1) / 60
121
+ left = f" (~{minutes:.0f} min left)"
122
+ on_progress(f"[{n}/{len(todo)}] {note.title}{left}")
123
+ try:
124
+ doc = _raw_doc(vault, note)
125
+ except ExtractError as exc:
126
+ result.skipped.append((note.title, str(exc)))
127
+ continue
128
+ with ExitStack() as writing:
129
+ try:
130
+ done = ingest(
131
+ note.title,
132
+ vault=vault,
133
+ llm=llm,
134
+ synth_llm=synth_llm,
135
+ private_llm=private_llm,
136
+ cfg=cfg,
137
+ force=True,
138
+ extractor=lambda _, d=doc: d,
139
+ today=today,
140
+ on_step=lambda step: on_progress(f" ... {step}"),
141
+ keep_title=note.title,
142
+ raw_path=vault.root / note.meta["raw"],
143
+ # from the moment the old note goes until the new one is restored and committed,
144
+ # a signal waits: stopping in between would lose the user's read state
145
+ before_write=lambda note=note: (
146
+ writing.enter_context(deferred()),
147
+ _forget(vault, note),
148
+ ),
149
+ )
150
+ except Exception as exc: # one bad source must not stop the rebuild
151
+ if isinstance(exc, LLMError) and not isinstance(exc, LLMTimeout):
152
+ result.stopped = True # the model is the problem, not this source: resume later
153
+ break
154
+ result.failed.append((note.title, f"{type(exc).__name__}: {exc}"))
155
+ continue
156
+ result.warnings += [(note.title, w) for w in done.warnings]
157
+ rebuilt = vault.read_page(done.applied.source_path)
158
+ for key in KEPT:
159
+ rebuilt.meta[key] = note.meta.get(key)
160
+ rebuilt.meta["captured"] = note.meta.get("captured", rebuilt.meta["captured"])
161
+ rebuilt.meta["tags"] = sorted({*(note.meta.get("tags") or []), *rebuilt.meta["tags"]})
162
+ vault.write_page(rebuilt)
163
+ commit_vault(vault.root, f"reingest: {done.applied.source_title}")
164
+ result.done.append(done.applied.source_title)
165
+ return result
File without changes