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.
- okf_loremaster/__init__.py +9 -0
- okf_loremaster/cli.py +483 -0
- okf_loremaster/clients/__init__.py +162 -0
- okf_loremaster/clients/_http.py +457 -0
- okf_loremaster/clients/bioc.py +141 -0
- okf_loremaster/clients/cassette.py +132 -0
- okf_loremaster/clients/eutils.py +552 -0
- okf_loremaster/clients/icite.py +161 -0
- okf_loremaster/clients/pubtator.py +116 -0
- okf_loremaster/config.py +303 -0
- okf_loremaster/curation.py +270 -0
- okf_loremaster/emitters/__init__.py +35 -0
- okf_loremaster/emitters/okf.py +1243 -0
- okf_loremaster/emitters/vectors.py +700 -0
- okf_loremaster/env.example +158 -0
- okf_loremaster/events.py +149 -0
- okf_loremaster/extraction_cache.py +108 -0
- okf_loremaster/finalize.py +62 -0
- okf_loremaster/graph/__init__.py +7 -0
- okf_loremaster/graph/build.py +446 -0
- okf_loremaster/graph/nodes/__init__.py +39 -0
- okf_loremaster/graph/nodes/charter.py +168 -0
- okf_loremaster/graph/nodes/curate.py +471 -0
- okf_loremaster/graph/nodes/dedupe.py +97 -0
- okf_loremaster/graph/nodes/emit_okf.py +182 -0
- okf_loremaster/graph/nodes/extract.py +270 -0
- okf_loremaster/graph/nodes/fulltext.py +215 -0
- okf_loremaster/graph/nodes/index_vectors.py +82 -0
- okf_loremaster/graph/nodes/rank.py +154 -0
- okf_loremaster/graph/nodes/reconcile.py +272 -0
- okf_loremaster/graph/nodes/review.py +59 -0
- okf_loremaster/graph/nodes/screen.py +222 -0
- okf_loremaster/graph/nodes/search.py +320 -0
- okf_loremaster/graph/nodes/validate.py +65 -0
- okf_loremaster/graph/state.py +255 -0
- okf_loremaster/llm/__init__.py +7 -0
- okf_loremaster/llm/estimate.py +383 -0
- okf_loremaster/llm/fake.py +107 -0
- okf_loremaster/llm/router.py +628 -0
- okf_loremaster/okf/__init__.py +97 -0
- okf_loremaster/okf/frontmatter.py +250 -0
- okf_loremaster/okf/layout.py +177 -0
- okf_loremaster/okf/markdown.py +49 -0
- okf_loremaster/okf/reader.py +347 -0
- okf_loremaster/okf/validate.py +535 -0
- okf_loremaster/prompts.py +456 -0
- okf_loremaster/queries.py +368 -0
- okf_loremaster/ranking.py +419 -0
- okf_loremaster/recurrence.py +401 -0
- okf_loremaster/retention.py +107 -0
- okf_loremaster/review.py +109 -0
- okf_loremaster/run.py +824 -0
- okf_loremaster/schemas/__init__.py +175 -0
- okf_loremaster/schemas/candidates.py +252 -0
- okf_loremaster/schemas/charter.py +246 -0
- okf_loremaster/schemas/common.py +253 -0
- okf_loremaster/schemas/concept.py +462 -0
- okf_loremaster/schemas/evidence.py +126 -0
- okf_loremaster/schemas/limits.py +207 -0
- okf_loremaster/schemas/manifest.py +150 -0
- okf_loremaster/schemas/parse.py +281 -0
- okf_loremaster/schemas/recurrence.py +148 -0
- okf_loremaster/schemas/screening.py +120 -0
- okf_loremaster/schemas/strength.py +52 -0
- okf_loremaster/selftest.py +153 -0
- okf_loremaster/strength.py +323 -0
- okf_loremaster/ui/__init__.py +7 -0
- okf_loremaster/ui/jsonl.py +56 -0
- okf_loremaster/ui/pauses.py +310 -0
- okf_loremaster/ui/plain.py +235 -0
- okf_loremaster/ui/review.py +186 -0
- okf_loremaster/ui/summary.py +186 -0
- okf_loremaster/ui/tui.py +659 -0
- okf_loremaster/verification.py +560 -0
- okf_loremaster-0.1.0.dist-info/METADATA +799 -0
- okf_loremaster-0.1.0.dist-info/RECORD +79 -0
- okf_loremaster-0.1.0.dist-info/WHEEL +4 -0
- okf_loremaster-0.1.0.dist-info/entry_points.txt +3 -0
- okf_loremaster-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|