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
primarysource/itu/mcp.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# mypy: disable-error-code="untyped-decorator"
|
|
2
|
+
# primarysource's mypy config does not follow the MCP SDK's types, so its decorators read as untyped here; in
|
|
3
|
+
# itulib's own repository (which followed them) this module was clean under --strict.
|
|
4
|
+
"""The MCP surface: eleven `itu_*` tools on the core contract (PLAN.md §5.9-§5.11).
|
|
5
|
+
|
|
6
|
+
- **Text results.** Every tool returns `render()` text: the answer, then the fixed footer (`cite:`, `unit:`,
|
|
7
|
+
`span:`, `next:`, `relaxed:`, `warning:`). Tools are registered by `scaffold.add_tools`, so none is sent as
|
|
8
|
+
structured content as well.
|
|
9
|
+
- **Errors are `isError` results.** A missing corpus, a missing or stale index, an unknown edition or a clause that
|
|
10
|
+
is not there raises `LibError(message, hint, action)`: `hint` names the tool to call next, `action` the command a
|
|
11
|
+
human must run (`primarysource itu sync X.690#2021-02`). A missing corpus is never an empty list:
|
|
12
|
+
`sync_roadmap.graphql()` once swallowed a scope failure into `""`, which its caller rendered as "the board has
|
|
13
|
+
no issues", and a tool that answers "no clauses match" when the corpus is not installed is the same bug.
|
|
14
|
+
**An empty result is not an error**: a search that matches nothing says so in text.
|
|
15
|
+
- **`next:` is what to pass back.** A read's is a ref ending `¶n-` (the clause from sentence n on), which
|
|
16
|
+
`itu_read` accepts as its `ref`; a list's (search, outline, browse, grep, catalog) is `start=<n>`, 0-based.
|
|
17
|
+
|
|
18
|
+
The SDK is imported inside `build()`: `mcp` 2.x renamed `FastMCP` to `MCPServer`, and importing lazily keeps the
|
|
19
|
+
collection usable without the `[mcp]` extra.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from primarysource.errors import LibError
|
|
28
|
+
from primarysource.model import span_hash
|
|
29
|
+
from primarysource.render import next_start, render
|
|
30
|
+
from primarysource.scaffold import add_tools
|
|
31
|
+
|
|
32
|
+
from . import __version__, corpus, index, sources, text
|
|
33
|
+
from .errata import errata_text
|
|
34
|
+
|
|
35
|
+
INSTRUCTIONS = (
|
|
36
|
+
"ITU-T recommendations (the ASN.1 family X.680-X.697) as clause-precise search and citation. The corpus is "
|
|
37
|
+
"fetched by the user and never shipped with this package. Refs look like X.690§11.6 or X.690§11.6¶2, where "
|
|
38
|
+
"¶n is the nth sentence of the extracted clause; X.690#2008-11§8.1 names an edition. Results are text with "
|
|
39
|
+
"footer lines: cite the `cite:` line (e.g. Rec. ITU-T X.690 (02/2021) §11.6). `next:` is exactly what to pass "
|
|
40
|
+
"back: a ref for itu_read, or start=<n> for the same list tool. No hits means no clause matched; `relaxed:` "
|
|
41
|
+
"means only some search terms matched; `warning:` means the clause number is ambiguous in the extracted text, "
|
|
42
|
+
"so check the PDF before relying on it. A missing corpus or index is an error whose `action:` line is the "
|
|
43
|
+
"command a human must run."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
READ_ONLY_HINTS = {
|
|
47
|
+
"read_only_hint": True,
|
|
48
|
+
"idempotent_hint": True,
|
|
49
|
+
"destructive_hint": False,
|
|
50
|
+
"open_world_hint": False,
|
|
51
|
+
}
|
|
52
|
+
REF_SHAPES = "refs look like X.690§11.6, X.690§11.6¶2 or X.690#2008-11§8.1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- shared --------------------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _edition(key: str) -> sources.Edition:
|
|
59
|
+
try:
|
|
60
|
+
return sources.resolve(key)
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise LibError(f"unknown edition {key!r}", "itu_info lists the registered editions") from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _doc(item: sources.Edition) -> str:
|
|
66
|
+
"""The prefix of a ref that names this edition: the bare recommendation when that resolves to it, else its key."""
|
|
67
|
+
try:
|
|
68
|
+
return item.rec if sources.resolve(item.rec) is item else item.key
|
|
69
|
+
except KeyError:
|
|
70
|
+
return item.key
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _target(ref: str, edition: str) -> tuple[sources.Edition, text.Target]:
|
|
74
|
+
try:
|
|
75
|
+
target = text.parse_target(ref)
|
|
76
|
+
except ValueError as error:
|
|
77
|
+
raise LibError(str(error), REF_SHAPES) from None
|
|
78
|
+
return _edition(target.rec or edition), target
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _no_clause(item: sources.Edition, number: str) -> LibError:
|
|
82
|
+
return LibError(
|
|
83
|
+
f"{item.key}: no clause §{number}",
|
|
84
|
+
f"itu_search for its words, or itu_outline('{_doc(item)}') for the numbering",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fragments_warning(item: sources.Edition, number: str) -> str:
|
|
89
|
+
extra = index.fragments(item, number) - 1
|
|
90
|
+
if not extra:
|
|
91
|
+
return ""
|
|
92
|
+
return (
|
|
93
|
+
f"{extra} other extracted fragment(s) carry the number {number} (a numbered sub-item, table cell or history "
|
|
94
|
+
"row the splitter could not tell from a heading); this is the first. Check the PDF before relying on it "
|
|
95
|
+
"for a normative claim."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _budget_lines(entries: list[str], max_tokens: int) -> list[str]:
|
|
100
|
+
"""The leading entries that fit `max_tokens`, always at least one."""
|
|
101
|
+
kept: list[str] = []
|
|
102
|
+
used = 0
|
|
103
|
+
for entry in entries:
|
|
104
|
+
cost = len(entry) // index.CHARS_PER_TOKEN + 1
|
|
105
|
+
if kept and used + cost > max_tokens:
|
|
106
|
+
break
|
|
107
|
+
used += cost
|
|
108
|
+
kept.append(entry)
|
|
109
|
+
return kept
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rows(rows: tuple[tuple[str, str], ...], doc: str) -> list[str]:
|
|
113
|
+
return [f"{doc}§{number} {title}" for number, title in rows]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- tools ---------------------------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def itu_info(rec: str = "") -> str:
|
|
120
|
+
"""Registered editions, their status, digests and amendments, and whether each is fetched. No corpus needed."""
|
|
121
|
+
pool = [item for item in (*sources.IN_FORCE, *sources.SUPERSEDED) if not rec or item.rec == rec]
|
|
122
|
+
lines = [
|
|
123
|
+
f"{item.key} {item.status} ({item.edition}) {'fetched' if corpus.has(item) else 'not fetched'} "
|
|
124
|
+
f"{item.octets} octets sha256 {item.sha256 or 'not registered'}"
|
|
125
|
+
+ (f" note: {item.note}" if item.note else "")
|
|
126
|
+
for item in pool
|
|
127
|
+
]
|
|
128
|
+
amendments = [item for item in sources.AMENDMENTS if not rec or item.rec == rec]
|
|
129
|
+
if amendments:
|
|
130
|
+
lines += ["", "amendments:"]
|
|
131
|
+
lines += [
|
|
132
|
+
f"{item.key} {item.status} {item.label}" + (f" note: {item.note}" if item.note else "")
|
|
133
|
+
for item in amendments
|
|
134
|
+
]
|
|
135
|
+
if not pool and not amendments:
|
|
136
|
+
lines.append(f"nothing is registered for {rec!r}")
|
|
137
|
+
lines += ["", f"corpus home: {corpus.home()}"]
|
|
138
|
+
return render("\n".join(lines), {})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def itu_search(query: str, edition: str = "X.690", limit: int = 8, start: int = 0, max_tokens: int = 1500) -> str:
|
|
142
|
+
"""Full-text clause search, best first. One hit per entry: '<ref> <title> (<citation>)' and an excerpt.
|
|
143
|
+
Every term is required first; 'relaxed:' says no clause held them all and these hold some. A 'next: start=N'
|
|
144
|
+
footer means more hits: call again with start=N (0-based). No hits means nothing matched."""
|
|
145
|
+
if limit < 1:
|
|
146
|
+
raise LibError(f"limit={limit}", "limit is the number of hits per page, at least 1")
|
|
147
|
+
item = _edition(edition)
|
|
148
|
+
doc = _doc(item)
|
|
149
|
+
found = index.search(item, query, limit, start)
|
|
150
|
+
entries = [
|
|
151
|
+
f"{doc}§{hit.number} {hit.title} ({index.citation(item, hit.number)})\n {hit.excerpt}"
|
|
152
|
+
for hit in found.hits
|
|
153
|
+
]
|
|
154
|
+
kept = _budget_lines(entries, max_tokens)
|
|
155
|
+
more = start + len(kept) if len(kept) < len(entries) else found.next_start
|
|
156
|
+
body = "\n".join(kept) if kept else f"no clause of {item.key} matches {query!r}"
|
|
157
|
+
relaxed = "no clause holds every term; these hold some of them" if found.relaxed and kept else ""
|
|
158
|
+
return render(body, {"next": next_start(more), "relaxed": relaxed})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def itu_read(ref: str, edition: str = "X.690", max_tokens: int = 2000, view: str = "text") -> str:
|
|
162
|
+
"""Read a clause by ref: 'X.690§11.6', '11.6' (in `edition`), one sentence 'X.690§11.6¶2', or the clause from
|
|
163
|
+
a sentence on 'X.690§11.6¶5-'. Long clauses are paged: a 'next:' footer is the ref to pass back as `ref`.
|
|
164
|
+
view: text | normative (shall/should/must/may sentences) | refs (what it points at and what points at it).
|
|
165
|
+
¶n is the nth sentence of the extracted clause."""
|
|
166
|
+
if view not in {"text", "normative", "refs"}:
|
|
167
|
+
raise LibError(f"view={view!r}", "view is text, normative or refs")
|
|
168
|
+
item, target = _target(ref, edition)
|
|
169
|
+
doc, number = _doc(item), target.number
|
|
170
|
+
if view == "refs":
|
|
171
|
+
return itu_links(f"{doc}§{number}", edition)
|
|
172
|
+
if view == "normative":
|
|
173
|
+
found = index.normative_statements(item, number)
|
|
174
|
+
if found is None:
|
|
175
|
+
raise _no_clause(item, number)
|
|
176
|
+
body = "\n".join(f"{doc}§{number}¶{n} {sentence}" for n, sentence in found)
|
|
177
|
+
return render(
|
|
178
|
+
body or f"§{number} has no sentence with shall, should, must or may",
|
|
179
|
+
{"cite": index.citation(item, number), "unit": "sentence"},
|
|
180
|
+
)
|
|
181
|
+
if target.para is not None and not target.onward:
|
|
182
|
+
one = index.sentence(item, number, target.para)
|
|
183
|
+
if one is None:
|
|
184
|
+
if index.read(item, number) is None:
|
|
185
|
+
raise _no_clause(item, number)
|
|
186
|
+
raise LibError(f"{item.key}: §{number} has no ¶{target.para}", f"itu_read('{doc}§{number}') numbers them")
|
|
187
|
+
footer = {
|
|
188
|
+
"cite": index.citation(item, number, target.para),
|
|
189
|
+
"unit": "sentence",
|
|
190
|
+
"span": span_hash(one),
|
|
191
|
+
"warning": _fragments_warning(item, number),
|
|
192
|
+
}
|
|
193
|
+
return render(one, footer)
|
|
194
|
+
page = index.page(item, number, start=target.para or 1, max_tokens=max_tokens)
|
|
195
|
+
if page is None or page.total == 0:
|
|
196
|
+
kids = index.children(item, number)
|
|
197
|
+
if kids:
|
|
198
|
+
body = f"§{number}: a heading with no text of its own; read a subclause:\n" + "\n".join(_rows(kids, doc))
|
|
199
|
+
return render(body, {"cite": index.citation(item, number)})
|
|
200
|
+
if page is None:
|
|
201
|
+
raise _no_clause(item, number)
|
|
202
|
+
return render(
|
|
203
|
+
f"§{number} {page.title}: a heading with no text of its own and no indexed subclauses",
|
|
204
|
+
{"cite": index.citation(item, number)},
|
|
205
|
+
)
|
|
206
|
+
footer = {
|
|
207
|
+
"cite": index.citation(item, page.number),
|
|
208
|
+
"unit": "sentence",
|
|
209
|
+
"next": f"{doc}§{page.number}¶{page.next}-" if page.next is not None else "",
|
|
210
|
+
"warning": _fragments_warning(item, number),
|
|
211
|
+
}
|
|
212
|
+
head = f"§{page.number} {page.title} [¶{page.first}-{page.last} of {page.total}]"
|
|
213
|
+
return render(f"{head}\n\n{page.text}", footer)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def itu_cite(ref: str, edition: str = "X.690") -> str:
|
|
217
|
+
"""The citation string for a clause or sentence, built from the registry, with the edition's digest. Its
|
|
218
|
+
'span:' line is the sha256 prefix of the exact text cited. Refuses a clause or ¶ that is not there."""
|
|
219
|
+
item, target = _target(ref, edition)
|
|
220
|
+
number, para = target.number, target.para
|
|
221
|
+
clause = index.read(item, number)
|
|
222
|
+
if clause is None:
|
|
223
|
+
error = _no_clause(item, number)
|
|
224
|
+
raise LibError(f"{error.message}; refusing to cite what is not there", error.hint)
|
|
225
|
+
cited = clause.body
|
|
226
|
+
if para is not None:
|
|
227
|
+
one = index.sentence(item, number, para)
|
|
228
|
+
if one is None:
|
|
229
|
+
raise LibError(f"{item.key}: §{number} has no ¶{para}", f"itu_read('{_doc(item)}§{number}') numbers them")
|
|
230
|
+
cited = one
|
|
231
|
+
cite = index.citation(item, number, para)
|
|
232
|
+
body = f"{cite}\nedition sha256: {item.sha256 or 'not registered'}"
|
|
233
|
+
return render(body, {"cite": cite, "unit": "sentence" if para else "", "span": span_hash(cited)})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def itu_links(ref: str, edition: str = "X.690") -> str:
|
|
237
|
+
"""References of a clause: same-document clauses it points at, other recommendations, ISO/IEC and RFC
|
|
238
|
+
citations, and which clauses of this edition cite it. 'unresolved' clause numbers were pointed at but are not
|
|
239
|
+
in the index (an extraction gap, or a clause of another document)."""
|
|
240
|
+
item, target = _target(ref, edition)
|
|
241
|
+
number = target.number
|
|
242
|
+
found = index.links(item, number)
|
|
243
|
+
if found is None:
|
|
244
|
+
raise _no_clause(item, number)
|
|
245
|
+
doc = _doc(item)
|
|
246
|
+
|
|
247
|
+
def refs(values: tuple[str, ...]) -> str:
|
|
248
|
+
return ", ".join(f"{doc}§{value}" for value in values) or "-"
|
|
249
|
+
|
|
250
|
+
lines = [
|
|
251
|
+
f"points at: {refs(found.clauses)}",
|
|
252
|
+
f"unresolved: {', '.join(found.unresolved) or '-'}",
|
|
253
|
+
f"recommendations: {', '.join(found.recommendations) or '-'}",
|
|
254
|
+
f"standards: {', '.join(found.standards) or '-'}",
|
|
255
|
+
f"cited by: {refs(found.cited_by)}",
|
|
256
|
+
]
|
|
257
|
+
return render("\n".join(lines), {"cite": index.citation(item, number)})
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def itu_browse(path: str = "", depth: int = 2, start: int = 0, max_tokens: int = 1500) -> str:
|
|
261
|
+
"""Browse the catalog. No argument: the ITU-T series A-Z and which have registered recommendations. A series
|
|
262
|
+
letter ('X'): its registered recommendations. A recommendation ('X.690'): its clauses down to `depth`, as
|
|
263
|
+
refs, paged: a 'next: start=N' footer means call again with start=N."""
|
|
264
|
+
if not path:
|
|
265
|
+
registered = {item.rec.split(".")[0] for item in sources.IN_FORCE}
|
|
266
|
+
lines = [
|
|
267
|
+
f"{letter} {title}" + (" [registered]" if letter in registered else "")
|
|
268
|
+
for letter, title in sources.SERIES.items()
|
|
269
|
+
]
|
|
270
|
+
return render("\n".join(lines), {})
|
|
271
|
+
if len(path) == 1:
|
|
272
|
+
lines = [
|
|
273
|
+
f"{item.rec} {'fetched' if corpus.has(item) else 'not fetched'} {item.title}"
|
|
274
|
+
for item in sources.IN_FORCE
|
|
275
|
+
if item.rec.startswith(path.upper() + ".")
|
|
276
|
+
]
|
|
277
|
+
return render("\n".join(lines) or f"no recommendation of series {path.upper()} is registered", {})
|
|
278
|
+
item = _edition(path)
|
|
279
|
+
shown, more = index.slice_rows(index.top_level(item, depth), start, max_tokens)
|
|
280
|
+
return render("\n".join(_rows(shown, _doc(item))), {"next": next_start(more)})
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def itu_catalog(
|
|
284
|
+
series: str = "X", text: str = "", fmt: str = "", free: bool = False, start: int = 0, max_tokens: int = 1500
|
|
285
|
+
) -> str:
|
|
286
|
+
"""The ITU-T catalogue read from itu.int by `primarysource itu catalog sync`: in-force editions and the
|
|
287
|
+
formats each is offered in (PDF, EPB=EPUB, MSW=Word, TOC-HTM, SUM-HTM; '*' marks not freely available).
|
|
288
|
+
Metadata only. Paged: a 'next: start=N' footer means call again with start=N."""
|
|
289
|
+
from . import catalog
|
|
290
|
+
|
|
291
|
+
letter = series.upper()
|
|
292
|
+
recs = catalog.load(letter)
|
|
293
|
+
found = catalog.rows(recs, text=text, fmt=fmt, free_only=free)
|
|
294
|
+
shown, more = index.slice_rows(
|
|
295
|
+
tuple((f"{r.rec} {r.label}", f"{r.summary()} {r.title}") for r in found), start, max_tokens
|
|
296
|
+
)
|
|
297
|
+
detailed = sum(1 for entry in recs.values() if entry.get("components") is not None)
|
|
298
|
+
head = (
|
|
299
|
+
f"series {letter}: {len(found)} matching components; {detailed} of {len(recs)} recommendations have details"
|
|
300
|
+
)
|
|
301
|
+
lines = [
|
|
302
|
+
f"{row.rec} {row.kind} {row.label} [{row.component}] approved {row.approved or '-'} "
|
|
303
|
+
f"{row.summary() or '-'} {row.title}"
|
|
304
|
+
for row in found[start : start + len(shown)]
|
|
305
|
+
]
|
|
306
|
+
return render("\n".join([head, *lines]), {"next": next_start(more)})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def itu_compare(
|
|
310
|
+
number: str, edition_a: str = "X.690#2008-11", edition_b: str = "X.690#2021-02", max_tokens: int = 2000
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Compare one clause between two editions, by sentence: '- [¶n]' lines were removed from a, '+ [¶n]' lines
|
|
313
|
+
added in b. Numbering can differ between editions, so a clause missing from one may have moved, not gone.
|
|
314
|
+
Matching ignores case, whitespace, punctuation and symbols: a change of a symbol alone is not reported."""
|
|
315
|
+
first, second = _edition(edition_a), _edition(edition_b)
|
|
316
|
+
try:
|
|
317
|
+
_rec, clause_number, _para = text.parse_ref(number)
|
|
318
|
+
except ValueError as error:
|
|
319
|
+
raise LibError(str(error), REF_SHAPES) from None
|
|
320
|
+
result = index.compare(first, second, clause_number)
|
|
321
|
+
lines = [f"a: {index.citation(first, clause_number)}", f"b: {index.citation(second, clause_number)}"]
|
|
322
|
+
if not (result.in_a and result.in_b):
|
|
323
|
+
missing = " or ".join(e.key for e, present in ((first, result.in_a), (second, result.in_b)) if not present)
|
|
324
|
+
lines.append(f"§{clause_number} is not in {missing}; the rule may have moved rather than gone")
|
|
325
|
+
return render("\n".join(lines), {})
|
|
326
|
+
if result.same:
|
|
327
|
+
lines.append("identical, ignoring case, whitespace, punctuation and symbols")
|
|
328
|
+
return render("\n".join(lines), {})
|
|
329
|
+
budget = max(max_tokens, 1) * index.CHARS_PER_TOKEN
|
|
330
|
+
left: list[str] = []
|
|
331
|
+
for sign, rows in (("-", result.removed), ("+", result.added)):
|
|
332
|
+
used = 0
|
|
333
|
+
for position, (para, sentence) in enumerate(rows):
|
|
334
|
+
if position and used + len(sentence) > budget:
|
|
335
|
+
left.append(f"{len(rows) - position} {'removed' if sign == '-' else 'added'}")
|
|
336
|
+
break
|
|
337
|
+
used += len(sentence)
|
|
338
|
+
lines.append(f"{sign} [¶{para}] {sentence}")
|
|
339
|
+
warning = f"cut at max_tokens: {' and '.join(left)} sentence(s) not shown; raise max_tokens" if left else ""
|
|
340
|
+
return render("\n".join(lines), {"warning": warning})
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def itu_outline(
|
|
344
|
+
edition: str = "X.690", prefix: str = "", depth: int = 0, start: int = 0, max_tokens: int = 1500
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Clause outline of an edition: one ref and title per line, no bodies. `prefix` limits it to one subtree
|
|
347
|
+
('8.1'); `depth` caps the numbering depth (0 = all). Paged: a 'next: start=N' footer means call again with
|
|
348
|
+
start=N."""
|
|
349
|
+
item = _edition(edition)
|
|
350
|
+
rows = index.outline(item)
|
|
351
|
+
if prefix:
|
|
352
|
+
rows = tuple(r for r in rows if r[0] == prefix or r[0].startswith(prefix + "."))
|
|
353
|
+
if depth:
|
|
354
|
+
rows = tuple(r for r in rows if r[0].count(".") + 1 <= depth)
|
|
355
|
+
if not rows:
|
|
356
|
+
return render(f"no clause of {item.key} is under {prefix!r} at depth {depth or 'any'}", {})
|
|
357
|
+
shown, more = index.slice_rows(rows, start, max_tokens)
|
|
358
|
+
head = f"{item.key}: {len(rows)} clauses"
|
|
359
|
+
return render("\n".join([head, *_rows(shown, _doc(item))]), {"next": next_start(more)})
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def itu_grep(pattern: str, edition: str = "X.690", start: int = 0, max_tokens: int = 1500) -> str:
|
|
363
|
+
"""Regular expression (case-insensitive) over clause bodies, one pass, an excerpt around the first match of
|
|
364
|
+
each clause, cut to a token budget. A 'next: start=N' footer means more clauses match: call again with start=N."""
|
|
365
|
+
item = _edition(edition)
|
|
366
|
+
hits, more = index.grep(item, pattern, start=start, max_tokens=max_tokens)
|
|
367
|
+
doc = _doc(item)
|
|
368
|
+
body = "\n".join(f"{doc}§{hit.number}\n {hit.excerpt}" for hit in hits)
|
|
369
|
+
return render(body or f"no clause of {item.key} matches /{pattern}/", {"next": next_start(more)})
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def itu_errata(rec: str = "X.690") -> str:
|
|
373
|
+
"""Errata, amendments and corrigenda registered against a recommendation, with the text of each fetched one."""
|
|
374
|
+
items = [item for item in sources.AMENDMENTS if item.rec == rec]
|
|
375
|
+
if not items:
|
|
376
|
+
return render(f"nothing is registered against {rec}", {})
|
|
377
|
+
lines: list[str] = []
|
|
378
|
+
for item in items:
|
|
379
|
+
if not item.artifact:
|
|
380
|
+
lines.append(f"{item.key} {item.status}; no download id {item.label}")
|
|
381
|
+
elif corpus.has(item):
|
|
382
|
+
lines += [f"{item.key} {item.status}; fetched {item.label}", f" {errata_text(item)}"]
|
|
383
|
+
else:
|
|
384
|
+
lines.append(f"{item.key} {item.status}; not fetched (primarysource itu fetch {item.key}) {item.label}")
|
|
385
|
+
caveat = next((item.note for item in items if item.note), "")
|
|
386
|
+
if caveat:
|
|
387
|
+
lines.append(f"note: {caveat}")
|
|
388
|
+
return render("\n".join(lines), {})
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
TOOLS = (
|
|
392
|
+
itu_info,
|
|
393
|
+
itu_search,
|
|
394
|
+
itu_read,
|
|
395
|
+
itu_cite,
|
|
396
|
+
itu_links,
|
|
397
|
+
itu_browse,
|
|
398
|
+
itu_catalog,
|
|
399
|
+
itu_compare,
|
|
400
|
+
itu_outline,
|
|
401
|
+
itu_grep,
|
|
402
|
+
itu_errata,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def build() -> Any:
|
|
407
|
+
from mcp.server.mcpserver import MCPServer
|
|
408
|
+
from mcp.types import ToolAnnotations
|
|
409
|
+
|
|
410
|
+
app = MCPServer(name="itulib", instructions=INSTRUCTIONS, version=__version__)
|
|
411
|
+
add_tools(app, TOOLS, annotations=ToolAnnotations(**READ_ONLY_HINTS))
|
|
412
|
+
|
|
413
|
+
@app.resource(
|
|
414
|
+
"itu://{rec}/{number}",
|
|
415
|
+
name="clause",
|
|
416
|
+
description="One clause as text with its citation, e.g. itu://X.690/11.6",
|
|
417
|
+
mime_type="text/plain",
|
|
418
|
+
)
|
|
419
|
+
def clause_resource(rec: str, number: str) -> str:
|
|
420
|
+
item = _edition(rec)
|
|
421
|
+
page = index.page(item, number, max_tokens=4000)
|
|
422
|
+
if page is None:
|
|
423
|
+
raise _no_clause(item, number)
|
|
424
|
+
return f"{index.citation(item, page.number)}\n\n{page.text}"
|
|
425
|
+
|
|
426
|
+
@app.prompt(description="Explain a clause in plain language, grounded in and cited to the fetched text.")
|
|
427
|
+
def explain_clause(ref: str) -> str:
|
|
428
|
+
return (
|
|
429
|
+
f"Explain {ref} in plain language. Call itu_read for the clause (and again with its `next:` ref until "
|
|
430
|
+
"there is none), and itu_links to find the clauses it depends on and read those too. Quote nothing you "
|
|
431
|
+
"did not read from a tool result, and cite every statement with the `cite:` line the tools return, "
|
|
432
|
+
"including the ¶ number for sentences. If a tool returns an error or a `warning:` line, say so instead "
|
|
433
|
+
"of filling the gap from memory."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
@app.prompt(description="Check a claim about an encoding rule against the text, with citations.")
|
|
437
|
+
def verify_claim(claim: str, rec: str = "X.690") -> str:
|
|
438
|
+
return (
|
|
439
|
+
f"Verify this claim against {rec}: {claim}\n\n"
|
|
440
|
+
"Use itu_search (and itu_grep for exact wording), read each candidate clause with itu_read, and "
|
|
441
|
+
"use view=normative to separate obligations from notes. Answer SUPPORTED, CONTRADICTED or NOT "
|
|
442
|
+
"FOUND, cite the clause and edition for each, and check itu_errata for a correction to the cited "
|
|
443
|
+
"text. NOT FOUND means no clause says it; it is not evidence the claim is true or false. Do not "
|
|
444
|
+
"answer from memory."
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return app
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def require_extra() -> None:
|
|
451
|
+
"""Raise ImportError naming the extra if the MCP SDK is not installed. Checked before anything is served, so
|
|
452
|
+
a missing extra is one clear sentence and exit status 2, never a traceback from deep inside the SDK."""
|
|
453
|
+
try:
|
|
454
|
+
import anyio # noqa: F401
|
|
455
|
+
from mcp.server.mcpserver import MCPServer # noqa: F401
|
|
456
|
+
except ImportError as error:
|
|
457
|
+
raise ImportError(
|
|
458
|
+
f"primarysource itu mcp needs the MCP extra: pip install 'primarysource[mcp]' ({error})"
|
|
459
|
+
) from error
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def main() -> None:
|
|
463
|
+
require_extra()
|
|
464
|
+
import anyio
|
|
465
|
+
|
|
466
|
+
anyio.run(build().run_stdio_async)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
if __name__ == "__main__":
|
|
470
|
+
# stderr, never stdout. stdout *is* the protocol: a friendly notice printed there arrives before the
|
|
471
|
+
# handshake and the client fails with "Failed to parse JSONRPC message from server", naming neither the
|
|
472
|
+
# line nor the reason. The stdio test caught this; importing the module never would.
|
|
473
|
+
print("itulib: speaking MCP on stdio; run it from a client, not a terminal", file=sys.stderr)
|
|
474
|
+
main()
|
|
File without changes
|