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,305 @@
|
|
|
1
|
+
"""Hybrid retrieval: RRF fusion of BM25 + KNN + doc-title matches, status-aware.
|
|
2
|
+
|
|
3
|
+
RRF (score = sum of 1/(k+rank) per list, k=60) is rank-based, so it needs
|
|
4
|
+
no score calibration between bm25 and cosine distance. On top, a small
|
|
5
|
+
multiplicative prior favors standards-track and BCP/STD documents — for
|
|
6
|
+
normative questions an Internet Standard outranks an Experimental memo
|
|
7
|
+
that happens to mention the same words.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
|
|
16
|
+
# k=25 (not the classic 60): our lists are shallow (depth ~30) and we want
|
|
17
|
+
# a #2 BM25 hit to actually move its document, not drown in flat 1/(60+r).
|
|
18
|
+
RRF_K = 25
|
|
19
|
+
|
|
20
|
+
# multiplicative prior on current_status (from the rfc-index manifest)
|
|
21
|
+
_STATUS_PRIOR = (
|
|
22
|
+
("INTERNET STANDARD", 1.25),
|
|
23
|
+
("BEST CURRENT PRACTICE", 1.20),
|
|
24
|
+
("DRAFT STANDARD", 1.15),
|
|
25
|
+
("PROPOSED STANDARD", 1.10),
|
|
26
|
+
("HISTORIC", 0.80),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# weight of the document title/abstract match in the RRF sum, proportional
|
|
30
|
+
# to IDF-weighted term COVERAGE: "Key words for use in RFCs..." (coverage
|
|
31
|
+
# 2.3, RFC2119's literal title) must lift its doc ~2.3x as hard as a doc
|
|
32
|
+
# whose title carries one weak term. Capped so one title can't outrank a
|
|
33
|
+
# doc backed by two strong lists unconditionally.
|
|
34
|
+
TITLE_BOOST_BASE, TITLE_BOOST_COV, TITLE_BOOST_CAP = 0.15, 0.7, 1.4
|
|
35
|
+
|
|
36
|
+
# weight of the exact-identifier list (lexical.ident_search) relative to the
|
|
37
|
+
# BM25 and KNN lists (1.0 each); only present when the query has identifiers
|
|
38
|
+
# (0.5: at 1.0 a frequent identifier such as base64url pushed the defining
|
|
39
|
+
# RFC out of the top 5; at 0.5 identifier MRR rises 0.556 -> 0.631 with no
|
|
40
|
+
# top-5 losses on scripts/eval_retrieval.py)
|
|
41
|
+
IDENT_WEIGHT = 0.5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def status_prior(current_status: str | None, is_current: bool = True) -> float:
|
|
45
|
+
st = (current_status or "").upper()
|
|
46
|
+
for needle, w in _STATUS_PRIOR:
|
|
47
|
+
if needle in st:
|
|
48
|
+
return w if is_current else min(w, 0.8)
|
|
49
|
+
return 1.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _has(text: str, word: str, alts: dict[str, str]) -> bool:
|
|
53
|
+
"""word occurs in text, or word is a query acronym whose expansion does."""
|
|
54
|
+
if re.search(rf"\b{re.escape(word)}\b", text):
|
|
55
|
+
return True
|
|
56
|
+
exp = alts.get(word)
|
|
57
|
+
return bool(exp) and re.search(rf"\b{re.escape(exp)}\b", text) is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _doc_title_list(conn: sqlite3.Connection, query: str,
|
|
61
|
+
include_obsolete: bool,
|
|
62
|
+
alts: dict[str, str] | None = None) -> list[dict]:
|
|
63
|
+
"""Document-level (title/abstract) matches ranked by TERM COVERAGE.
|
|
64
|
+
|
|
65
|
+
Raw bm25 over an OR of terms lets one rare word ("NewReno", "QUIC")
|
|
66
|
+
win the list; what identifies the canonical spec is the FRACTION of
|
|
67
|
+
query terms the document's own title/abstract carries ("TCP
|
|
68
|
+
Congestion Control" ⊃ "congestion control TCP Reno NewReno"). So:
|
|
69
|
+
bm25 pulls a wide candidate pool, then IDF-weighted coverage reorders
|
|
70
|
+
it. AND conjuncts would drop docs missing one incidental word.
|
|
71
|
+
|
|
72
|
+
alts maps a query acronym to its expansion: "ntp" is covered by a title
|
|
73
|
+
that says "Network Time Protocol" (RFC 5905 never says "NTP").
|
|
74
|
+
"""
|
|
75
|
+
alts = alts or {}
|
|
76
|
+
words, seen = [], set()
|
|
77
|
+
# keep decimal version tokens ("1.3", "2.0", "802.11"): they are often
|
|
78
|
+
# THE discriminator — "TLS 1.3 handshake" must beat generic TLS docs.
|
|
79
|
+
for t in re.findall(r"[A-Za-z0-9]+(?:\.[0-9]+)*", query):
|
|
80
|
+
if (re.search(r"[A-Za-z]", t) or "." in t) and t.lower() not in seen:
|
|
81
|
+
seen.add(t.lower())
|
|
82
|
+
words.append(t.lower())
|
|
83
|
+
if not words:
|
|
84
|
+
return []
|
|
85
|
+
sql = ("SELECT d.doc_id, bm25(docs_fts) AS rank FROM docs_fts "
|
|
86
|
+
"JOIN docs d ON d.doc_id = docs_fts.doc_id "
|
|
87
|
+
"WHERE docs_fts MATCH ?")
|
|
88
|
+
params: list = [" OR ".join([f'"{w}"' for w in words]
|
|
89
|
+
+ [f'"{e}"' for e in alts.values()])]
|
|
90
|
+
if not include_obsolete:
|
|
91
|
+
sql += " AND d.is_current = 1"
|
|
92
|
+
sql += " ORDER BY rank LIMIT 300"
|
|
93
|
+
try:
|
|
94
|
+
cand = [dict(r) for r in conn.execute(sql, params).fetchall()]
|
|
95
|
+
except sqlite3.OperationalError:
|
|
96
|
+
return []
|
|
97
|
+
# long specs have diluted bm25 and can miss a 300-row pool, yet their
|
|
98
|
+
# TITLE may be the exact answer — sweep titles directly (docs is ~10k
|
|
99
|
+
# rows, LIKE is negligible) and merge them into the candidate set.
|
|
100
|
+
have = {c["doc_id"] for c in cand}
|
|
101
|
+
for w in words + list(alts.values()):
|
|
102
|
+
wsql = ("SELECT doc_id, 1000 AS rank FROM docs "
|
|
103
|
+
"WHERE lower(title) LIKE ?")
|
|
104
|
+
if not include_obsolete:
|
|
105
|
+
wsql += " AND is_current = 1"
|
|
106
|
+
for r in conn.execute(wsql, (f"%{w}%",)).fetchall():
|
|
107
|
+
if r[0] not in have:
|
|
108
|
+
have.add(r[0])
|
|
109
|
+
cand.append({"doc_id": r[0], "rank": r[1]})
|
|
110
|
+
|
|
111
|
+
total = max(conn.execute("SELECT COUNT(*) FROM docs_fts").fetchone()[0],
|
|
112
|
+
1)
|
|
113
|
+
weights = {}
|
|
114
|
+
for w in words:
|
|
115
|
+
try:
|
|
116
|
+
df = conn.execute(
|
|
117
|
+
"SELECT COUNT(*) FROM docs_fts WHERE docs_fts MATCH ?",
|
|
118
|
+
(f'"{w}"',)).fetchone()[0]
|
|
119
|
+
except sqlite3.OperationalError:
|
|
120
|
+
df = 1
|
|
121
|
+
weights[w] = max(0.0, math.log(total / max(df, 1)))
|
|
122
|
+
wsum = sum(weights.values()) or 1.0
|
|
123
|
+
|
|
124
|
+
scored = []
|
|
125
|
+
for c in cand:
|
|
126
|
+
row = conn.execute(
|
|
127
|
+
"SELECT lower(COALESCE(title,'')) t, lower(COALESCE(title,'') "
|
|
128
|
+
"|| ' ' || COALESCE(abstract,'') || ' ' || "
|
|
129
|
+
"COALESCE(keywords,'')) a FROM docs WHERE doc_id=?",
|
|
130
|
+
(c["doc_id"],)).fetchone()
|
|
131
|
+
title = row["t"] if row else ""
|
|
132
|
+
full = row["a"] if row else ""
|
|
133
|
+
hit_t = sum(weights[w] for w in words if _has(title, w, alts)) / wsum
|
|
134
|
+
hit_a = sum(weights[w] for w in words if _has(full, w, alts)) / wsum
|
|
135
|
+
# THE TITLE IS THE DOCUMENT'S IDENTITY: a spec whose title carries
|
|
136
|
+
# the query terms outscores one whose long abstract merely
|
|
137
|
+
# mentions them (surveys lose to RFC9000 for "QUIC transport").
|
|
138
|
+
scored.append((2.0 * hit_t + 0.3 * hit_a, c))
|
|
139
|
+
scored.sort(key=lambda x: (-x[0], x[1]["rank"]))
|
|
140
|
+
out = []
|
|
141
|
+
for cov, c in scored[:25]:
|
|
142
|
+
if cov <= 0.15:
|
|
143
|
+
break
|
|
144
|
+
c["coverage"] = round(cov, 3)
|
|
145
|
+
out.append(c)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def hybrid_search(conn: sqlite3.Connection, query: str, limit: int = 10,
|
|
150
|
+
include_obsolete: bool = False,
|
|
151
|
+
depth: int = 0) -> list[dict]:
|
|
152
|
+
"""Fuse section BM25, embedding KNN and doc-title lists via weighted RRF."""
|
|
153
|
+
from . import embed as ix_embed
|
|
154
|
+
from . import lexical
|
|
155
|
+
from . import search as ix_search
|
|
156
|
+
|
|
157
|
+
depth = depth or max(5 * limit, 100)
|
|
158
|
+
alts = lexical.expansions(conn, query) # {'ntp': 'network ...'}
|
|
159
|
+
# acronym expansions stay out of the BM25 query: "border gateway
|
|
160
|
+
# protocol" pulls in every BGP document; they only count toward title
|
|
161
|
+
# coverage, where a title spells the acronym out (RFC 5905's)
|
|
162
|
+
bm25 = ix_search.search_chunks(conn, query, limit=depth,
|
|
163
|
+
include_obsolete=include_obsolete,
|
|
164
|
+
hybrid_bm25=True)
|
|
165
|
+
try:
|
|
166
|
+
# (acronym expansions are NOT appended here: measured on the golden
|
|
167
|
+
# set, the gains came from wrong expansions and were offset by
|
|
168
|
+
# losses on TLS, TCP, PKCS and BGP queries)
|
|
169
|
+
knn = ix_embed.semantic_search(conn, query, limit=depth,
|
|
170
|
+
include_obsolete=include_obsolete)
|
|
171
|
+
except RuntimeError:
|
|
172
|
+
knn = [] # vectors not built yet: degrade to pure BM25
|
|
173
|
+
# exact identifier matches (preferred_address, X25519, Content-Length):
|
|
174
|
+
# its own list, so the section that contains THE identifier is not
|
|
175
|
+
# outvoted by sections that merely share its words
|
|
176
|
+
ident = lexical.ident_search(conn, query, depth, include_obsolete)
|
|
177
|
+
ident_w = (IDENT_WEIGHT * lexical.ident_weight(conn, query) if ident
|
|
178
|
+
else 0.0)
|
|
179
|
+
|
|
180
|
+
doc_hits = _doc_title_list(conn, query, include_obsolete, alts)
|
|
181
|
+
doc_rank = {h["doc_id"]: r for r, h in enumerate(doc_hits)}
|
|
182
|
+
|
|
183
|
+
# diversify each input list BEFORE fusing: three chunks of one hot RFC
|
|
184
|
+
# should not occupy ranks 1-3 and push the canonical spec out of reach
|
|
185
|
+
def _once_per_doc(lst: list[dict]) -> list[dict]:
|
|
186
|
+
seen, out = set(), []
|
|
187
|
+
for h in lst:
|
|
188
|
+
if h["doc_id"] not in seen:
|
|
189
|
+
seen.add(h["doc_id"])
|
|
190
|
+
out.append(h)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
bm25 = _once_per_doc(bm25)
|
|
194
|
+
knn = _once_per_doc(knn)
|
|
195
|
+
ident = _once_per_doc(ident)
|
|
196
|
+
|
|
197
|
+
fused: dict[int, dict] = {}
|
|
198
|
+
for rank, h in enumerate(bm25):
|
|
199
|
+
e = fused.setdefault(h["chunk_id"], {
|
|
200
|
+
"chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
|
|
201
|
+
"sec_num": h["sec_num"], "sec_title": h["sec_title"],
|
|
202
|
+
"start_line": h["start_line"], "end_line": h["end_line"],
|
|
203
|
+
"title": h["title"], "is_current": h["is_current"],
|
|
204
|
+
"current_status": h.get("current_status"),
|
|
205
|
+
"snippet": h["snippet"], "rrf": 0.0, "bm25_rank": None,
|
|
206
|
+
"knn_rank": None})
|
|
207
|
+
e["rrf"] += 1.0 / (RRF_K + rank + 1)
|
|
208
|
+
e["bm25_rank"] = rank + 1
|
|
209
|
+
for rank, h in enumerate(knn):
|
|
210
|
+
e = fused.get(h["chunk_id"])
|
|
211
|
+
if e is None:
|
|
212
|
+
e = fused[h["chunk_id"]] = {
|
|
213
|
+
"chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
|
|
214
|
+
"sec_num": h["sec_num"], "sec_title": h["sec_title"],
|
|
215
|
+
"start_line": h["start_line"], "end_line": h["end_line"],
|
|
216
|
+
"title": h["title"], "is_current": h["is_current"],
|
|
217
|
+
"current_status": h.get("current_status"),
|
|
218
|
+
"snippet": h["preview"][:240], "rrf": 0.0,
|
|
219
|
+
"bm25_rank": None, "knn_rank": None}
|
|
220
|
+
e["rrf"] += 1.0 / (RRF_K + rank + 1)
|
|
221
|
+
e["knn_rank"] = rank + 1
|
|
222
|
+
for rank, h in enumerate(ident):
|
|
223
|
+
e = fused.get(h["chunk_id"])
|
|
224
|
+
if e is None:
|
|
225
|
+
e = fused[h["chunk_id"]] = {
|
|
226
|
+
"chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
|
|
227
|
+
"sec_num": h["sec_num"], "sec_title": h["sec_title"],
|
|
228
|
+
"start_line": h["start_line"], "end_line": h["end_line"],
|
|
229
|
+
"title": h["title"], "is_current": h["is_current"],
|
|
230
|
+
"current_status": h.get("current_status"),
|
|
231
|
+
"snippet": h["preview"][:240], "rrf": 0.0,
|
|
232
|
+
"bm25_rank": None, "knn_rank": None}
|
|
233
|
+
e["rrf"] += ident_w / (RRF_K + rank + 1)
|
|
234
|
+
e["ident_rank"] = rank + 1
|
|
235
|
+
|
|
236
|
+
# a title-matching doc with no chunk in either section list is exactly
|
|
237
|
+
# the "find me the TLS 1.3 spec" case: surface its Abstract section as
|
|
238
|
+
# a rank-`dr` entry of the title list (the final loop adds the boost)
|
|
239
|
+
for dr, dh in enumerate(doc_hits[:5]):
|
|
240
|
+
if any(e["doc_id"] == dh["doc_id"] for e in fused.values()):
|
|
241
|
+
continue
|
|
242
|
+
rep = conn.execute(
|
|
243
|
+
"""SELECT c.id AS chunk_id, c.doc_id, c.sec_num, c.sec_title,
|
|
244
|
+
c.start_line, c.end_line, d.title, d.is_current,
|
|
245
|
+
d.current_status,
|
|
246
|
+
substr(replace(c.body, char(10), ' '), 1, 240) AS preview
|
|
247
|
+
FROM chunks c JOIN docs d ON d.doc_id = c.doc_id
|
|
248
|
+
WHERE c.doc_id = ?
|
|
249
|
+
ORDER BY (c.sec_title = 'Abstract') DESC, c.start_line
|
|
250
|
+
LIMIT 1""", (dh["doc_id"],)).fetchone()
|
|
251
|
+
if rep is None:
|
|
252
|
+
continue
|
|
253
|
+
fused[rep["chunk_id"]] = {
|
|
254
|
+
"chunk_id": rep["chunk_id"], "doc_id": rep["doc_id"],
|
|
255
|
+
"sec_num": rep["sec_num"], "sec_title": rep["sec_title"],
|
|
256
|
+
"start_line": rep["start_line"], "end_line": rep["end_line"],
|
|
257
|
+
"title": rep["title"], "is_current": rep["is_current"],
|
|
258
|
+
"current_status": rep["current_status"],
|
|
259
|
+
"snippet": rep["preview"], "rrf": 1.0 / (RRF_K + dr + 1),
|
|
260
|
+
"bm25_rank": None, "knn_rank": None}
|
|
261
|
+
|
|
262
|
+
# doc-level corroboration: fusion is chunk-keyed, so BM25 hitting §6
|
|
263
|
+
# and KNN hitting §3 of the SAME doc never sum (RFC5905 for "NTP era
|
|
264
|
+
# rollover" died exactly this way). The doc's best chunk absorbs a
|
|
265
|
+
# share of its siblings' rrf before the title boost is applied.
|
|
266
|
+
best_by_doc: dict[str, dict] = {}
|
|
267
|
+
for e in fused.values():
|
|
268
|
+
cur = best_by_doc.get(e["doc_id"])
|
|
269
|
+
if cur is None or e["rrf"] > cur["rrf"]:
|
|
270
|
+
best_by_doc[e["doc_id"]] = e
|
|
271
|
+
for e in fused.values():
|
|
272
|
+
b = best_by_doc[e["doc_id"]]
|
|
273
|
+
if b is not e:
|
|
274
|
+
b["rrf"] += 0.5 * e["rrf"]
|
|
275
|
+
|
|
276
|
+
for e in fused.values():
|
|
277
|
+
p = status_prior(e["current_status"], bool(e["is_current"]))
|
|
278
|
+
if ix_search.NOISE_TITLE_RE.search(e["sec_title"] or ""):
|
|
279
|
+
p *= 0.55
|
|
280
|
+
e["rrf"] *= p
|
|
281
|
+
dr = doc_rank.get(e["doc_id"])
|
|
282
|
+
if dr is not None and dr < 15: # chunk of a doc whose title matched
|
|
283
|
+
cov = min(doc_hits[dr]["coverage"], TITLE_BOOST_CAP)
|
|
284
|
+
e["rrf"] += (TITLE_BOOST_BASE + TITLE_BOOST_COV * cov) \
|
|
285
|
+
/ (RRF_K + dr + 1)
|
|
286
|
+
e["sources"] = "+".join(
|
|
287
|
+
n for n, hit in (("bm25", e["bm25_rank"]), ("knn", e["knn_rank"]),
|
|
288
|
+
("ident", e.get("ident_rank")), ("title", dr))
|
|
289
|
+
if hit is not None)
|
|
290
|
+
e["rrf"] = round(e["rrf"], 5)
|
|
291
|
+
# diversify: the best chunk of each document first, then overflow —
|
|
292
|
+
# ten hits on six chunks of one RFC teach the agent nothing new
|
|
293
|
+
ordered = sorted(fused.values(), key=lambda e: -e["rrf"])
|
|
294
|
+
seen: set[str] = set()
|
|
295
|
+
ranked, overflow = [], []
|
|
296
|
+
for e in ordered:
|
|
297
|
+
if e["doc_id"] in seen:
|
|
298
|
+
overflow.append(e)
|
|
299
|
+
else:
|
|
300
|
+
seen.add(e["doc_id"])
|
|
301
|
+
ranked.append(e)
|
|
302
|
+
ranked = (ranked + overflow)[:limit]
|
|
303
|
+
for e in ranked:
|
|
304
|
+
e.pop("preview", None)
|
|
305
|
+
return ranked
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Lexical helpers for searching protocol text: identifiers and acronyms.
|
|
2
|
+
|
|
3
|
+
The section index (chunks_fts) uses `porter unicode61`, which is right for
|
|
4
|
+
prose but splits the tokens RFC readers search for most precisely:
|
|
5
|
+
"preferred_address" becomes "preferr" + "address", "Sec-WebSocket-Accept"
|
|
6
|
+
three words, "X25519" stays whole but "KeyUpdate" loses its identity once
|
|
7
|
+
stemmed. Two small additions keep the main index and repair the rest:
|
|
8
|
+
|
|
9
|
+
ident_fts one row per section holding only its identifier-shaped tokens
|
|
10
|
+
(snake_case, SCREAMING_CASE, hyphenated names, CamelCase, mixed
|
|
11
|
+
letters+digits), tokenized with '_' and '-' kept inside tokens.
|
|
12
|
+
A query carrying such tokens gets an extra ranked list in hybrid
|
|
13
|
+
fusion: sections that contain THE identifier, not its words.
|
|
14
|
+
|
|
15
|
+
acronyms "Network Time Protocol (NTP)" pairs mined from the corpus, kept
|
|
16
|
+
only when the acronym's letters are the initials of the words.
|
|
17
|
+
A query acronym is then also covered by a title that spells it
|
|
18
|
+
out (RFC 5905's title never says "NTP").
|
|
19
|
+
|
|
20
|
+
Query stop words were tried and measured worse (topic MRR 0.694 -> 0.653
|
|
21
|
+
on scripts/eval_retrieval.py): BM25's IDF already discounts them, and
|
|
22
|
+
dropping them broke title matches like RFC 2119's "Key words for use in
|
|
23
|
+
RFCs ...". So there are none.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import sqlite3
|
|
30
|
+
from collections import Counter, defaultdict
|
|
31
|
+
|
|
32
|
+
_IDENT = re.compile(r"""
|
|
33
|
+
[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+ # snake_case, SCREAMING_SNAKE
|
|
34
|
+
| [A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+ # Content-Length, SYN-RECEIVED
|
|
35
|
+
| [A-Za-z]+[0-9][A-Za-z0-9]* # X25519, SHA256, UUIDv7, IPv6
|
|
36
|
+
| [A-Z][a-z]+[A-Z][A-Za-z0-9]* # KeyUpdate, NewReno, WebSocket
|
|
37
|
+
| [a-z]+[A-Z][A-Za-z0-9]* # camelCase
|
|
38
|
+
""", re.X)
|
|
39
|
+
_NOT_IDENT = re.compile(r"^(rfc|bcp|std|fyi)\d+$|^[a-z]+-$", re.I)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def identifiers(text: str) -> list[str]:
|
|
43
|
+
"""Identifier-shaped tokens of text, lowercased, in order (with repeats:
|
|
44
|
+
term frequency still means something). RFC numbers are left out: they
|
|
45
|
+
are citations, served by the citation graph."""
|
|
46
|
+
out = []
|
|
47
|
+
for m in _IDENT.finditer(text):
|
|
48
|
+
t = m.group(0).strip("-_").lower()
|
|
49
|
+
if len(t) >= 3 and not _NOT_IDENT.match(t):
|
|
50
|
+
out.append(t)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------
|
|
55
|
+
# identifier index
|
|
56
|
+
|
|
57
|
+
def index_identifiers(conn: sqlite3.Connection, chunk_ids: list[int],
|
|
58
|
+
drop_ids: list[int] = ()) -> None:
|
|
59
|
+
"""(Re)write ident_fts rows for these chunks; drop rows of chunks that
|
|
60
|
+
no longer exist."""
|
|
61
|
+
if drop_ids:
|
|
62
|
+
conn.executemany("DELETE FROM ident_fts WHERE rowid=?",
|
|
63
|
+
[(i,) for i in drop_ids])
|
|
64
|
+
for cid in chunk_ids:
|
|
65
|
+
row = conn.execute("SELECT body FROM chunks WHERE id=?",
|
|
66
|
+
(cid,)).fetchone()
|
|
67
|
+
conn.execute("DELETE FROM ident_fts WHERE rowid=?", (cid,))
|
|
68
|
+
terms = identifiers(row[0]) if row else []
|
|
69
|
+
if terms:
|
|
70
|
+
conn.execute("INSERT INTO ident_fts(rowid, terms) VALUES(?,?)",
|
|
71
|
+
(cid, " ".join(terms)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def backfill_identifiers(conn: sqlite3.Connection, progress=None) -> int:
|
|
75
|
+
"""Index sections that have no ident_fts row yet (after an upgrade)."""
|
|
76
|
+
todo = [r[0] for r in conn.execute(
|
|
77
|
+
"SELECT id FROM chunks WHERE id NOT IN (SELECT rowid FROM ident_fts)"
|
|
78
|
+
" AND id NOT IN (SELECT chunk_id FROM ident_none)")]
|
|
79
|
+
for i in range(0, len(todo), 2000):
|
|
80
|
+
part = todo[i:i + 2000]
|
|
81
|
+
for cid, body in conn.execute(
|
|
82
|
+
"SELECT id, body FROM chunks WHERE id IN (%s)"
|
|
83
|
+
% ",".join("?" * len(part)), part).fetchall():
|
|
84
|
+
terms = identifiers(body)
|
|
85
|
+
if terms:
|
|
86
|
+
conn.execute("INSERT INTO ident_fts(rowid, terms) "
|
|
87
|
+
"VALUES(?,?)", (cid, " ".join(terms)))
|
|
88
|
+
else: # remember "nothing to index" so backfill stays cheap
|
|
89
|
+
conn.execute("INSERT OR IGNORE INTO ident_none(chunk_id) "
|
|
90
|
+
"VALUES(?)", (cid,))
|
|
91
|
+
conn.commit()
|
|
92
|
+
if progress:
|
|
93
|
+
progress(min(i + 2000, len(todo)), len(todo))
|
|
94
|
+
return len(todo)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ident_weight(conn: sqlite3.Connection, query: str) -> float:
|
|
98
|
+
"""How much an exact-identifier match should count, 0..1: by the
|
|
99
|
+
rarest identifier's document frequency. 'preferred_address' (1 RFC)
|
|
100
|
+
pins its section; 'base64url' (78 RFCs) or 'Content-Length' (278) says
|
|
101
|
+
little about which RFC is meant, and must not outvote the other lists."""
|
|
102
|
+
import math
|
|
103
|
+
terms = list(dict.fromkeys(identifiers(query)))
|
|
104
|
+
if not terms:
|
|
105
|
+
return 0.0
|
|
106
|
+
total = max(conn.execute("SELECT COUNT(*) FROM chunk_src").fetchone()[0],
|
|
107
|
+
2)
|
|
108
|
+
best = 0.0
|
|
109
|
+
for t in terms:
|
|
110
|
+
try:
|
|
111
|
+
df = conn.execute(
|
|
112
|
+
"SELECT COUNT(DISTINCT c.doc_id) FROM ident_fts JOIN chunks c "
|
|
113
|
+
"ON c.id = ident_fts.rowid WHERE ident_fts MATCH ?",
|
|
114
|
+
(f'"{t}"',)).fetchone()[0]
|
|
115
|
+
except sqlite3.OperationalError:
|
|
116
|
+
continue
|
|
117
|
+
if df:
|
|
118
|
+
best = max(best, math.log(total / df) / math.log(total))
|
|
119
|
+
return best ** 2 # sharpen: rare -> ~1, common -> small
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ident_search(conn: sqlite3.Connection, query: str, limit: int,
|
|
123
|
+
include_obsolete: bool = False) -> list[dict]:
|
|
124
|
+
"""Sections ranked by BM25 over the query's identifier tokens only;
|
|
125
|
+
[] when the query has none."""
|
|
126
|
+
terms = list(dict.fromkeys(identifiers(query)))
|
|
127
|
+
if not terms:
|
|
128
|
+
return []
|
|
129
|
+
sql = """
|
|
130
|
+
SELECT c.id AS chunk_id, c.doc_id, c.sec_num, c.sec_title,
|
|
131
|
+
c.start_line, c.end_line, d.title, d.is_current,
|
|
132
|
+
d.current_status,
|
|
133
|
+
substr(replace(c.body, char(10), ' '), 1, 240) AS preview
|
|
134
|
+
FROM ident_fts JOIN chunks c ON c.id = ident_fts.rowid
|
|
135
|
+
JOIN docs d ON d.doc_id = c.doc_id
|
|
136
|
+
WHERE ident_fts MATCH ?"""
|
|
137
|
+
if not include_obsolete:
|
|
138
|
+
sql += " AND d.is_current = 1"
|
|
139
|
+
sql += " ORDER BY bm25(ident_fts) LIMIT ?"
|
|
140
|
+
match = " OR ".join(f'"{t}"' for t in terms)
|
|
141
|
+
try:
|
|
142
|
+
return [dict(r) for r in conn.execute(sql, (match, limit))]
|
|
143
|
+
except sqlite3.OperationalError:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------
|
|
148
|
+
# acronyms
|
|
149
|
+
|
|
150
|
+
_EXPANSION = re.compile(
|
|
151
|
+
r"((?:[A-Za-z][\w/]*[\s-]+){1,8}?[A-Za-z][\w/]*)\s+\(([A-Z][A-Za-z0-9]{1,9})s?\)")
|
|
152
|
+
_JOIN = {"of", "for", "and", "the", "to", "over", "in", "on", "a", "an", "with"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _match_initials(acro: str, words: list[str]) -> list[str] | None:
|
|
156
|
+
"""The shortest suffix of words whose initials spell acro's letters
|
|
157
|
+
(join words may be skipped, a word may contribute 2 letters as in
|
|
158
|
+
'IPsec' / 'Ethernet' cases are left out). None if no suffix fits."""
|
|
159
|
+
letters = [ch.lower() for ch in acro if ch.isalpha()]
|
|
160
|
+
if len(letters) < 2:
|
|
161
|
+
return None
|
|
162
|
+
for start in range(len(words) - 1, -1, -1):
|
|
163
|
+
cand = words[start:]
|
|
164
|
+
initials = [w[0].lower() for w in cand
|
|
165
|
+
if w.lower() not in _JOIN or w[0].isupper()]
|
|
166
|
+
if initials == letters:
|
|
167
|
+
if cand[0].lower() in _JOIN:
|
|
168
|
+
return None
|
|
169
|
+
return cand
|
|
170
|
+
if len(initials) > len(letters):
|
|
171
|
+
break
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def mine_acronyms(conn: sqlite3.Connection) -> int:
|
|
176
|
+
"""Rebuild the acronyms table from titles, abstracts, keywords and the
|
|
177
|
+
opening sections of every indexed document (where terms are defined)."""
|
|
178
|
+
counts: dict[str, Counter] = defaultdict(Counter)
|
|
179
|
+
|
|
180
|
+
def scan(text: str) -> None:
|
|
181
|
+
for m in _EXPANSION.finditer(text or ""):
|
|
182
|
+
acro = m.group(2)
|
|
183
|
+
words = re.split(r"[\s-]+", m.group(1).strip())
|
|
184
|
+
hit = _match_initials(acro, words)
|
|
185
|
+
if hit:
|
|
186
|
+
counts[acro.upper()][" ".join(w.lower() for w in hit)] += 1
|
|
187
|
+
|
|
188
|
+
for r in conn.execute("SELECT title, abstract, keywords FROM docs"):
|
|
189
|
+
scan(" ".join(x or "" for x in r))
|
|
190
|
+
for (body,) in conn.execute(
|
|
191
|
+
"SELECT body FROM chunks WHERE sec_title IN ('Abstract', "
|
|
192
|
+
"'Introduction', 'Terminology', 'Overview') OR sec_num IN "
|
|
193
|
+
"('1', '1.1', '2')"):
|
|
194
|
+
scan(body.replace("\n", " "))
|
|
195
|
+
conn.execute("DELETE FROM acronyms")
|
|
196
|
+
rows = []
|
|
197
|
+
for acro, exp in counts.items():
|
|
198
|
+
expansion, n = exp.most_common(1)[0]
|
|
199
|
+
# one sighting is noise ('QUIC' is not an acronym); series names
|
|
200
|
+
# are document ids, not terms to expand
|
|
201
|
+
if n >= 2 and acro not in ("RFC", "RFCS", "BCP", "STD", "FYI"):
|
|
202
|
+
rows.append((acro, expansion, n))
|
|
203
|
+
conn.executemany("INSERT INTO acronyms(acro, expansion, n) "
|
|
204
|
+
"VALUES(?,?,?)", rows)
|
|
205
|
+
conn.commit()
|
|
206
|
+
return len(rows)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def expansions(conn: sqlite3.Connection, query: str) -> dict[str, str]:
|
|
210
|
+
"""{query acronym (lowercase): expansion} for acronyms the query uses
|
|
211
|
+
in capitals ('NTP', 'DCCP'); an all-lowercase query word is left
|
|
212
|
+
alone so ordinary words never get expanded."""
|
|
213
|
+
out = {}
|
|
214
|
+
for w in re.findall(r"\b[A-Z][A-Z0-9]{1,9}s?\b", query):
|
|
215
|
+
key = w.rstrip("s") if w.endswith("s") and w[:-1].isupper() else w
|
|
216
|
+
try:
|
|
217
|
+
row = conn.execute("SELECT expansion FROM acronyms WHERE acro=?",
|
|
218
|
+
(key,)).fetchone()
|
|
219
|
+
except sqlite3.OperationalError: # table not built yet
|
|
220
|
+
return {}
|
|
221
|
+
if row:
|
|
222
|
+
out[key.lower()] = row[0]
|
|
223
|
+
return out
|