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,281 @@
1
+ """Heuristic section chunking for RFC .txt documents.
2
+
3
+ RFC txt files have a predictable structure: numbered headings (up to 4
4
+ levels) indented 0-5 spaces with two spaces before the title, dot-leader
5
+ table-of-contents entries, repeated page headers/footers and page breaks.
6
+ We detect real headings (excluding TOC leaders and renumbered inline
7
+ lists), cut sections until the next heading, strip page furniture, and
8
+ split oversized sections into paragraph-boundary parts.
9
+
10
+ Line ranges refer to the ORIGINAL file lines so an agent can re-read the
11
+ exact window with get_rfc(doc, start_line=..., max_lines=...).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import re
18
+ from dataclasses import dataclass
19
+
20
+ _HEADING = re.compile(r"^ {0,7}(\d{1,3}(?:\.\d{1,3}){0,3})\.?\s+(\S.*)$")
21
+ _APPENDIX = re.compile(r"^ {0,5}Appendix\s+([A-Z][a-z]?)[.:]?\s\s*(\S.*)$")
22
+ _APPS = re.compile(r"^ {0,7}([A-Z])\.\d+(?:\.\d+)*\.?\s+(\S.*)$")
23
+ _UNNUMBERED = {
24
+ "Status of This Memo", "Abstract", "Table of Contents",
25
+ "Copyright Notice", "Authors' Addresses", "Author Addresses",
26
+ "Acknowledgments", "Acknowledgement", "Full Copyright Statement",
27
+ "Security Considerations", "IANA Considerations", "References",
28
+ "Contributors", "Editor's Address", "Editor(s) Address",
29
+ }
30
+ _TOC_LEADER = re.compile(r"\.{2,}\s*\d+\s*$")
31
+ # Old-format ToC entry WITHOUT dot leaders: "4.1. Format 25" —
32
+ # a number-shaped line ending in a wide-spaced page number.
33
+ _TOC_PLAIN = re.compile(r"^ {0,10}\d{1,3}(\.\d{1,3}){0,3}\.?[ \t]+\S.*"
34
+ r"[ \t]{3,}\d{1,4}[ \t]*$")
35
+ _JUNK = (
36
+ re.compile(r"^\s*\[Page \d+\]\s*$"), # page break marker
37
+ re.compile(r"\[Page \d+\]\s*$"), # running footer
38
+ re.compile(r"^\s{0,3}(Request for Comments: \d+|Internet-Draft .+ "
39
+ r"\(expires .+\))\s*$"),
40
+ re.compile(r"\.{2,}\s*\d+\s*$"), # dot-leader ToC line
41
+ re.compile(r"(\s\.){4,}\s*\d*\s*$"), # spaced-leader ToC line
42
+ re.compile(r"^[\s.\u00b7\u2026]{4,}\d*\s*$"), # pure leader line
43
+ _TOC_PLAIN, # plain-leader ToC line
44
+ )
45
+ _PAGE_FURNITURE = re.compile(r"\f")
46
+ MAX_CHARS = 10000
47
+ PART_CHARS = 2400
48
+ HARD_CHARS = 4800 # forced cut inside blank-line-free runs (tables)
49
+
50
+
51
+ @dataclass
52
+ class Section:
53
+ sec_num: str
54
+ sec_title: str
55
+ start_line: int # 1-based, inclusive (original file)
56
+ end_line: int # 1-based, inclusive
57
+ text: str
58
+
59
+
60
+ def _looks_like_title(title: str) -> bool:
61
+ if not title or title.endswith(",") or len(title) > 90:
62
+ return False
63
+ return bool(re.search(r"[A-Za-z]", title))
64
+
65
+
66
+ # A bare lowercase name, no spaces: "class", "holdconnect", "client/server".
67
+ # Legacy (pre-1998) RFCs title directive subsections this way -- RFC 2167 3.3.5
68
+ # is "holdconnect". Never matches a run-on of prose, which always has a space.
69
+ _DIRECTIVE_TITLE = re.compile(r"[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*\Z")
70
+
71
+
72
+ def _clean_title(raw: str, *, allow_lowercase: bool = False) -> str | None:
73
+ """Extract a real section title from the heading line's tail.
74
+
75
+ Real RFC section titles: a short Title Case noun phrase (no commas,
76
+ <=12 words, start uppercase/digit). Two-or-more spaces after the first
77
+ token means an inline definition ("1. MUST This word ...") -> keep
78
+ the term only. Wrapped prose caught by the number prefix is rejected.
79
+
80
+ `allow_lowercase` widens only the single-token lowercase case, for the
81
+ caller that has a structural guard to back it up (txtseg's scan rejects a
82
+ heading whose number is not the successor of the previous one). Left off by
83
+ default because elsewhere in the corpus a lowercase line-initial is wrapped
84
+ prose, not a title.
85
+ """
86
+ t = re.split(r"\s{2,}", raw.strip())[0].strip()
87
+ if not t or t.endswith(","):
88
+ return None
89
+ if t.endswith(".") and " " not in t:
90
+ return None # "(SMTP)." wrapped-reference fragment, never a title
91
+ if re.fullmatch(r"[\d.,\s]+", t):
92
+ return None # digit/figure runs ("0 1 2", hex offsets), never titles
93
+ if not re.match(r"[A-Z0-9\"'(]", t):
94
+ if not (allow_lowercase and _DIRECTIVE_TITLE.fullmatch(t)):
95
+ return None
96
+ if re.search(r"\.\d{1,3}$", t):
97
+ return None # ToC page-number bleed: "...Dates.89"
98
+ words = t.split()
99
+ if len(words) > 1 and t.endswith("."):
100
+ return None # prose caught by a renumbered list item, not a title
101
+ if len(words) > 12 or ("," in t and len(words) > 6):
102
+ return None
103
+ if len(words) > 2 and not re.search(r"[a-z]", t):
104
+ return None # all-caps token runs: figures/hex dumps, not titles
105
+ return t
106
+
107
+
108
+ def find_headings(lines: list[str]) -> list[tuple[int, str, str]]:
109
+ """Return (line_idx, sec_num, sec_title) for body section headings.
110
+
111
+ Two-pass: collect raw candidates, then keep only those followed by real
112
+ body text before the next candidate. That drops ToC entries (new-format
113
+ txt has no dot leaders) and consecutive inline-list items in one rule.
114
+ """
115
+ raw: list[tuple[int, str, str]] = []
116
+ for i, ln in enumerate(lines):
117
+ if _TOC_LEADER.search(ln) or _TOC_PLAIN.match(ln):
118
+ continue
119
+ m = _HEADING.match(ln)
120
+ if m:
121
+ title = _clean_title(m.group(2))
122
+ if title:
123
+ raw.append((i, m.group(1), title))
124
+ continue
125
+ m = _APPENDIX.match(ln)
126
+ if m:
127
+ title = _clean_title(m.group(2) or "Appendix")
128
+ if title:
129
+ raw.append((i, f"Appendix {m.group(1)}", title))
130
+ continue
131
+ m = _APPS.match(ln)
132
+ if m:
133
+ title = _clean_title(m.group(2))
134
+ if title:
135
+ raw.append((i, m.group(1), title))
136
+ continue
137
+ stripped = ln.strip().rstrip(".")
138
+ if stripped in _UNNUMBERED and len(ln) - len(ln.lstrip(" ")) <= 5 \
139
+ and ln.strip():
140
+ raw.append((i, "", stripped))
141
+
142
+ kept: list[tuple[int, str, str]] = []
143
+ last_major = -1
144
+ for k, (i, num, title) in enumerate(raw):
145
+ if num == "" and title == "Table of Contents":
146
+ kept.append((i, num, title)) # forced boundary; body dropped
147
+ continue
148
+ nxt = raw[k + 1][0] if k + 1 < len(raw) else len(lines)
149
+ if not _clean(lines[i + 1:nxt]).strip():
150
+ continue # ToC remnant / empty list item: no body before next
151
+ if re.fullmatch(r"\d{1,3}(\.\d{1,3}){0,3}", num):
152
+ major = int(num.split(".")[0])
153
+ if major < last_major:
154
+ continue # renumbered inline list under a later section
155
+ last_major = max(last_major, major)
156
+ elif num.startswith("Appendix"):
157
+ last_major = max(last_major, 10**6)
158
+ elif len(num) == 1 and num.isupper():
159
+ if last_major < 10**6:
160
+ continue # 'A.1'-style before any Appendix heading: noise
161
+ kept.append((i, num, title))
162
+ return kept
163
+
164
+
165
+ # Boilerplate that appears ONLY in the header/footer zone of a page. Stripped
166
+ # for reading (see strip_page_furniture), never for indexing.
167
+ _PAGE_EDGE = (
168
+ re.compile(r"^\s*\[?Page \d+\]?\s*$"),
169
+ re.compile(r"\[Page \d+\]\s*$"), # running footer w/ title
170
+ re.compile(r"^\s*(Request for Comments: \d+|Category: .+|ISSN: \S+|"
171
+ r"Network Working Group|Internet Engineering Task Force|"
172
+ r"Internet Research Task Force|Internet-Draft .+ \(expires .+\)"
173
+ r"|BCP \d+ .+ \[[A-Z ]+\])\s*$"),
174
+ )
175
+
176
+
177
+ def strip_page_furniture(text: str) -> str:
178
+ """Return `text` with RFC page furniture removed: form feeds, running
179
+ headers/footers and `[Page N]` markers, with blank runs collapsed to one.
180
+
181
+ Only lines in the first/last 4 lines of a page qualify (canonical RFC
182
+ .txt delimits pages with form feeds), so a body line that happens to
183
+ look like a header is never removed. Everything else stays byte-identical
184
+ to the source, which keeps quote hashes stable across releases.
185
+
186
+ Differs from _clean(), which drops EVERY blank line for index chunks:
187
+ this keeps paragraph structure because a human/agent is reading it.
188
+ """
189
+ out: list[str] = []
190
+ blanks = 0
191
+ for page in text.split("\f"):
192
+ lines = [ln.rstrip() for ln in page.splitlines()]
193
+ n = len(lines)
194
+ for i, ln in enumerate(lines):
195
+ if not ln.strip():
196
+ blanks += 1
197
+ continue
198
+ if (i <= 4 or i >= n - 4) and any(p.match(ln) for p in _PAGE_EDGE):
199
+ continue
200
+ if out and blanks:
201
+ out.append("")
202
+ blanks = 0
203
+ out.append(ln)
204
+ return "\n".join(out)
205
+
206
+
207
+ def _clean(lines: list[str]) -> str:
208
+ out = []
209
+ for ln in lines:
210
+ ln = _PAGE_FURNITURE.sub("", ln)
211
+ if not ln.strip():
212
+ continue
213
+ if any(p.match(ln) for p in _JUNK):
214
+ continue
215
+ out.append(ln.rstrip())
216
+ return "\n".join(out)
217
+
218
+
219
+ def _split_range(lines: list[str], i0: int, i1: int, num: str,
220
+ title: str) -> list[Section]:
221
+ """Cut a raw line range [i0, i1) into <=PART_CHARS chunks.
222
+
223
+ Cuts at blank-line (paragraph) boundaries; tables/code runs without
224
+ blank lines are cut hard at any line once HARD_CHARS is exceeded.
225
+ Each part carries its OWN original line range.
226
+ """
227
+ whole = _clean(lines[i0:i1])
228
+ if len(whole) <= MAX_CHARS:
229
+ return [Section(num, title, i0 + 1, i1, whole)]
230
+ parts: list[Section] = []
231
+ start, acc = i0, 0
232
+ for i in range(i0, i1):
233
+ ln = _PAGE_FURNITURE.sub("", lines[i])
234
+ keep = bool(ln.strip()) and not any(p.match(ln) for p in _JUNK)
235
+ if keep:
236
+ acc += len(ln) + 1
237
+ over = acc > PART_CHARS and (not ln.strip() or acc > HARD_CHARS)
238
+ if over and i - start > 1:
239
+ body = _clean(lines[start:i])
240
+ if body.strip():
241
+ parts.append(Section(num, title, start + 1, i, body))
242
+ start, acc = i, 0
243
+ body = _clean(lines[start:i1])
244
+ if body.strip():
245
+ parts.append(Section(num, title, start + 1, i1, body))
246
+ n = len(parts)
247
+ if n > 1: # disambiguate + keep section context in every part
248
+ head = f"{(num + ' ') if num else ''}{title}\n"
249
+ parts = [Section(p.sec_num, f"{title or num} (part {k + 1}/{n})",
250
+ p.start_line, p.end_line,
251
+ p.text if k == 0 else head + p.text)
252
+ for k, p in enumerate(parts)]
253
+ return parts
254
+
255
+
256
+ def split_sections(text: str) -> list[Section]:
257
+ lines = text.lstrip("\ufeff").splitlines()
258
+ heads = find_headings(lines)
259
+ if not heads:
260
+ body = _clean(lines)
261
+ return [Section("", "Entire document", 1, len(lines), body)] \
262
+ if body.strip() else []
263
+ secs: list[Section] = []
264
+ if heads[0][0] > 0:
265
+ secs.extend(_split_range(lines, 0, heads[0][0], "",
266
+ "Front matter & boilerplate"))
267
+ for k, (idx, num, title) in enumerate(heads):
268
+ end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
269
+ if title == "Table of Contents" or num == "Table of Contents":
270
+ continue
271
+ body = _clean(lines[idx:end])
272
+ # Heading-only section == ToC remnant (new-format txt has no dot
273
+ # leaders) or inline-list artifact: real sections always have body.
274
+ if not body.strip() or len(body.strip().split("\n")) <= 1:
275
+ continue
276
+ secs.extend(_split_range(lines, idx, end, num, title or num))
277
+ return secs
278
+
279
+
280
+ def sha256_text(s: str) -> str:
281
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
@@ -0,0 +1,166 @@
1
+ """Semantic retrieval: local static embeddings (model2vec) + sqlite-vec KNN.
2
+
3
+ Everything stays inside the one SQLite DB and runs fully offline after the
4
+ first model download (~30 MB, Hugging Face). Vectors are L2-normalized so
5
+ sqlite-vec's default L2 distance orders identically to cosine similarity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from datetime import datetime, timezone
12
+
13
+ DEFAULT_MODEL = "minishlab/potion-base-8M"
14
+
15
+ _model_cache: dict[str, object] = {}
16
+
17
+
18
+ def load_vec_extension(conn: sqlite3.Connection) -> None:
19
+ try:
20
+ import sqlite_vec
21
+ except ImportError as exc:
22
+ raise RuntimeError(
23
+ "semantic search is an optional feature and is not installed "
24
+ "(pip install 'rfclib[semantic]'); search uses keywords, "
25
+ "identifiers and titles meanwhile.") from exc
26
+ try:
27
+ conn.enable_load_extension(True)
28
+ except (AttributeError, sqlite3.NotSupportedError) as exc:
29
+ # some Python builds (macOS system Python, some pyenv builds)
30
+ # compile SQLite without extension loading: keyword search only
31
+ raise RuntimeError(
32
+ "this Python's sqlite3 cannot load extensions, so semantic "
33
+ "search is unavailable; search falls back to keywords. A uv- "
34
+ "or python.org-installed Python supports it.") from exc
35
+ try:
36
+ sqlite_vec.load(conn)
37
+ except sqlite3.OperationalError:
38
+ pass # already loaded on this connection
39
+ finally:
40
+ conn.enable_load_extension(False)
41
+
42
+
43
+ def model_dir(name: str = DEFAULT_MODEL):
44
+ """<home>/models/<org>__<name>: the model travels with the library, so
45
+ loading it never needs the network or a Hugging Face cache."""
46
+ from ..config import Paths
47
+ return Paths.default().models / name.replace("/", "__")
48
+
49
+
50
+ def get_model(name: str = DEFAULT_MODEL):
51
+ if name not in _model_cache:
52
+ try:
53
+ from model2vec import StaticModel
54
+ except ImportError as exc: # callers treat RuntimeError as "no
55
+ raise RuntimeError( # semantic search": keyword fallback
56
+ "semantic search is an optional feature and is not "
57
+ "installed (pip install 'rfclib[semantic]').") from exc
58
+ local = model_dir(name)
59
+ if (local / "config.json").is_file():
60
+ _model_cache[name] = StaticModel.from_pretrained(str(local))
61
+ return _model_cache[name]
62
+ try:
63
+ # already in the Hugging Face cache: no etag revalidation
64
+ model = StaticModel.from_pretrained(name, local_files_only=True)
65
+ except Exception:
66
+ from ..config import require_network
67
+ require_network(f"the embedding model {name} (~30 MB)")
68
+ model = StaticModel.from_pretrained(name)
69
+ try:
70
+ local.parent.mkdir(parents=True, exist_ok=True)
71
+ model.save_pretrained(str(local))
72
+ except Exception: # noqa: BLE001 - a cache miss next time, no more
73
+ pass
74
+ _model_cache[name] = model
75
+ return _model_cache[name]
76
+
77
+
78
+ def ensure_vec_table(conn: sqlite3.Connection, dim: int) -> None:
79
+ load_vec_extension(conn)
80
+ conn.execute(
81
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0("
82
+ f"embedding float[{dim}])")
83
+
84
+
85
+ def embed_pending(conn: sqlite3.Connection, model_name: str = DEFAULT_MODEL,
86
+ limit: int = 0, batch: int = 256, progress=None,
87
+ doc_id: str | None = None) -> dict:
88
+ """Embed chunks whose content changed since their last embedding
89
+ (only those of doc_id when given: the on-demand path)."""
90
+ try:
91
+ import sqlite_vec
92
+ except ImportError as exc:
93
+ raise RuntimeError(
94
+ "semantic search is an optional feature and is not installed "
95
+ "(pip install 'rfclib[semantic]').") from exc
96
+ rows = conn.execute(
97
+ """SELECT c.id, c.body FROM chunks c
98
+ LEFT JOIN embed_state e ON e.chunk_id = c.id
99
+ WHERE (e.chunk_id IS NULL OR e.sha <> c.sha256 OR e.model <> ?)
100
+ AND (? IS NULL OR c.doc_id = ?)
101
+ ORDER BY c.id""", (model_name, doc_id, doc_id)).fetchall()
102
+ if limit:
103
+ rows = rows[:limit]
104
+ stats = {"pending": len(rows), "embedded": 0}
105
+ if not rows:
106
+ return stats
107
+ model = get_model(model_name)
108
+ dim = int(model.encode(["dimension probe"], normalize=True).shape[1])
109
+ ensure_vec_table(conn, dim)
110
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
111
+ for i in range(0, len(rows), batch):
112
+ part = rows[i:i + batch]
113
+ vecs = model.encode([r["body"][:4000] for r in part],
114
+ batch_size=len(part), normalize=True)
115
+ for r, v in zip(part, vecs):
116
+ conn.execute(
117
+ "INSERT OR REPLACE INTO vec_chunks(rowid, embedding) "
118
+ "VALUES(?,?)", (r["id"], sqlite_vec.serialize_float32(v)))
119
+ conn.execute(
120
+ "INSERT INTO embed_state(chunk_id, sha, model, at) "
121
+ "VALUES(?,?,?,?) ON CONFLICT(chunk_id) DO UPDATE SET "
122
+ "sha=excluded.sha, model=excluded.model, at=excluded.at",
123
+ (r["id"], conn.execute(
124
+ "SELECT sha256 s FROM chunks WHERE id=?",
125
+ (r["id"],)).fetchone()["s"], model_name, now))
126
+ conn.commit()
127
+ stats["embedded"] += len(part)
128
+ if progress:
129
+ progress(stats["embedded"], len(rows))
130
+ return stats
131
+
132
+
133
+ def semantic_search(conn: sqlite3.Connection, query: str, limit: int = 10,
134
+ include_obsolete: bool = False,
135
+ model_name: str = DEFAULT_MODEL) -> list[dict]:
136
+ load_vec_extension(conn)
137
+ n = conn.execute("SELECT COUNT(*) c FROM embed_state").fetchone()["c"]
138
+ if n == 0:
139
+ raise RuntimeError(
140
+ "No embeddings built yet. Run `rfclib index embed` first.")
141
+ import sqlite_vec
142
+ model = get_model(model_name)
143
+ qv = sqlite_vec.serialize_float32(
144
+ model.encode([query], normalize=True)[0])
145
+ sql = """
146
+ SELECT v.rowid AS chunk_id, v.distance, c.doc_id, c.sec_num,
147
+ c.sec_title, c.start_line, c.end_line, d.title, d.is_current,
148
+ d.current_status,
149
+ substr(replace(c.body, char(10), ' '), 1, 240) AS preview
150
+ FROM vec_chunks v
151
+ JOIN chunks c ON c.id = v.rowid
152
+ JOIN docs d ON d.doc_id = c.doc_id
153
+ WHERE v.embedding MATCH ? AND k = ?"""
154
+ params: list = [qv, max(limit * 4, 20)]
155
+ if not include_obsolete:
156
+ sql += " AND d.is_current = 1"
157
+ sql += " ORDER BY v.distance"
158
+ rows = [dict(r) for r in conn.execute(sql, params).fetchall()]
159
+ # boilerplate sections sit deceptively close to every query vector:
160
+ # push them out of the top of the list instead of trusting raw distance
161
+ from .search import NOISE_TITLE_RE
162
+ for r in rows:
163
+ if NOISE_TITLE_RE.search(r["sec_title"] or ""):
164
+ r["distance"] = r["distance"] + 0.35
165
+ rows.sort(key=lambda r: r["distance"])
166
+ return rows[:limit]