primarysource 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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
"""What the tools do, as functions returning plain dicts. The CLI prints them and the MCP server serialises
|
|
2
|
+
them; neither adds behaviour, so what a test pins here is what both surfaces do.
|
|
3
|
+
|
|
4
|
+
Budgets are in *estimated* tokens, `len(text) // CHARS_PER_TOKEN`. That is the same crude estimate rfclib and
|
|
5
|
+
itulib use: the point is that a response is bounded and says when the bound, not the corpus, ended it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from bisect import bisect_left
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from primarysource import budget
|
|
21
|
+
from primarysource.model import span_hash
|
|
22
|
+
|
|
23
|
+
from . import _libzim, library, manage, snapshot
|
|
24
|
+
from .errors import LibraryNotFound, ZimloreError
|
|
25
|
+
from .library import Book
|
|
26
|
+
from .markdown import MAX_OUTPUT_CHARS, Document, Heading, convert, join_blocks, to_plain
|
|
27
|
+
from .refs import Ref, format_ref, parse_ref
|
|
28
|
+
|
|
29
|
+
CHARS_PER_TOKEN = 4
|
|
30
|
+
HTML_TYPES = ("text/html", "application/xhtml+xml")
|
|
31
|
+
TEXT_TYPES = ("text/plain", "text/markdown")
|
|
32
|
+
MAX_REDIRECTS = 8
|
|
33
|
+
DEFAULT_MAX_ENTRY_BYTES = 8 * 1024 * 1024
|
|
34
|
+
MAX_ENTRY_ENV = "PRIMARYSOURCE_ZIM_MAX_ENTRY_BYTES"
|
|
35
|
+
LEGACY_MAX_ENTRY_ENV = "ZIMLORE_MAX_ENTRY_BYTES"
|
|
36
|
+
"""The largest entry `document` will read and convert, by the size libzim reports before any byte is read. A page
|
|
37
|
+
this size is not documentation: a 16 MB page of 2,000,000 paragraphs took 47 s to convert, and an entry of
|
|
38
|
+
gigabytes was read into memory whole. Set `PRIMARYSOURCE_ZIM_MAX_ENTRY_BYTES`
|
|
39
|
+
(legacy: `ZIMLORE_MAX_ENTRY_BYTES`) to a larger number to read one anyway."""
|
|
40
|
+
MAX_TOKENS_CAP = 8_000
|
|
41
|
+
"""The most one `read`, `search`, `links` or `outline` call may spend. A model that passed `max_tokens=100000` once
|
|
42
|
+
received a 52,000-token page; 8,000 is four default reads, and a longer page is reached by paging, which is
|
|
43
|
+
cheaper to decide on than to undo. A larger request is cut to this and the result says so in `clamped`."""
|
|
44
|
+
MAX_LIMIT = 500
|
|
45
|
+
SEARCH_DEPTH = 25
|
|
46
|
+
"""How many candidates each library is asked for at least, whatever page of hits is wanted (see `search`)."""
|
|
47
|
+
_DOCS: OrderedDict[tuple[str, str], tuple[Any, str, Document]] = OrderedDict()
|
|
48
|
+
_DOC_CACHE = 64
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tokens(text: str) -> int:
|
|
52
|
+
return max(1, len(text) // CHARS_PER_TOKEN)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bounded(name: str, value: int, low: int, high: int, clamped: dict[str, int] | None = None) -> int:
|
|
56
|
+
"""Validate an agent-supplied number. Below `low` (or not an integer) is an error that says the range, because
|
|
57
|
+
a quiet fix would turn a mistake into a wrong answer (`limit=0` once read as 'no entry matched'). Above `high`
|
|
58
|
+
is cut to it and recorded in `clamped`, which the caller puts in its result."""
|
|
59
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < low:
|
|
60
|
+
raise ZimloreError(f"{name} must be an integer of at least {low}, got {value!r}", f"{name} is {low}..{high}")
|
|
61
|
+
if value > high:
|
|
62
|
+
if clamped is not None:
|
|
63
|
+
clamped[name] = high
|
|
64
|
+
return high
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- locating things -----------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _scan() -> library.Scan:
|
|
72
|
+
scanned = library.scan()
|
|
73
|
+
if not scanned.books:
|
|
74
|
+
error = library.empty_library_error(scanned)
|
|
75
|
+
error.extra.update(
|
|
76
|
+
tool_hint="no ZIM files are installed, so nothing can be read; zim_available lists what could be fetched",
|
|
77
|
+
user_action="ask the user to download a library and put it in the directory named in `searched`",
|
|
78
|
+
user_command=error.cli_hint,
|
|
79
|
+
)
|
|
80
|
+
error.settle()
|
|
81
|
+
raise error
|
|
82
|
+
return scanned
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _select(name: str, scanned: library.Scan) -> library.Selection:
|
|
86
|
+
"""`library.select`, and when nothing installed matches, say what the offline snapshot says is obtainable.
|
|
87
|
+
A missing or unreadable snapshot never turns the miss into a different error. A version that is not installed
|
|
88
|
+
is a miss of another kind (the library IS installed) and is left as it is."""
|
|
89
|
+
try:
|
|
90
|
+
return library.select(name, scanned)
|
|
91
|
+
except LibraryNotFound as error:
|
|
92
|
+
if "requested_version" not in error.extra:
|
|
93
|
+
error.extra.update(_obtainable(name, scanned))
|
|
94
|
+
error.extra["tool_hint"] = (
|
|
95
|
+
"call zim_libraries to see what is installed; to install one, call zim_available, then ask the user "
|
|
96
|
+
"to run the `install` command it returns"
|
|
97
|
+
)
|
|
98
|
+
error.settle()
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find(name: str, scanned: library.Scan) -> Book:
|
|
103
|
+
return _select(name, scanned).book
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _newest_per_family(books: list[Book]) -> list[Book]:
|
|
107
|
+
"""One book for each family: its newest version, in its newest edition. Searching every installed version of
|
|
108
|
+
a package would spend the budget on the same documentation five times."""
|
|
109
|
+
families: dict[str, list[Book]] = {}
|
|
110
|
+
for book in sorted(books, key=lambda b: library.date_key(b.date), reverse=True): # newest first: the order
|
|
111
|
+
families.setdefault(book.family, []).append(book) # the interleaved ranking has always used
|
|
112
|
+
return [library.newest_version(members) for members in families.values()]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _installed(scanned: library.Scan) -> dict[str, str]:
|
|
116
|
+
newest: dict[str, str] = {}
|
|
117
|
+
for book in scanned.books:
|
|
118
|
+
newest[book.name] = max(newest.get(book.name, ""), book.date)
|
|
119
|
+
return newest
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _no_snapshot(error: ZimloreError) -> dict[str, Any]:
|
|
123
|
+
"""What a model needs when there is no usable catalogue snapshot: it cannot make one (that needs the network
|
|
124
|
+
and the user's say-so), so the next move is to ask. `user_command` is the CLI text for the human."""
|
|
125
|
+
command = error.cli_hint or snapshot.HINT
|
|
126
|
+
return {
|
|
127
|
+
"user_action": f"ask the user to run `{command}` once; you cannot, the server never touches the network",
|
|
128
|
+
"user_command": command,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _obtainable(name: str, scanned: library.Scan) -> dict[str, Any]:
|
|
133
|
+
try:
|
|
134
|
+
found = snapshot.available(name, 5, _installed(scanned))
|
|
135
|
+
except ZimloreError as error:
|
|
136
|
+
return {
|
|
137
|
+
"snapshot_hint": f"{error.message}; run `{error.cli_hint or snapshot.HINT}` "
|
|
138
|
+
"to learn what could be installed",
|
|
139
|
+
**_no_snapshot(error),
|
|
140
|
+
}
|
|
141
|
+
return {"obtainable": found["available"], "snapshot_age_days": found["snapshot"]["age_days"]}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _obtainable_for_query(query: str, scanned: library.Scan) -> dict[str, Any]:
|
|
145
|
+
"""`obtainable` for a search that found nothing it is sure of. A snapshot is matched against library names and
|
|
146
|
+
titles, so a sentence rarely matches whole: the query as written first, then each content word, at most 5 rows
|
|
147
|
+
in all. No snapshot, an unusable one or no match is an empty dict: a search never fails for want of one."""
|
|
148
|
+
rows: list[dict[str, Any]] = []
|
|
149
|
+
age: int | None = None
|
|
150
|
+
for text in [query, *_query_words(query)]:
|
|
151
|
+
try:
|
|
152
|
+
found = snapshot.available(text, 5, _installed(scanned))
|
|
153
|
+
except ZimloreError:
|
|
154
|
+
return {}
|
|
155
|
+
age = found["snapshot"]["age_days"]
|
|
156
|
+
names = {row["name"] for row in rows}
|
|
157
|
+
rows += [row for row in found["available"] if row["name"] not in names]
|
|
158
|
+
if len(rows) >= 5:
|
|
159
|
+
break
|
|
160
|
+
if not rows:
|
|
161
|
+
return {}
|
|
162
|
+
return {"obtainable": rows[:5], "snapshot_age_days": age}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def available(query: str, limit: int = 8, start: int = 0) -> dict[str, Any]:
|
|
166
|
+
"""What the offline catalogue snapshot says could be installed for `query` (no network), with which of it
|
|
167
|
+
is already installed. Without a usable snapshot it raises, naming the installed libraries (so "not installed"
|
|
168
|
+
is still answerable) and what only the user can do."""
|
|
169
|
+
scanned = library.scan()
|
|
170
|
+
start = _bounded("start", start, 0, 2**62)
|
|
171
|
+
try:
|
|
172
|
+
found = snapshot.available(query, start + limit, _installed(scanned))
|
|
173
|
+
except ZimloreError as error:
|
|
174
|
+
if error.cli_hint != snapshot.HINT: # an empty query is not a missing snapshot
|
|
175
|
+
raise
|
|
176
|
+
names = sorted(_installed(scanned))
|
|
177
|
+
raise ZimloreError(
|
|
178
|
+
f"{error.message}; the libraries installed are: {', '.join(names) or 'none'}",
|
|
179
|
+
snapshot.HINT,
|
|
180
|
+
installed=names,
|
|
181
|
+
tool_hint="no snapshot, so what could be installed is unknown; if the library you want is not among "
|
|
182
|
+
"the installed ones, it is not installed",
|
|
183
|
+
**_no_snapshot(error),
|
|
184
|
+
) from error
|
|
185
|
+
found["available"] = found["available"][start:]
|
|
186
|
+
if found["matches"] > start + len(found["available"]):
|
|
187
|
+
found["next"] = start + len(found["available"])
|
|
188
|
+
return found
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _book(name: str, scanned: library.Scan | None = None) -> Book:
|
|
192
|
+
return _find(name, scanned or _scan())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class EntryTooLarge(ZimloreError):
|
|
196
|
+
"""An entry over the read budget. A subclass so `search` can keep the hit and say so, where `read` fails."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def max_entry_bytes() -> int:
|
|
200
|
+
name = MAX_ENTRY_ENV
|
|
201
|
+
raw = os.environ.get(name, "").strip()
|
|
202
|
+
if not raw:
|
|
203
|
+
name = LEGACY_MAX_ENTRY_ENV
|
|
204
|
+
raw = os.environ.get(name, "").strip()
|
|
205
|
+
if not raw:
|
|
206
|
+
return DEFAULT_MAX_ENTRY_BYTES
|
|
207
|
+
try:
|
|
208
|
+
value = int(raw)
|
|
209
|
+
except ValueError:
|
|
210
|
+
value = 0
|
|
211
|
+
if value < 1:
|
|
212
|
+
raise ZimloreError(f"{name}={raw!r} is not a positive number of bytes", f"unset {name}")
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_redirect(entry: Any, label: str) -> Any:
|
|
217
|
+
"""Follow redirects from `entry` to the entry they end at, at most `MAX_REDIRECTS` hops. A loop (only a
|
|
218
|
+
hand-edited ZIM has one: libzim's writer refuses them) or a target that cannot be read raises `ZimloreError`;
|
|
219
|
+
callers that are listing things skip the entry, callers that were asked for it report the error."""
|
|
220
|
+
hops = 0
|
|
221
|
+
while entry.is_redirect:
|
|
222
|
+
hops += 1
|
|
223
|
+
if hops > MAX_REDIRECTS:
|
|
224
|
+
raise ZimloreError(f"redirect loop at {label!r}")
|
|
225
|
+
try:
|
|
226
|
+
entry = entry.get_redirect_entry()
|
|
227
|
+
except (RuntimeError, KeyError, OSError) as error:
|
|
228
|
+
raise ZimloreError(f"redirect at {label!r} points at an entry that cannot be read ({error})") from error
|
|
229
|
+
return entry
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _entry(book: Book, path: str) -> tuple[Any, str, str | None]:
|
|
233
|
+
"""The entry at `path`, following redirects. Returns (entry, final path, redirected-from or None)."""
|
|
234
|
+
archive = library.open_archive(book.path)
|
|
235
|
+
if not path:
|
|
236
|
+
if not archive.has_main_entry:
|
|
237
|
+
raise ZimloreError(
|
|
238
|
+
f"{book.name} has no main page",
|
|
239
|
+
f"primarysource zim outline {book.name}",
|
|
240
|
+
tool_hint=f"call zim_outline with ref={book.name!r} to list its entries",
|
|
241
|
+
)
|
|
242
|
+
entry = archive.main_entry
|
|
243
|
+
first = None
|
|
244
|
+
else:
|
|
245
|
+
if not archive.has_entry_by_path(path):
|
|
246
|
+
raise _missing(book, path)
|
|
247
|
+
entry = archive.get_entry_by_path(path)
|
|
248
|
+
first = path
|
|
249
|
+
try:
|
|
250
|
+
entry = resolve_redirect(entry, path or "(main page)")
|
|
251
|
+
except ZimloreError as error:
|
|
252
|
+
raise ZimloreError(f"{book.name}: {error.message}") from error
|
|
253
|
+
return entry, str(entry.path), first if first and first != entry.path else None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _missing(book: Book, path: str) -> ZimloreError:
|
|
257
|
+
near = _suggest(book, path.rsplit("/", 1)[-1].replace("-", " ").replace("_", " "), 5)
|
|
258
|
+
return ZimloreError(
|
|
259
|
+
f"{book.name} has no entry {path!r}",
|
|
260
|
+
f"primarysource zim search {book.name} <words> finds entries; "
|
|
261
|
+
f"primarysource zim outline {book.name} lists them",
|
|
262
|
+
tool_hint=f"call zim_search with library={book.name!r} to find the entry, or zim_outline to list entries",
|
|
263
|
+
**({"did_you_mean": [format_ref(book.name, p) for p, _ in near]} if near else {}),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def document(book: Book, path: str) -> tuple[Document, str, str | None]:
|
|
268
|
+
"""The converted article at `path` (cached). Returns (document, final path, redirected-from)."""
|
|
269
|
+
entry, final, via = _entry(book, path)
|
|
270
|
+
key = (book.uuid, final)
|
|
271
|
+
if key in _DOCS:
|
|
272
|
+
_DOCS.move_to_end(key)
|
|
273
|
+
cached = _DOCS[key]
|
|
274
|
+
return cached[2], final, via
|
|
275
|
+
item = entry.get_item()
|
|
276
|
+
mimetype = str(item.mimetype).split(";")[0].strip().lower()
|
|
277
|
+
size, budget = int(item.size), max_entry_bytes()
|
|
278
|
+
if size > budget and mimetype in HTML_TYPES + TEXT_TYPES:
|
|
279
|
+
raise EntryTooLarge(
|
|
280
|
+
f"{book.name}§{final} is {size:,} bytes, over the {budget:,}-byte read limit",
|
|
281
|
+
f"to read it anyway, set {MAX_ENTRY_ENV}={size} (or more) and repeat the call",
|
|
282
|
+
tool_hint="read a different entry; this one is over the server's size limit",
|
|
283
|
+
user_action=f"only the user can raise the limit: {MAX_ENTRY_ENV}={size} in the server's environment",
|
|
284
|
+
size=size,
|
|
285
|
+
limit=budget,
|
|
286
|
+
)
|
|
287
|
+
raw = bytes(item.content).decode("utf-8", errors="replace")
|
|
288
|
+
if mimetype in HTML_TYPES:
|
|
289
|
+
doc = convert(raw, final)
|
|
290
|
+
elif mimetype in TEXT_TYPES:
|
|
291
|
+
blocks = [part.strip("\n") for part in re.split(r"\n\s*\n", raw) if part.strip()]
|
|
292
|
+
doc = Document(entry.title or final, blocks)
|
|
293
|
+
else:
|
|
294
|
+
raise ZimloreError(
|
|
295
|
+
f"{book.name}§{final} is {mimetype}, not text",
|
|
296
|
+
"only HTML and plain-text entries can be read; primarysource zim outline lists the readable ones",
|
|
297
|
+
tool_hint="only HTML and plain-text entries can be read; zim_outline lists the readable ones",
|
|
298
|
+
mimetype=mimetype,
|
|
299
|
+
size=int(item.size),
|
|
300
|
+
)
|
|
301
|
+
_DOCS[key] = (entry.title, final, doc)
|
|
302
|
+
while len(_DOCS) > _DOC_CACHE:
|
|
303
|
+
_DOCS.popitem(last=False)
|
|
304
|
+
return doc, final, via
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _title(book: Book, path: str) -> str:
|
|
308
|
+
archive = library.open_archive(book.path)
|
|
309
|
+
return str(archive.get_entry_by_path(path).title) if archive.has_entry_by_path(path) else path
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
EMBED_HOSTS = ("disqus.com", "disquscdn.com", "accounts.google.com")
|
|
313
|
+
"""Third-party hosts whose frames a web-crawl ZIM stores as HTML entries under `host/path`. Measured in
|
|
314
|
+
devhints.io_en_all (2026-08): 690 of its 1,037 HTML entries are `disqus.com/...` (an "unable to load Disqus"
|
|
315
|
+
notice, or a JSON API reply labelled text/html) and one is `accounts.google.com/o/oauth2/iframe` (no text at all);
|
|
316
|
+
the 346 real cheatsheets are all under `devhints.io/`. A host list is the cheap test: judging by converted length
|
|
317
|
+
cannot work, because the real page `devhints.io/nocode` is 10 characters and the Disqus notices are 128-144."""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_embed(path: str) -> bool:
|
|
321
|
+
host = path.split("/", 1)[0].lower()
|
|
322
|
+
return any(host == h or host.endswith("." + h) for h in EMBED_HOSTS)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _is_article(entry: Any) -> bool:
|
|
326
|
+
if _is_embed(str(entry.path)):
|
|
327
|
+
return False
|
|
328
|
+
if entry.is_redirect:
|
|
329
|
+
return True
|
|
330
|
+
return str(entry.get_item().mimetype).split(";")[0].strip().lower() in HTML_TYPES + TEXT_TYPES
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --- citation ------------------------------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def citation(book: Book, path: str, anchor: str = "", para: int | str | None = None) -> str:
|
|
337
|
+
"""Built from the ZIM's own metadata, never typed by hand: library, edition date, entry path."""
|
|
338
|
+
where = path + (f"#{anchor}" if anchor else "") + (f" ¶{para}" if para else "")
|
|
339
|
+
text = f"{book.title} [{book.name}], ZIM edition {book.date or 'undated'}, entry {where or '(main page)'}"
|
|
340
|
+
if book.source:
|
|
341
|
+
text += f"; source {book.source}"
|
|
342
|
+
return text
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def licence_note(book: Book) -> str:
|
|
346
|
+
if book.license:
|
|
347
|
+
return book.license
|
|
348
|
+
return "not stated in the ZIM; it is the upstream project's licence, so check the project before reusing text"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- libraries -----------------------------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _book_row(
|
|
355
|
+
book: Book, scanned: library.Scan | None = None, verbose: bool = False, family: bool = False
|
|
356
|
+
) -> dict[str, Any]:
|
|
357
|
+
"""One library as a row. The default row is what choosing a library needs (about 40 tokens); `verbose` adds
|
|
358
|
+
what describing one needs (description, creator, tags, licence, the full provenance record)."""
|
|
359
|
+
row: dict[str, Any] = {
|
|
360
|
+
"name": book.name,
|
|
361
|
+
"title": book.title,
|
|
362
|
+
"language": book.language,
|
|
363
|
+
"articles": book.articles,
|
|
364
|
+
"size_bytes": book.size,
|
|
365
|
+
"search": "full-text" if book.has_fulltext else ("titles only" if book.has_titles else "none"),
|
|
366
|
+
"freshness": _fresh(book),
|
|
367
|
+
}
|
|
368
|
+
provenance = manage.provenance_summary(book.path)
|
|
369
|
+
if verbose:
|
|
370
|
+
row.update(
|
|
371
|
+
description=book.description,
|
|
372
|
+
creator=book.creator,
|
|
373
|
+
tags=list(book.tags),
|
|
374
|
+
license=book.license or None,
|
|
375
|
+
provenance=provenance,
|
|
376
|
+
)
|
|
377
|
+
elif provenance["state"] != "recorded":
|
|
378
|
+
row["provenance"] = provenance["state"] # only the exception is worth a token: `unrecorded` or `unreadable`
|
|
379
|
+
if scanned is not None:
|
|
380
|
+
others = [b.date for b in library.editions(book, scanned.books) if b.path != book.path]
|
|
381
|
+
if others:
|
|
382
|
+
row["other_editions"] = others
|
|
383
|
+
if book.version:
|
|
384
|
+
row["version"] = book.version # a row of a family is its newest version
|
|
385
|
+
versions = library.versions_of(book, scanned.books)
|
|
386
|
+
if family and len(versions) > 1:
|
|
387
|
+
row["family"] = book.family
|
|
388
|
+
row["versions"] = versions
|
|
389
|
+
return row
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _fresh(book: Book) -> dict[str, Any]:
|
|
393
|
+
fresh = book.freshness()
|
|
394
|
+
fresh.pop("stale_after_days", None) # a constant, stated once in the server instructions
|
|
395
|
+
return fresh
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def libraries(
|
|
399
|
+
query: str = "", limit: int = 50, verbose: bool = False, all_versions: bool = False, start: int = 0
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""Installed libraries (or those matching `query`, best first), newest edition of each, at most `limit`. The
|
|
402
|
+
versions of one package are ONE row (its newest version, `versions` listing them all) unless `all_versions`
|
|
403
|
+
asks for a row per ZIM `Name`.
|
|
404
|
+
|
|
405
|
+
A library with 243 DevDocs ZIMs installed would otherwise cost about 35,000 tokens in one call."""
|
|
406
|
+
clamped: dict[str, int] = {}
|
|
407
|
+
limit = _bounded("limit", limit, 1, 1000, clamped)
|
|
408
|
+
start = _bounded("start", start, 0, 2**62)
|
|
409
|
+
scanned = _scan()
|
|
410
|
+
home = str(library.zim_dirs()[0]) if library.zim_dirs() else ""
|
|
411
|
+
if query:
|
|
412
|
+
ranked = library.rank(query, scanned.books, library.user_aliases())
|
|
413
|
+
rows = []
|
|
414
|
+
for candidate in ranked[start : start + limit]:
|
|
415
|
+
row = _book_row(candidate.book, scanned, verbose, family=True)
|
|
416
|
+
row["match"] = candidate.why
|
|
417
|
+
row["score"] = round(candidate.score, 1)
|
|
418
|
+
rows.append(row)
|
|
419
|
+
result: dict[str, Any] = {"query": query, "total": len(ranked), "libraries": rows}
|
|
420
|
+
else:
|
|
421
|
+
if all_versions:
|
|
422
|
+
newest: dict[str, Book] = {}
|
|
423
|
+
for book in sorted(scanned.books, key=lambda b: library.date_key(b.date), reverse=True):
|
|
424
|
+
newest.setdefault(book.name, book)
|
|
425
|
+
ordered = sorted(newest.values(), key=lambda b: b.name)
|
|
426
|
+
else:
|
|
427
|
+
ordered = sorted(_newest_per_family(scanned.books), key=lambda b: b.family)
|
|
428
|
+
rows = [_book_row(b, scanned, verbose, family=not all_versions) for b in ordered[start : start + limit]]
|
|
429
|
+
result = {"total": len(ordered), "libraries": rows}
|
|
430
|
+
result["truncated"] = result["total"] > start + len(rows)
|
|
431
|
+
if result["truncated"]:
|
|
432
|
+
result["next"] = start + len(rows)
|
|
433
|
+
result["directory"] = home
|
|
434
|
+
if scanned.problems:
|
|
435
|
+
result["problems"] = scanned.problems
|
|
436
|
+
if clamped:
|
|
437
|
+
result["clamped"] = clamped
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def resolve(name: str) -> dict[str, Any]:
|
|
442
|
+
"""The one library a name means, with its edition. Raises with the candidates rather than guessing."""
|
|
443
|
+
scanned = _scan()
|
|
444
|
+
chosen = _select(name, scanned)
|
|
445
|
+
book = chosen.book
|
|
446
|
+
row = _book_row(book, scanned, verbose=True)
|
|
447
|
+
row["match"] = chosen.why
|
|
448
|
+
if book.version:
|
|
449
|
+
row["version"] = {
|
|
450
|
+
"upstream": book.version,
|
|
451
|
+
"selected": chosen.kind,
|
|
452
|
+
"pinned_by": "purl tag" if book.package and book.package.version else "version in the Name",
|
|
453
|
+
"edition": book.date or None,
|
|
454
|
+
}
|
|
455
|
+
if chosen.requested:
|
|
456
|
+
row["version"]["requested"] = chosen.requested
|
|
457
|
+
if chosen.other_versions:
|
|
458
|
+
row["other_versions"] = chosen.other_versions
|
|
459
|
+
else:
|
|
460
|
+
row["version"] = {
|
|
461
|
+
"pinned_by": "ZIM edition date",
|
|
462
|
+
"edition": book.date or None,
|
|
463
|
+
"note": "a ZIM records its date, not the upstream release number",
|
|
464
|
+
}
|
|
465
|
+
return row
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# --- search --------------------------------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass
|
|
472
|
+
class Hit:
|
|
473
|
+
book: Book
|
|
474
|
+
path: str
|
|
475
|
+
title: str
|
|
476
|
+
match: str # "full-text" (every word) | "some terms" (partial) | "title"
|
|
477
|
+
lang: str = "" # the language the page's content declares, when the HTML says (MediaWiki does)
|
|
478
|
+
exact: bool = False # a title that is the query: the one `title` match that is not a guess
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
_CONTENT_LANG = re.compile(r"""<div\b[^>]*\bid=["']mw-content-text["'][^>]*?\blang=["']([A-Za-z0-9-]+)["']""")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _content_lang(entry: Any) -> str:
|
|
485
|
+
"""The language a page's body declares: MediaWiki writes `lang` on `<div id="mw-content-text">`, and that is
|
|
486
|
+
the one reliable marker in ArchWiki (measured, 2026-10 ZIM: 3,674 of 3,779 pages with a `_(Español)`-style path
|
|
487
|
+
suffix declare a language other than `en`; 2,356 of 2,395 pages without a suffix declare `en`). The `<html>`
|
|
488
|
+
element's own `lang` is `en` on every page, and the path suffix alone is not a language marker (`Foo_(2022)`)."""
|
|
489
|
+
item = entry.get_item()
|
|
490
|
+
if str(item.mimetype).split(";")[0].strip().lower() not in HTML_TYPES:
|
|
491
|
+
return ""
|
|
492
|
+
head = bytes(memoryview(item.content)[:16384]) # not bytes(item.content)[:n]: that copies a whole big entry
|
|
493
|
+
match = _CONTENT_LANG.search(head.decode("utf-8", errors="replace"))
|
|
494
|
+
return match.group(1).lower() if match else ""
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _same_language(lang: str, wanted: str) -> bool:
|
|
498
|
+
"""`en` matches `en-GB`; a three-letter ISO 639-3 code (the ZIM's `Language` metadata, `eng`) matches the
|
|
499
|
+
two-letter code it extends by prefix, which holds for eng/en, fra/fr, deu/de and is only a convention."""
|
|
500
|
+
lang, wanted = lang.split("-")[0], wanted.lower().split("-")[0]
|
|
501
|
+
return (
|
|
502
|
+
lang == wanted
|
|
503
|
+
or (len(wanted) == 3 and wanted.startswith(lang))
|
|
504
|
+
or (len(lang) == 3 and lang.startswith(wanted))
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _suggest(book: Book, query: str, limit: int) -> list[tuple[str, str]]:
|
|
509
|
+
if not book.has_titles or not query.strip():
|
|
510
|
+
return []
|
|
511
|
+
suggestion = _libzim.load("suggestion")
|
|
512
|
+
archive = library.open_archive(book.path)
|
|
513
|
+
try:
|
|
514
|
+
result = suggestion.SuggestionSearcher(archive).suggest(query)
|
|
515
|
+
paths = list(result.get_results(0, limit))
|
|
516
|
+
except RuntimeError:
|
|
517
|
+
return []
|
|
518
|
+
return [(str(p), _title(book, str(p))) for p in paths]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
TITLE_BOOST = 3.0
|
|
522
|
+
STOPWORDS = frozenset(
|
|
523
|
+
[
|
|
524
|
+
"a",
|
|
525
|
+
"an",
|
|
526
|
+
"and",
|
|
527
|
+
"are",
|
|
528
|
+
"as",
|
|
529
|
+
"at",
|
|
530
|
+
"be",
|
|
531
|
+
"by",
|
|
532
|
+
"do",
|
|
533
|
+
"does",
|
|
534
|
+
"for",
|
|
535
|
+
"from",
|
|
536
|
+
"how",
|
|
537
|
+
"i",
|
|
538
|
+
"in",
|
|
539
|
+
"into",
|
|
540
|
+
"is",
|
|
541
|
+
"it",
|
|
542
|
+
"its",
|
|
543
|
+
"my",
|
|
544
|
+
"of",
|
|
545
|
+
"on",
|
|
546
|
+
"or",
|
|
547
|
+
"so",
|
|
548
|
+
"that",
|
|
549
|
+
"the",
|
|
550
|
+
"this",
|
|
551
|
+
"to",
|
|
552
|
+
"use",
|
|
553
|
+
"using",
|
|
554
|
+
"what",
|
|
555
|
+
"when",
|
|
556
|
+
"where",
|
|
557
|
+
"which",
|
|
558
|
+
"with",
|
|
559
|
+
"you",
|
|
560
|
+
"your",
|
|
561
|
+
"can",
|
|
562
|
+
]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _run_query(book: Book, text: str, want: int) -> list[str]:
|
|
567
|
+
search = _libzim.load("search")
|
|
568
|
+
archive = library.open_archive(book.path)
|
|
569
|
+
try:
|
|
570
|
+
found = search.Searcher(archive).search(search.Query().set_query(text))
|
|
571
|
+
return [str(r.path) if hasattr(r, "path") else str(r) for r in found.get_results(0, want)]
|
|
572
|
+
except RuntimeError:
|
|
573
|
+
return []
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _fulltext(book: Book, query: str, want: int) -> tuple[list[str], list[str]]:
|
|
577
|
+
"""Full-text results as (every-term matches, some-term matches).
|
|
578
|
+
|
|
579
|
+
libzim's query parser requires every term, so a question whose words do not all occur in one page finds
|
|
580
|
+
nothing ("automated" occurs in none of Flask's pages, so "write automated tests for a flask application"
|
|
581
|
+
matched nothing at all). When strict search leaves fewer than `want`, each content word is searched on its
|
|
582
|
+
own and the lists are fused: a page earns, for each word it matches, a weight of ln(pages / pages matching
|
|
583
|
+
the word) divided by 60 plus its rank in that word's list. A word found on every page therefore weighs
|
|
584
|
+
nothing, and a rare one decides. Those extra results are marked as partial matches.
|
|
585
|
+
"""
|
|
586
|
+
cleaned = re.sub(r'[^\w\s"+-]', " ", query)
|
|
587
|
+
strict = _run_query(book, query, want) or _run_query(book, cleaned, want)
|
|
588
|
+
if len(strict) >= want:
|
|
589
|
+
return strict, []
|
|
590
|
+
terms = list(
|
|
591
|
+
dict.fromkeys(w for w in re.findall(r"[\w+-]+", cleaned.lower()) if w not in STOPWORDS and len(w) > 1)
|
|
592
|
+
)
|
|
593
|
+
if len(terms) < 2:
|
|
594
|
+
return strict, []
|
|
595
|
+
search = _libzim.load("search")
|
|
596
|
+
archive = library.open_archive(book.path)
|
|
597
|
+
total = max(book.articles, 2)
|
|
598
|
+
scores: dict[str, float] = {}
|
|
599
|
+
for term in terms:
|
|
600
|
+
try:
|
|
601
|
+
found = search.Searcher(archive).search(search.Query().set_query(term))
|
|
602
|
+
matches = int(found.get_estimated_matches())
|
|
603
|
+
ranked = [str(r.path) if hasattr(r, "path") else str(r) for r in found.get_results(0, 50)]
|
|
604
|
+
except RuntimeError:
|
|
605
|
+
continue
|
|
606
|
+
if not matches:
|
|
607
|
+
continue
|
|
608
|
+
weight = math.log(total / matches) if matches < total else 0.0
|
|
609
|
+
for rank, path in enumerate(ranked):
|
|
610
|
+
scores[path] = scores.get(path, 0.0) + weight / (60 + rank)
|
|
611
|
+
for rank, (path, _) in enumerate(_suggest(book, term, 20)): # a word in a page's title is a strong signal
|
|
612
|
+
scores[path] = scores.get(path, 0.0) + TITLE_BOOST * max(weight, 0.5) / (60 + rank)
|
|
613
|
+
loose = [p for p, _ in sorted(scores.items(), key=lambda kv: -kv[1]) if p not in strict and scores[p] > 0]
|
|
614
|
+
return strict, loose[: want * 2]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _book_hits(book: Book, query: str, want: int, language: str = "") -> tuple[list[Hit], str]:
|
|
618
|
+
"""Title matches that are exact come first, then full-text relevance, then the remaining title matches.
|
|
619
|
+
Entries that are not articles (stylesheets, scripts) and redirects to the same article are folded.
|
|
620
|
+
|
|
621
|
+
`language` decides what a page's declared content language does. Empty: pages in another language than the
|
|
622
|
+
library's are kept but ranked after the pages in its own language (ArchWiki holds a translation of most pages
|
|
623
|
+
and they crowded the base page out of the top 5). `any`: no preference. A code (`ru`): only pages in it."""
|
|
624
|
+
archive = library.open_archive(book.path)
|
|
625
|
+
titled = _suggest(book, query, want)
|
|
626
|
+
mode = "full-text" if book.has_fulltext else "titles only"
|
|
627
|
+
strict, loose = _fulltext(book, query, want * 2) if book.has_fulltext else ([], [])
|
|
628
|
+
wanted = " ".join(query.lower().split())
|
|
629
|
+
exact = [p for p, t in titled if " ".join(t.lower().split()) == wanted]
|
|
630
|
+
ordered: list[tuple[str, str]] = (
|
|
631
|
+
[(p, "exact") for p in exact]
|
|
632
|
+
+ [(p, "full-text") for p in strict]
|
|
633
|
+
+ [(p, "title") for p, _ in titled if p not in exact]
|
|
634
|
+
+ [(p, "some terms") for p in loose]
|
|
635
|
+
)
|
|
636
|
+
hits: list[Hit] = []
|
|
637
|
+
foreign: list[Hit] = []
|
|
638
|
+
seen: set[str] = set()
|
|
639
|
+
folding = language not in {"any"}
|
|
640
|
+
for path, how in ordered:
|
|
641
|
+
if not archive.has_entry_by_path(path):
|
|
642
|
+
continue
|
|
643
|
+
entry = archive.get_entry_by_path(path)
|
|
644
|
+
if not _is_article(entry):
|
|
645
|
+
continue
|
|
646
|
+
try:
|
|
647
|
+
entry = resolve_redirect(entry, path)
|
|
648
|
+
except ZimloreError:
|
|
649
|
+
continue # a redirect loop or dangling target is one bad hit, not a failed search
|
|
650
|
+
if entry.path in seen:
|
|
651
|
+
continue
|
|
652
|
+
seen.add(entry.path)
|
|
653
|
+
lang = _content_lang(entry) if folding else ""
|
|
654
|
+
hit = Hit(book, entry.path, str(entry.title), "title" if how == "exact" else how, lang, how == "exact")
|
|
655
|
+
if language and folding:
|
|
656
|
+
if lang and _same_language(lang, language):
|
|
657
|
+
hits.append(hit)
|
|
658
|
+
elif lang and not _same_language(lang, book.language or "eng"):
|
|
659
|
+
foreign.append(hit)
|
|
660
|
+
else:
|
|
661
|
+
hits.append(hit)
|
|
662
|
+
if len(hits) >= want:
|
|
663
|
+
break
|
|
664
|
+
return (hits + foreign)[:want], mode
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
LARGE_ARTICLE_BLOCKS = 60
|
|
668
|
+
RARE_WORD_BLOCKS = 3 # a single query word is distinctive on a page if at most this many blocks contain it
|
|
669
|
+
"""An article with more blocks than this gets section-level hits. 60 blocks is about one default `read` page
|
|
670
|
+
(2000 tokens is roughly 50-100 blocks): past it, an agent that lands on the page pays a whole page to find the
|
|
671
|
+
part it wanted, and an agent testing the server did (Go `httptest`, 168 blocks, ~1.1k tokens to reach the example,
|
|
672
|
+
when the threshold was 200). Measured 2026-11-07 on a 60-400 page random sample per library, the share of articles
|
|
673
|
+
over 60 blocks: PHP 3.5%, Flask 22%, Rust 29%, Python 59%, Go 63%, Node 92% (over 200 it was 0.3%, 7%, 11%, 21%,
|
|
674
|
+
27% and 65%). The rarity and two-word rules in `_best_section` still decide whether a hit gets one, so a short page
|
|
675
|
+
and a stray word stay article-level."""
|
|
676
|
+
|
|
677
|
+
_CHROME_LEAD = re.compile(
|
|
678
|
+
r"^(pages that refer to this page|colophon\b|this page is part of|table of contents$"
|
|
679
|
+
r"|this document has been placed in the public domain)",
|
|
680
|
+
re.IGNORECASE,
|
|
681
|
+
)
|
|
682
|
+
_BARE_URL = re.compile(r"^[\W_]*(?:https?://|www\.)\S+[\W_]*$", re.IGNORECASE)
|
|
683
|
+
_FIELD_LABEL = re.compile(r"^\*\*[\w -]+:\*\*$")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _header_end(blocks: list[str]) -> int:
|
|
687
|
+
"""Number of leading blocks that are a metadata header: the PEPs open with a field list (`**Author:**`, a
|
|
688
|
+
value block, `**Discussions-To:**`, `[Discourse thread](...)`, ...) closed by a rule. Measured on
|
|
689
|
+
peps.python_en_all: 735 of 765 PEP pages carry it, and 'Discourse thread' was the search excerpt of PEP 649,
|
|
690
|
+
PEP 669 and PEP 728. Requires at least two labels before a rule within the first 60 blocks."""
|
|
691
|
+
labels = 0
|
|
692
|
+
for index, block in enumerate(blocks[:60]):
|
|
693
|
+
if _FIELD_LABEL.match(block):
|
|
694
|
+
labels += 1
|
|
695
|
+
elif block.strip() == "---":
|
|
696
|
+
return index + 1 if labels >= 2 else 0
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _is_chrome(block: str) -> bool:
|
|
701
|
+
"""Page furniture that is never the answer: a rule, a back-reference list, or a block that is only a URL."""
|
|
702
|
+
flat = " ".join(to_plain(block).split())
|
|
703
|
+
if not flat or set(flat) <= set("-*_= "):
|
|
704
|
+
return True
|
|
705
|
+
return bool(_CHROME_LEAD.match(flat) or _BARE_URL.match(flat))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _query_words(query: str) -> list[str]:
|
|
709
|
+
"""The distinct content words of a query: longer than two characters and not a stopword (`how`, `use`, `the`
|
|
710
|
+
decide nothing about which block answers). One definition for excerpts and section choice."""
|
|
711
|
+
return list(dict.fromkeys(w for w in re.findall(r"\w+", query.lower()) if len(w) > 2 and w not in STOPWORDS))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _word_patterns(words: list[str]) -> list[re.Pattern[str]]:
|
|
715
|
+
"""A word matches from a word boundary (`rotor` finds `rotors`, `cat` does not find `concatenate`)."""
|
|
716
|
+
return [re.compile(r"\b" + re.escape(w)) for w in words]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _excerpt(blocks: list[str], query: str, width: int = 240) -> str:
|
|
720
|
+
"""The best-matching block of `blocks`, flattened and cut to `width`. Prose is preferred; code, tables and
|
|
721
|
+
preformatted text are used when no prose block mentions a query word (man pages are one fence per section).
|
|
722
|
+
Chrome blocks (see `_is_chrome`) and headings are never chosen unless nothing else is left."""
|
|
723
|
+
words = _query_words(query)
|
|
724
|
+
patterns = _word_patterns(words)
|
|
725
|
+
lead = _header_end(blocks)
|
|
726
|
+
body = [b for b in blocks[lead:] if not b.startswith("#") and not _is_chrome(b) and b != "×"] or blocks
|
|
727
|
+
prose = [b for b in body if not b.startswith(("```", "|"))]
|
|
728
|
+
pick = ""
|
|
729
|
+
for pool in (prose, body):
|
|
730
|
+
scored = [(sum(bool(p.search(b.lower())) for p in patterns), -i, b) for i, b in enumerate(pool)]
|
|
731
|
+
best = max(scored, default=(0, 0, ""))
|
|
732
|
+
if best[0]:
|
|
733
|
+
pick = best[2]
|
|
734
|
+
break
|
|
735
|
+
choice = pick or (prose[0] if prose else (body[0] if body else ""))
|
|
736
|
+
flat = " ".join(to_plain(choice).split())
|
|
737
|
+
if len(flat) <= width:
|
|
738
|
+
return flat
|
|
739
|
+
lowered = flat.lower()
|
|
740
|
+
at = min((m.start() for m in (p.search(lowered) for p in patterns) if m), default=0)
|
|
741
|
+
start = max(0, at - width // 3)
|
|
742
|
+
return ("…" if start else "") + flat[start : start + width].strip() + "…"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _best_section(doc: Document, query: str) -> tuple[Heading, int, int] | None:
|
|
746
|
+
"""The addressable heading or definition of a large article whose own text covers most of the query's words.
|
|
747
|
+
|
|
748
|
+
Score = distinct query words found in the section's blocks (sub-sections included) plus 1 for each found in
|
|
749
|
+
the heading text itself; ties go to the shorter section, so a child beats the parent that contains it. A
|
|
750
|
+
section with no query word is never chosen: the caller then keeps the article-level hit as it was.
|
|
751
|
+
|
|
752
|
+
A parent always covers at least what its children do, so a query whose words sit in different parts of the
|
|
753
|
+
page is won by the section that holds them all, often the whole article. A winner that spans more than half of
|
|
754
|
+
the article is no better than the article: `None`, and the caller keeps the article-level hit."""
|
|
755
|
+
words = _query_words(query)
|
|
756
|
+
if not words:
|
|
757
|
+
return None
|
|
758
|
+
patterns = _word_patterns(words)
|
|
759
|
+
masks = []
|
|
760
|
+
for block in doc.blocks:
|
|
761
|
+
low = block.lower()
|
|
762
|
+
masks.append(sum(1 << n for n, p in enumerate(patterns) if p.search(low)))
|
|
763
|
+
best: tuple[tuple[float, int], Heading, int, int] | None = None
|
|
764
|
+
for position, head in enumerate(doc.headings):
|
|
765
|
+
if not head.anchor:
|
|
766
|
+
continue
|
|
767
|
+
lo, hi = doc.span(position)
|
|
768
|
+
found = 0
|
|
769
|
+
for mask in masks[lo:hi]:
|
|
770
|
+
found |= mask
|
|
771
|
+
if not found:
|
|
772
|
+
continue
|
|
773
|
+
title = head.text.lower()
|
|
774
|
+
key = (bin(found).count("1") + sum(bool(p.search(title)) for p in patterns), -(hi - lo))
|
|
775
|
+
if best is None or key > best[0]:
|
|
776
|
+
best = (key, head, lo, hi)
|
|
777
|
+
if best is None or (best[3] - best[2]) * 2 > len(doc.blocks):
|
|
778
|
+
return None
|
|
779
|
+
if best[0][0] < 2:
|
|
780
|
+
# Weak evidence: one query word found, none in the heading. For a query of several words that is a stray
|
|
781
|
+
# match in an unrelated section (found live: 'directory' in 'create a directory with missing parents').
|
|
782
|
+
if len(words) > 1:
|
|
783
|
+
return None
|
|
784
|
+
# For a one-word query it is an answer only if the word is distinctive on this page. 'x509' on the x509
|
|
785
|
+
# reference page is in dozens of blocks and attached 'Custom PKCS7 Test Vectors' by luck; 'quibbleframe',
|
|
786
|
+
# in one definition's body, is the answer.
|
|
787
|
+
if sum(1 for mask in masks if mask & 1) > RARE_WORD_BLOCKS:
|
|
788
|
+
return None
|
|
789
|
+
return best[1:]
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def search(
|
|
793
|
+
query: str, library_name: str = "", limit: int = 8, max_tokens: int = 1500, language: str = "", start: int = 0
|
|
794
|
+
) -> dict[str, Any]:
|
|
795
|
+
"""Search one library, or all of them when `library_name` is empty. Hits are refs ready for `read`.
|
|
796
|
+
|
|
797
|
+
`language`: empty ranks pages written in another language than the library's after those in its own (a hit
|
|
798
|
+
then carries `language`), `any` keeps the engine's order, a code such as `ru` returns only pages declaring it.
|
|
799
|
+
|
|
800
|
+
Across libraries the order is rank-by-rank interleaving: libzim does not expose a relevance score, so scores
|
|
801
|
+
from different ZIMs cannot be compared and pretending otherwise would be invented precision.
|
|
802
|
+
"""
|
|
803
|
+
if not query.strip():
|
|
804
|
+
raise ZimloreError(
|
|
805
|
+
"empty query",
|
|
806
|
+
"give a few words, e.g. primarysource zim search flask 'application factory'",
|
|
807
|
+
tool_hint="give a few words, e.g. query='application factory'",
|
|
808
|
+
)
|
|
809
|
+
clamped: dict[str, int] = {}
|
|
810
|
+
limit = _bounded("limit", limit, 1, MAX_LIMIT, clamped)
|
|
811
|
+
start = _bounded("start", start, 0, 2**62)
|
|
812
|
+
max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
|
|
813
|
+
scanned = _scan()
|
|
814
|
+
books = [_find(library_name, scanned)] if library_name else _newest_per_family(scanned.books)
|
|
815
|
+
# The engine has no offset: a later page asks it for everything up to its own end, and the earlier hits are
|
|
816
|
+
# skipped. How many title and full-text candidates the engine is asked for decides the order past the first few
|
|
817
|
+
# (`_book_hits` fills from its tiers), so every page asks for at least SEARCH_DEPTH: pages are then cuts of one
|
|
818
|
+
# list, which is what lets `next: start=<n>` lose nothing. Reading a page's hits, the costly step, is unchanged.
|
|
819
|
+
per_book = max(SEARCH_DEPTH, start + (limit if len(books) == 1 else max(2, min(limit, 5))))
|
|
820
|
+
lists: list[list[Hit]] = []
|
|
821
|
+
modes: dict[str, str] = {}
|
|
822
|
+
for book in books:
|
|
823
|
+
hits, mode = _book_hits(book, query, per_book, language.strip().lower())
|
|
824
|
+
modes[book.name] = mode
|
|
825
|
+
lists.append(hits)
|
|
826
|
+
ordered: list[Hit] = []
|
|
827
|
+
for rank in range(per_book):
|
|
828
|
+
for hits in lists:
|
|
829
|
+
if rank < len(hits):
|
|
830
|
+
ordered.append(hits[rank])
|
|
831
|
+
kept: list[dict[str, Any]] = []
|
|
832
|
+
used, truncated, sure = 0, False, False
|
|
833
|
+
for hit in ordered[start : start + limit]:
|
|
834
|
+
too_large = ""
|
|
835
|
+
try:
|
|
836
|
+
doc, _, _ = document(hit.book, hit.path)
|
|
837
|
+
except EntryTooLarge as error:
|
|
838
|
+
doc, too_large = Document("", []), error.message
|
|
839
|
+
picked = _best_section(doc, query) if len(doc.blocks) > LARGE_ARTICLE_BLOCKS else None
|
|
840
|
+
row: dict[str, Any] = {
|
|
841
|
+
"ref": format_ref(hit.book.name, hit.path),
|
|
842
|
+
"title": hit.title,
|
|
843
|
+
"excerpt": _excerpt(doc.blocks[picked[1] : picked[2]] if picked else doc.blocks, query),
|
|
844
|
+
"match": hit.match,
|
|
845
|
+
}
|
|
846
|
+
if too_large:
|
|
847
|
+
row["note"] = f"no excerpt: {too_large}"
|
|
848
|
+
else:
|
|
849
|
+
row["blocks"] = len(doc.blocks) # what reading the whole page would cost, before deciding to
|
|
850
|
+
if hit.lang and not _same_language(hit.lang, hit.book.language or "eng"):
|
|
851
|
+
row["language"] = hit.lang
|
|
852
|
+
cost = tokens(row["excerpt"] + hit.title) + 30
|
|
853
|
+
if picked:
|
|
854
|
+
head = picked[0]
|
|
855
|
+
row["section"] = {
|
|
856
|
+
"anchor": head.anchor,
|
|
857
|
+
"heading": head.text,
|
|
858
|
+
"ref": format_ref(hit.book.name, hit.path, head.anchor),
|
|
859
|
+
"blocks": picked[2] - picked[1],
|
|
860
|
+
}
|
|
861
|
+
cost += tokens(head.text) + 30
|
|
862
|
+
if kept and used + cost > max_tokens:
|
|
863
|
+
truncated = True
|
|
864
|
+
break
|
|
865
|
+
used += cost
|
|
866
|
+
kept.append(row)
|
|
867
|
+
sure = sure or hit.match == "full-text" or hit.exact
|
|
868
|
+
result: dict[str, Any] = {
|
|
869
|
+
"query": query,
|
|
870
|
+
"libraries_searched": [b.name for b in books],
|
|
871
|
+
"editions": {b.name: b.date or None for b in books}, # once, not per hit; `zim_cite` builds the citation
|
|
872
|
+
"hits": kept,
|
|
873
|
+
"truncated": truncated or len(ordered) > start + limit,
|
|
874
|
+
}
|
|
875
|
+
if result["truncated"]:
|
|
876
|
+
result["next"] = start + len(kept)
|
|
877
|
+
title_only = [name for name, mode in modes.items() if mode == "titles only"]
|
|
878
|
+
if title_only:
|
|
879
|
+
result["title_only"] = f"no full-text index in: {', '.join(title_only)}; only titles were searched there"
|
|
880
|
+
if len(books) > 1:
|
|
881
|
+
result["ranking"] = "interleaved rank by rank across libraries; ZIM search exposes no comparable score"
|
|
882
|
+
if clamped:
|
|
883
|
+
result["clamped"] = clamped
|
|
884
|
+
if not kept:
|
|
885
|
+
result["note"] = "no entry matched; this is the search, not proof the topic is absent from the library"
|
|
886
|
+
if not sure:
|
|
887
|
+
# Every hit is partial ('some terms') or a title guess: the engine found pages that share a word, not a page
|
|
888
|
+
# that answers. Found live: an uncovered topic returned 8 plausible hits and nothing said to stop.
|
|
889
|
+
result["confidence"] = "low"
|
|
890
|
+
if kept:
|
|
891
|
+
result["note"] = (
|
|
892
|
+
"no hit matched all your words; the topic may not be covered - check zim_available "
|
|
893
|
+
"(`obtainable`, when present, is what the offline catalogue snapshot lists)"
|
|
894
|
+
)
|
|
895
|
+
result.update(_obtainable_for_query(query, scanned))
|
|
896
|
+
return result
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# --- reading -------------------------------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _context(ref: str, library_name: str) -> tuple[Book, Ref]:
|
|
903
|
+
parsed = parse_ref(ref, library_name)
|
|
904
|
+
return _book(parsed.book), parsed
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _no_section(book: Book, final: str, doc: Document, anchor: str) -> ZimloreError:
|
|
908
|
+
"""A bad anchor. The three closest real anchors by spelling are named as refs ready to read; the first 25 in
|
|
909
|
+
document order (the old answer) said nothing about a typo like `os.scandirr`, whose fix is one letter."""
|
|
910
|
+
lowered = {h.anchor.lower(): h.anchor for h in doc.headings if h.anchor}
|
|
911
|
+
close = [lowered[m] for m in difflib.get_close_matches(anchor.lower(), list(lowered), n=3, cutoff=0.6)]
|
|
912
|
+
return ZimloreError(
|
|
913
|
+
f"{book.name}§{final} has no section #{anchor}",
|
|
914
|
+
f"primarysource zim outline {book.name}§{final} lists the anchors",
|
|
915
|
+
tool_hint=f"call zim_outline with ref={book.name}§{final} to list its anchors",
|
|
916
|
+
**({"did_you_mean": [format_ref(book.name, final, a) for a in close]} if close else {}),
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _scope(book: Book, final: str, doc: Document, parsed: Ref, onward: bool = False) -> tuple[int, int, str]:
|
|
921
|
+
"""The blocks a ref names, as (lo, hi, scope) over `doc.blocks`. `¶n` counts inside the scope it is written
|
|
922
|
+
after: the nth block of the section when there is an `#anchor`, of the article otherwise. Counting the page's
|
|
923
|
+
nth block under a section's name would cite a section for text it does not contain.
|
|
924
|
+
|
|
925
|
+
`¶n` names that one block (what `cite` checks) unless `onward`: a read of `ref¶n` starts at block n and
|
|
926
|
+
continues to the end of the scope, which is how a cut read's `next:` ref pages (PLAN.md §5.11)."""
|
|
927
|
+
lo, hi, scope = 0, len(doc.blocks), "article"
|
|
928
|
+
if parsed.anchor:
|
|
929
|
+
span = doc.section(parsed.anchor)
|
|
930
|
+
if span is None:
|
|
931
|
+
raise _no_section(book, final, doc, parsed.anchor)
|
|
932
|
+
(lo, hi), scope = span, "section"
|
|
933
|
+
if parsed.para is not None:
|
|
934
|
+
if parsed.para > hi - lo:
|
|
935
|
+
where = f"#{parsed.anchor}" if parsed.anchor else ""
|
|
936
|
+
raise ZimloreError(
|
|
937
|
+
f"{book.name}§{final}{where} has {hi - lo} blocks; no ¶{parsed.para}",
|
|
938
|
+
"¶n counts the blocks of the section when the ref has an #anchor, of the article otherwise",
|
|
939
|
+
blocks=hi - lo,
|
|
940
|
+
)
|
|
941
|
+
lo = lo + parsed.para - 1
|
|
942
|
+
if not onward:
|
|
943
|
+
hi, scope = lo + 1, "block"
|
|
944
|
+
return lo, hi, scope
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _label(first: int, last: int, para: int | None) -> int | str | None:
|
|
948
|
+
"""What a read's citation names: nothing for a whole scope read from its first block, `¶n` for one block, `¶a-b`
|
|
949
|
+
for a page that starts inside the scope or holds several blocks of a `¶` read (blocks 1-based in the scope)."""
|
|
950
|
+
if para is None and first <= 1:
|
|
951
|
+
return None
|
|
952
|
+
return first if first == last else f"{first}-{last}"
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def read(
|
|
956
|
+
ref: str,
|
|
957
|
+
library_name: str = "",
|
|
958
|
+
max_tokens: int = 2000,
|
|
959
|
+
format: str = "markdown", # noqa: A002 - the tool parameter is called format
|
|
960
|
+
) -> dict[str, Any]:
|
|
961
|
+
"""One article, one section (`#anchor`), cut to a token budget, from block `¶n` on when the ref has one. `next`
|
|
962
|
+
is a ref (the same scope with `¶` at the first block not shown) when the budget, not the scope, ended the page."""
|
|
963
|
+
if format not in {"markdown", "text"}:
|
|
964
|
+
raise ZimloreError(f"unknown format {format!r}", "format is 'markdown' or 'text'")
|
|
965
|
+
clamped: dict[str, int] = {}
|
|
966
|
+
max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
|
|
967
|
+
book, parsed = _context(ref, library_name)
|
|
968
|
+
doc, final, via = document(book, parsed.path)
|
|
969
|
+
whole = replace(parsed, para=None)
|
|
970
|
+
scope_lo, hi, scope = _scope(book, final, doc, whole)
|
|
971
|
+
lo, _, _ = _scope(book, final, doc, parsed, onward=True)
|
|
972
|
+
first = lo
|
|
973
|
+
# The core pager: whole blocks until the next would pass the budget, the first always taken (so a cursor
|
|
974
|
+
# following `next` terminates), and `oversize` when that one alone is over.
|
|
975
|
+
page = budget.page(doc.blocks[lo:hi], 1, max_tokens)
|
|
976
|
+
chunk = list(page.units)
|
|
977
|
+
index = lo + len(chunk)
|
|
978
|
+
marks = [doc.tight[i] if i < len(doc.tight) else False for i in range(lo, index)]
|
|
979
|
+
body = join_blocks(chunk, marks)
|
|
980
|
+
if format == "text":
|
|
981
|
+
body = to_plain(body)
|
|
982
|
+
ref_out = format_ref(book.name, final, parsed.anchor)
|
|
983
|
+
label = _label(first - scope_lo + 1, index - scope_lo, parsed.para)
|
|
984
|
+
result: dict[str, Any] = {
|
|
985
|
+
"ref": ref_out,
|
|
986
|
+
"cite": citation(book, final, parsed.anchor, label),
|
|
987
|
+
"title": _title(book, final),
|
|
988
|
+
"scope": scope,
|
|
989
|
+
"freshness": _fresh(book),
|
|
990
|
+
"format": format,
|
|
991
|
+
"blocks": {"from": first - scope_lo + 1, "to": index - scope_lo, "of": hi - scope_lo},
|
|
992
|
+
"content": body,
|
|
993
|
+
"next": None,
|
|
994
|
+
}
|
|
995
|
+
if doc.title and doc.title != result["title"]:
|
|
996
|
+
result["page_title"] = doc.title # only when it differs: DevDocs entry titles are not always page names
|
|
997
|
+
if via:
|
|
998
|
+
result["redirected_from"] = via
|
|
999
|
+
if index < hi:
|
|
1000
|
+
result["next"] = format_ref(book.name, final, parsed.anchor, index - scope_lo + 1)
|
|
1001
|
+
# What is left, in the unit a model budgets in: block counts alone made "17 of 76" mean nothing.
|
|
1002
|
+
result["remaining_blocks"] = hi - index
|
|
1003
|
+
result["remaining_tokens"] = sum(tokens(b) for b in doc.blocks[index:hi])
|
|
1004
|
+
if page.oversize:
|
|
1005
|
+
result["oversize"] = "one block alone exceeds the budget and is returned whole so paging makes progress"
|
|
1006
|
+
if doc.truncated:
|
|
1007
|
+
result["converted_truncated"] = (
|
|
1008
|
+
f"the page is larger than the converter's {MAX_OUTPUT_CHARS:,}-character ceiling; its end is not shown"
|
|
1009
|
+
)
|
|
1010
|
+
if clamped:
|
|
1011
|
+
result["clamped"] = clamped
|
|
1012
|
+
if doc.dropped_images:
|
|
1013
|
+
result["images"] = f"{doc.dropped_images} image(s) are not text and appear as [image] placeholders"
|
|
1014
|
+
return result
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _within(rows: list[dict[str, Any]], budget: int) -> tuple[list[dict[str, Any]], bool]:
|
|
1018
|
+
"""The leading rows whose estimated cost fits `budget` tokens (always at least one), and whether rows were cut."""
|
|
1019
|
+
used = 0
|
|
1020
|
+
kept: list[dict[str, Any]] = []
|
|
1021
|
+
for row in rows:
|
|
1022
|
+
cost = tokens(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
|
1023
|
+
if kept and used + cost > budget:
|
|
1024
|
+
return kept, True
|
|
1025
|
+
used += cost
|
|
1026
|
+
kept.append(row)
|
|
1027
|
+
return kept, False
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def outline(
|
|
1031
|
+
ref: str, library_name: str = "", start: int = 0, limit: int = 100, prefix: str = "", max_tokens: int = 1500
|
|
1032
|
+
) -> dict[str, Any]:
|
|
1033
|
+
"""Headings (with anchors) of one article, or a page of a library's articles when `ref` is a library alone."""
|
|
1034
|
+
clamped: dict[str, int] = {}
|
|
1035
|
+
start = _bounded("start", start, 0, 2**62)
|
|
1036
|
+
limit = _bounded("limit", limit, 1, 1000, clamped)
|
|
1037
|
+
max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
|
|
1038
|
+
book, parsed = _context(ref, library_name)
|
|
1039
|
+
if parsed.path:
|
|
1040
|
+
doc, final, _ = document(book, parsed.path)
|
|
1041
|
+
rows = []
|
|
1042
|
+
for h in doc.headings:
|
|
1043
|
+
# The page ref is stated once; a row's own ref is that plus `#anchor` (the library and path repeated on
|
|
1044
|
+
# every row cost about 60 characters each and told an agent nothing).
|
|
1045
|
+
row: dict[str, Any] = {"text": h.text}
|
|
1046
|
+
if h.kind == "heading":
|
|
1047
|
+
row["level"] = h.level
|
|
1048
|
+
else:
|
|
1049
|
+
row["kind"] = "definition"
|
|
1050
|
+
if h.anchor:
|
|
1051
|
+
row["anchor"] = h.anchor
|
|
1052
|
+
rows.append(row)
|
|
1053
|
+
if start > len(rows):
|
|
1054
|
+
raise ZimloreError(f"{book.name}§{final} has {len(rows)} outline rows; start={start} is past the end")
|
|
1055
|
+
page, cut = _within(rows[start : start + limit], max_tokens)
|
|
1056
|
+
out: dict[str, Any] = {
|
|
1057
|
+
"ref": format_ref(book.name, final),
|
|
1058
|
+
"title": doc.title,
|
|
1059
|
+
"total": len(rows),
|
|
1060
|
+
"outline": page,
|
|
1061
|
+
}
|
|
1062
|
+
if start + len(page) < len(rows):
|
|
1063
|
+
out["next"] = start + len(page)
|
|
1064
|
+
out["truncated"] = cut or "next" in out # `next` present means rows are missing, whoever ended the page
|
|
1065
|
+
if clamped:
|
|
1066
|
+
out["clamped"] = clamped
|
|
1067
|
+
return out
|
|
1068
|
+
archive = library.open_archive(book.path)
|
|
1069
|
+
total = int(archive.entry_count)
|
|
1070
|
+
if start > total:
|
|
1071
|
+
raise ZimloreError(f"{book.name} has {total} entries; start={start} is past the end")
|
|
1072
|
+
first = start
|
|
1073
|
+
if prefix and start == 0:
|
|
1074
|
+
first = bisect_left(range(total), prefix, key=lambda i: str(archive.get_entry_by_id(i).path))
|
|
1075
|
+
entries: list[dict[str, Any]] = []
|
|
1076
|
+
positions: list[int] = [] # the entry id to resume from if the listing is cut after entries[n]
|
|
1077
|
+
index = first
|
|
1078
|
+
scanned_ids = 0
|
|
1079
|
+
while index < total and len(entries) < limit and scanned_ids < 20000:
|
|
1080
|
+
entry = archive.get_entry_by_id(index)
|
|
1081
|
+
index += 1
|
|
1082
|
+
scanned_ids += 1
|
|
1083
|
+
if prefix and not str(entry.path).startswith(prefix):
|
|
1084
|
+
index = total
|
|
1085
|
+
break
|
|
1086
|
+
if entry.is_redirect or not _is_article(entry):
|
|
1087
|
+
continue
|
|
1088
|
+
entries.append({"ref": format_ref(book.name, str(entry.path)), "title": str(entry.title)})
|
|
1089
|
+
positions.append(index)
|
|
1090
|
+
kept, cut = _within(entries, max_tokens)
|
|
1091
|
+
resume = positions[len(kept) - 1] if cut else index
|
|
1092
|
+
result: dict[str, Any] = {"library": book.name, "entries": kept, "freshness": _fresh(book)}
|
|
1093
|
+
if clamped:
|
|
1094
|
+
result["clamped"] = clamped
|
|
1095
|
+
if cut or index < total:
|
|
1096
|
+
result["next"] = resume
|
|
1097
|
+
result["truncated"] = cut or "next" in result
|
|
1098
|
+
return result
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def links(
|
|
1102
|
+
ref: str, library_name: str = "", limit: int = 100, max_tokens: int = 1500, start: int = 0
|
|
1103
|
+
) -> dict[str, Any]:
|
|
1104
|
+
"""Links out of an article, internal ones resolved to refs, each marked with whether the target exists in
|
|
1105
|
+
this ZIM. An internal link whose target is absent is reported, not dropped: it tells the reader the
|
|
1106
|
+
article points outside what was archived."""
|
|
1107
|
+
clamped: dict[str, int] = {}
|
|
1108
|
+
limit = _bounded("limit", limit, 1, 1000, clamped)
|
|
1109
|
+
start = _bounded("start", start, 0, 2**62)
|
|
1110
|
+
max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
|
|
1111
|
+
book, parsed = _context(ref, library_name)
|
|
1112
|
+
doc, final, _ = document(book, parsed.path)
|
|
1113
|
+
archive = library.open_archive(book.path)
|
|
1114
|
+
rows: list[dict[str, Any]] = []
|
|
1115
|
+
seen: set[tuple[str, str, str]] = set()
|
|
1116
|
+
used, truncated = 0, False
|
|
1117
|
+
distinct = []
|
|
1118
|
+
for link in doc.links:
|
|
1119
|
+
key = (link.path or link.href, link.anchor, link.text)
|
|
1120
|
+
if key in seen:
|
|
1121
|
+
continue
|
|
1122
|
+
seen.add(key)
|
|
1123
|
+
distinct.append(link)
|
|
1124
|
+
if start > len(distinct):
|
|
1125
|
+
raise ZimloreError(f"{book.name}§{final} has {len(distinct)} links; start={start} is past the end")
|
|
1126
|
+
for link in distinct[start:]:
|
|
1127
|
+
if link.path is None:
|
|
1128
|
+
row: dict[str, Any] = {"text": link.text, "url": link.href, "external": True}
|
|
1129
|
+
else:
|
|
1130
|
+
row = {
|
|
1131
|
+
"text": link.text,
|
|
1132
|
+
"ref": format_ref(book.name, link.path, link.anchor),
|
|
1133
|
+
"external": False,
|
|
1134
|
+
"in_zim": bool(archive.has_entry_by_path(link.path)),
|
|
1135
|
+
}
|
|
1136
|
+
cost = tokens(str(row)) + 4
|
|
1137
|
+
if len(rows) >= limit or (rows and used + cost > max_tokens):
|
|
1138
|
+
truncated = True
|
|
1139
|
+
break
|
|
1140
|
+
used += cost
|
|
1141
|
+
rows.append(row)
|
|
1142
|
+
result: dict[str, Any] = {
|
|
1143
|
+
"ref": format_ref(book.name, final),
|
|
1144
|
+
"links": rows,
|
|
1145
|
+
"total": len(distinct),
|
|
1146
|
+
"truncated": truncated,
|
|
1147
|
+
}
|
|
1148
|
+
if truncated:
|
|
1149
|
+
result["next"] = start + len(rows)
|
|
1150
|
+
if clamped:
|
|
1151
|
+
result["clamped"] = clamped
|
|
1152
|
+
return result
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
JOINER = "\n"
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def cite(ref: str, library_name: str = "") -> dict[str, Any]:
|
|
1159
|
+
book, parsed = _context(ref, library_name)
|
|
1160
|
+
entry, final, _ = _entry(book, parsed.path)
|
|
1161
|
+
span = ""
|
|
1162
|
+
if parsed.anchor or parsed.para is not None:
|
|
1163
|
+
doc, _, _ = document(book, final)
|
|
1164
|
+
lo, hi, _ = _scope(book, final, doc, parsed) # the same check `read` makes: a cite must name something real
|
|
1165
|
+
span = span_hash(JOINER.join(doc.blocks[lo:hi]))
|
|
1166
|
+
return {
|
|
1167
|
+
**({"span": span} if span else {}),
|
|
1168
|
+
"ref": format_ref(book.name, final, parsed.anchor, parsed.para),
|
|
1169
|
+
"cite": citation(book, final, parsed.anchor, parsed.para),
|
|
1170
|
+
"title": str(entry.title),
|
|
1171
|
+
"edition": book.date or None,
|
|
1172
|
+
"freshness": _fresh(book),
|
|
1173
|
+
"license": licence_note(book),
|
|
1174
|
+
"provenance": manage.provenance_summary(book.path),
|
|
1175
|
+
"creator": book.creator or None,
|
|
1176
|
+
"uuid": book.uuid,
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def forget() -> None:
|
|
1181
|
+
"""Drop the converted-article cache (used by tests that rebuild a ZIM at the same path)."""
|
|
1182
|
+
_DOCS.clear()
|