okf-loremaster 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. okf_loremaster/__init__.py +9 -0
  2. okf_loremaster/cli.py +483 -0
  3. okf_loremaster/clients/__init__.py +162 -0
  4. okf_loremaster/clients/_http.py +457 -0
  5. okf_loremaster/clients/bioc.py +141 -0
  6. okf_loremaster/clients/cassette.py +132 -0
  7. okf_loremaster/clients/eutils.py +552 -0
  8. okf_loremaster/clients/icite.py +161 -0
  9. okf_loremaster/clients/pubtator.py +116 -0
  10. okf_loremaster/config.py +303 -0
  11. okf_loremaster/curation.py +270 -0
  12. okf_loremaster/emitters/__init__.py +35 -0
  13. okf_loremaster/emitters/okf.py +1243 -0
  14. okf_loremaster/emitters/vectors.py +700 -0
  15. okf_loremaster/env.example +158 -0
  16. okf_loremaster/events.py +149 -0
  17. okf_loremaster/extraction_cache.py +108 -0
  18. okf_loremaster/finalize.py +62 -0
  19. okf_loremaster/graph/__init__.py +7 -0
  20. okf_loremaster/graph/build.py +446 -0
  21. okf_loremaster/graph/nodes/__init__.py +39 -0
  22. okf_loremaster/graph/nodes/charter.py +168 -0
  23. okf_loremaster/graph/nodes/curate.py +471 -0
  24. okf_loremaster/graph/nodes/dedupe.py +97 -0
  25. okf_loremaster/graph/nodes/emit_okf.py +182 -0
  26. okf_loremaster/graph/nodes/extract.py +270 -0
  27. okf_loremaster/graph/nodes/fulltext.py +215 -0
  28. okf_loremaster/graph/nodes/index_vectors.py +82 -0
  29. okf_loremaster/graph/nodes/rank.py +154 -0
  30. okf_loremaster/graph/nodes/reconcile.py +272 -0
  31. okf_loremaster/graph/nodes/review.py +59 -0
  32. okf_loremaster/graph/nodes/screen.py +222 -0
  33. okf_loremaster/graph/nodes/search.py +320 -0
  34. okf_loremaster/graph/nodes/validate.py +65 -0
  35. okf_loremaster/graph/state.py +255 -0
  36. okf_loremaster/llm/__init__.py +7 -0
  37. okf_loremaster/llm/estimate.py +383 -0
  38. okf_loremaster/llm/fake.py +107 -0
  39. okf_loremaster/llm/router.py +628 -0
  40. okf_loremaster/okf/__init__.py +97 -0
  41. okf_loremaster/okf/frontmatter.py +250 -0
  42. okf_loremaster/okf/layout.py +177 -0
  43. okf_loremaster/okf/markdown.py +49 -0
  44. okf_loremaster/okf/reader.py +347 -0
  45. okf_loremaster/okf/validate.py +535 -0
  46. okf_loremaster/prompts.py +456 -0
  47. okf_loremaster/queries.py +368 -0
  48. okf_loremaster/ranking.py +419 -0
  49. okf_loremaster/recurrence.py +401 -0
  50. okf_loremaster/retention.py +107 -0
  51. okf_loremaster/review.py +109 -0
  52. okf_loremaster/run.py +824 -0
  53. okf_loremaster/schemas/__init__.py +175 -0
  54. okf_loremaster/schemas/candidates.py +252 -0
  55. okf_loremaster/schemas/charter.py +246 -0
  56. okf_loremaster/schemas/common.py +253 -0
  57. okf_loremaster/schemas/concept.py +462 -0
  58. okf_loremaster/schemas/evidence.py +126 -0
  59. okf_loremaster/schemas/limits.py +207 -0
  60. okf_loremaster/schemas/manifest.py +150 -0
  61. okf_loremaster/schemas/parse.py +281 -0
  62. okf_loremaster/schemas/recurrence.py +148 -0
  63. okf_loremaster/schemas/screening.py +120 -0
  64. okf_loremaster/schemas/strength.py +52 -0
  65. okf_loremaster/selftest.py +153 -0
  66. okf_loremaster/strength.py +323 -0
  67. okf_loremaster/ui/__init__.py +7 -0
  68. okf_loremaster/ui/jsonl.py +56 -0
  69. okf_loremaster/ui/pauses.py +310 -0
  70. okf_loremaster/ui/plain.py +235 -0
  71. okf_loremaster/ui/review.py +186 -0
  72. okf_loremaster/ui/summary.py +186 -0
  73. okf_loremaster/ui/tui.py +659 -0
  74. okf_loremaster/verification.py +560 -0
  75. okf_loremaster-0.1.0.dist-info/METADATA +799 -0
  76. okf_loremaster-0.1.0.dist-info/RECORD +79 -0
  77. okf_loremaster-0.1.0.dist-info/WHEEL +4 -0
  78. okf_loremaster-0.1.0.dist-info/entry_points.txt +3 -0
  79. okf_loremaster-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,9 @@
