Files
kjv_api/tools/fuzz_refs.py
T
2026-10-19 15:10:59 -07:00

111 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fuzzes /find with many ways of typing a reference and checks the verses that come back.
Run from the project root: python3 tools/fuzz_refs.py [sample_size]
Expected verse ids come straight from data/kjv.db, not from the app.
"""
import sys, re, random, sqlite3, itertools, json
sys.path.insert(0, ".") # run from the project root
import logging
import app as a
a.app.testing = True
client = a.app.test_client()
db = sqlite3.connect("data/kjv.db")
BOOKS = [r[0] for r in db.execute("select n from key_english order by b")]
ids = {} # (book, chap) -> [(verse, id)]
for vid, b, c, v in db.execute("select id,b,c,v from fts_kjv"):
ids.setdefault((int(b), int(c)), []).append((int(v), int(vid)))
maxch = {b: max(c for (bb, c) in ids if bb == b) for b in range(1, 67)}
NAME_VARIANTS = {
1: ["Gen", "Ge", "Gn"], 2: ["Exod", "Ex", "Exo"], 3: ["Lev", "Lv"], 4: ["Num", "Nm", "Nu"],
5: ["Deut", "Dt", "Deu"], 6: ["Josh", "Jos"], 7: ["Judg", "Jdg", "Jg"], 8: ["Ru"],
9: ["1 Sam", "1 Sm", "1Sa", "I Samuel", "1st Samuel", "First Samuel"],
10: ["2 Sam", "2 Sm", "2Sa", "II Samuel", "2nd Samuel", "Second Samuel"],
11: ["1 Kgs", "1 Ki", "1Kin", "I Kings", "First Kings"], 12: ["2 Kgs", "2 Ki", "II Kings", "Second Kings"],
13: ["1 Chron", "1 Chr", "1Ch", "I Chronicles", "First Chronicles"], 14: ["2 Chron", "2 Chr", "II Chronicles"],
15: ["Ezr"], 16: ["Neh", "Ne"], 17: ["Est", "Esth"], 18: [], 19: ["Ps", "Psa", "Psalm", "Pslm"],
20: ["Prov", "Pr", "Prv"], 21: ["Eccl", "Ecc", "Eccles", "Ecclesiasts"],
22: ["Song", "Song of Songs", "Songs of Solomon", "Song of Sol", "SOS", "Canticles", "SongofSolomon"],
23: ["Isa", "Is"], 24: ["Jer", "Je"], 25: ["Lam"], 26: ["Ezek", "Eze", "Ezk"], 27: ["Dan", "Dn"],
28: ["Hos", "Ho"], 29: ["Joe", "Jl"], 30: ["Am"], 31: ["Obad", "Ob"], 32: ["Jon", "Jnh"], 33: ["Mic"],
34: ["Nah", "Na"], 35: ["Hab"], 36: ["Zeph", "Zep"], 37: ["Hag", "Hg"], 38: ["Zech", "Zec"], 39: ["Mal"],
40: ["Matt", "Mt", "Mat"], 41: ["Mk", "Mar"], 42: ["Lk", "Luk"], 43: ["Jn", "Joh"], 44: ["Act"],
45: ["Rom", "Ro"], 46: ["1 Cor", "1Co", "I Corinthians", "First Corinthians", "1st Corinthians"],
47: ["2 Cor", "2Co", "II Corinthians", "Second Corinthians"], 48: ["Gal", "Ga"], 49: ["Eph"],
50: ["Phil", "Php", "Philip"], 51: ["Col"], 52: ["1 Thess", "1 Th", "1Thes", "I Thessalonians"],
53: ["2 Thess", "2 Th", "II Thessalonians"], 54: ["1 Tim", "1 Ti", "I Timothy", "First Timothy"],
55: ["2 Tim", "2 Ti", "II Timothy"], 56: ["Tit", "Ti"], 57: ["Phlm", "Phm", "Philem"], 58: ["Heb"],
59: ["Jas", "Jam"], 60: ["1 Pet", "1 Pe", "1Pt", "I Peter", "First Peter"], 61: ["2 Pet", "2 Pe", "II Peter"],
62: ["1 Jn", "1 Jo", "1Joh", "I John", "First John", "1st John"], 63: ["2 Jn", "2 John", "II John"],
64: ["3 Jn", "3 John", "III John"], 65: ["Jud"], 66: ["Rev", "Re", "Revelations", "Revelation of John"],
}
def name_forms(b):
full = BOOKS[b - 1]
forms = {full, full.lower(), full.upper(), full.replace(" ", ""), full.lower().replace(" ", "")}
forms |= set(NAME_VARIANTS.get(b, []))
forms |= {f.lower() for f in NAME_VARIANTS.get(b, [])}
return sorted(forms)
def cases():
rnd = random.Random(1611)
for b in range(1, 67):
for nm in name_forms(b):
ch = rnd.randint(1, maxch[b])
vs = ids[(b, ch)]
v1 = rnd.choice(vs)[0]
v2 = min(vs[-1][0], v1 + rnd.randint(1, 3))
chap_ids = [i for _, i in vs]
single = [i for v, i in vs if v == v1]
rng = [i for v, i in vs if v1 <= v <= v2]
key = a.normalize_book_text(nm)
if key in a.BOOK_CANONICAL or key in a.BARE_BOOK_EXTRA: # abbreviations alone stay keyword searches
for sp in ("", " ", " "):
yield (f"{nm}{sp}", [i for _, i in ids[(b, 1)]], "bare") # book alone -> chapter 1
for fmt, exp, kind in (
(f"{nm} {ch}", chap_ids, "chapter"),
(f"{nm}{ch}", chap_ids, "chapter-nospace"),
(f"{nm} {ch}:{v1}", single, "verse"),
(f"{nm}{ch}:{v1}", single, "verse-nospace"),
(f" {nm} {ch} : {v1} ", single, "verse-spaced"),
(f"{nm} {ch}.{v1}", single, "verse-dot"),
(f"{nm} {ch} {v1}", single, "verse-space-sep"),
(f"{nm} {ch}:{v1}-{v2}", rng, "range"),
(f"{nm} {ch}:{v1} - {v2}", rng, "range-spaced"),
(f"{nm} {ch}:{v1}–{v2}", rng, "range-endash"),
(f"{nm.upper()} {ch}:{v1}", single, "verse-upper"),
(f"{nm} {ch}:{v1}-{v2}\t", rng, "range-trailing-tab"),
):
if v2 > v1 or kind not in ("range", "range-spaced", "range-endash", "range-trailing-tab"):
yield fmt, exp, kind
def verse_ids_in(html):
return [int(x) for x in re.findall(r'data-verse-id="(\d+)"', html)]
def run(limit=None, show=25):
allcases = list(cases())
if limit:
random.Random(7).shuffle(allcases); allcases = allcases[:limit]
fails = {}
total = 0
for q, exp, kind in allcases:
total += 1
try:
r = client.get("/find", query_string={"kw": q, "view": "rich"})
got = verse_ids_in(r.get_data(as_text=True)) if r.status_code == 200 else None
except Exception as e:
got = None
ok = got is not None and got[:len(exp)] == exp and (kind.startswith("range") or kind.startswith("verse") or kind == "chapter" or True) and (len(got) == len(exp) if kind != "bare" else got[:len(exp)] == exp)
if not ok:
fails.setdefault(kind, []).append((q, len(exp), None if got is None else len(got)))
nfail = sum(len(v) for v in fails.values())
print(f"{total} cases, {nfail} failed ({100*nfail//max(total,1)}%)")
for k, v in sorted(fails.items(), key=lambda kv: -len(kv[1])):
print(f" {k}: {len(v)} e.g. {v[:3]}")
return fails
if __name__ == "__main__":
logging.disable(logging.CRITICAL)
run(int(sys.argv[1]) if len(sys.argv) > 1 else None)