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
@@ -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
+ )
@@ -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
File without changes
@@ -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)