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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. 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."""