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/lint/checks.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Deterministic wiki health checks. They only report: nothing is ever fixed silently."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from difflib import SequenceMatcher
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
|
|
8
|
+
from esbi_cli.links import link_targets
|
|
9
|
+
from esbi_cli.vault import Vault, fold
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Issue:
|
|
14
|
+
kind: str # orphan | broken-link | missing-field | unlinked-mention | near-duplicate | missing-concept
|
|
15
|
+
page: str
|
|
16
|
+
detail: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LintReport:
|
|
21
|
+
issues: list[Issue] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _orphans(vault: Vault) -> list[Issue]:
|
|
25
|
+
pages = list(vault.iter_pages())
|
|
26
|
+
linked: set[str] = set()
|
|
27
|
+
for page in pages:
|
|
28
|
+
linked |= {fold(t) for t in link_targets(page.body)} - {fold(page.title)}
|
|
29
|
+
return [
|
|
30
|
+
Issue("orphan", p.title, "no page links here")
|
|
31
|
+
for p in pages
|
|
32
|
+
if fold(p.title) not in linked and not any(fold(a) in linked for a in p.aliases)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolvable_names(vault: Vault) -> set[str]:
|
|
37
|
+
"""Everything a [[link]] can point at: any note in the vault (Home, daily, review...) or an alias."""
|
|
38
|
+
notes = [*vault.root.glob("*.md"), *vault.wiki.rglob("*.md")]
|
|
39
|
+
names = {fold(n.stem) for n in notes}
|
|
40
|
+
for page in vault.iter_pages():
|
|
41
|
+
names |= {fold(a) for a in page.aliases}
|
|
42
|
+
return names
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _broken_links(vault: Vault) -> list[Issue]:
|
|
46
|
+
known = _resolvable_names(vault)
|
|
47
|
+
issues = []
|
|
48
|
+
for page in vault.iter_pages():
|
|
49
|
+
for target in dict.fromkeys(link_targets(page.body)):
|
|
50
|
+
if (
|
|
51
|
+
fold(target) not in known and not (vault.root / target).is_file()
|
|
52
|
+
): # an embedded file
|
|
53
|
+
issues.append(Issue("broken-link", page.title, f"[[{target}]]"))
|
|
54
|
+
return issues
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _missing_fields(vault: Vault) -> list[Issue]:
|
|
58
|
+
return [
|
|
59
|
+
Issue("missing-field", page.title, field_name)
|
|
60
|
+
for page in vault.iter_pages()
|
|
61
|
+
for field_name in ("title", "summary")
|
|
62
|
+
if not page.meta.get(field_name)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MIN_MENTION_CHARS = 4 # shorter names ("IA") match far too much to be a useful suggestion
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mentions(names: list[str], body: str, folded_body: str) -> bool:
|
|
70
|
+
"""Is any of `names` written in the text?
|
|
71
|
+
|
|
72
|
+
Multi-word names match ignoring case and accents. A single word matches only exactly as
|
|
73
|
+
written: common words ("enfoque") appear lowercase everywhere and would flood the report.
|
|
74
|
+
"""
|
|
75
|
+
for name in names:
|
|
76
|
+
if " " in name:
|
|
77
|
+
found = re.search(rf"(?<!\w){re.escape(fold(name))}(?!\w)", folded_body)
|
|
78
|
+
else:
|
|
79
|
+
found = re.search(rf"(?<!\w){re.escape(name)}(?!\w)", body)
|
|
80
|
+
if found:
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unlinked_mentions(vault: Vault) -> list[Issue]:
|
|
86
|
+
"""A concept/entity is named in another page's text but that page never links to it."""
|
|
87
|
+
pages = list(vault.iter_pages())
|
|
88
|
+
folded = {p.path: fold(p.body) for p in pages}
|
|
89
|
+
vault.index.sync() # once, not for each of the thousands of names below
|
|
90
|
+
by_path = {str(p.path.resolve()): p for p in pages}
|
|
91
|
+
linked = {p.path: {fold(t) for t in link_targets(p.body)} for p in pages} # once per page
|
|
92
|
+
issues = []
|
|
93
|
+
for target in (p for p in pages if p.kind in ("concepts", "entities")):
|
|
94
|
+
names = [
|
|
95
|
+
n.strip() for n in (target.title, *target.aliases) if len(fold(n)) >= MIN_MENTION_CHARS
|
|
96
|
+
]
|
|
97
|
+
if not names:
|
|
98
|
+
continue
|
|
99
|
+
own_names = {fold(target.title)} | {fold(a) for a in target.aliases}
|
|
100
|
+
paths = vault.index.body_matches(names, sync=False) # only these can mention it
|
|
101
|
+
hosts = pages if paths is None else [by_path[x] for x in sorted(paths) if x in by_path]
|
|
102
|
+
for host in hosts:
|
|
103
|
+
if host.path == target.path:
|
|
104
|
+
continue
|
|
105
|
+
if linked[host.path] & own_names:
|
|
106
|
+
continue
|
|
107
|
+
if _mentions(names, host.body, folded[host.path]):
|
|
108
|
+
issues.append(Issue("unlinked-mention", host.title, target.title))
|
|
109
|
+
return issues
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
SIMILAR_TITLES = 0.88 # "agente"/"agentes" ~ 0.92; "harness interface"/"harness mechanisms" ~ 0.6
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _near_duplicates(vault: Vault) -> list[Issue]:
|
|
116
|
+
"""Concept/entity pairs that look like the same idea: similar titles, or a shared name/alias.
|
|
117
|
+
|
|
118
|
+
Similar titles differ by at most 3 characters in length, so each page is compared only with
|
|
119
|
+
those in that length window, and shared names come from a name -> pages table: no all-pairs scan.
|
|
120
|
+
"""
|
|
121
|
+
pages = sorted(
|
|
122
|
+
vault.iter_pages(("concepts", "entities")), key=lambda p: (fold(p.title), p.title)
|
|
123
|
+
)
|
|
124
|
+
names = {p.path: {fold(n) for n in (p.title, *p.aliases)} for p in pages}
|
|
125
|
+
pairs: set[tuple[int, int]] = set()
|
|
126
|
+
by_name: dict[str, list[int]] = {}
|
|
127
|
+
for i, page in enumerate(pages):
|
|
128
|
+
for name in names[page.path]:
|
|
129
|
+
by_name.setdefault(name, []).append(i)
|
|
130
|
+
for owners in by_name.values():
|
|
131
|
+
pairs.update(combinations(sorted(owners), 2))
|
|
132
|
+
by_length = sorted(range(len(pages)), key=lambda i: len(fold(pages[i].title)))
|
|
133
|
+
folded = [fold(p.title) for p in pages]
|
|
134
|
+
for n, i in enumerate(by_length):
|
|
135
|
+
for j in by_length[n + 1 :]:
|
|
136
|
+
if len(folded[j]) - len(folded[i]) > 3:
|
|
137
|
+
break
|
|
138
|
+
matcher = SequenceMatcher(None, folded[i], folded[j])
|
|
139
|
+
# the two cheap bounds never rule out a true match, so the answer is the same
|
|
140
|
+
if (
|
|
141
|
+
matcher.real_quick_ratio() >= SIMILAR_TITLES
|
|
142
|
+
and matcher.quick_ratio() >= SIMILAR_TITLES
|
|
143
|
+
and matcher.ratio() >= SIMILAR_TITLES
|
|
144
|
+
):
|
|
145
|
+
pairs.add((min(i, j), max(i, j)))
|
|
146
|
+
return [Issue("near-duplicate", pages[a].title, pages[b].title) for a, b in sorted(pairs)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_GLOSSARY_TERM = re.compile(
|
|
150
|
+
r"^- \*\*([^\[\]*]+)\*\*:", re.M
|
|
151
|
+
) # an unlinked term: it has no page yet
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _missing_concepts(vault: Vault) -> list[Issue]:
|
|
155
|
+
"""Glossary terms two or more sources define, with no concept or entity page of their own."""
|
|
156
|
+
users: dict[str, tuple[str, list[str]]] = {}
|
|
157
|
+
for source in vault.iter_pages(("sources",)):
|
|
158
|
+
for term in {t.strip() for t in _GLOSSARY_TERM.findall(source.body)}:
|
|
159
|
+
name, titles = users.setdefault(fold(term), (term, []))
|
|
160
|
+
titles.append(source.title)
|
|
161
|
+
return [
|
|
162
|
+
Issue("missing-concept", name, ", ".join(titles))
|
|
163
|
+
for key, (name, titles) in sorted(users.items())
|
|
164
|
+
if len(titles) >= 2 and vault.find_page(name, ("concepts", "entities")) is None
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def lint_vault(vault: Vault) -> LintReport:
|
|
169
|
+
return LintReport(
|
|
170
|
+
issues=[
|
|
171
|
+
*_orphans(vault),
|
|
172
|
+
*_broken_links(vault),
|
|
173
|
+
*_missing_fields(vault),
|
|
174
|
+
*_unlinked_mentions(vault),
|
|
175
|
+
*_near_duplicates(vault),
|
|
176
|
+
*_missing_concepts(vault),
|
|
177
|
+
]
|
|
178
|
+
)
|
esbi_cli/lint/report.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Write the lint findings to wiki/review/Lint.md (only while there is something to fix)."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from esbi_cli import lang
|
|
7
|
+
from esbi_cli.lint.checks import Issue, LintReport
|
|
8
|
+
from esbi_cli.vault import Page, Vault
|
|
9
|
+
|
|
10
|
+
KINDS = (
|
|
11
|
+
"orphan",
|
|
12
|
+
"broken-link",
|
|
13
|
+
"missing-field",
|
|
14
|
+
"unlinked-mention",
|
|
15
|
+
"near-duplicate",
|
|
16
|
+
"missing-concept",
|
|
17
|
+
)
|
|
18
|
+
MAX_PER_SECTION = 50
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _line(issue: Issue, language: str) -> str:
|
|
22
|
+
match issue.kind:
|
|
23
|
+
case "orphan":
|
|
24
|
+
return f"- [[{issue.page}]]"
|
|
25
|
+
case "broken-link":
|
|
26
|
+
return f"- [[{issue.page}]] → `{issue.detail}`" # code span: no ghost link in the graph
|
|
27
|
+
case "missing-field":
|
|
28
|
+
return lang.t(language, "lint_missing_field_line", page=issue.page, field=issue.detail)
|
|
29
|
+
case "unlinked-mention":
|
|
30
|
+
return lang.t(language, "lint_unlinked_line", page=issue.page, target=issue.detail)
|
|
31
|
+
case "missing-concept":
|
|
32
|
+
sources = ", ".join(f"[[{t}]]" for t in issue.detail.split(", "))
|
|
33
|
+
return lang.t(language, "lint_missing_concept_line", name=issue.page, sources=sources)
|
|
34
|
+
case _:
|
|
35
|
+
return f"- [[{issue.page}]] ≈ [[{issue.detail}]]"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_lint_report(vault: Vault, report: LintReport, today: date) -> Path | None:
|
|
39
|
+
path = vault.wiki / "review" / "Lint.md"
|
|
40
|
+
if not report.issues:
|
|
41
|
+
path.unlink(missing_ok=True)
|
|
42
|
+
return None
|
|
43
|
+
L = vault.language
|
|
44
|
+
body = [f"# {lang.t(L, 'lint_title', date=today.isoformat())}", ""]
|
|
45
|
+
for kind in KINDS:
|
|
46
|
+
found = [i for i in report.issues if i.kind == kind]
|
|
47
|
+
if not found:
|
|
48
|
+
continue
|
|
49
|
+
body += [
|
|
50
|
+
f"## {lang.t(L, 'lint_' + kind.replace('-', '_'))}",
|
|
51
|
+
*(_line(i, L) for i in found[:MAX_PER_SECTION]),
|
|
52
|
+
]
|
|
53
|
+
if len(found) > MAX_PER_SECTION:
|
|
54
|
+
body.append(lang.t(L, "lint_more", n=len(found) - MAX_PER_SECTION))
|
|
55
|
+
body.append("")
|
|
56
|
+
body.append(lang.t(L, "lint_footer"))
|
|
57
|
+
vault.write_page(
|
|
58
|
+
Page(path, {"type": "review", "kind": "lint", "date": today.isoformat()}, "\n".join(body))
|
|
59
|
+
)
|
|
60
|
+
return path
|
esbi_cli/llm/__init__.py
ADDED
|
File without changes
|
esbi_cli/llm/adapter.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Provider-agnostic structured-output LLM access.
|
|
2
|
+
|
|
3
|
+
One method, `complete_json`, returns raw JSON text constrained to a schema. Providers:
|
|
4
|
+
ollama/<model> native Ollama API (lets us set the context window)
|
|
5
|
+
openai/<model> any OpenAI-compatible endpoint (OpenAI, LM Studio, OpenRouter): set base_url
|
|
6
|
+
anthropic/<model> Anthropic Messages API (forced tool call)
|
|
7
|
+
lmstudio/<model> a local LM Studio server (OpenAI protocol, no key; base_url if not on port 1234)
|
|
8
|
+
claude-cli/<model> the official `claude -p`, paid by a Claude Pro/Max subscription (model `default` = the account's)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from esbi_cli.config import LLMConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LLMTimeout(LLMError):
|
|
30
|
+
"""The model answered too slowly (a small model looping on one input): a problem with that
|
|
31
|
+
input, not proof that the model is down, so callers fail the input and go on."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LLM(Protocol):
|
|
35
|
+
tokens_used: int # cumulative input+output tokens reported by the provider
|
|
36
|
+
|
|
37
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClaudeCliLLM:
|
|
41
|
+
"""The official `claude -p`, so a Claude Pro/Max subscription pays for the call.
|
|
42
|
+
|
|
43
|
+
The worker never touches the login: it only runs the tool, which holds it. Everything beyond
|
|
44
|
+
the prompt is switched off (tools, settings, plugins, MCP servers): measured, a call otherwise
|
|
45
|
+
carries ~450,000 tokens of the user's own setup, which a nightly run would burn in minutes.
|
|
46
|
+
The subscription's usage window still applies: when it is spent the tool says so, and the run
|
|
47
|
+
stops like any other LLM outage and resumes next time."""
|
|
48
|
+
|
|
49
|
+
sends_text_out = True
|
|
50
|
+
|
|
51
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
52
|
+
self.name, self.cfg = name, cfg
|
|
53
|
+
self.tokens_used = 0
|
|
54
|
+
|
|
55
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str:
|
|
56
|
+
cmd = [
|
|
57
|
+
"claude", "-p", "--output-format", "json", "--json-schema", json.dumps(schema),
|
|
58
|
+
"--system-prompt", system, "--tools", "", "--setting-sources", "",
|
|
59
|
+
"--no-session-persistence", "--disable-slash-commands",
|
|
60
|
+
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
61
|
+
] # fmt: skip
|
|
62
|
+
if self.name != "default":
|
|
63
|
+
cmd += ["--model", self.name]
|
|
64
|
+
try:
|
|
65
|
+
with tempfile.TemporaryDirectory() as workdir: # no project files for it to wander into
|
|
66
|
+
done = subprocess.run(
|
|
67
|
+
cmd, input=user, capture_output=True, text=True, timeout=self.cfg.timeout_seconds,
|
|
68
|
+
cwd=workdir,
|
|
69
|
+
) # fmt: skip
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
raise LLMError(
|
|
72
|
+
"The `claude` command is not installed (see https://claude.com/code)"
|
|
73
|
+
) from None
|
|
74
|
+
except subprocess.TimeoutExpired:
|
|
75
|
+
raise LLMTimeout(
|
|
76
|
+
f"claude gave no answer within {self.cfg.timeout_seconds:g}s"
|
|
77
|
+
) from None
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(done.stdout)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise LLMError(
|
|
82
|
+
f"claude returned no JSON (exit {done.returncode}): {done.stderr[:200]}"
|
|
83
|
+
) from None
|
|
84
|
+
usage = data.get("usage", {})
|
|
85
|
+
self.tokens_used += sum(
|
|
86
|
+
usage.get(k, 0)
|
|
87
|
+
for k in (
|
|
88
|
+
"input_tokens",
|
|
89
|
+
"cache_creation_input_tokens",
|
|
90
|
+
"cache_read_input_tokens",
|
|
91
|
+
"output_tokens",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
if data.get("is_error"):
|
|
95
|
+
reason = str(data.get("result", "")).strip()
|
|
96
|
+
if "authenticate" in reason.lower() or "oauth" in reason.lower():
|
|
97
|
+
reason += " Run `claude auth login`."
|
|
98
|
+
raise LLMError(f"claude: {reason}")
|
|
99
|
+
if data.get("structured_output") is not None:
|
|
100
|
+
return json.dumps(data["structured_output"])
|
|
101
|
+
return str(data.get("result", ""))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FallbackLLM:
|
|
105
|
+
"""The primary model, and a second one for when the primary cannot be reached (a spent
|
|
106
|
+
subscription window, a lost login, a server down). A timeout is not that: it says something
|
|
107
|
+
about this input, so it still fails the input instead of silently changing model."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, primary: LLM, fallback: LLM):
|
|
110
|
+
self.primary, self.fallback = primary, fallback
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def tokens_used(self) -> int:
|
|
114
|
+
return self.primary.tokens_used + self.fallback.tokens_used
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def sends_text_out(self) -> bool:
|
|
118
|
+
# either may be shown the text: the fallback takes over when the primary cannot be reached
|
|
119
|
+
return bool(
|
|
120
|
+
getattr(self.primary, "sends_text_out", False)
|
|
121
|
+
or getattr(self.fallback, "sends_text_out", False)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str:
|
|
125
|
+
try:
|
|
126
|
+
return self.primary.complete_json(system=system, user=user, schema=schema)
|
|
127
|
+
except LLMTimeout:
|
|
128
|
+
raise
|
|
129
|
+
except LLMError:
|
|
130
|
+
return self.fallback.complete_json(system=system, user=user, schema=schema)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def make_llm(cfg: LLMConfig) -> LLM:
|
|
134
|
+
if cfg.fallback:
|
|
135
|
+
return FallbackLLM(
|
|
136
|
+
make_llm(replace(cfg, fallback=None)),
|
|
137
|
+
make_llm(replace(cfg, model=cfg.fallback, fallback=None)),
|
|
138
|
+
)
|
|
139
|
+
provider, _, name = cfg.model.partition("/")
|
|
140
|
+
if not name:
|
|
141
|
+
raise ValueError(f"Model must look like '<provider>/<name>', got {cfg.model!r}")
|
|
142
|
+
backends = {
|
|
143
|
+
"ollama": OllamaLLM,
|
|
144
|
+
"openai": OpenAILLM,
|
|
145
|
+
"anthropic": AnthropicLLM,
|
|
146
|
+
"claude-cli": ClaudeCliLLM,
|
|
147
|
+
"lmstudio": LMStudioLLM,
|
|
148
|
+
}
|
|
149
|
+
if provider not in backends:
|
|
150
|
+
raise ValueError(f"Unknown LLM provider {provider!r}; use one of {sorted(backends)}")
|
|
151
|
+
return backends[provider](name, cfg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
OCR_PROMPT = (
|
|
155
|
+
"Transcribe all the text in this image exactly as written, in reading order. "
|
|
156
|
+
"Output only the text, nothing else. If there is no text, output nothing."
|
|
157
|
+
)
|
|
158
|
+
OCR_MAX_TOKENS = (
|
|
159
|
+
4096 # a page of dense text is ~1,500 tokens; stops a model looping on a blank image
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def make_ocr(cfg: LLMConfig) -> "OllamaLLM":
|
|
164
|
+
"""The model that reads images. Images (and the text read from them) never leave this machine:
|
|
165
|
+
only a vision model served by Ollama on this computer is accepted."""
|
|
166
|
+
llm = make_llm(cfg)
|
|
167
|
+
if not isinstance(llm, OllamaLLM) or llm.sends_text_out:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"[llm.ocr] must be a vision model served by Ollama on this machine "
|
|
170
|
+
'(model = "ollama/qwen3-vl:2b-instruct"): images are never sent anywhere else'
|
|
171
|
+
)
|
|
172
|
+
return llm
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _post(url: str, *, headers: dict, payload: dict, timeout_seconds: float) -> dict:
|
|
176
|
+
try:
|
|
177
|
+
resp = httpx.post(url, headers=headers, json=payload, timeout=timeout_seconds)
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
except httpx.HTTPStatusError as exc:
|
|
180
|
+
raise LLMError(
|
|
181
|
+
f"{url} -> HTTP {exc.response.status_code}: {exc.response.text[:300]}"
|
|
182
|
+
) from exc
|
|
183
|
+
except httpx.TimeoutException as exc:
|
|
184
|
+
raise LLMTimeout(f"{url} gave no answer within {timeout_seconds:g}s") from exc
|
|
185
|
+
except httpx.HTTPError as exc:
|
|
186
|
+
raise LLMError(f"{url} unreachable: {exc}") from exc
|
|
187
|
+
return resp.json()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _api_key(cfg: LLMConfig, default_env: str) -> str:
|
|
191
|
+
env = cfg.api_key_env or default_env
|
|
192
|
+
key = os.environ.get(env)
|
|
193
|
+
if not key:
|
|
194
|
+
raise LLMError(f"Environment variable {env} is not set")
|
|
195
|
+
return key
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_loopback(url: str) -> bool:
|
|
199
|
+
host = (urlparse(url).hostname or "").lower()
|
|
200
|
+
return host in ("localhost", "::1") or host.startswith("127.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class OllamaLLM:
|
|
204
|
+
@property
|
|
205
|
+
def sends_text_out(self) -> bool:
|
|
206
|
+
"""False only for a model served from this machine: Ollama can be pointed at another host,
|
|
207
|
+
and its `-cloud` models run on Ollama's servers."""
|
|
208
|
+
return not is_loopback(self.base) or self.name.lower().endswith((":cloud", "-cloud"))
|
|
209
|
+
|
|
210
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
211
|
+
self.name, self.cfg = name, cfg
|
|
212
|
+
self.tokens_used = 0
|
|
213
|
+
self.base = (cfg.base_url or "http://localhost:11434").rstrip("/")
|
|
214
|
+
|
|
215
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str:
|
|
216
|
+
data = _post(
|
|
217
|
+
f"{self.base}/api/chat",
|
|
218
|
+
headers={},
|
|
219
|
+
timeout_seconds=self.cfg.timeout_seconds,
|
|
220
|
+
payload={
|
|
221
|
+
"model": self.name,
|
|
222
|
+
"stream": False,
|
|
223
|
+
"format": schema,
|
|
224
|
+
"options": {
|
|
225
|
+
"num_ctx": self.cfg.num_ctx,
|
|
226
|
+
"temperature": self.cfg.temperature,
|
|
227
|
+
**({"num_predict": self.cfg.max_tokens} if self.cfg.max_tokens else {}),
|
|
228
|
+
},
|
|
229
|
+
"messages": [
|
|
230
|
+
{"role": "system", "content": system},
|
|
231
|
+
{"role": "user", "content": user},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
self.tokens_used += data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
|
|
236
|
+
return data["message"]["content"]
|
|
237
|
+
|
|
238
|
+
def read_image(self, png: bytes) -> str:
|
|
239
|
+
"""The text printed in an image (a vision model: qwen3-vl, ...), in reading order."""
|
|
240
|
+
data = _post(
|
|
241
|
+
f"{self.base}/api/chat",
|
|
242
|
+
headers={},
|
|
243
|
+
timeout_seconds=self.cfg.timeout_seconds,
|
|
244
|
+
payload={
|
|
245
|
+
"model": self.name,
|
|
246
|
+
"stream": False,
|
|
247
|
+
"options": {
|
|
248
|
+
"num_ctx": self.cfg.num_ctx,
|
|
249
|
+
"temperature": 0,
|
|
250
|
+
"num_predict": self.cfg.max_tokens or OCR_MAX_TOKENS,
|
|
251
|
+
},
|
|
252
|
+
"messages": [
|
|
253
|
+
{
|
|
254
|
+
"role": "user",
|
|
255
|
+
"content": OCR_PROMPT,
|
|
256
|
+
"images": [base64.b64encode(png).decode()],
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
self.tokens_used += data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
|
|
262
|
+
return data["message"]["content"].strip()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class OpenAILLM:
|
|
266
|
+
sends_text_out = True
|
|
267
|
+
|
|
268
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
269
|
+
self.name, self.cfg = name, cfg
|
|
270
|
+
self.tokens_used = 0
|
|
271
|
+
self.base = (cfg.base_url or "https://api.openai.com/v1").rstrip("/")
|
|
272
|
+
|
|
273
|
+
def _headers(self) -> dict:
|
|
274
|
+
return {"Authorization": f"Bearer {_api_key(self.cfg, 'OPENAI_API_KEY')}"}
|
|
275
|
+
|
|
276
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str:
|
|
277
|
+
data = _post(
|
|
278
|
+
f"{self.base}/chat/completions",
|
|
279
|
+
headers=self._headers(),
|
|
280
|
+
timeout_seconds=self.cfg.timeout_seconds,
|
|
281
|
+
payload={
|
|
282
|
+
"model": self.name,
|
|
283
|
+
"temperature": self.cfg.temperature,
|
|
284
|
+
"response_format": {
|
|
285
|
+
"type": "json_schema",
|
|
286
|
+
"json_schema": {"name": "edit_plan", "schema": schema},
|
|
287
|
+
},
|
|
288
|
+
"messages": [
|
|
289
|
+
{"role": "system", "content": system},
|
|
290
|
+
{"role": "user", "content": user},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
usage = data.get("usage", {})
|
|
295
|
+
self.tokens_used += usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
|
296
|
+
return data["choices"][0]["message"]["content"]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class LMStudioLLM(OpenAILLM):
|
|
300
|
+
"""LM Studio's local server speaks the OpenAI protocol (default http://localhost:1234/v1) and
|
|
301
|
+
needs no key; the model name is the identifier LM Studio shows (`lms ls`)."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
304
|
+
super().__init__(name, cfg)
|
|
305
|
+
self.base = (cfg.base_url or "http://localhost:1234/v1").rstrip("/")
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def sends_text_out(self) -> bool: # False unless base_url points at another machine
|
|
309
|
+
return not is_loopback(self.base)
|
|
310
|
+
|
|
311
|
+
def _headers(self) -> dict:
|
|
312
|
+
return {}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class AnthropicLLM:
|
|
316
|
+
sends_text_out = True
|
|
317
|
+
|
|
318
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
319
|
+
self.name, self.cfg = name, cfg
|
|
320
|
+
self.tokens_used = 0
|
|
321
|
+
self.base = (cfg.base_url or "https://api.anthropic.com").rstrip("/")
|
|
322
|
+
|
|
323
|
+
def complete_json(self, *, system: str, user: str, schema: dict) -> str:
|
|
324
|
+
headers = {
|
|
325
|
+
"x-api-key": _api_key(self.cfg, "ANTHROPIC_API_KEY"),
|
|
326
|
+
"anthropic-version": "2023-06-01",
|
|
327
|
+
}
|
|
328
|
+
data = _post(
|
|
329
|
+
f"{self.base}/v1/messages",
|
|
330
|
+
headers=headers,
|
|
331
|
+
timeout_seconds=self.cfg.timeout_seconds,
|
|
332
|
+
payload={
|
|
333
|
+
"model": self.name,
|
|
334
|
+
"max_tokens": 4096,
|
|
335
|
+
"temperature": self.cfg.temperature,
|
|
336
|
+
"system": system,
|
|
337
|
+
"messages": [{"role": "user", "content": user}],
|
|
338
|
+
"tools": [
|
|
339
|
+
{
|
|
340
|
+
"name": "submit_edit_plan",
|
|
341
|
+
"description": "Submit the edit plan for this source.",
|
|
342
|
+
"input_schema": schema,
|
|
343
|
+
}
|
|
344
|
+
],
|
|
345
|
+
"tool_choice": {"type": "tool", "name": "submit_edit_plan"},
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
usage = data.get("usage", {})
|
|
349
|
+
self.tokens_used += usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
|
|
350
|
+
for block in data.get("content", []):
|
|
351
|
+
if block.get("type") == "tool_use":
|
|
352
|
+
return json.dumps(block["input"])
|
|
353
|
+
raise LLMError("Anthropic response had no tool_use block")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class OllamaEmbedder:
|
|
357
|
+
"""Vectors from Ollama's `/api/embed`. `nomic-embed-text` was trained with a task prefix on
|
|
358
|
+
both sides; without it the same notes retrieved measurably worse (see rag-fit.md)."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, name: str, cfg: LLMConfig):
|
|
361
|
+
self.name, self.cfg = name, cfg
|
|
362
|
+
self.base = (cfg.base_url or "http://localhost:11434").rstrip("/")
|
|
363
|
+
self.identity = f"{self.base}/{name}" # a vector is only comparable with its own model's
|
|
364
|
+
self.prefixed = name.startswith("nomic-embed")
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def sends_text_out(self) -> bool:
|
|
368
|
+
return not is_loopback(self.base) or self.name.lower().endswith((":cloud", "-cloud"))
|
|
369
|
+
|
|
370
|
+
def embed(self, texts: list[str], query: bool = False) -> list[list[float]]:
|
|
371
|
+
if self.prefixed:
|
|
372
|
+
prefix = "search_query: " if query else "search_document: "
|
|
373
|
+
texts = [prefix + t for t in texts]
|
|
374
|
+
data = _post(
|
|
375
|
+
f"{self.base}/api/embed",
|
|
376
|
+
headers={},
|
|
377
|
+
timeout_seconds=self.cfg.timeout_seconds,
|
|
378
|
+
payload={"model": self.name, "input": texts, "truncate": True},
|
|
379
|
+
)
|
|
380
|
+
vectors = data.get("embeddings", [])
|
|
381
|
+
if len(vectors) != len(texts):
|
|
382
|
+
raise LLMError(f"{self.base} returned {len(vectors)} vectors for {len(texts)} texts")
|
|
383
|
+
return vectors
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def make_embedder(cfg: LLMConfig) -> OllamaEmbedder:
|
|
387
|
+
provider, _, name = cfg.model.partition("/")
|
|
388
|
+
if provider != "ollama" or not name:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"[llm.embed] model must look like 'ollama/<name>' (only Ollama embeds for now), "
|
|
391
|
+
f"got {cfg.model!r}"
|
|
392
|
+
)
|
|
393
|
+
return OllamaEmbedder(name, cfg)
|