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,19 @@
|
|
|
1
|
+
"""MCP server for the local NIST publications: the shared server (primarysource.pdfcorpus.server) with the
|
|
2
|
+
nist spec. `primarysource mcp --collection nist` serves `mcp`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from primarysource.nist.spec import SPEC
|
|
7
|
+
from primarysource.pdfcorpus.server import CorpusServer
|
|
8
|
+
|
|
9
|
+
SERVER = CorpusServer(SPEC)
|
|
10
|
+
mcp = SERVER.mcp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Serve over stdio (`primarysource mcp --collection nist` is the usual way)."""
|
|
15
|
+
mcp.run(transport="stdio")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The five nist tools: the shared ones (primarysource.pdfcorpus.tools)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.pdfcorpus.tools import (
|
|
6
|
+
GRAMMAR,
|
|
7
|
+
OUTLINE_ROWS,
|
|
8
|
+
READ_TOKENS,
|
|
9
|
+
ToolError,
|
|
10
|
+
cite,
|
|
11
|
+
info,
|
|
12
|
+
outline,
|
|
13
|
+
parse_lines,
|
|
14
|
+
read,
|
|
15
|
+
resolve,
|
|
16
|
+
resolve_doc,
|
|
17
|
+
resolve_section,
|
|
18
|
+
search,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"GRAMMAR",
|
|
23
|
+
"OUTLINE_ROWS",
|
|
24
|
+
"READ_TOKENS",
|
|
25
|
+
"ToolError",
|
|
26
|
+
"cite",
|
|
27
|
+
"info",
|
|
28
|
+
"outline",
|
|
29
|
+
"parse_lines",
|
|
30
|
+
"read",
|
|
31
|
+
"resolve",
|
|
32
|
+
"resolve_doc",
|
|
33
|
+
"resolve_section",
|
|
34
|
+
"search",
|
|
35
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The nist collection's settings for the shared skeleton (primarysource.pdfcorpus)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from primarysource.nist.paths import corpus_dir
|
|
10
|
+
from primarysource.pdfcorpus.spec import CorpusSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def publication_name(key: str) -> str:
|
|
14
|
+
"""A reference with its issuer stripped: "nist sp 800-53 low" -> "800-53 low", "nist csf" -> "csf"."""
|
|
15
|
+
name = re.sub(r"^nist ", "", key)
|
|
16
|
+
return re.sub(r"^(special publication|sp|csrc) ", "", name)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_defaults(root: Path) -> Mapping[str, str]:
|
|
20
|
+
from primarysource.nist.ingest.status import read_defaults
|
|
21
|
+
|
|
22
|
+
return read_defaults(root)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SPEC = CorpusSpec(
|
|
26
|
+
name="nist",
|
|
27
|
+
server_name="nistlib",
|
|
28
|
+
env_var="NISTLIB_CORPUS",
|
|
29
|
+
fetch_command="primarysource nist fetch",
|
|
30
|
+
index_command="primarysource nist index",
|
|
31
|
+
default_dir=corpus_dir,
|
|
32
|
+
instructions=(
|
|
33
|
+
"NIST publications (SP 800-73 PIV, SP 800-78, SP 800-76, FIPS 201) from a locally fetched corpus. search('<words>') -> read('<doc_id>§<section>') -> cite('<doc_id>§<section>'). Search hits start with that ref; a read's 'next:' line is a ref to pass back to read. Documents are addressed by doc_id or by number. Check info(doc) for status before quoting. A missing corpus or search index is an error whose 'action:' line is the command to run (`primarysource nist fetch`, `primarysource nist index`)."
|
|
34
|
+
),
|
|
35
|
+
cite_example='NIST SP 800-73pt2-5 §3.2.4',
|
|
36
|
+
read_defaults=_read_defaults,
|
|
37
|
+
alias=publication_name,
|
|
38
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The shared skeleton of a fetched-document collection: user-fetched PDFs, extracted text, a section index,
|
|
2
|
+
a search index in the core FTS5 index (`fts`), five tools (search, read, outline, info, cite) and an MCP server.
|
|
3
|
+
|
|
4
|
+
nistlib and usblib were one skeleton copied twice (primarysource PLAN.md §1). This is the one copy. A collection
|
|
5
|
+
supplies a `CorpusSpec` (its names, where its corpus lives, its fetch command, and optional hooks such as NIST's
|
|
6
|
+
default-revision aliases) and gets the loader, the tools and the server from here. Fetching and splitting stay in
|
|
7
|
+
the collection for now (ROADMAP `us.base_segmenter`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from primarysource.pdfcorpus.corpus import Corpus, CorpusError, load_corpus
|
|
13
|
+
from primarysource.pdfcorpus.models import Document, Section
|
|
14
|
+
from primarysource.pdfcorpus.spec import CorpusSpec
|
|
15
|
+
|
|
16
|
+
__all__ = ["Corpus", "CorpusError", "CorpusSpec", "Document", "Section", "load_corpus"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Load and validate a collection's ``corpus/index.json`` against ``primarysource.pdfcorpus.models``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from primarysource.errors import LibError
|
|
13
|
+
from primarysource.pdfcorpus.models import Document, Section
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from primarysource.pdfcorpus.spec import CorpusSpec
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
_DOC_FIELDS = tuple(f.name for f in dataclasses.fields(Document))
|
|
21
|
+
_SECTION_FIELDS = tuple(f.name for f in dataclasses.fields(Section))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CorpusError(LibError):
|
|
25
|
+
"""The corpus or its search index is missing, stale or malformed. `action` is the fetch or index command."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True)
|
|
29
|
+
class Corpus:
|
|
30
|
+
"""A loaded corpus. `defaults` and `alias` resolve a bare publication name to a document (see `CorpusSpec`).
|
|
31
|
+
|
|
32
|
+
`fetch_command` and `index_command` are the `action:` of an error that a fetch or an index rebuild fixes;
|
|
33
|
+
`prefix` is put before a tool name in a `hint:` (`nist_` gives `nist_outline(...)`). `digest` is the sha256 of
|
|
34
|
+
the `index.json` this was loaded from, which the search index records to tell whether it is stale.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
root: Path
|
|
38
|
+
documents: tuple[Document, ...]
|
|
39
|
+
defaults: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
40
|
+
alias: Callable[[str], str] | None = None
|
|
41
|
+
fetch_command: str = ""
|
|
42
|
+
index_command: str = ""
|
|
43
|
+
prefix: str = ""
|
|
44
|
+
digest: str = ""
|
|
45
|
+
|
|
46
|
+
def tool(self, name: str) -> str:
|
|
47
|
+
"""The served name of one of this collection's tools, for a `hint:` line."""
|
|
48
|
+
return self.prefix + name
|
|
49
|
+
|
|
50
|
+
def text_lines(self, doc: Document) -> list[str]:
|
|
51
|
+
"""Lines of the document's extracted text (index 0 is line 1)."""
|
|
52
|
+
path = (self.root / doc.text_file).resolve()
|
|
53
|
+
if not path.is_relative_to(self.root.resolve()):
|
|
54
|
+
raise CorpusError(
|
|
55
|
+
f"{doc.doc_id}: text_file {doc.text_file!r} escapes the corpus directory", action=self.fetch_command
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
# Split on newline only, as the ingest side does. splitlines() also breaks on form feeds,
|
|
59
|
+
# which pdftotext puts at every page break, and that shifts every line number. Read with
|
|
60
|
+
# newline="" for the same reason: universal newlines would also break on a lone "\r" that
|
|
61
|
+
# pdftotext left inside a line (sp800-38g line 449). Only the "\r" of a CRLF is dropped.
|
|
62
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
63
|
+
lines = [line.removesuffix("\r") for line in handle.read().split("\n")]
|
|
64
|
+
if lines and lines[-1] == "":
|
|
65
|
+
lines.pop()
|
|
66
|
+
return lines
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
raise CorpusError(
|
|
69
|
+
f"{doc.doc_id}: text file {doc.text_file!r} is missing", action=self.fetch_command
|
|
70
|
+
) from None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_fields(where: str, obj: object, expected: tuple[str, ...]) -> Mapping[str, Any]:
|
|
74
|
+
if not isinstance(obj, dict):
|
|
75
|
+
raise CorpusError(f"{where}: expected an object, got {type(obj).__name__}")
|
|
76
|
+
missing = [f for f in expected if f not in obj]
|
|
77
|
+
extra = [f for f in obj if f not in expected]
|
|
78
|
+
if missing:
|
|
79
|
+
raise CorpusError(f"{where}: missing field(s) {', '.join(missing)}")
|
|
80
|
+
if extra:
|
|
81
|
+
raise CorpusError(f"{where}: unknown field(s) {', '.join(extra)}")
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_section(where: str, raw: object) -> Section:
|
|
86
|
+
d = _check_fields(where, raw, _SECTION_FIELDS)
|
|
87
|
+
for name in ("id", "heading"):
|
|
88
|
+
if not isinstance(d[name], str) or not d[name]:
|
|
89
|
+
raise CorpusError(f"{where}.{name}: expected a non-empty string")
|
|
90
|
+
for name in ("start_line", "end_line"):
|
|
91
|
+
v = d[name]
|
|
92
|
+
if isinstance(v, bool) or not isinstance(v, int) or v < 1:
|
|
93
|
+
raise CorpusError(f"{where}.{name}: expected a positive integer")
|
|
94
|
+
if d["start_line"] > d["end_line"]:
|
|
95
|
+
raise CorpusError(f"{where}: start_line {d['start_line']} is after end_line {d['end_line']}")
|
|
96
|
+
return Section(d["id"], d["heading"], d["start_line"], d["end_line"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_document(where: str, raw: object) -> Document:
|
|
100
|
+
d = _check_fields(where, raw, _DOC_FIELDS)
|
|
101
|
+
for name in _DOC_FIELDS:
|
|
102
|
+
if name == "sections":
|
|
103
|
+
continue
|
|
104
|
+
if not isinstance(d[name], str) or not d[name]:
|
|
105
|
+
raise CorpusError(f"{where}.{name}: expected a non-empty string")
|
|
106
|
+
secs = d["sections"]
|
|
107
|
+
if not isinstance(secs, list):
|
|
108
|
+
raise CorpusError(f"{where}.sections: expected a list")
|
|
109
|
+
sections = tuple(_parse_section(f"{where}.sections[{i}]", s) for i, s in enumerate(secs))
|
|
110
|
+
ids = [s.id for s in sections]
|
|
111
|
+
dup = sorted({i for i in ids if ids.count(i) > 1})
|
|
112
|
+
if dup:
|
|
113
|
+
raise CorpusError(f"{where}.sections: duplicate section id(s) {', '.join(dup)}")
|
|
114
|
+
values = {n: d[n] for n in _DOC_FIELDS if n != "sections"}
|
|
115
|
+
return Document(sections=sections, **values)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_corpus(spec: CorpusSpec, root: Path | None = None) -> Corpus:
|
|
119
|
+
"""Read and validate the index; raise ``CorpusError`` whose `action` is the fetch command."""
|
|
120
|
+
root = spec.corpus_dir() if root is None else root
|
|
121
|
+
fetch = spec.fetch_command
|
|
122
|
+
index = root / "index.json"
|
|
123
|
+
if not index.is_file():
|
|
124
|
+
raise CorpusError(f"no corpus index at {index}: the corpus is not fetched yet", action=fetch)
|
|
125
|
+
raw = index.read_bytes()
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(raw.decode("utf-8"))
|
|
128
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
129
|
+
raise CorpusError(f"{index} is not valid JSON ({e})", action=fetch) from None
|
|
130
|
+
if not isinstance(data, dict):
|
|
131
|
+
raise CorpusError(f"{index}: expected a JSON object at the top level", action=fetch)
|
|
132
|
+
if data.get("schema") != SCHEMA_VERSION:
|
|
133
|
+
raise CorpusError(
|
|
134
|
+
f"{index}: schema is {data.get('schema')!r}, this server reads schema {SCHEMA_VERSION}",
|
|
135
|
+
"update primarysource if the corpus is newer than it",
|
|
136
|
+
fetch,
|
|
137
|
+
)
|
|
138
|
+
docs = data.get("documents")
|
|
139
|
+
if not isinstance(docs, list):
|
|
140
|
+
raise CorpusError(f"{index}: 'documents' must be a list", action=fetch)
|
|
141
|
+
try:
|
|
142
|
+
documents = tuple(_parse_document(f"documents[{i}]", d) for i, d in enumerate(docs))
|
|
143
|
+
except CorpusError as e:
|
|
144
|
+
raise CorpusError(f"{index}: {e.message}", action=fetch) from None
|
|
145
|
+
ids = [d.doc_id for d in documents]
|
|
146
|
+
dup = sorted({i for i in ids if ids.count(i) > 1})
|
|
147
|
+
if dup:
|
|
148
|
+
raise CorpusError(f"{index}: duplicate doc_id(s) {', '.join(dup)}", action=fetch)
|
|
149
|
+
defaults = spec.read_defaults(root) if spec.read_defaults is not None else {}
|
|
150
|
+
return Corpus(
|
|
151
|
+
root=root,
|
|
152
|
+
documents=documents,
|
|
153
|
+
defaults=defaults,
|
|
154
|
+
alias=spec.alias,
|
|
155
|
+
fetch_command=fetch,
|
|
156
|
+
index_command=spec.index_command or fetch,
|
|
157
|
+
prefix=f"{spec.name}_",
|
|
158
|
+
digest=hashlib.sha256(raw).hexdigest(),
|
|
159
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The search index of a fetched-document collection: its sections in the core FTS5 index (`primarysource.index`).
|
|
2
|
+
|
|
3
|
+
`build` writes `<corpus>/sections.sqlite` from `index.json` and the extracted text, atomically, when the corpus is
|
|
4
|
+
fetched or when `primarysource <collection> index` is run. `open_index` opens it read-only (`mode=ro`) for search,
|
|
5
|
+
so the server never re-reads or re-tokenises the text to answer a query.
|
|
6
|
+
|
|
7
|
+
A section's indexed body is the text it owns: the lines in its range that no narrower section claims, so a parent
|
|
8
|
+
section is not credited with its children's text (the rule the old in-memory scan had).
|
|
9
|
+
|
|
10
|
+
**Staleness.** The index records the sha256 of the `index.json` it was built from, as a sentinel row in the core
|
|
11
|
+
`documents` table (`SOURCE_ID`, title = the digest, no sections, so it is never a search hit). It is written in the
|
|
12
|
+
same atomic rebuild as the sections. An index whose digest differs from the loaded corpus is refused with a
|
|
13
|
+
`CorpusError` whose `action` is the index command, as is a missing one. (The core index has no metadata table;
|
|
14
|
+
when it grows one, the digest moves there.)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sqlite3
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from primarysource import index
|
|
27
|
+
from primarysource.errors import LibError
|
|
28
|
+
from primarysource.model import Document as IndexedDocument
|
|
29
|
+
from primarysource.model import Section as IndexedSection
|
|
30
|
+
from primarysource.model import Unit
|
|
31
|
+
from primarysource.pdfcorpus.corpus import Corpus, CorpusError, load_corpus
|
|
32
|
+
from primarysource.pdfcorpus.models import Document
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from primarysource.pdfcorpus.spec import CorpusSpec
|
|
36
|
+
|
|
37
|
+
INDEX_FILE = "sections.sqlite"
|
|
38
|
+
SOURCE_ID = "#source"
|
|
39
|
+
|
|
40
|
+
# The old scan scored a heading word 3 above a body word, plus 5 per distinct word: heading hits weigh more. As
|
|
41
|
+
# BM25 column weights: the section heading counts four times the body, the document's number and title once.
|
|
42
|
+
RANKING = index.Ranking(doc_title=1.0, title=4.0, body=1.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def index_path(root: Path) -> Path:
|
|
46
|
+
return root / INDEX_FILE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def owned_lines(document: Document, line_count: int) -> dict[str, list[int]]:
|
|
50
|
+
"""Section id -> the 1-based line numbers it owns: a line belongs to the narrowest section containing it."""
|
|
51
|
+
owner: dict[int, str] = {}
|
|
52
|
+
for section in sorted(document.sections, key=lambda s: s.end_line - s.start_line, reverse=True):
|
|
53
|
+
for number in range(section.start_line, min(section.end_line, line_count) + 1):
|
|
54
|
+
owner[number] = section.id
|
|
55
|
+
owned: dict[str, list[int]] = {section.id: [] for section in document.sections}
|
|
56
|
+
for number in sorted(owner):
|
|
57
|
+
owned[owner[number]].append(number)
|
|
58
|
+
return owned
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def indexed_document(corpus: Corpus, document: Document) -> IndexedDocument:
|
|
62
|
+
"""One corpus document as the core index stores it: one unit per section, the text the section owns."""
|
|
63
|
+
lines = corpus.text_lines(document)
|
|
64
|
+
owned = owned_lines(document, len(lines))
|
|
65
|
+
return IndexedDocument(
|
|
66
|
+
id=document.doc_id,
|
|
67
|
+
title=f"{document.number} {document.title}",
|
|
68
|
+
unit=Unit.BLOCK,
|
|
69
|
+
sections=tuple(
|
|
70
|
+
IndexedSection(
|
|
71
|
+
id=section.id,
|
|
72
|
+
title=section.heading,
|
|
73
|
+
units=("\n".join(lines[n - 1] for n in owned[section.id]),),
|
|
74
|
+
depth=section.id.count(".") + 1,
|
|
75
|
+
)
|
|
76
|
+
for section in document.sections
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build(corpus: Corpus) -> int:
|
|
82
|
+
"""Rebuild `<corpus>/sections.sqlite` from the loaded corpus; returns the number of sections indexed."""
|
|
83
|
+
documents = [indexed_document(corpus, document) for document in corpus.documents]
|
|
84
|
+
documents.append(IndexedDocument(id=SOURCE_ID, title=corpus.digest, unit=Unit.BLOCK))
|
|
85
|
+
return index.rebuild(index_path(corpus.root), documents, rebuild_command=corpus.index_command)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def open_index(corpus: Corpus) -> sqlite3.Connection:
|
|
89
|
+
"""A read-only connection to the corpus's search index, or a `CorpusError` naming the index command."""
|
|
90
|
+
path = index_path(corpus.root)
|
|
91
|
+
action = corpus.index_command
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
raise CorpusError(f"no search index at {path}: the corpus has not been indexed", action=action)
|
|
94
|
+
try:
|
|
95
|
+
conn = index.open_read(path, rebuild_command=action)
|
|
96
|
+
except LibError as error:
|
|
97
|
+
raise CorpusError(error.message, action=action) from None
|
|
98
|
+
try:
|
|
99
|
+
row = conn.execute("SELECT title FROM documents WHERE doc_id = ?", (SOURCE_ID,)).fetchone()
|
|
100
|
+
except sqlite3.DatabaseError as error:
|
|
101
|
+
conn.close()
|
|
102
|
+
raise CorpusError(f"{path} is not a readable search index: {error}", action=action) from None
|
|
103
|
+
if row is None or row[0] != corpus.digest:
|
|
104
|
+
conn.close()
|
|
105
|
+
stale = f"the search index at {path} is stale: index.json changed since it was built"
|
|
106
|
+
raise CorpusError(stale, action=action)
|
|
107
|
+
return conn
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _match_in(
|
|
111
|
+
conn: sqlite3.Connection, expression: str, doc_id: str, ranking: index.Ranking, fetch: int
|
|
112
|
+
) -> list[index.Hit]:
|
|
113
|
+
order = ranking.bm25()
|
|
114
|
+
rows = conn.execute(
|
|
115
|
+
"SELECT s.doc_id, s.section_id, s.doc_title, s.title, " # noqa: S608 - only formatted floats are interpolated
|
|
116
|
+
f"snippet(sections_fts, 2, '', '', ' … ', 24), {order} "
|
|
117
|
+
"FROM sections_fts JOIN sections AS s ON s.id = sections_fts.rowid "
|
|
118
|
+
f"WHERE sections_fts MATCH ? AND s.doc_id = ? ORDER BY {order}, s.id LIMIT ?",
|
|
119
|
+
(expression, doc_id, fetch),
|
|
120
|
+
).fetchall()
|
|
121
|
+
return [index.Hit(row[0], row[1], row[2], row[3], row[4] or row[3], -row[5]) for row in rows]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def search(
|
|
125
|
+
conn: sqlite3.Connection, query: str, *, doc_id: str | None = None, limit: int = 8, start: int = 0
|
|
126
|
+
) -> index.SearchResult:
|
|
127
|
+
"""`index.search` with this collection's ranking, optionally within one document.
|
|
128
|
+
|
|
129
|
+
The core search has no document filter yet, so a restricted search runs the same strict-then-relaxed query
|
|
130
|
+
here with `s.doc_id = ?` added: the decision to relax is then taken within the document, not across the corpus.
|
|
131
|
+
"""
|
|
132
|
+
if doc_id is None:
|
|
133
|
+
return index.search(conn, query, ranking=RANKING, limit=limit, start=start)
|
|
134
|
+
fetch = start + limit + 1
|
|
135
|
+
strict = index.fts_query(query)
|
|
136
|
+
hits = _match_in(conn, strict, doc_id, RANKING, fetch)
|
|
137
|
+
relaxed = False
|
|
138
|
+
if not hits:
|
|
139
|
+
loose = index.fts_query(query, any_term=True)
|
|
140
|
+
if loose != strict:
|
|
141
|
+
hits = _match_in(conn, loose, doc_id, RANKING, fetch)
|
|
142
|
+
relaxed = bool(hits)
|
|
143
|
+
page = tuple(hits[start : start + limit])
|
|
144
|
+
more = len(hits) > start + limit
|
|
145
|
+
return index.SearchResult(page, relaxed, start + len(page) if more else None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(spec: CorpusSpec, argv: Sequence[str]) -> int:
|
|
149
|
+
"""`primarysource <collection> index [--corpus DIR]`: rebuild the search index from the fetched corpus."""
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
prog=spec.index_command or f"primarysource {spec.name} index",
|
|
152
|
+
description="Rebuild the search index from the fetched corpus (index.json and the extracted text).",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument("--corpus", type=Path, default=None, help=f"corpus directory (default: ${spec.env_var})")
|
|
155
|
+
args = parser.parse_args(list(argv))
|
|
156
|
+
try:
|
|
157
|
+
corpus = load_corpus(spec, args.corpus)
|
|
158
|
+
written = build(corpus)
|
|
159
|
+
except LibError as error:
|
|
160
|
+
print(error.as_text(), file=sys.stderr)
|
|
161
|
+
return 2
|
|
162
|
+
print(f"indexed {written} section(s) of {len(corpus.documents)} document(s) into {index_path(corpus.root)}")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_after_fetch(spec: CorpusSpec, root: Path) -> bool:
|
|
167
|
+
"""Called by a fetch once `index.json` is written: rebuild the search index, report, and say if it worked."""
|
|
168
|
+
try:
|
|
169
|
+
corpus = load_corpus(spec, root)
|
|
170
|
+
written = build(corpus)
|
|
171
|
+
except LibError as error:
|
|
172
|
+
print(f"search index not rebuilt:\n{error.as_text()}", file=sys.stderr)
|
|
173
|
+
return False
|
|
174
|
+
print(f"indexed {written} section(s) into {index_path(root)}")
|
|
175
|
+
return True
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The corpus index: ``index.json``, ``{"schema": 1, "documents": [...]}``, written atomically."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from primarysource.pdfcorpus.models import Document, Section
|
|
12
|
+
|
|
13
|
+
SCHEMA = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sha256_file(path: Path) -> str:
|
|
17
|
+
"""Hex sha256 of a file, read in chunks."""
|
|
18
|
+
digest = hashlib.sha256()
|
|
19
|
+
with path.open("rb") as handle:
|
|
20
|
+
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
21
|
+
digest.update(chunk)
|
|
22
|
+
return digest.hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_bytes(data: bytes) -> str:
|
|
26
|
+
return hashlib.sha256(data).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def document_from_dict(raw: dict[str, object]) -> Document:
|
|
30
|
+
sections = raw["sections"]
|
|
31
|
+
if not isinstance(sections, list):
|
|
32
|
+
raise ValueError("sections must be a list")
|
|
33
|
+
return Document(
|
|
34
|
+
doc_id=str(raw["doc_id"]),
|
|
35
|
+
number=str(raw["number"]),
|
|
36
|
+
title=str(raw["title"]),
|
|
37
|
+
date=str(raw["date"]),
|
|
38
|
+
status=str(raw["status"]),
|
|
39
|
+
url=str(raw["url"]),
|
|
40
|
+
sha256=str(raw["sha256"]),
|
|
41
|
+
text_file=str(raw["text_file"]),
|
|
42
|
+
sections=tuple(
|
|
43
|
+
Section(
|
|
44
|
+
id=str(s["id"]),
|
|
45
|
+
heading=str(s["heading"]),
|
|
46
|
+
start_line=int(s["start_line"]),
|
|
47
|
+
end_line=int(s["end_line"]),
|
|
48
|
+
)
|
|
49
|
+
for s in sections
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_index(path: Path, documents: list[Document]) -> None:
|
|
55
|
+
"""Write ``{"schema": 1, "documents": [...]}`` atomically."""
|
|
56
|
+
payload = {"schema": SCHEMA, "documents": [asdict(d) for d in documents]}
|
|
57
|
+
temp = path.with_name(path.name + ".tmp")
|
|
58
|
+
temp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
59
|
+
os.replace(temp, path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_index(path: Path) -> list[Document]:
|
|
63
|
+
"""Read an index; a missing, unreadable or foreign-schema file yields no documents."""
|
|
64
|
+
try:
|
|
65
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
if raw.get("schema") != SCHEMA:
|
|
67
|
+
return []
|
|
68
|
+
return [document_from_dict(item) for item in raw["documents"]]
|
|
69
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
70
|
+
return []
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Data contract shared by the ingest pipeline and the MCP server. Change it only with both sides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Section:
|
|
10
|
+
"""One numbered section of a document, located by line range in its extracted text."""
|
|
11
|
+
|
|
12
|
+
id: str # dotted section number, e.g. "3.2.4"
|
|
13
|
+
heading: str
|
|
14
|
+
start_line: int # 1-based, inclusive
|
|
15
|
+
end_line: int # 1-based, inclusive
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Document:
|
|
20
|
+
"""One publication in the corpus index."""
|
|
21
|
+
|
|
22
|
+
doc_id: str # file-safe slug, e.g. "sp800-73pt2-5"
|
|
23
|
+
number: str # as cited, e.g. "NIST SP 800-73pt2-5" or "USB-IF CCID Rev 1.1"
|
|
24
|
+
title: str
|
|
25
|
+
date: str # "YYYY-MM"
|
|
26
|
+
status: str # e.g. "final", "withdrawn"
|
|
27
|
+
url: str # where the user fetched the PDF
|
|
28
|
+
sha256: str # of the PDF as fetched
|
|
29
|
+
text_file: str # path relative to data/corpus/
|
|
30
|
+
sections: tuple[Section, ...]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The MCP server for a fetched-document collection: search, read, outline, info, cite.
|
|
2
|
+
|
|
3
|
+
Reads local files only. The corpus is loaded on the first call and kept until its `index.json` changes on disk
|
|
4
|
+
(a fetch or an OSCAL merge rewrites it), so a running server picks up a new corpus without a restart; `reset()`
|
|
5
|
+
drops it outright. Search reads the FTS5 index (`primarysource.pdfcorpus.fts`) read-only.
|
|
6
|
+
|
|
7
|
+
Tools are registered through `scaffold.add_tools` under their bare names (`search`, `read`, ...): `primarysource mcp`
|
|
8
|
+
serves them as `<collection>_<tool>` (`nist_search`), and `primarysource mcp --bare-names` or this module's own
|
|
9
|
+
stdio server keeps the bare names the old `nistlib`/`usblib` servers had. Every `LibError` reaches the client as an
|
|
10
|
+
`isError` result with `error:`, `hint:` and `action:` lines (PLAN.md §5.10).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from mcp.server.mcpserver import MCPServer
|
|
18
|
+
from mcp.types import ToolAnnotations
|
|
19
|
+
|
|
20
|
+
from primarysource.pdfcorpus import tools
|
|
21
|
+
from primarysource.pdfcorpus.corpus import Corpus, load_corpus
|
|
22
|
+
from primarysource.pdfcorpus.spec import CorpusSpec
|
|
23
|
+
from primarysource.scaffold import add_tools
|
|
24
|
+
|
|
25
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CorpusServer:
|
|
29
|
+
"""One collection's MCP server. `mcp` is the `MCPServer`; the tool functions are attributes, callable directly
|
|
30
|
+
(they raise `LibError`; through `mcp` it becomes an `isError` result)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, spec: CorpusSpec) -> None:
|
|
33
|
+
self.spec = spec
|
|
34
|
+
self._corpus: Corpus | None = None
|
|
35
|
+
self._stamp: tuple[int, int] | None = None
|
|
36
|
+
self.mcp = MCPServer(spec.server_name, instructions=spec.instructions)
|
|
37
|
+
|
|
38
|
+
def search(query: str, doc: str | None = None, limit: int = 8, start: int = 0) -> str:
|
|
39
|
+
return tools.search(self._load(), query, doc, limit, start)
|
|
40
|
+
|
|
41
|
+
def read(
|
|
42
|
+
doc: str, section: str | None = None, lines: str | None = None, max_tokens: int = tools.READ_TOKENS
|
|
43
|
+
) -> str:
|
|
44
|
+
return tools.read(self._load(), doc, section, lines, max_tokens)
|
|
45
|
+
|
|
46
|
+
def outline(doc: str, start: int = 0, limit: int = tools.OUTLINE_ROWS) -> str:
|
|
47
|
+
return tools.outline(self._load(), doc, start, limit)
|
|
48
|
+
|
|
49
|
+
def info(doc: str) -> str:
|
|
50
|
+
return tools.info(self._load(), doc)
|
|
51
|
+
|
|
52
|
+
def cite(doc: str, section: str | None = None) -> str:
|
|
53
|
+
return tools.cite(self._load(), doc, section)
|
|
54
|
+
|
|
55
|
+
search.__doc__ = _SEARCH
|
|
56
|
+
read.__doc__ = _READ
|
|
57
|
+
outline.__doc__ = _OUTLINE
|
|
58
|
+
info.__doc__ = _INFO
|
|
59
|
+
cite.__doc__ = _CITE.format(example=spec.cite_example)
|
|
60
|
+
self.search, self.read, self.outline, self.info, self.cite = search, read, outline, info, cite
|
|
61
|
+
functions: tuple[Callable[..., str], ...] = (search, read, outline, info, cite)
|
|
62
|
+
add_tools(self.mcp, functions, annotations=READ_ONLY)
|
|
63
|
+
|
|
64
|
+
def reset(self) -> None:
|
|
65
|
+
self._corpus = None
|
|
66
|
+
self._stamp = None
|
|
67
|
+
|
|
68
|
+
def _load(self) -> Corpus:
|
|
69
|
+
stamp = None
|
|
70
|
+
try:
|
|
71
|
+
stat = (self.spec.corpus_dir() / "index.json").stat()
|
|
72
|
+
stamp = (stat.st_mtime_ns, stat.st_size)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
if self._corpus is None or stamp != self._stamp or self._corpus.root != self.spec.corpus_dir():
|
|
76
|
+
self._corpus = load_corpus(self.spec)
|
|
77
|
+
self._stamp = stamp
|
|
78
|
+
return self._corpus
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_SEARCH = """Find sections by keywords (BM25 over the local FTS5 index; a heading match weighs more), best first.
|
|
82
|
+
|
|
83
|
+
Each hit starts with a ref '<doc_id>§<section>' to pass to read() and cite(), then the heading, line range and a
|
|
84
|
+
snippet. doc restricts the search to one document (doc_id or number). Every word is required first; if no section
|
|
85
|
+
has them all, sections with some of them are returned and a 'relaxed:' line says so. A 'next: start=N' line means
|
|
86
|
+
call again with start=N for more."""
|
|
87
|
+
_READ = """Read numbered lines of one section or a line range, cut at whole lines within max_tokens.
|
|
88
|
+
|
|
89
|
+
doc is a doc_id or number with section='3.2.4' (ids from outline) or lines='120-160', or a ref on its own:
|
|
90
|
+
'<doc_id>§3.2.4' or '<doc_id>:120-160'. A 'next:' line holds a ref; pass it back as doc to continue."""
|
|
91
|
+
_OUTLINE = """List a document's section tree: ids, headings and line ranges. A 'next: start=N' line means call
|
|
92
|
+
again with start=N for the rest."""
|
|
93
|
+
_INFO = """Document metadata: number, title, date, status, url, sha256.
|
|
94
|
+
A 'warning:' line appears when the status is not 'final'."""
|
|
95
|
+
_CITE = """Citation string such as '{example}' plus the document URL.
|
|
96
|
+
doc may be a ref ('<doc_id>§<section>'). Headings come verbatim from the index."""
|