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,508 @@
1
+ """Full-text retrieval over section chunks (FTS5/BM25) + table of contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sqlite3
7
+ from pathlib import Path
8
+
9
+ from ..sync.state import file_path, mirror_of
10
+ from .chunker import sha256_text, split_sections
11
+
12
+ # Reference/boilerplate sections match every vague query and pollute result
13
+ # lists: shared discount pattern used by both rankings (never drops rows).
14
+ NOISE_TITLE_RE = re.compile(
15
+ r"\b(references?|acknowledg\w*|authors?|contributors?|participants|"
16
+ r"editors'? address\w*|table of contents|change log|copyright)\b", re.I)
17
+
18
+
19
+ def _doc_sections(conn: sqlite3.Connection, doc_id: str, text: str,
20
+ xml_path: Path | None, stats: dict) -> list:
21
+ """Sections for one document: RFCXML structure when the doc has XML
22
+ (exact boundaries, aligned back onto txt lines), else the ToC-driven
23
+ txt segmenter (txtseg); the legacy heuristic only if both fail. Also
24
+ refreshes the doc's `blocks`/`xrefs`/`seg_quality` rows."""
25
+ from . import rfcxml, txtseg
26
+ for t in ("blocks", "xrefs", "seg_quality"):
27
+ conn.execute(f"DELETE FROM {t} WHERE doc_id=?", (doc_id,))
28
+
29
+ def quality(method: str, q=None, notes: str = ""):
30
+ conn.execute(
31
+ "INSERT INTO seg_quality(doc_id, method, toc_entries, matched, "
32
+ "extra, headings, score, notes) VALUES(?,?,?,?,?,?,?,?)",
33
+ (doc_id, method, q.toc_entries if q else 0,
34
+ q.matched if q else 0, q.extra if q else 0,
35
+ q.headings if q else 0, q.score if q else None,
36
+ notes or ("; ".join(q.notes) if q else "")))
37
+
38
+ px = lines = None
39
+ if xml_path and Path(xml_path).exists():
40
+ try:
41
+ px = rfcxml.parse(Path(xml_path).read_text(
42
+ encoding="utf-8", errors="replace"), doc_id)
43
+ lines = rfcxml.align(px, text)
44
+ stats["xml_docs"] += 1
45
+ quality("rfcxml")
46
+ except Exception as e: # noqa: BLE001 - fall back, never block a build
47
+ stats["xml_failed"] += 1
48
+ stats.setdefault("xml_errors", []).append(
49
+ f"{doc_id}: {type(e).__name__}: {e}"[:160])
50
+ px = None
51
+ if px is None:
52
+ try:
53
+ px, q = txtseg.segment(text, doc_id)
54
+ lines = text.lstrip("").splitlines()
55
+ stats["txtseg_docs"] += 1
56
+ quality(q.method, q)
57
+ except Exception as e: # noqa: BLE001
58
+ stats.setdefault("txtseg_errors", []).append(
59
+ f"{doc_id}: {type(e).__name__}: {e}"[:160])
60
+ quality("legacy", notes=f"txtseg failed: {type(e).__name__}")
61
+ return split_sections(text)
62
+ conn.executemany(
63
+ "INSERT OR REPLACE INTO blocks(doc_id, pn, ord, kind, sec_num, "
64
+ "sec_title, sec_anchor, start_line, end_line, text, bcp14, lang, "
65
+ "exact) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", rfcxml.blocks_rows(px))
66
+ conn.executemany(
67
+ "INSERT OR REPLACE INTO xrefs(doc_id, src_pn, src_sec, dst_doc, "
68
+ "dst_sec, ref_anchor, normative) VALUES(?,?,?,?,?,?,?)",
69
+ rfcxml.xref_rows(px))
70
+ return rfcxml.xml_sections(px, lines)
71
+
72
+
73
+ def _src_sha(txt_sha: str, xml_sha: str | None) -> str:
74
+ """Source hash a document's chunks are built from: txt + xml + the
75
+ parser that read them, so a parser fix re-chunks exactly its docs."""
76
+ from .rfcxml import PARSER_VERSION
77
+ from .txtseg import VERSION as TXTSEG_VERSION
78
+ if xml_sha:
79
+ return sha256_text(f"{txt_sha}:{xml_sha}:rfcxml-{PARSER_VERSION}")
80
+ return sha256_text(f"{txt_sha}:txtseg-{TXTSEG_VERSION}")
81
+
82
+
83
+ def _index_one(conn: sqlite3.Connection, doc_id: str, path: Path,
84
+ src_sha: str, xml_path: Path | None, stats: dict,
85
+ fts_incremental: bool) -> None:
86
+ text = path.read_text(encoding="utf-8", errors="replace")
87
+ # content-addressed id carryover: a re-chunk keeps the old id for
88
+ # every section whose body hash is unchanged, so its embedding
89
+ # stays valid (embed_state is keyed by chunk_id + content sha);
90
+ # only genuinely new sections take fresh ids.
91
+ old_ids: dict[str, list[int]] = {}
92
+ for o in conn.execute("SELECT id, sha256 FROM chunks WHERE doc_id=?",
93
+ (doc_id,)).fetchall():
94
+ old_ids.setdefault(o["sha256"], []).append(o["id"])
95
+ next_id = (conn.execute("SELECT MAX(id) FROM chunks")
96
+ .fetchone()[0] or 0) + 1
97
+ taken: set[int] = set()
98
+ plan: list[tuple[int, object, str]] = []
99
+ for s in _doc_sections(conn, doc_id, text, xml_path, stats):
100
+ sh = sha256_text(s.text)
101
+ sid = next((i for i in old_ids.get(sh, ()) if i not in taken), None)
102
+ if sid is None:
103
+ sid = next_id
104
+ next_id += 1
105
+ taken.add(sid)
106
+ plan.append((sid, s, sh))
107
+ stale = [i for ids in old_ids.values() for i in ids if i not in taken]
108
+ conn.executemany("DELETE FROM embed_state WHERE chunk_id=?",
109
+ [(i,) for i in stale])
110
+ if fts_incremental: # external-content FTS needs the old values to delete
111
+ conn.execute(
112
+ "INSERT INTO chunks_fts(chunks_fts, rowid, sec, body) "
113
+ "SELECT 'delete', id, sec, body FROM chunks WHERE doc_id=?",
114
+ (doc_id,))
115
+ conn.execute("DELETE FROM chunks WHERE doc_id=?", (doc_id,))
116
+ for sid, s, sh in plan:
117
+ conn.execute(
118
+ "INSERT INTO chunks(id, doc_id, sec_num, sec_title, "
119
+ "start_line, end_line, body, sha256) VALUES(?,?,?,?,?,?,?,?)",
120
+ (sid, doc_id, s.sec_num, s.sec_title, s.start_line,
121
+ s.end_line, s.text, sh))
122
+ if fts_incremental:
123
+ conn.execute(
124
+ "INSERT INTO chunks_fts(rowid, sec, body) "
125
+ "SELECT id, sec, body FROM chunks WHERE doc_id=?", (doc_id,))
126
+ from .lexical import index_identifiers
127
+ reused = {i for ids in old_ids.values() for i in ids}
128
+ index_identifiers(conn, [sid for sid, _s, _h in plan if sid not in reused],
129
+ drop_ids=stale)
130
+ conn.execute(
131
+ "INSERT INTO chunk_src(doc_id, sha256) VALUES(?,?) "
132
+ "ON CONFLICT(doc_id) DO UPDATE SET sha256=excluded.sha256",
133
+ (doc_id, src_sha))
134
+
135
+
136
+ def index_doc(conn: sqlite3.Connection, doc_id: str) -> bool:
137
+ """(Re)index ONE document whose txt (and xml, if any) is on disk, keeping
138
+ the FTS index in sync incrementally: the on-demand path, cheap enough
139
+ to run inside a tool call (well under a second for most RFCs). Returns
140
+ False when the document has no local txt."""
141
+ rows = conn.execute(
142
+ """SELECT t.path, t.sha256, x.path AS xml_path, x.sha256 AS xml_sha
143
+ FROM files t LEFT JOIN files x
144
+ ON x.doc_id = t.doc_id AND x.fmt = 'xml'
145
+ WHERE t.fmt = 'txt' AND t.doc_id = ?""", (doc_id,)).fetchall()
146
+ if not rows:
147
+ return False
148
+ r = rows[0]
149
+ mirror = mirror_of(conn)
150
+ path = file_path(conn, r["path"], mirror)
151
+ if not path.exists():
152
+ return False
153
+ xml_path = (file_path(conn, r["xml_path"], mirror) if r["xml_path"]
154
+ else None)
155
+ stats = {"xml_docs": 0, "xml_failed": 0, "txtseg_docs": 0}
156
+ # Take the write lock BEFORE _index_one reads MAX(id) and the old
157
+ # chunks. sqlite3 otherwise opens the transaction at the first INSERT,
158
+ # so two writers (threads of the shared server, or two processes)
159
+ # indexing different documents at once would read the same MAX(id),
160
+ # pick the same new chunk ids, and the second would fail on
161
+ # chunks.id. A waiting writer blocks here for up to busy_timeout.
162
+ if not conn.in_transaction:
163
+ conn.execute("BEGIN IMMEDIATE")
164
+ try:
165
+ _index_one(conn, doc_id, path, _src_sha(r["sha256"], r["xml_sha"]),
166
+ xml_path, stats, fts_incremental=True)
167
+ conn.commit()
168
+ except BaseException:
169
+ conn.rollback()
170
+ raise
171
+ return True
172
+
173
+
174
+ def build_chunks(conn: sqlite3.Connection, mirror_root: Path,
175
+ progress=None) -> dict:
176
+ """(Re)chunk every locally-held .txt, incrementally by source hash.
177
+
178
+ Documents that also have RFCXML (RFC 8650+) take section boundaries
179
+ from the XML; the source hash then covers txt + xml + parser version, so
180
+ an XML download or a parser fix re-chunks exactly the affected docs.
181
+ """
182
+ rows = conn.execute(
183
+ """SELECT t.doc_id, t.path, t.sha256, x.path AS xml_path,
184
+ x.sha256 AS xml_sha
185
+ FROM files t LEFT JOIN files x
186
+ ON x.doc_id = t.doc_id AND x.fmt = 'xml'
187
+ WHERE t.fmt = 'txt'""").fetchall()
188
+ stats = {"scanned": len(rows), "rebuilt": 0, "unchanged": 0, "missing": 0,
189
+ "xml_docs": 0, "xml_failed": 0, "txtseg_docs": 0}
190
+ mirror = mirror_of(conn)
191
+ for r in rows:
192
+ path = file_path(conn, r["path"], mirror)
193
+ xml_path = (file_path(conn, r["xml_path"], mirror)
194
+ if r["xml_path"] else None)
195
+ if not path.exists():
196
+ stats["missing"] += 1
197
+ continue
198
+ src_sha = _src_sha(r["sha256"], r["xml_sha"] if r["xml_path"]
199
+ else None)
200
+ prev = conn.execute("SELECT sha256 FROM chunk_src WHERE doc_id=?",
201
+ (r["doc_id"],)).fetchone()
202
+ if prev and prev["sha256"] == src_sha:
203
+ stats["unchanged"] += 1
204
+ continue
205
+ _index_one(conn, r["doc_id"], path, src_sha, xml_path, stats,
206
+ fts_incremental=False)
207
+ stats["rebuilt"] += 1
208
+ if progress and stats["rebuilt"] % 250 == 0:
209
+ progress(stats["rebuilt"], stats["scanned"])
210
+ if stats["rebuilt"] and stats["rebuilt"] % 1000 == 0:
211
+ conn.commit()
212
+ conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
213
+ from .lexical import backfill_identifiers, mine_acronyms
214
+ stats["idents_backfilled"] = backfill_identifiers(conn)
215
+ if stats["rebuilt"] or not conn.execute(
216
+ "SELECT 1 FROM acronyms LIMIT 1").fetchone():
217
+ stats["acronyms"] = mine_acronyms(conn)
218
+ conn.commit()
219
+ stats["chunks"] = conn.execute("SELECT COUNT(*) c FROM chunks").fetchone()["c"]
220
+ return stats
221
+
222
+
223
+ def _fts_query(query: str, or_mode: bool = False,
224
+ hybrid_bm25: bool = False,
225
+ extra_phrases: list[str] | None = None) -> str:
226
+ """Pass through expert FTS syntax; fall back to quoted-word AND/OR.
227
+
228
+ Pure-numeric tokens are dropped from the default query: the tokenizer
229
+ splits decimals, so "TLS 1.3" would otherwise match every section
230
+ number containing 1.3 (e.g. §4.1.3) in any document. Versioned protocol
231
+ names are still covered by the doc-title ranking list and by embeddings.
232
+
233
+ or_mode ranks by number of matched (IDF-weighted) terms instead of
234
+ requiring all of them — the AND conjunct starves recall on real
235
+ questions ("NTP timestamp era rollover" AND-matches 1 chunk in 300k).
236
+
237
+ hybrid_bm25 (for RRF candidates) is a two-level OR: bare terms, plus
238
+ the full AND restricted to {sec body} — a chunk whose SECTION TITLE and
239
+ ABSTRACT together carry every term is the canonical passage, and that
240
+ pattern survives porter stemming noise ("era"->"er") and documents that
241
+ merely mention one hot keyword a lot. Porter folds "Reno NewReno" into
242
+ one token; dedup terms for that.
243
+ """
244
+ q = query.strip()
245
+ if re.search(r'"|AND|OR|NEAR|\*|-|\{|\}', q):
246
+ return q
247
+ terms, seen = [], set()
248
+ for t in re.findall(r"[A-Za-z0-9.\-]+", q):
249
+ if re.search(r"[A-Za-z]", t) and t not in seen:
250
+ seen.add(t)
251
+ terms.append(t)
252
+ if not terms:
253
+ terms = re.findall(r"[A-Za-z0-9.\-]+", q)
254
+ if not terms:
255
+ return '""'
256
+ if hybrid_bm25:
257
+ # two-level OR: bare terms for recall; a NEAR proximity group so a
258
+ # chunk carrying ALL terms in one passage outscores term scattered
259
+ # hits. ({col} (...) grouping is not valid FTS5 grammar.)
260
+ parts = [f'"{t}"' for t in terms]
261
+ if 2 <= len(terms) <= 5:
262
+ parts.append("NEAR(" + " ".join(f'"{t}"' for t in terms)
263
+ + f", {40 * len(terms)})")
264
+ # acronym expansions: "NTP" also matches "network time protocol"
265
+ parts += [f'"{p}"' for p in (extra_phrases or [])]
266
+ return " OR ".join(parts)
267
+ joiner = " OR " if or_mode else " "
268
+ return joiner.join(f'"{t}"' for t in terms)
269
+
270
+
271
+ def search_chunks(conn: sqlite3.Connection, query: str, limit: int = 10,
272
+ include_obsolete: bool = False,
273
+ or_mode: bool = False,
274
+ hybrid_bm25: bool = False,
275
+ extra_phrases: list[str] | None = None) -> list[dict]:
276
+ sql = """
277
+ SELECT c.id AS chunk_id, c.doc_id, c.sec_num, c.sec_title,
278
+ c.start_line, c.end_line, d.title, d.is_current,
279
+ d.current_status, d.pub_date,
280
+ bm25(chunks_fts, 3.0, 1.0) AS rank,
281
+ snippet(chunks_fts, 1, '>>>', '<<<', ' … ', 16) AS snippet
282
+ FROM chunks_fts
283
+ JOIN chunks c ON c.id = chunks_fts.rowid
284
+ JOIN docs d ON d.doc_id = c.doc_id
285
+ WHERE chunks_fts MATCH ?"""
286
+ params: list = [_fts_query(query, or_mode=or_mode,
287
+ hybrid_bm25=hybrid_bm25,
288
+ extra_phrases=extra_phrases)]
289
+ if not include_obsolete:
290
+ sql += " AND d.is_current = 1"
291
+ sql += " ORDER BY rank LIMIT ?"
292
+ params.append(limit)
293
+ try:
294
+ rows = conn.execute(sql, params).fetchall()
295
+ except sqlite3.OperationalError:
296
+ terms = [t for t in re.findall(r"[A-Za-z0-9.\-]+", query)[:8]
297
+ if re.search(r"[A-Za-z]", t)] or \
298
+ re.findall(r"[A-Za-z0-9.\-]+", query)[:8]
299
+ params[0] = " ".join(f'"{t}"' for t in terms) or '""'
300
+ rows = conn.execute(sql, params).fetchall()
301
+ return [dict(r) for r in rows]
302
+
303
+
304
+ # --------------------------------------------------------------------------
305
+ # section addressing: let a caller ask for "3.4" / "Appendix C" instead of
306
+ # doing line arithmetic against a .txt file it should not be reading.
307
+
308
+ _PART_RE = re.compile(r"\s*\(part \d+/\d+\)$")
309
+ _APX_LETTER = re.compile(r"^([A-Z])(?:\.\d+)*$")
310
+
311
+
312
+ def norm_sec(sec: str) -> str:
313
+ """'Section 3.4' | '§3.4' | '3.4.' | 'Appendix C' | 'C' -> the canonical
314
+ sec_num as stored in chunks ('3.4' | 'Appendix C' | '' for unnumbered).
315
+
316
+ A bare letter becomes 'Appendix X': the chunker only stores bare letters
317
+ for appendix sub-headings, so 'C' means Appendix C, never section C.
318
+ """
319
+ s = (sec or "").strip().rstrip(".:")
320
+ s = re.sub(r"^(see\s+)?(section|sect|sec|clause|paragraph)\s+", "", s,
321
+ flags=re.I)
322
+ s = s.lstrip("§ ").strip()
323
+ if not s:
324
+ return ""
325
+ m = re.fullmatch(r"(?:appendix|appx)\s*:?\s*([A-Z][a-z]?(?:\.\d+)?)", s,
326
+ re.I)
327
+ if m:
328
+ lab = m.group(1)[0].upper() + m.group(1)[1:]
329
+ # sub-appendices are stored bare ('Appendix B.1' -> 'B.1')
330
+ return lab if "." in lab else f"Appendix {lab}"
331
+ if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){0,3}", s):
332
+ return s
333
+ m = re.fullmatch(r"([A-Z][a-z]?)((?:\.\d+)+)", s)
334
+ if m: # 'A.1' stays 'A.1' as stored
335
+ return m.group(1) + m.group(2)
336
+ if re.fullmatch(r"[A-Z][a-z]?", s):
337
+ return f"Appendix {s[0].upper()}"
338
+ return s
339
+
340
+
341
+ def url_frag(sec: str) -> str:
342
+ """rfc-editor.org html anchor for a section number ('' -> no fragment)."""
343
+ if not sec:
344
+ return ""
345
+ if sec.startswith("Appendix"):
346
+ return "appendix-" + sec.split(None, 1)[1]
347
+ if _APX_LETTER.match(sec):
348
+ return "appendix-" + sec
349
+ return "section-" + sec
350
+
351
+
352
+ def section_rows(conn: sqlite3.Connection, doc_id: str, sec: str,
353
+ deep: bool = True) -> list[dict]:
354
+ """Chunk rows for a section, all parts in order. deep=True also returns
355
+ sub-sections: the chunker stores a parent's own text and its children as
356
+ sibling rows, so the parent row alone is not the whole section."""
357
+ want = norm_sec(sec)
358
+ rows = conn.execute(
359
+ """SELECT id, sec_num, sec_title, start_line, end_line, sha256
360
+ FROM chunks WHERE doc_id = ? ORDER BY start_line, id""",
361
+ (doc_id,)).fetchall()
362
+ out: list[dict] = []
363
+ for r in rows:
364
+ sn = r["sec_num"]
365
+ if sn == want:
366
+ out.append(dict(r))
367
+ elif deep and want and sn.startswith(want + "."):
368
+ out.append(dict(r))
369
+ elif deep and want and want.startswith("Appendix ") and \
370
+ _APX_LETTER.match(sn or "") and \
371
+ sn[0] == want.split(None, 1)[1][0]:
372
+ out.append(dict(r)) # 'Appendix C' owns its 'C.1' sub-headings
373
+ return out
374
+
375
+
376
+ def get_toc(conn: sqlite3.Connection, doc_id: str) -> list[dict]:
377
+ rows = conn.execute(
378
+ """SELECT sec_num, sec_title, start_line, end_line,
379
+ substr(replace(body, char(10), ' '), 1, 90) AS preview
380
+ FROM chunks WHERE doc_id = ?
381
+ ORDER BY start_line, id""", (doc_id,)).fetchall()
382
+ return [dict(r) for r in rows]
383
+
384
+
385
+ def resolve_span(conn: sqlite3.Connection, doc_id: str, sec: str) -> dict:
386
+ """Resolve a section reference to a line span, or explain why it failed.
387
+
388
+ ok=False carries 'suggestions' (nearest section numbers) plus the real
389
+ outline: the usual cause is a number borrowed from another document or
390
+ dropped by a rename, and guessing is worse than showing the outline.
391
+ """
392
+ import difflib
393
+ want = norm_sec(sec)
394
+ rows = section_rows(conn, doc_id, want)
395
+ if rows:
396
+ title = _PART_RE.sub("", rows[0]["sec_title"] or "")
397
+ start = min(r["start_line"] for r in rows)
398
+ end = max(r["end_line"] for r in rows)
399
+ # RFCXML heading row: the section's own title and heading line even
400
+ # when it has no text of its own (rows[0] is then its first child).
401
+ head = conn.execute(
402
+ "SELECT sec_title, start_line, end_line FROM blocks WHERE "
403
+ "doc_id=? AND kind='heading' AND sec_num=?", (doc_id, want)
404
+ ).fetchone() if want else None
405
+ if head:
406
+ title = head["sec_title"]
407
+ start = min(start, head["start_line"])
408
+ end = max(end, head["end_line"])
409
+ return {"ok": True, "sec_num": want, "sec_title": title,
410
+ "start_line": start, "end_line": end,
411
+ "subsections": sorted({r["sec_num"] for r in rows
412
+ if r["sec_num"] != want}),
413
+ "url_frag": url_frag(want), "parts": len(rows)}
414
+ known = conn.execute(
415
+ "SELECT DISTINCT sec_num, sec_title FROM chunks WHERE doc_id=?",
416
+ (doc_id,)).fetchall()
417
+ if not known:
418
+ return {"ok": False, "sec_num": want, "indexed": False,
419
+ "reason": f"{doc_id} is not section-indexed "
420
+ "(run `rfclib index build`)"}
421
+ names = [k["sec_num"] for k in known if k["sec_num"]]
422
+ near = difflib.get_close_matches(want, names, n=4, cutoff=0.5)
423
+ if not near: # numbers wrong but the title right is common
424
+ low = (sec or "").strip().lower()
425
+ near = [k["sec_num"] for k in known
426
+ if low and low in (k["sec_title"] or "").lower()][:4]
427
+ return {"ok": False, "sec_num": want, "indexed": True,
428
+ "reason": f"no section {want or '(unnumbered)'} in {doc_id}",
429
+ "suggestions": near,
430
+ "outline": [f"{k['sec_num']} {k['sec_title']}".strip()
431
+ for k in known if k["sec_num"]][:40]}
432
+
433
+
434
+ def toc(conn: sqlite3.Connection, doc_id: str, depth: int | None = None,
435
+ collapse_parts: bool = True, contains: str | None = None,
436
+ preview: bool = False) -> list[dict]:
437
+ """Section outline with one row per real section (chunk parts merged), so
438
+ a 70-section RFC costs 70 rows instead of 120 `(part 3/21)` artifacts.
439
+
440
+ depth: keep sections with at most this many dot-levels ('7.2' -> 2).
441
+ contains: substring filter on section number or title.
442
+ """
443
+ rows = conn.execute(
444
+ """SELECT sec_num, sec_title, start_line, end_line,
445
+ substr(replace(body, char(10), ' '), 1, 90) AS preview
446
+ FROM chunks WHERE doc_id = ? ORDER BY start_line, id""",
447
+ (doc_id,)).fetchall()
448
+ merged: dict[str, dict] = {}
449
+ order: list[str] = []
450
+ for r in rows:
451
+ title = _PART_RE.sub("", r["sec_title"] or "")
452
+ key = f"{r['sec_num']}\x00{title}"
453
+ m = merged.get(key)
454
+ if m is None:
455
+ m = merged[key] = {"sec_num": r["sec_num"], "title": title,
456
+ "start_line": r["start_line"],
457
+ "end_line": r["end_line"], "parts": 1,
458
+ "preview": r["preview"]}
459
+ order.append(key)
460
+ elif collapse_parts:
461
+ m["start_line"] = min(m["start_line"], r["start_line"])
462
+ m["end_line"] = max(m["end_line"], r["end_line"])
463
+ m["parts"] += 1
464
+ else:
465
+ key2 = f"{key}\x01{len(order)}"
466
+ merged[key2] = dict(m, parts=1, preview=r["preview"])
467
+ order.append(key2)
468
+ # RFCXML docs: a section with no text of its own ('1. Introduction'
469
+ # holding only 1.1-1.4) has no chunk; its heading row fills the gap
470
+ # (parts=0: read it with section='1' to get the subsections).
471
+ have = {m["sec_num"] for m in merged.values()}
472
+ for h in conn.execute(
473
+ "SELECT sec_num, sec_title, start_line, end_line FROM blocks "
474
+ "WHERE doc_id=? AND kind='heading' AND sec_num<>''", (doc_id,)):
475
+ if h["sec_num"] in have:
476
+ continue
477
+ key = f"{h['sec_num']}\x00{h['sec_title']}"
478
+ merged[key] = {"sec_num": h["sec_num"], "title": h["sec_title"],
479
+ "start_line": h["start_line"],
480
+ "end_line": h["end_line"], "parts": 0,
481
+ "preview": ""}
482
+ order.append(key)
483
+ order.sort(key=lambda k: merged[k]["start_line"])
484
+ out = []
485
+ for k in order:
486
+ s = merged[k]
487
+ if depth is not None and s["sec_num"].count(".") + 1 > depth:
488
+ continue
489
+ if contains:
490
+ needle = contains.lower()
491
+ if needle not in s["sec_num"].lower() and \
492
+ needle not in (s["title"] or "").lower():
493
+ continue
494
+ if not preview:
495
+ s.pop("preview", None)
496
+ out.append(s)
497
+ return out
498
+
499
+
500
+ def index_stats(conn: sqlite3.Connection) -> dict:
501
+ return {
502
+ "chunked_docs": conn.execute(
503
+ "SELECT COUNT(*) c FROM chunk_src").fetchone()["c"],
504
+ "chunks": conn.execute(
505
+ "SELECT COUNT(*) c FROM chunks").fetchone()["c"],
506
+ "embedded_chunks": conn.execute(
507
+ "SELECT COUNT(*) c FROM embed_state").fetchone()["c"],
508
+ }