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.
- esbi_cli/__init__.py +8 -0
- esbi_cli/ask/__init__.py +0 -0
- esbi_cli/ask/answer.py +256 -0
- esbi_cli/bench/__init__.py +0 -0
- esbi_cli/bench/cases.py +57 -0
- esbi_cli/bench/metrics.py +23 -0
- esbi_cli/bench/report.py +117 -0
- esbi_cli/bench/runner.py +114 -0
- esbi_cli/capture/__init__.py +0 -0
- esbi_cli/capture/inbox.py +63 -0
- esbi_cli/capture/legacy.py +49 -0
- esbi_cli/cli.py +1387 -0
- esbi_cli/config.py +344 -0
- esbi_cli/doctor.py +391 -0
- esbi_cli/evaluate.py +91 -0
- esbi_cli/export.py +137 -0
- esbi_cli/extract/__init__.py +107 -0
- esbi_cli/extract/clip.py +30 -0
- esbi_cli/extract/html.py +60 -0
- esbi_cli/extract/image.py +58 -0
- esbi_cli/extract/pdf.py +109 -0
- esbi_cli/gitops.py +101 -0
- esbi_cli/index.py +303 -0
- esbi_cli/ingest/__init__.py +0 -0
- esbi_cli/ingest/apply.py +480 -0
- esbi_cli/ingest/chunks.py +49 -0
- esbi_cli/ingest/connect.py +87 -0
- esbi_cli/ingest/digest.py +91 -0
- esbi_cli/ingest/pipeline.py +176 -0
- esbi_cli/ingest/plan.py +231 -0
- esbi_cli/ingest/read.py +105 -0
- esbi_cli/ingest/retrieve.py +59 -0
- esbi_cli/init.py +176 -0
- esbi_cli/interrupts.py +90 -0
- esbi_cli/lang.py +341 -0
- esbi_cli/links.py +10 -0
- esbi_cli/lint/__init__.py +0 -0
- esbi_cli/lint/checks.py +178 -0
- esbi_cli/lint/report.py +60 -0
- esbi_cli/llm/__init__.py +0 -0
- esbi_cli/llm/adapter.py +393 -0
- esbi_cli/llm/schemas.py +146 -0
- esbi_cli/mail/__init__.py +0 -0
- esbi_cli/mail/convert.py +194 -0
- esbi_cli/mail/credentials.py +65 -0
- esbi_cli/mail/fetch.py +154 -0
- esbi_cli/mail/imap.py +92 -0
- esbi_cli/netguard.py +127 -0
- esbi_cli/privacy.py +81 -0
- esbi_cli/queue.py +179 -0
- esbi_cli/reingest.py +165 -0
- esbi_cli/report/__init__.py +0 -0
- esbi_cli/report/daily_index.py +235 -0
- esbi_cli/report/index_md.py +21 -0
- esbi_cli/report/readstate.py +26 -0
- esbi_cli/run.py +100 -0
- esbi_cli/runlock.py +31 -0
- esbi_cli/runlog.py +80 -0
- esbi_cli/schedule.py +106 -0
- esbi_cli/templates/SCHEMA.md +52 -0
- esbi_cli/templates/clipper-template.json +17 -0
- esbi_cli/templates/clipper-youtube-template.json +18 -0
- esbi_cli/templates/config.example.toml +108 -0
- esbi_cli/update.py +247 -0
- esbi_cli/vault.py +188 -0
- esbi_cli/wizards/clipper.sh +271 -0
- esbi_cli/wizards/email.sh +265 -0
- esbi_cli-0.2.1.dist-info/METADATA +167 -0
- esbi_cli-0.2.1.dist-info/RECORD +72 -0
- esbi_cli-0.2.1.dist-info/WHEEL +4 -0
- esbi_cli-0.2.1.dist-info/entry_points.txt +3 -0
- 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
|