1
+ """OKF Loremaster — task-scoped biomedical literature bundles in Open Knowledge Format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["DISPLAY_NAME", "__version__"]
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ DISPLAY_NAME = "OKF Loremaster"
okf_loremaster/cli.py ADDED
@@ -0,0 +1,483 @@
1
+ """Command-line interface for OKF Loremaster.
2
+
3
+ Heavy modules are imported inside the commands that need them. `litellm` and `chromadb`
4
+ each cost seconds to import, and `--help` should not pay for either. The commands that
5
+ touch a bundle rather than build one — `validate`, `export`, `inspect` — import nothing
6
+ from the graph at all, so they run against a directory on a machine with no API key.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.markup import escape
21
+
22
+ from okf_loremaster import DISPLAY_NAME, __version__
23
+ from okf_loremaster.finalize import Finalize
24
+
25
+ console = Console(stderr=True)
26
+
27
+ app = typer.Typer(
28
+ name="okf-loremaster",
29
+ help=f"{DISPLAY_NAME} — build a task-scoped biomedical literature bundle "
30
+ "from PubMed/PMC in Open Knowledge Format.",
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ rich_markup_mode="rich",
34
+ )
35
+
36
+
37
+ @contextmanager
38
+ def _reported() -> Iterator[None]:
39
+ """Turn the failures a user can act on into one line and an exit code.
40
+
41
+ Config, file and interrupt errors are the user's to fix and a traceback tells them
42
+ nothing. Everything else propagates: a bug in a node should look like one.
43
+ """
44
+ from okf_loremaster.config import ConfigError
45
+ from okf_loremaster.run import RunInterrupted
46
+
47
+ try:
48
+ yield
49
+ except RunInterrupted as exc:
50
+ # Not a failure: the user asked it to stop and the checkpoint is intact. 130
51
+ # anyway, because to whatever ran us this is the same event as a Ctrl-C.
52
+ console.print(
53
+ f"[yellow]stopped[/yellow] — resume with "
54
+ f"[cyan]okf-loremaster build --resume {exc.run_id}[/cyan]"
55
+ )
56
+ raise typer.Exit(code=130) from exc
57
+ except (ConfigError, FileNotFoundError, ValueError) as exc:
58
+ # Escaped, because the fix these messages name is often an extra —
59
+ # `okf-loremaster[tui]` — and Rich reads the brackets as a markup tag and drops
60
+ # the one word the user needs to type.
61
+ console.print(f"[red]{escape(str(exc))}[/red]")
62
+ raise typer.Exit(code=1) from exc
63
+ except KeyboardInterrupt as exc:
64
+ # No id to offer: Ctrl-C can land before one is generated, and this handler
65
+ # cannot see it in any case. `runs` is where it can be looked up.
66
+ console.print(
67
+ "[yellow]interrupted[/yellow] — [cyan]okf-loremaster runs[/cyan] lists the "
68
+ "run id, then [cyan]build --resume <id>[/cyan] continues it"
69
+ )
70
+ raise typer.Exit(code=130) from exc
71
+
72
+
73
+ def _report_outputs(directory: Path) -> None:
74
+ """Say where the deliverable is, and that moving it is one copy.
75
+
76
+ Printed rather than left to the reader because the whole point of the one-folder
77
+ layout is that it can be handed to a consumer without instructions, and a path that
78
+ is never shown is a path nobody knows to copy.
79
+ """
80
+ from okf_loremaster.okf.layout import okf_bundle_path, vector_store_path
81
+ from okf_loremaster.run import TRANSCRIPT_FILENAME
82
+
83
+ corpus = okf_bundle_path(directory)
84
+ store = vector_store_path(corpus)
85
+ console.print(f"\n[bold]output[/bold] {directory}")
86
+ for label, path in (("okf", corpus), ("vectors", store)):
87
+ mark = "[green]+[/green]" if path.exists() else "[dim]-[/dim]"
88
+ console.print(f" {mark} {label}/{'' if path.exists() else ' [dim]not kept[/dim]'}")
89
+ console.print(f"[dim]move it with[/dim] [cyan]cp -r {directory} <somewhere>[/cyan]")
90
+ # Only `--tui` writes one: the plain renderer's output is already in the scrollback,
91
+ # where it can be selected. A full-screen app's is not, and it is gone the moment the
92
+ # alternate screen closes.
93
+ transcript = directory / TRANSCRIPT_FILENAME
94
+ if transcript.exists():
95
+ console.print(f"[dim]the run log, to paste[/dim] [cyan]cat {transcript}[/cyan]")
96
+
97
+
98
+ def _version_callback(value: bool) -> None:
99
+ if value:
100
+ # stdout, not stderr: `--version` output is meant to be piped.
101
+ typer.echo(f"{DISPLAY_NAME} {__version__}")
102
+ raise typer.Exit()
103
+
104
+
105
+ @app.callback()
106
+ def main(
107
+ version: Annotated[
108
+ bool,
109
+ typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
110
+ ] = False,
111
+ ) -> None:
112
+ """Build and inspect Open Knowledge Format literature bundles."""
113
+
114
+
115
+ def _env_template() -> tuple[str, str | None]:
116
+ """The annotated template `init` copies, and the name to report it by.
117
+
118
+ A checkout has `.env.example` at its root and that copy wins, because in a checkout it
119
+ is the file being edited. A `pip install` has no checkout, so the wheel carries the
120
+ same bytes as package data; without that fallback `init` writes nothing at all for
121
+ anyone who installed from PyPI. Never the user's own `.env` — only the template.
122
+ """
123
+ local = Path(".env.example")
124
+ if local.is_file():
125
+ return str(local), local.read_text(encoding="utf-8")
126
+ packaged = resources.files("okf_loremaster") / "env.example"
127
+ if packaged.is_file():
128
+ return "the packaged template", packaged.read_text(encoding="utf-8")
129
+ return str(local), None
130
+
131
+
132
+ @app.command()
133
+ def init(
134
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing .env.")] = False,
135
+ ) -> None:
136
+ """Write a .env from the template and check the environment is usable."""
137
+ from rich.table import Table
138
+
139
+ from okf_loremaster.config import (
140
+ ENV_PREFIX,
141
+ ConfigError,
142
+ Role,
143
+ env_file_candidates,
144
+ load_settings,
145
+ )
146
+
147
+ target = Path(".env")
148
+ origin, template = _env_template()
149
+ if template is not None and (force or not target.exists()):
150
+ target.write_text(template, encoding="utf-8")
151
+ console.print(f"[green]wrote[/green] {target} from {origin} — fill it in, then rerun")
152
+ elif template is None and not target.exists():
153
+ console.print(f"[yellow]neither {origin} nor {target} found[/yellow]")
154
+
155
+ try:
156
+ settings = load_settings()
157
+ except ConfigError as exc:
158
+ console.print(f"[red]{escape(str(exc))}[/red]")
159
+ raise typer.Exit(code=1) from exc
160
+
161
+ found = [str(path) for path in env_file_candidates() if path.exists()]
162
+ table = Table.grid(padding=(0, 2))
163
+ table.add_column(justify="right", style="dim")
164
+ table.add_column()
165
+ table.add_row("env files", ", ".join(found) if found else "[yellow]none found[/yellow]")
166
+
167
+ missing = set(settings.missing_for_llm())
168
+ for role in Role:
169
+ name = f"{ENV_PREFIX}MODEL_{role.value.upper()}"
170
+ value = {
171
+ Role.FAST: settings.model_fast,
172
+ Role.BALANCED: settings.model_balanced,
173
+ Role.REASONING: settings.model_reasoning,
174
+ }[role]
175
+ table.add_row(role.value, value if value else f"[red]unset[/red] ({name})")
176
+
177
+ table.add_row(
178
+ "api key",
179
+ "[red]unset[/red] (ANTHROPIC_API_KEY)" if "ANTHROPIC_API_KEY" in missing else "set",
180
+ )
181
+ if settings.api_base:
182
+ table.add_row("api base", settings.api_base)
183
+ table.add_row(
184
+ "NCBI email",
185
+ settings.ncbi_email or f"[yellow]unset[/yellow] ({ENV_PREFIX}NCBI_EMAIL)",
186
+ )
187
+ table.add_row(
188
+ "NCBI key",
189
+ "set (10 req/s)" if settings.ncbi_api_key else "[dim]unset (3 req/s)[/dim]",
190
+ )
191
+ revision = settings.embed_revision or "[yellow]unpinned[/yellow]"
192
+ table.add_row("embeddings", f"{settings.embed_model} @ {revision}")
193
+ table.add_row("HF_HOME", str(settings.hf_home) if settings.hf_home else "[dim]default[/dim]")
194
+ table.add_row("cache dir", str(settings.cache_dir))
195
+ table.add_row("output dir", str(settings.output_dir))
196
+ console.print(table)
197
+
198
+ warning = settings.hf_home_warning()
199
+ if warning:
200
+ console.print(f"[yellow]![/yellow] {warning}")
201
+
202
+ unpriced = settings.unpriced_roles()
203
+ if unpriced:
204
+ names = ", ".join(role.value for role in unpriced)
205
+ console.print(
206
+ f"[dim]note[/dim] no price override for: {names}. If the provider is not in "
207
+ f"LiteLLM's price map, those calls report as 'cost unavailable' rather than a "
208
+ f"USD figure. Set {ENV_PREFIX}PRICE_<ROLE>_IN/_OUT to get one."
209
+ )
210
+
211
+ if missing:
212
+ console.print(f"[red]not ready[/red] — {len(missing)} required variable(s) unset")
213
+ raise typer.Exit(code=1)
214
+ console.print("[green]ready[/green]")
215
+
216
+
217
+ @app.command(hidden=True)
218
+ def selftest(
219
+ live: Annotated[
220
+ bool | None, typer.Option("--live/--no-live", help="Force or suppress the live meter.")
221
+ ] = None,
222
+ verbose: Annotated[int, typer.Option("-v", "--verbose", count=True)] = 0,
223
+ ) -> None:
224
+ """Exercise events, routing, retries, and cost accounting against a fake model."""
225
+ from okf_loremaster.selftest import run_selftest
226
+
227
+ code = asyncio.run(run_selftest(live=live, verbose=verbose, console=console))
228
+ raise typer.Exit(code=code)
229
+
230
+
231
+ @app.command()
232
+ def build(
233
+ # Optional only because `--resume` supplies it: a resumed run reads its question back
234
+ # out of the checkpoint, and asking for it again invites a retyped one that differs
235
+ # from the one the run was actually built on. Omitted without `--resume`, the run
236
+ # stops with a sentence saying so.
237
+ prompt: Annotated[
238
+ str | None,
239
+ typer.Argument(help="What you want to know. Not needed with --resume."),
240
+ ] = None,
241
+ charter: Annotated[
242
+ Path | None,
243
+ typer.Option(
244
+ "--charter",
245
+ help="Reuse a charter.yaml instead of drafting one. Skips the reasoning call.",
246
+ exists=True,
247
+ dir_okay=False,
248
+ ),
249
+ ] = None,
250
+ out: Annotated[
251
+ Path | None,
252
+ typer.Option("-o", "--out", help="Folder name, under the output directory."),
253
+ ] = None,
254
+ pool_size: Annotated[int, typer.Option(help="Candidate pool before screening.")] = 800,
255
+ screen_budget: Annotated[int, typer.Option(help="Max abstracts sent to the screener.")] = 400,
256
+ # Literals rather than the constants they mirror: importing them would pull pydantic
257
+ # into `--help`. `test_cli_defaults` fails if the two ever drift apart.
258
+ target_papers: Annotated[int, typer.Option(help="Target retained paper count.")] = 200,
259
+ topic_paper_min: Annotated[int, typer.Option(help="Minimum papers inside one topic.")] = 8,
260
+ topic_paper_max: Annotated[int, typer.Option(help="Maximum papers inside one topic.")] = 40,
261
+ max_topics: Annotated[
262
+ int, typer.Option(help="Topic folders the charter may divide the review into.", min=1)
263
+ ] = 8,
264
+ max_rounds: Annotated[
265
+ int, typer.Option(help="Search rounds, including the first. 1 disables re-query.", min=1)
266
+ ] = 2,
267
+ finalize: Annotated[
268
+ Finalize | None,
269
+ typer.Option(
270
+ "--finalize",
271
+ help="What to keep. Asked at the end if not given. `okf` skips embedding.",
272
+ ),
273
+ ] = None,
274
+ review: Annotated[bool, typer.Option("--review", help="Human sign-off before emit.")] = False,
275
+ interactive: Annotated[
276
+ bool,
277
+ typer.Option(
278
+ "--interactive", "-i", help="Stop at the charter and the pool, and ask before going on."
279
+ ),
280
+ ] = False,
281
+ dry_run: Annotated[
282
+ bool, typer.Option("--dry-run", help="Plan and cost the run. Makes zero LLM calls.")
283
+ ] = False,
284
+ resume: Annotated[
285
+ str | None,
286
+ typer.Option("--resume", help="Resume a run by id; `runs` lists them."),
287
+ ] = None,
288
+ tui: Annotated[bool, typer.Option("--tui", help="Full-screen Textual interface.")] = False,
289
+ json_out: Annotated[bool, typer.Option("--json", help="Emit machine-readable events.")] = False,
290
+ verbose: Annotated[int, typer.Option("-v", "--verbose", count=True, help="Verbosity.")] = 0,
291
+ ) -> None:
292
+ """Build a knowledge bundle from PubMed. This is the whole system."""
293
+ from okf_loremaster.curation import MAX_ROUNDS
294
+ from okf_loremaster.run import RunOptions, build_run, require_textual
295
+ from okf_loremaster.ui.plain import rich_enabled
296
+ from okf_loremaster.ui.summary import render_bundle, render_extraction, render_topics
297
+
298
+ if tui and json_out:
299
+ # Refused rather than degraded: a full-screen app writes escape sequences over
300
+ # the stream --json exists to keep parsable.
301
+ console.print(
302
+ "[red]--tui cannot be combined with --json[/red] — one paints a terminal, "
303
+ "the other feeds a program."
304
+ )
305
+ raise typer.Exit(code=1)
306
+ if finalize is not None and dry_run:
307
+ # A dry run writes nothing, so there is nothing to keep or discard.
308
+ console.print(
309
+ "[red]--finalize cannot be combined with --dry-run[/red] — a dry run "
310
+ "writes no bundle."
311
+ )
312
+ raise typer.Exit(code=1)
313
+ if review and (dry_run or json_out):
314
+ # Not a usability nicety. `--json` has nobody to ask and a dry run writes no
315
+ # bundle — signing under either would stamp `verified: human:<id>` on work no
316
+ # human looked at, which is the one claim in the format that has to be true.
317
+ # `--review` is independent of `--interactive`: signing off on a finished bundle
318
+ # and steering the search are different moments, and either can be wanted alone.
319
+ blocking = ", ".join(
320
+ flag for flag, on in (("--dry-run", dry_run), ("--json", json_out)) if on
321
+ )
322
+ console.print(
323
+ f"[red]--review cannot be combined with {blocking}[/red] — sign-off has to be "
324
+ "given by a person who saw the bundle."
325
+ )
326
+ raise typer.Exit(code=1)
327
+ if max_rounds > MAX_ROUNDS:
328
+ # A cap, not a preference. A third round re-screens a pool to ask the question
329
+ # the second one already failed to answer.
330
+ console.print(
331
+ f"[red]--max-rounds {max_rounds} exceeds the hard cap of {MAX_ROUNDS}[/red]"
332
+ )
333
+ raise typer.Exit(code=1)
334
+
335
+ # Declined rather than refused, because neither is the user asking for something
336
+ # incoherent. A dry run's deliverable *is* the printed plan, and a full-screen app
337
+ # takes the screen back when it closes; a terminal that cannot drive a live region
338
+ # cannot drive an app either.
339
+ if charter is not None and resume is not None:
340
+ # Refused rather than ranked. A resumed run replays from its checkpoint, where
341
+ # the charter node has already run and its answer is recorded, so a charter
342
+ # passed here would be read, reported in `--help` as doing something, and then
343
+ # quietly ignored. Silently disregarding a file the user named is worse than
344
+ # stopping.
345
+ console.print(
346
+ "[red]--charter cannot be combined with --resume[/red] — a resumed run "
347
+ "replays the charter it was built with. Start a fresh run to use another."
348
+ )
349
+ raise typer.Exit(code=1)
350
+
351
+ full_screen = tui
352
+ if tui and dry_run:
353
+ console.print("[dim]note[/dim] --dry-run prints its plan, so --tui is not used here.")
354
+ full_screen = False
355
+ elif tui and not rich_enabled():
356
+ console.print("[dim]note[/dim] no terminal to drive — falling back from --tui.")
357
+ full_screen = False
358
+ if full_screen:
359
+ # Before the screen is cleared, so a missing extra is readable.
360
+ with _reported():
361
+ require_textual()
362
+
363
+ options = RunOptions(
364
+ prompt=prompt or "",
365
+ charter_path=charter,
366
+ out=out,
367
+ pool_size=pool_size,
368
+ screen_budget=screen_budget,
369
+ target_papers=target_papers,
370
+ topic_paper_min=topic_paper_min,
371
+ topic_paper_max=topic_paper_max,
372
+ max_topics=max_topics,
373
+ max_rounds=max_rounds,
374
+ interactive=interactive,
375
+ review=review,
376
+ finalize=finalize,
377
+ dry_run=dry_run,
378
+ resume=resume,
379
+ tui=full_screen,
380
+ json_out=json_out,
381
+ verbose=verbose,
382
+ )
383
+
384
+ with _reported():
385
+ if full_screen:
386
+ from okf_loremaster.ui.tui import build_run_tui
387
+
388
+ state, directory = asyncio.run(build_run_tui(options))
389
+ else:
390
+ state, directory = asyncio.run(build_run(options, console=console))
391
+
392
+ if json_out:
393
+ return
394
+ render_topics(console, state)
395
+ render_extraction(console, state)
396
+ render_bundle(console, state)
397
+ _report_outputs(directory)
398
+ if not state.get("pool"):
399
+ raise typer.Exit(code=1)
400
+ # A bundle that failed the gate is still on disk and still worth reading; the exit
401
+ # code is what says so to whatever ran us.
402
+ if state.get("bundle") and not state.get("validated"):
403
+ raise typer.Exit(code=1)
404
+
405
+
406
+ @app.command()
407
+ def runs(
408
+ limit: Annotated[int, typer.Option("-n", "--limit", help="How many to show.", min=1)] = 10,
409
+ ) -> None:
410
+ """List recent runs and how far each one got, with the id to resume it by."""
411
+ from rich.table import Table
412
+
413
+ from okf_loremaster.config import load_settings
414
+ from okf_loremaster.retention import (
415
+ MB,
416
+ directory_bytes,
417
+ extraction_cache_path,
418
+ http_cache_path,
419
+ )
420
+ from okf_loremaster.run import list_runs, store_size
421
+
422
+ with _reported():
423
+ settings = load_settings()
424
+ past = asyncio.run(list_runs(settings, limit=limit))
425
+ held = store_size(settings)
426
+ http = directory_bytes(http_cache_path(settings))
427
+ read = directory_bytes(extraction_cache_path(settings))
428
+
429
+ if not past:
430
+ console.print(
431
+ f"[dim]no runs in[/dim] {settings.cache_dir}\n"
432
+ f"[dim]start one with[/dim] [cyan]okf-loremaster build \"your question\"[/cyan]"
433
+ )
434
+ return
435
+
436
+ table = Table(box=None, pad_edge=False, header_style="dim")
437
+ table.add_column("run id")
438
+ table.add_column("started", style="dim")
439
+ table.add_column("reached")
440
+ # One row per run, clipped rather than wrapped: this is a list to pick an id out of,
441
+ # and a four-line paragraph per entry buries the ids it exists to show.
442
+ table.add_column("question", overflow="ellipsis", no_wrap=True, max_width=52)
443
+ for run in past:
444
+ done = run.finished
445
+ table.add_row(
446
+ f"[dim]{run.run_id}[/dim]" if done else run.run_id,
447
+ f"{run.started:%b %d %H:%M}" if run.started else "—",
448
+ "[green]finished[/green]" if done else f"[yellow]{run.reached}[/yellow]",
449
+ escape(run.prompt) or "[dim]—[/dim]",
450
+ )
451
+ console.print(table)
452
+
453
+ # Only the unfinished ones are worth resuming, and only the newest of those is worth
454
+ # spelling out — the point is to show the shape of the command, not to enumerate it.
455
+ unfinished = next((run for run in past if not run.finished), None)
456
+ if unfinished is not None:
457
+ console.print(
458
+ f"\n[dim]resume with[/dim] "
459
+ f"[cyan]okf-loremaster build --resume {unfinished.run_id}[/cyan]"
460
+ f" [dim](the question is read back from the run)[/dim]"
461
+ )
462
+
463
+ # What the caches cost, next to what bounds each of them. Nothing else in the tool
464
+ # ever mentions this, so without it the only way to find out where the disk went is
465
+ # to go looking in the cache directory — which is how it reached three gigabytes
466
+ # before anyone noticed. Each line is a measurement and its ceiling, so a store
467
+ # sitting at its limit is visible rather than inferred.
468
+ keep = settings.checkpoint_keep_runs
469
+ kept = f"newest {keep}" if keep > 0 else "all"
470
+ console.print(f"\n[dim]caches in[/dim] {settings.cache_dir}")
471
+ for label, size, cap, note in (
472
+ ("checkpoints", held, settings.checkpoint_max_mb, f"{kept} runs"),
473
+ ("responses", http, settings.http_cache_max_mb, f"{settings.http_cache_ttl_days}d"),
474
+ ("readings", read, settings.extraction_cache_max_mb, "no expiry"),
475
+ ):
476
+ ceiling = f"{cap:,} MB" if cap > 0 else "uncapped"
477
+ console.print(
478
+ f" [dim]{label:<12}[/dim] {size / MB:>7,.0f} MB "
479
+ f"[dim]of {ceiling}, {note}[/dim]"
480
+ )
481
+
482
+ if __name__ == "__main__":
483
+ app()
@@ -0,0 +1,162 @@
1
+ """HTTP clients for the four data sources.
2
+
3
+ E-utilities, BioC and PubTator are all `*.ncbi.nlm.nih.gov` and the rate limit is
4
+ enforced **per IP across all of them**, so they share one `HttpClient` and therefore one
5
+ token bucket. Giving each client its own limiter is the obvious design and it is wrong:
6
+ three clients at 8 rps each is 24 rps from NCBI's point of view.
7
+
8
+ iCite is a different host with its own budget, so it gets its own.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import httpx
16
+
17
+ from okf_loremaster.clients._http import (
18
+ ICITE_RPS,
19
+ NCBI_CEILING_WITH_KEY,
20
+ NCBI_CEILING_WITHOUT_KEY,
21
+ NCBI_RPS_WITH_KEY,
22
+ NCBI_RPS_WITHOUT_KEY,
23
+ DiskCache,
24
+ HttpClient,
25
+ HttpStats,
26
+ RateLimiter,
27
+ )
28
+ from okf_loremaster.clients.bioc import BioCClient, BioCDocument, BioCSection
29
+ from okf_loremaster.clients.eutils import (
30
+ Author,
31
+ ESearchResult,
32
+ EUtilsClient,
33
+ MeshTerm,
34
+ PubMedRecord,
35
+ )
36
+ from okf_loremaster.clients.icite import CitationMetrics, ICiteClient
37
+ from okf_loremaster.clients.pubtator import (
38
+ AnnotatedDocument,
39
+ Annotation,
40
+ PubTatorClient,
41
+ )
42
+ from okf_loremaster.config import ConfigError, Settings
43
+ from okf_loremaster.events import EventBus
44
+ from okf_loremaster.retention import http_cache_path
45
+
46
+ __all__ = [
47
+ "AnnotatedDocument",
48
+ "Annotation",
49
+ "Author",
50
+ "BioCClient",
51
+ "BioCDocument",
52
+ "BioCSection",
53
+ "CitationMetrics",
54
+ "Clients",
55
+ "DiskCache",
56
+ "ESearchResult",
57
+ "EUtilsClient",
58
+ "HttpClient",
59
+ "HttpStats",
60
+ "ICiteClient",
61
+ "MeshTerm",
62
+ "PubMedRecord",
63
+ "PubTatorClient",
64
+ "RateLimiter",
65
+ "build_clients",
66
+ "ncbi_rate",
67
+ ]
68
+
69
+
70
+ def ncbi_rate(settings: Settings) -> float:
71
+ """Requests per second to allow against NCBI, given whether a key is configured."""
72
+ has_key = bool(settings.ncbi_api_key)
73
+ rate = NCBI_RPS_WITH_KEY if has_key else NCBI_RPS_WITHOUT_KEY
74
+ ceiling = NCBI_CEILING_WITH_KEY if has_key else NCBI_CEILING_WITHOUT_KEY
75
+ return min(rate, ceiling)
76
+
77
+
78
+ @dataclass
79
+ class Clients:
80
+ """Every data source, sharing the right limiters."""
81
+
82
+ eutils: EUtilsClient
83
+ bioc: BioCClient
84
+ pubtator: PubTatorClient
85
+ icite: ICiteClient
86
+ ncbi_http: HttpClient
87
+ icite_http: HttpClient
88
+ # Both clients share it, and a run sweeps it. Held here rather than reached for
89
+ # through one of them, since it belongs to neither.
90
+ cache: DiskCache
91
+
92
+ @property
93
+ def stats(self) -> HttpStats:
94
+ """Combined counters across both hosts."""
95
+ a, b = self.ncbi_http.stats, self.icite_http.stats
96
+ return HttpStats(
97
+ requests=a.requests + b.requests,
98
+ cache_hits=a.cache_hits + b.cache_hits,
99
+ retries=a.retries + b.retries,
100
+ bytes_downloaded=a.bytes_downloaded + b.bytes_downloaded,
101
+ )
102
+
103
+ async def aclose(self) -> None:
104
+ await self.ncbi_http.aclose()
105
+ await self.icite_http.aclose()
106
+
107
+
108
+ def build_clients(
109
+ settings: Settings,
110
+ *,
111
+ bus: EventBus | None = None,
112
+ transport: httpx.AsyncBaseTransport | None = None,
113
+ require_email: bool = True,
114
+ ) -> Clients:
115
+ """Wire all four clients.
116
+
117
+ `transport` is how a cassette gets injected: it sits below the cache and the
118
+ limiter, so replayed tests run the same code path as a live call.
119
+ """
120
+ if require_email and not settings.ncbi_email:
121
+ raise ConfigError(
122
+ "NCBI requires a contact address on every request and throttles traffic "
123
+ "that omits it. Set OKF_LOREMASTER_NCBI_EMAIL in your .env."
124
+ )
125
+
126
+ cache = DiskCache(
127
+ http_cache_path(settings),
128
+ ttl_days=settings.http_cache_ttl_days,
129
+ enabled=settings.http_cache_enabled,
130
+ )
131
+ user_agent = f"{settings.ncbi_tool} (mailto:{settings.ncbi_email or 'unset'})"
132
+
133
+ if settings.ca_bundle is not None and not settings.ca_bundle.is_file():
134
+ raise ConfigError(
135
+ f"OKF_LOREMASTER_CA_BUNDLE points at {settings.ca_bundle}, which is not a "
136
+ "file. Leave it unset to use the default trust store."
137
+ )
138
+
139
+ def make(rate: float) -> HttpClient:
140
+ return HttpClient(
141
+ limiter=RateLimiter(rate),
142
+ cache=cache,
143
+ transport=transport,
144
+ bus=bus,
145
+ timeout=settings.http_timeout,
146
+ max_retries=settings.http_max_retries,
147
+ user_agent=user_agent,
148
+ ca_bundle=settings.ca_bundle,
149
+ )
150
+
151
+ ncbi_http = make(ncbi_rate(settings))
152
+ icite_http = make(ICITE_RPS)
153
+
154
+ return Clients(
155
+ eutils=EUtilsClient(ncbi_http, settings),
156
+ bioc=BioCClient(ncbi_http),
157
+ pubtator=PubTatorClient(ncbi_http),
158
+ icite=ICiteClient(icite_http),
159
+ ncbi_http=ncbi_http,
160
+ icite_http=icite_http,
161
+ cache=cache,
162
+ )