feat(bib): context-aware FR jump resolver — page/ordinal/anchor/quote + transmutations (closes #635)

This commit is contained in:
kert
2026-08-17 14:28:44 -04:00
parent 5ea78cffd7
commit 01e5d9588b
2 changed files with 293 additions and 0 deletions

View File

@@ -210,3 +210,194 @@ def backfill(
if not out.get("skipped") and "error" not in out: if not out.get("skipped") and "error" not in out:
time.sleep(sleep) time.sleep(sleep)
return results return results
# ── Resolve ─────────────────────────────────────────────────────────
def _doc_rows(store: Store) -> list[Any]:
con = store._con() # noqa: SLF001
return con.execute(
"SELECT item_key, html_url, start_page, end_page, fr_volume FROM fr_anchor_docs"
).fetchall()
def _doc_for_key(store: Store, item_key: str) -> Any:
con = store._con() # noqa: SLF001
row = con.execute(
"SELECT item_key, html_url, start_page, end_page, fr_volume "
"FROM fr_anchor_docs WHERE item_key = ?",
(item_key,),
).fetchone()
if row is None:
raise ValueError(
f"{item_key}: no FR anchor map grabbed — run `stack bib fr-grab --key {item_key}`"
)
return row
def _doc_for_cite(store: Store, volume: int, page: int) -> Any:
"""The grabbed rule whose volume + printed-page range contains a cite."""
hits = [
r
for r in _doc_rows(store)
if r["fr_volume"] == volume and r["start_page"] <= page <= r["end_page"]
]
if len(hits) == 1:
return hits[0]
if not hits:
raise ValueError(
f"{volume} FR {page}: no grabbed rule spans that page "
f"(run `stack bib fr-grab` for the rule first)"
)
keys = ", ".join(h["item_key"] for h in hits)
raise ValueError(
f"{volume} FR {page}: ambiguous — spanned by {keys}; pass item_key="
)
def _anchor_row(con: Any, item_key: str, p_id: int) -> Any:
return con.execute(
"SELECT p_id, page, ordinal, text FROM fr_anchors "
"WHERE item_key = ? AND p_id = ?",
(item_key, p_id),
).fetchone()
def _para_link(doc: Any, row: Any) -> JumpLink:
return JumpLink(
url=f"{doc['html_url']}#p-{row['p_id']}",
item_key=doc["item_key"],
page=row["page"],
p_id=row["p_id"],
ordinal=row["ordinal"],
snippet=row["text"][:120],
)
def resolve(ref: str, *, store: Store, item_key: str = "") -> JumpLink:
"""Resolve any FR reference to a deep jump link.
Accepted forms, in precedence order: raw anchor (``p-3600``, needs
``item_key`` unless exactly one rule is grabbed), FR cite
(``91 FR 44218`` → page link; ``91 FR 44218 ¶3``/``para 3``/``p.3``
→ that page's 3rd paragraph), else a quoted passage (≥ 15 chars)
searched over the stored paragraph text — exactly one match
required.
"""
ref = " ".join(ref.split())
con = store._con() # noqa: SLF001
m = _RAW_ANCHOR_RE.match(ref)
if m:
p_id = int(m.group(1))
if item_key:
doc = _doc_for_key(store, item_key)
else:
docs = _doc_rows(store)
if len(docs) != 1:
raise ValueError(
f"{ref}: raw anchors need item_key= when "
f"{len(docs)} rules are grabbed"
)
doc = docs[0]
row = _anchor_row(con, doc["item_key"], p_id)
if row is None:
raise ValueError(f"{doc['item_key']}: no anchor {ref} (p-{p_id})")
return _para_link(doc, row)
m = _FR_CITE_RE.match(ref)
if m:
volume, page = int(m.group(1)), int(m.group(2))
doc = (
_doc_for_key(store, item_key)
if item_key
else _doc_for_cite(store, volume, page)
)
if not doc["start_page"] <= page <= doc["end_page"]:
raise ValueError(
f"{ref}: page outside {doc['item_key']}'s range "
f"{doc['start_page']}-{doc['end_page']}"
)
if m.group(3) is None:
return JumpLink(
url=f"{doc['html_url']}#page-{page}",
item_key=doc["item_key"],
page=page,
)
ordinal = int(m.group(3))
rows = con.execute(
"SELECT p_id, page, ordinal, text FROM fr_anchors "
"WHERE item_key = ? AND page = ? ORDER BY ordinal",
(doc["item_key"], page),
).fetchall()
if not rows:
raise ValueError(
f"{ref}: page {page} has no paragraph anchors (tables/addenda "
f"pages carry none) — use the plain page link"
)
if ordinal > len(rows):
raise ValueError(
f"{ref}: page {page} has {len(rows)} paragraph(s), "
f"{ordinal} does not exist"
)
return _para_link(doc, rows[ordinal - 1])
quote = _norm(ref)
if len(quote) < _MIN_QUOTE_LEN:
raise ValueError(
f"{ref!r}: not an FR cite/anchor, and too short (<{_MIN_QUOTE_LEN} "
f"chars) to search as a quote"
)
params: list[Any] = [f"%{quote}%"]
sql = (
"SELECT a.p_id, a.page, a.ordinal, a.text, a.item_key "
"FROM fr_anchors a WHERE a.text LIKE ?"
)
if item_key:
sql += " AND a.item_key = ?"
params.append(item_key)
hits = con.execute(sql, params).fetchall()
if not hits:
raise ValueError(f"{ref!r}: no paragraph contains this quote")
if len(hits) > 1:
where = ", ".join(f"{h['item_key']} p-{h['p_id']}" for h in hits[:5])
raise ValueError(
f"{ref!r}: matches {len(hits)} paragraphs ({where}) — "
f"lengthen the quote or pass item_key="
)
hit = hits[0]
doc = _doc_for_key(store, hit["item_key"])
return _para_link(doc, hit)
# ── Transmutations + rendering ──────────────────────────────────────
def page_of(store: Store, item_key: str, p_id: int) -> int:
"""The printed FR page a paragraph anchor sits on."""
row = _anchor_row(store._con(), item_key, p_id) # noqa: SLF001
if row is None:
raise ValueError(f"{item_key}: no anchor p-{p_id}")
return row["page"]
def paragraphs_of(ref: str, *, store: Store, item_key: str = "") -> list[Anchor]:
"""All paragraph anchors on a cited printed page (page → ¶ upgrade)."""
link = resolve(ref, store=store, item_key=item_key)
con = store._con() # noqa: SLF001
rows = con.execute(
"SELECT p_id, page, ordinal, text FROM fr_anchors "
"WHERE item_key = ? AND page = ? ORDER BY ordinal",
(link.item_key, link.page),
).fetchall()
return [
Anchor(p_id=r["p_id"], page=r["page"], ordinal=r["ordinal"], text=r["text"])
for r in rows
]
def md_link(ref: str, *, store: Store, item_key: str = "", text: str = "") -> str:
"""Markdown ``[text](url)`` for a resolved FR reference."""
link = resolve(ref, store=store, item_key=item_key)
return f"[{text or ref}]({link.url})"

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import pytest
from bib import frlink from bib import frlink
from bib.item import Rule from bib.item import Rule
from bib.store import Store from bib.store import Store
@@ -137,3 +139,103 @@ class TestGrab:
).fetchone()[0] ).fetchone()[0]
assert n == 4 # untouched assert n == 4 # untouched
s.close() s.close()
# ── resolve / transmutations / md_link (#635) ───────────────────────
class TestResolve:
def test_page_ref(self) -> None:
s, key = _grabbed_store()
link = frlink.resolve("91 FR 100", store=s)
assert link.url == "https://example.test/doc#page-100"
assert (link.item_key, link.page, link.p_id) == (key, 100, None)
s.close()
def test_page_plus_ordinal(self) -> None:
s, _key = _grabbed_store()
link = frlink.resolve("91 FR 100 ¶2", store=s)
assert link.url == "https://example.test/doc#p-2"
assert (link.page, link.p_id, link.ordinal) == (100, 2, 2)
assert "markup" in link.snippet
# alternate spellings
assert frlink.resolve("91 FR 100, para 2", store=s).p_id == 2
assert frlink.resolve("91 FR 101 p.2", store=s).p_id == 4
s.close()
def test_raw_anchor(self) -> None:
s, key = _grabbed_store()
link = frlink.resolve("p-3", store=s, item_key=key)
assert link.url == "https://example.test/doc#p-3"
assert link.page == 101
with pytest.raises(ValueError, match="p-99"):
frlink.resolve("p-99", store=s, item_key=key)
s.close()
def test_raw_anchor_without_key_single_doc_context(self) -> None:
s, _key = _grabbed_store()
# only one grabbed rule → unambiguous without item_key
assert frlink.resolve("p-1", store=s).page == 100
s.close()
def test_quote(self) -> None:
s, _key = _grabbed_store()
link = frlink.resolve("Only para on 101", store=s)
assert link.p_id == 3
assert link.page == 101
s.close()
def test_quote_multi_hit_raises(self) -> None:
dup_html = FIXTURE_HTML.replace(
"</body>",
'<p id="p-9" data-page="101">Only para on 101, repeated.</p></body>',
)
s, _key = _grabbed_store(dup_html)
with pytest.raises(ValueError, match="matches 2"):
frlink.resolve("Only para on 101", store=s)
s.close()
def test_quote_no_hit_raises(self) -> None:
s, _key = _grabbed_store()
with pytest.raises(ValueError, match="[Nn]o paragraph"):
frlink.resolve("nothing like this appears anywhere", store=s)
s.close()
def test_unknown_page_raises(self) -> None:
s, _key = _grabbed_store()
with pytest.raises(ValueError, match="99 FR 100"):
frlink.resolve("99 FR 100", store=s)
s.close()
def test_ordinal_out_of_range_raises(self) -> None:
s, _key = _grabbed_store()
with pytest.raises(ValueError, match="2 paragraph"):
frlink.resolve("91 FR 100 ¶7", store=s)
s.close()
class TestTransmutations:
def test_page_of(self) -> None:
s, key = _grabbed_store()
assert frlink.page_of(s, key, 3) == 101
s.close()
def test_paragraphs_of(self) -> None:
s, _key = _grabbed_store()
anchors = frlink.paragraphs_of("91 FR 100", store=s)
assert [(a.p_id, a.ordinal) for a in anchors] == [(1, 1), (2, 2)]
s.close()
class TestMdLink:
def test_default_text_is_ref(self) -> None:
s, _key = _grabbed_store()
md = frlink.md_link("91 FR 100 ¶2", store=s)
assert md == "[91 FR 100 ¶2](https://example.test/doc#p-2)"
s.close()
def test_custom_text(self) -> None:
s, _key = _grabbed_store()
md = frlink.md_link("91 FR 100", store=s, text="the CF discussion")
assert md == "[the CF discussion](https://example.test/doc#page-100)"
s.close()