feat(bib): context-aware FR jump resolver — page/ordinal/anchor/quote + transmutations (closes #635)
This commit is contained in:
@@ -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})"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user