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,490 @@
|
|
|
1
|
+
"""A full-text index over one edition, on the core's index (`primarysource/index.py`), tagged with its source.
|
|
2
|
+
|
|
3
|
+
One SQLite file per edition, built by `primarysource.index.rebuild` (a temporary file `os.replace`d over the old
|
|
4
|
+
one, so a server sees the old index or the new one, never half of one) and opened for serving with
|
|
5
|
+
`primarysource.index.open_read` (`?mode=ro`: nothing is created or migrated, and a read-only volume works).
|
|
6
|
+
|
|
7
|
+
How a clause sits in the core's schema, chosen so itulib's measured BM25 weights carry over unchanged:
|
|
8
|
+
|
|
9
|
+
- every clause is a core document of one section, in reading order. `doc_id` is its zero-padded ordinal, so two
|
|
10
|
+
fragments that carry the same number both survive and `fragments` can say so;
|
|
11
|
+
- the document title is the clause number. It fills the core's `doc_title` column, weighted 2.0 as itulib's
|
|
12
|
+
`number` column was;
|
|
13
|
+
- the section title is the document's own heading, or empty when the clause has none (the label `outline` shows
|
|
14
|
+
is then the first sentence, derived from the body). It is weighted 15.0 as itulib's `head` column was;
|
|
15
|
+
- the section's one unit is the clause body, weighted 1.0. ¶n is still the nth sentence of that body.
|
|
16
|
+
|
|
17
|
+
One more document, `~edition`, has no sections and holds what the index was built from as JSON in its title: the
|
|
18
|
+
collection's own schema, the edition key and the PDF's sha256, size and modification time. The index refuses to
|
|
19
|
+
serve when the PDF no longer matches. That is the difference between "this clause says X in the edition you
|
|
20
|
+
hold" and a stale table that reports the same confident answer after ITU republished something - a gate that
|
|
21
|
+
silently skips, in the shape of a search result. The check is by `stat` first: the PDF is hashed only when its
|
|
22
|
+
size or modification time differs from the recorded ones, and a hash that matches is remembered for the life of
|
|
23
|
+
the process, so serving does not read the whole PDF on every call. The cost, stated: a file rewritten with the
|
|
24
|
+
same size and its old modification time restored is not noticed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import pathlib
|
|
32
|
+
import re
|
|
33
|
+
import sqlite3
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from contextlib import contextmanager
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
from primarysource import index as core
|
|
39
|
+
from primarysource.errors import LibError
|
|
40
|
+
from primarysource.model import Document, Section, Unit
|
|
41
|
+
|
|
42
|
+
from . import corpus, text
|
|
43
|
+
from .sources import Amendment, Edition
|
|
44
|
+
|
|
45
|
+
SCHEMA = "4" # this collection's layout on the core schema; the core's own version is its PRAGMA user_version
|
|
46
|
+
META_ID = "~edition"
|
|
47
|
+
CANDIDATES = 150
|
|
48
|
+
DEMOTION = 0.6 # score multiplier for clauses that are rarely the answer; flat optimum between 0.4 and 0.8
|
|
49
|
+
_INFORMATIVE = re.compile(r"does not form an integral part", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
# BM25 column weights as itulib measured them: the clause number 2.0, the document's own heading 15.0 (a heading
|
|
52
|
+
# match is a much stronger signal than the same word somewhere in a body), the body 1.0.
|
|
53
|
+
NUMBER_WEIGHT, HEADING_WEIGHT, BODY_WEIGHT = 2.0, 15.0, 1.0
|
|
54
|
+
CHARS_PER_TOKEN = 4
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Hit:
|
|
59
|
+
number: str
|
|
60
|
+
title: str
|
|
61
|
+
excerpt: str
|
|
62
|
+
score: float # higher is better: BM25 negated, times the demotion
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Results:
|
|
67
|
+
"""One page of search hits. `relaxed`: no clause held every term, so these hold some. `next_start`: the 0-based
|
|
68
|
+
`start` of the next page, or None at the end. No hits means nothing matched."""
|
|
69
|
+
|
|
70
|
+
hits: tuple[Hit, ...]
|
|
71
|
+
relaxed: bool
|
|
72
|
+
next_start: int | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _db_path(edition: Edition) -> pathlib.Path:
|
|
76
|
+
return corpus.home() / "index" / f"{edition.key.replace('#', '-')}.sqlite3"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def index_command(edition: Edition) -> str:
|
|
80
|
+
return f"primarysource itu index {edition.key}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sync_command(edition: Edition) -> str:
|
|
84
|
+
return f"primarysource itu sync {edition.key}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sha256(path: pathlib.Path) -> str:
|
|
88
|
+
digest = hashlib.sha256()
|
|
89
|
+
with path.open("rb") as handle:
|
|
90
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
91
|
+
digest.update(block)
|
|
92
|
+
return digest.hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _documents(rows: tuple[text.Clause, ...], meta: dict[str, object]) -> Iterator[Document]:
|
|
96
|
+
yield Document(META_ID, json.dumps(meta, sort_keys=True), Unit.SENTENCE)
|
|
97
|
+
for ordinal, row in enumerate(rows):
|
|
98
|
+
section = Section(
|
|
99
|
+
id=row.number,
|
|
100
|
+
title=row.title if row.heading else "",
|
|
101
|
+
units=(row.body,) if row.body else (),
|
|
102
|
+
depth=row.number.count(".") + 1,
|
|
103
|
+
)
|
|
104
|
+
yield Document(f"{ordinal:05d}", row.number, Unit.SENTENCE, (section,))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build(edition: Edition) -> int:
|
|
108
|
+
"""(Re)build the index for one edition and swap it in atomically. Returns the clause count."""
|
|
109
|
+
pdf = corpus.require(edition)
|
|
110
|
+
stat = pdf.stat()
|
|
111
|
+
digest = _sha256(pdf)
|
|
112
|
+
extracted = text.pages(pdf)
|
|
113
|
+
rows = text.clauses(extracted, text.headings(pdf, extracted))
|
|
114
|
+
if not rows:
|
|
115
|
+
raise LibError(
|
|
116
|
+
f"{edition.key}: extracted 0 clauses. The corpus exists but nothing parsed, which is a failure of the "
|
|
117
|
+
"extractor, not an empty document. Refusing to write an empty index.",
|
|
118
|
+
"",
|
|
119
|
+
f"check that {pdf} opens as a PDF, then run: primarysource itu fetch {edition.key} --refresh",
|
|
120
|
+
)
|
|
121
|
+
meta: dict[str, object] = {
|
|
122
|
+
"schema": SCHEMA,
|
|
123
|
+
"edition": edition.key,
|
|
124
|
+
"pdf_sha256": digest,
|
|
125
|
+
"pdf_octets": stat.st_size,
|
|
126
|
+
"pdf_mtime_ns": stat.st_mtime_ns,
|
|
127
|
+
"clauses": len(rows),
|
|
128
|
+
}
|
|
129
|
+
core.rebuild(_db_path(edition), _documents(rows, meta), rebuild_command=index_command(edition))
|
|
130
|
+
_VERIFIED.pop(pdf, None)
|
|
131
|
+
return len(rows)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# PDF path -> (size, mtime_ns, sha256) that this process hashed and found to match its index.
|
|
135
|
+
_VERIFIED: dict[pathlib.Path, tuple[int, int, str]] = {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_source(edition: Edition, meta: dict[str, object]) -> None:
|
|
139
|
+
"""Refuse when the PDF on disk is not the one the index was built from. Hashes only when `stat` differs."""
|
|
140
|
+
pdf = corpus.pdf_path(edition)
|
|
141
|
+
try:
|
|
142
|
+
stat = pdf.stat()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
raise LibError(
|
|
145
|
+
f"{edition.key}: the PDF this index was built from is not in {corpus.home()}",
|
|
146
|
+
"itu_info shows which editions are fetched",
|
|
147
|
+
sync_command(edition),
|
|
148
|
+
) from None
|
|
149
|
+
expected = str(meta.get("pdf_sha256", ""))
|
|
150
|
+
if (stat.st_size, stat.st_mtime_ns) == (meta.get("pdf_octets"), meta.get("pdf_mtime_ns")):
|
|
151
|
+
return
|
|
152
|
+
if _VERIFIED.get(pdf) == (stat.st_size, stat.st_mtime_ns, expected):
|
|
153
|
+
return
|
|
154
|
+
if _sha256(pdf) != expected:
|
|
155
|
+
raise LibError(
|
|
156
|
+
f"{edition.key}: the corpus changed since the index was built",
|
|
157
|
+
"itu_info shows the registered digest of each edition",
|
|
158
|
+
index_command(edition),
|
|
159
|
+
)
|
|
160
|
+
_VERIFIED[pdf] = (stat.st_size, stat.st_mtime_ns, expected)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextmanager
|
|
164
|
+
def _open(edition: Edition) -> Iterator[sqlite3.Connection]:
|
|
165
|
+
"""A read-only connection to an edition's index that matches the PDF on disk, closed on exit."""
|
|
166
|
+
path = _db_path(edition)
|
|
167
|
+
if not path.is_file():
|
|
168
|
+
raise LibError(f"{edition.key}: no index", "itu_info shows which editions are fetched", sync_command(edition))
|
|
169
|
+
try:
|
|
170
|
+
connection = core.open_read(path, rebuild_command=index_command(edition))
|
|
171
|
+
except LibError as error: # the core names the command in `hint`; here it is a human's to run, so `action`
|
|
172
|
+
raise LibError(error.message, "", index_command(edition)) from None
|
|
173
|
+
try:
|
|
174
|
+
row = connection.execute("SELECT title FROM documents WHERE doc_id = ?", (META_ID,)).fetchone()
|
|
175
|
+
meta = json.loads(row[0]) if row else {}
|
|
176
|
+
if not isinstance(meta, dict) or meta.get("schema") != SCHEMA:
|
|
177
|
+
raise LibError(
|
|
178
|
+
f"{edition.key}: the index was built by an older itulib or primarysource", "", index_command(edition)
|
|
179
|
+
)
|
|
180
|
+
_check_source(edition, meta)
|
|
181
|
+
yield connection
|
|
182
|
+
finally:
|
|
183
|
+
connection.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _display(title: str, body: str) -> str:
|
|
187
|
+
"""The label of a clause: its own heading, or the first sentence of its body when it has none."""
|
|
188
|
+
return title or text.first_sentence(body)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def search(edition: Edition, query: str, limit: int = 8, start: int = 0) -> Results:
|
|
192
|
+
"""The best clauses for a question, best first, `limit` from the 0-based `start`.
|
|
193
|
+
|
|
194
|
+
The core's search (`primarysource.index.search`): every term is required first, and only when no clause holds
|
|
195
|
+
them all will any term do, which the result says with `relaxed`. The terms are the query's words without
|
|
196
|
+
question words and filler (`text.query_words`, measured on itulib's blind retrieval set). BM25 weights the
|
|
197
|
+
heading above the number above the body, and each hit is then adjusted by where the clause sits: definitions
|
|
198
|
+
(clause 3.x) and informative annexes are demoted unless the query asks what a term means, because a
|
|
199
|
+
definition that merely mentions the words is rarely the rule that was asked about.
|
|
200
|
+
|
|
201
|
+
Tried and dropped on the blind set because they did not help: pushing a subtree's best score up to its
|
|
202
|
+
heading-only parent, and a bonus for words found in the heading.
|
|
203
|
+
"""
|
|
204
|
+
if start < 0:
|
|
205
|
+
raise LibError(f"start={start} is negative", "start is a 0-based offset; pass the value of next:")
|
|
206
|
+
words = " ".join(text.query_words(query))
|
|
207
|
+
wants_definitions = text.asks_for_definition(query)
|
|
208
|
+
with _open(edition) as connection:
|
|
209
|
+
informative = _informative_annexes(connection)
|
|
210
|
+
|
|
211
|
+
def adjust(hit: core.Hit, _query: str) -> float:
|
|
212
|
+
number, factor = hit.section_id, 1.0
|
|
213
|
+
if number.startswith("3.") and not wants_definitions:
|
|
214
|
+
factor *= DEMOTION
|
|
215
|
+
if number.split(".")[0] in informative:
|
|
216
|
+
factor *= DEMOTION
|
|
217
|
+
return factor
|
|
218
|
+
|
|
219
|
+
ranking = core.Ranking(NUMBER_WEIGHT, HEADING_WEIGHT, BODY_WEIGHT, adjust=adjust, candidates=CANDIDATES)
|
|
220
|
+
found = core.search(connection, words, ranking=ranking, limit=limit, start=start)
|
|
221
|
+
marks = ",".join("?" * len(found.hits))
|
|
222
|
+
bodies: dict[str, str] = dict(
|
|
223
|
+
connection.execute(
|
|
224
|
+
f"SELECT doc_id, body FROM sections WHERE doc_id IN ({marks})", # noqa: S608 - only '?' marks
|
|
225
|
+
[hit.doc_id for hit in found.hits],
|
|
226
|
+
).fetchall()
|
|
227
|
+
)
|
|
228
|
+
hits = []
|
|
229
|
+
for hit in found.hits:
|
|
230
|
+
title = _display(hit.title, bodies.get(hit.doc_id, ""))
|
|
231
|
+
hits.append(Hit(hit.section_id, title, hit.snippet or title, hit.score))
|
|
232
|
+
return Results(tuple(hits), found.relaxed, found.next_start)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _informative_annexes(connection: sqlite3.Connection) -> set[str]:
|
|
236
|
+
"""Letters of the annexes that say they do not form an integral part of the recommendation."""
|
|
237
|
+
return {
|
|
238
|
+
number
|
|
239
|
+
for number, body in connection.execute("SELECT section_id, body FROM sections WHERE length(section_id) = 1")
|
|
240
|
+
if _INFORMATIVE.search(body)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _rows(edition: Edition) -> list[tuple[str, str, str]]:
|
|
245
|
+
"""(number, heading or "", body) of every clause, in reading order."""
|
|
246
|
+
with _open(edition) as connection:
|
|
247
|
+
return connection.execute("SELECT section_id, title, body FROM sections ORDER BY id").fetchall()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def read(edition: Edition, number: str) -> text.Clause | None:
|
|
251
|
+
with _open(edition) as connection:
|
|
252
|
+
row = connection.execute(
|
|
253
|
+
"SELECT section_id, title, body FROM sections WHERE section_id = ? ORDER BY id LIMIT 1", (number,)
|
|
254
|
+
).fetchone()
|
|
255
|
+
if row is None:
|
|
256
|
+
return None
|
|
257
|
+
return text.Clause(number=row[0], title=_display(row[1], row[2]), body=row[2], heading=bool(row[1]))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def outline(edition: Edition) -> tuple[tuple[str, str], ...]:
|
|
261
|
+
return tuple((number, _display(title, body)) for number, title, body in _rows(edition))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- paging, citation, references ----------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class Page:
|
|
269
|
+
"""A budgeted slice of one clause. `next` is the ¶ the rest starts at, or None at the end."""
|
|
270
|
+
|
|
271
|
+
number: str
|
|
272
|
+
title: str
|
|
273
|
+
first: int
|
|
274
|
+
last: int
|
|
275
|
+
total: int
|
|
276
|
+
text: str
|
|
277
|
+
next: int | None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def citation(edition: Edition | Amendment, number: str, para: int | None = None) -> str:
|
|
281
|
+
"""`Rec. ITU-T X.690 (02/2021) §11.6¶2`. Built from the registry, never typed by hand."""
|
|
282
|
+
label = edition.edition if isinstance(edition, Edition) else edition.label
|
|
283
|
+
tail = f"¶{para}" if para else ""
|
|
284
|
+
return f"Rec. ITU-T {edition.rec} ({label}) §{number}{tail}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def page(edition: Edition, number: str, *, start: int = 1, max_tokens: int = 2000) -> Page | None:
|
|
288
|
+
"""Sentences `start`.. of a clause, cut at a sentence boundary within the token budget.
|
|
289
|
+
|
|
290
|
+
At least one sentence is always returned, so a single over-long sentence cannot stall a caller that is
|
|
291
|
+
following `next`. Each sentence is prefixed with its `[¶n]` so any of them is citable.
|
|
292
|
+
"""
|
|
293
|
+
clause = read(edition, number)
|
|
294
|
+
if clause is None:
|
|
295
|
+
return None
|
|
296
|
+
if not clause.body:
|
|
297
|
+
return Page(clause.number, clause.title, 1, 0, 0, "", None)
|
|
298
|
+
units = text.sentences(clause.body)
|
|
299
|
+
if not 1 <= start <= max(len(units), 1):
|
|
300
|
+
raise LibError(
|
|
301
|
+
f"§{number} has {len(units)} sentences; ¶{start} is outside 1..{len(units)}",
|
|
302
|
+
f"itu_read('{number}') reads the clause from its first sentence",
|
|
303
|
+
)
|
|
304
|
+
budget = max(max_tokens, 1) * CHARS_PER_TOKEN
|
|
305
|
+
used, last = 0, start - 1
|
|
306
|
+
for n in range(start, len(units) + 1):
|
|
307
|
+
cost = len(units[n - 1]) + 8
|
|
308
|
+
if last >= start and used + cost > budget:
|
|
309
|
+
break
|
|
310
|
+
used += cost
|
|
311
|
+
last = n
|
|
312
|
+
body = " ".join(f"[¶{n}] {units[n - 1]}" for n in range(start, last + 1))
|
|
313
|
+
return Page(clause.number, clause.title, start, last, len(units), body, last + 1 if last < len(units) else None)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def fragments(edition: Edition, number: str) -> int:
|
|
317
|
+
"""How many extracted fragments carry this clause number. More than one means the splitter could not tell a
|
|
318
|
+
heading from a numbered sub-item or a table cell, and `read` returns the first. Callers should say so."""
|
|
319
|
+
with _open(edition) as connection:
|
|
320
|
+
row = connection.execute("SELECT count(*) FROM sections WHERE section_id = ?", (number,)).fetchone()
|
|
321
|
+
return int(row[0])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def sentence(edition: Edition, number: str, para: int) -> str | None:
|
|
325
|
+
clause = read(edition, number)
|
|
326
|
+
units = text.sentences(clause.body) if clause else ()
|
|
327
|
+
return units[para - 1] if 1 <= para <= len(units) else None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def normative_statements(edition: Edition, number: str) -> tuple[tuple[int, str], ...] | None:
|
|
331
|
+
clause = read(edition, number)
|
|
332
|
+
return None if clause is None else text.normative(clause.body)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass(frozen=True)
|
|
336
|
+
class Links:
|
|
337
|
+
number: str
|
|
338
|
+
clauses: tuple[str, ...] # same-document clauses this one points at that actually exist
|
|
339
|
+
unresolved: tuple[str, ...] # pointed at but not found in the index: extraction gap, or a cross-document ref
|
|
340
|
+
recommendations: tuple[str, ...]
|
|
341
|
+
standards: tuple[str, ...]
|
|
342
|
+
cited_by: tuple[str, ...]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def links(edition: Edition, number: str) -> Links | None:
|
|
346
|
+
"""Outgoing references of a clause, and the clauses of the same edition that point back at it."""
|
|
347
|
+
rows = [(n, body) for n, _title, body in _rows(edition)]
|
|
348
|
+
known = {row[0] for row in rows}
|
|
349
|
+
body = next((row[1] for row in rows if row[0] == number), None)
|
|
350
|
+
if body is None:
|
|
351
|
+
return None
|
|
352
|
+
found = text.refs(body)
|
|
353
|
+
back = tuple(n for n, b in rows if n != number and _points_at(b, number))
|
|
354
|
+
return Links(
|
|
355
|
+
number=number,
|
|
356
|
+
clauses=tuple(c for c in (*found.clauses, *found.bare) if c in known and c != number),
|
|
357
|
+
unresolved=tuple(c for c in found.clauses if c not in known),
|
|
358
|
+
recommendations=tuple(r for r in found.recommendations if r != edition.rec),
|
|
359
|
+
standards=found.standards,
|
|
360
|
+
cited_by=back,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class GrepHit:
|
|
366
|
+
number: str
|
|
367
|
+
excerpt: str
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def grep(
|
|
371
|
+
edition: Edition, pattern: str, *, start: int = 0, max_tokens: int = 2000, context: int = 120
|
|
372
|
+
) -> tuple[tuple[GrepHit, ...], int | None]:
|
|
373
|
+
"""Regex over clause bodies in ONE query, with an excerpt around the first match, cut to a token budget.
|
|
374
|
+
|
|
375
|
+
Returns (hits, next start): matching clauses from the 0-based `start`, and the `start` of the rest when the
|
|
376
|
+
budget stopped the listing, or None when it is complete. At least one hit is returned when any remain.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
compiled = re.compile(pattern, re.IGNORECASE)
|
|
380
|
+
except re.error as error:
|
|
381
|
+
raise LibError(f"{pattern!r} is not a regular expression: {error}", "itu_search takes plain words") from None
|
|
382
|
+
if start < 0:
|
|
383
|
+
raise LibError(f"start={start} is negative", "start is a 0-based offset; pass the value of next:")
|
|
384
|
+
budget, used, seen = max(max_tokens, 1) * CHARS_PER_TOKEN, 0, 0
|
|
385
|
+
hits: list[GrepHit] = []
|
|
386
|
+
for number, _title, body in _rows(edition):
|
|
387
|
+
match = compiled.search(body)
|
|
388
|
+
if not match:
|
|
389
|
+
continue
|
|
390
|
+
seen += 1
|
|
391
|
+
if seen <= start:
|
|
392
|
+
continue
|
|
393
|
+
excerpt = body[max(match.start() - context, 0) : match.end() + context]
|
|
394
|
+
cost = len(excerpt) + len(number) + 20
|
|
395
|
+
if hits and used + cost > budget:
|
|
396
|
+
return tuple(hits), start + len(hits)
|
|
397
|
+
used += cost
|
|
398
|
+
hits.append(GrepHit(number, excerpt))
|
|
399
|
+
return tuple(hits), None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _points_at(body: str, number: str) -> bool:
|
|
403
|
+
found = text.refs(body)
|
|
404
|
+
return number in found.clauses or number in found.bare
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def slice_rows(
|
|
408
|
+
rows: tuple[tuple[str, str], ...], start: int, max_tokens: int
|
|
409
|
+
) -> tuple[tuple[tuple[str, str], ...], int | None]:
|
|
410
|
+
"""(rows[start:...], next start or None), cut to a token budget. Always returns at least one row."""
|
|
411
|
+
if not 0 <= start <= len(rows):
|
|
412
|
+
raise LibError(
|
|
413
|
+
f"start={start} is outside 0..{len(rows)}", "start is a 0-based offset; pass the value of next:"
|
|
414
|
+
)
|
|
415
|
+
budget, used, end = max(max_tokens, 1) * CHARS_PER_TOKEN, 0, start
|
|
416
|
+
while end < len(rows):
|
|
417
|
+
cost = len(rows[end][0]) + len(rows[end][1]) + 12
|
|
418
|
+
if end > start and used + cost > budget:
|
|
419
|
+
break
|
|
420
|
+
used += cost
|
|
421
|
+
end += 1
|
|
422
|
+
return rows[start:end], (end if end < len(rows) else None)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@dataclass(frozen=True)
|
|
426
|
+
class Comparison:
|
|
427
|
+
number: str
|
|
428
|
+
in_a: bool
|
|
429
|
+
in_b: bool
|
|
430
|
+
same: bool
|
|
431
|
+
removed: tuple[tuple[int, str], ...] # (¶ in edition a, sentence) present only in a
|
|
432
|
+
added: tuple[tuple[int, str], ...] # (¶ in edition b, sentence) present only in b
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _sentence_key(sentence: str) -> str:
|
|
436
|
+
"""What two sentences must share to count as the same across editions.
|
|
437
|
+
|
|
438
|
+
The extractor splits words differently in different editions ("a ppear"), drops mathematical symbols from some
|
|
439
|
+
("M ≠ M'" loses its sign) and the editions cite each other in a different order ("ITU-T Rec. X.680" became
|
|
440
|
+
"Rec. ITU-T X.680"). So matching ignores case, whitespace, punctuation and symbols. The cost is stated in the
|
|
441
|
+
docstring of `compare`: a change that consists only of a symbol or punctuation mark is invisible.
|
|
442
|
+
"""
|
|
443
|
+
return re.sub(r"[\W_]+", "", sentence.replace("ITU-T Rec.", "Rec. ITU-T")).casefold()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def compare(a: Edition, b: Edition, number: str) -> Comparison:
|
|
447
|
+
"""What changed in clause `number` between two editions, by sentence.
|
|
448
|
+
|
|
449
|
+
Numbering can differ between editions, so a clause that exists in only one is reported as such rather than
|
|
450
|
+
as a deletion: the rule may simply have moved. Matching is by sentence, so a reworded sentence appears as one
|
|
451
|
+
removed and one added. Matching ignores punctuation and symbols (see `_sentence_key`), so a change consisting
|
|
452
|
+
only of a symbol, such as a sign or a comparison operator, is NOT reported: check the PDF when that matters.
|
|
453
|
+
"""
|
|
454
|
+
import difflib
|
|
455
|
+
|
|
456
|
+
clause_a, clause_b = read(a, number), read(b, number)
|
|
457
|
+
sa = text.sentences(clause_a.body) if clause_a and clause_a.body else ()
|
|
458
|
+
sb = text.sentences(clause_b.body) if clause_b and clause_b.body else ()
|
|
459
|
+
keys_a, keys_b = [_sentence_key(s) for s in sa], [_sentence_key(s) for s in sb]
|
|
460
|
+
matcher = difflib.SequenceMatcher(a=keys_a, b=keys_b, autojunk=False)
|
|
461
|
+
removed: list[tuple[int, str]] = []
|
|
462
|
+
added: list[tuple[int, str]] = []
|
|
463
|
+
for op, a0, a1, b0, b1 in matcher.get_opcodes():
|
|
464
|
+
if op in {"delete", "replace"}:
|
|
465
|
+
removed += [(n + 1, sa[n]) for n in range(a0, a1)]
|
|
466
|
+
if op in {"insert", "replace"}:
|
|
467
|
+
added += [(n + 1, sb[n]) for n in range(b0, b1)]
|
|
468
|
+
return Comparison(
|
|
469
|
+
number,
|
|
470
|
+
clause_a is not None,
|
|
471
|
+
clause_b is not None,
|
|
472
|
+
clause_a is not None and clause_b is not None and not removed and not added,
|
|
473
|
+
tuple(removed),
|
|
474
|
+
tuple(added),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def children(edition: Edition, number: str) -> tuple[tuple[str, str], ...]:
|
|
479
|
+
"""The nearest descendants of a clause that has no body of its own. Heading-only clauses ("8.1.2 Identifier
|
|
480
|
+
octets") are not indexed, so `read` would otherwise say "no clause" about a clause that plainly exists."""
|
|
481
|
+
below = [(n, t) for n, t in outline(edition) if n.startswith(number + ".")]
|
|
482
|
+
if not below:
|
|
483
|
+
return ()
|
|
484
|
+
shallowest = min(n.count(".") for n, _ in below)
|
|
485
|
+
return tuple(row for row in below if row[0].count(".") == shallowest)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def top_level(edition: Edition, depth: int = 2) -> tuple[tuple[str, str], ...]:
|
|
489
|
+
"""Clauses whose number has at most `depth` dotted components: the shape of the document, not its detail."""
|
|
490
|
+
return tuple((n, t) for n, t in outline(edition) if n.count(".") + 1 <= depth)
|