primarysource 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,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]
|