fix(bib): tag/collection changes stamp updated_at (refs #615)

This commit is contained in:
kert
2026-09-08 13:51:12 -04:00
parent fc18ffc1b9
commit 118032d974
2 changed files with 122 additions and 2 deletions

View File

@@ -210,6 +210,11 @@ class Store:
self._sync_tags(item_id, new_tags) self._sync_tags(item_id, new_tags)
if new_collections is not None: if new_collections is not None:
self._sync_collections(item_id, new_collections) self._sync_collections(item_id, new_collections)
if not fields:
# A tags/collections-only rewrite still changes the item
# as the indexer sees it (year:/project:/cms-rule: land in
# chunk metadata), and nothing else stamped the row.
self._stamp(key)
con.commit() con.commit()
@@ -516,6 +521,19 @@ class Store:
(row["id"], item_id), (row["id"], item_id),
) )
def _stamp(self, item_key: str) -> None:
"""Mark the item row as changed *now*.
Tags carry indexed metadata (``year:``, ``project:``,
``cms-rule:``), so a tag-only edit has to move ``updated_at`` or
the llm indexer's fingerprint can never see it (#615).
"""
self._con().execute(
"UPDATE items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') "
"WHERE key = ?",
(item_key,),
)
def add_tag(self, item_key: str, tag: str) -> None: def add_tag(self, item_key: str, tag: str) -> None:
"""Add a single tag to an item.""" """Add a single tag to an item."""
con = self._con() con = self._con()
@@ -523,21 +541,25 @@ class Store:
if row is None: if row is None:
raise KeyError(f"Item not found: {item_key}") raise KeyError(f"Item not found: {item_key}")
tag_id = self._ensure_tag(tag) tag_id = self._ensure_tag(tag)
con.execute( cur = con.execute(
"INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)", "INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)",
(row["id"], tag_id), (row["id"], tag_id),
) )
if cur.rowcount: # re-tagging an already-tagged item changes nothing
self._stamp(item_key)
con.commit() con.commit()
def remove_tag(self, item_key: str, tag: str) -> None: def remove_tag(self, item_key: str, tag: str) -> None:
"""Remove a tag from an item.""" """Remove a tag from an item."""
con = self._con() con = self._con()
con.execute( cur = con.execute(
"""DELETE FROM item_tags """DELETE FROM item_tags
WHERE item_id = (SELECT id FROM items WHERE key = ?) WHERE item_id = (SELECT id FROM items WHERE key = ?)
AND tag_id = (SELECT id FROM tags WHERE name = ?)""", AND tag_id = (SELECT id FROM tags WHERE name = ?)""",
(item_key, tag), (item_key, tag),
) )
if cur.rowcount: # the tag wasn't there — nothing changed
self._stamp(item_key)
con.commit() con.commit()
def list_tags(self, *, namespace: str = "") -> list[dict[str, int]]: def list_tags(self, *, namespace: str = "") -> list[dict[str, int]]:

View File

@@ -0,0 +1,98 @@
"""Tag/collection changes must stamp ``items.updated_at``.
The llm indexer fingerprints an item as ``updated_at`` + its file stats,
so a tag-only edit (``year:``, ``project:``, ``cms-rule:`` — all of which
land in chunk metadata) is invisible to a re-index unless the row is
stamped. An *unchanged* upsert must still write nothing.
"""
from __future__ import annotations
from bib.item import Source
from bib.store import Store
OLD = "2000-01-01T00:00:00Z"
def _store() -> Store:
return Store(":memory:", storage_dir="/tmp/nope")
def _item(**kw) -> Source:
it = Source(title="T", url="https://www.regulations.gov/comment/CMS-2026-2377-1")
it.abstract = kw.get("abstract", "body")
for t in kw.get("tags", ["a:1", "b:2"]):
it.add_tag(t)
return it
def _backdate(s: Store, key: str) -> None:
"""updated_at has 1s granularity — backdate so a bump is observable."""
s._con().execute("UPDATE items SET updated_at = ? WHERE key = ?", (OLD, key))
s._con().commit()
def _updated_at(s: Store, key: str) -> str:
return (
s._con()
.execute("SELECT updated_at FROM items WHERE key = ?", (key,))
.fetchone()["updated_at"]
)
def test_add_tag_bumps_updated_at():
s = _store()
key = s.create(_item())
_backdate(s, key)
s.add_tag(key, "year:2026")
assert _updated_at(s, key) != OLD
def test_re_adding_the_same_tag_does_not_bump():
s = _store()
key = s.create(_item())
s.add_tag(key, "year:2026")
_backdate(s, key)
s.add_tag(key, "year:2026") # already there — nothing changed
assert _updated_at(s, key) == OLD
def test_remove_tag_bumps_updated_at():
s = _store()
key = s.create(_item(tags=["year:2026"]))
_backdate(s, key)
s.remove_tag(key, "year:2026")
assert _updated_at(s, key) != OLD
def test_removing_an_absent_tag_does_not_bump():
s = _store()
key = s.create(_item())
_backdate(s, key)
s.remove_tag(key, "nope:1")
assert _updated_at(s, key) == OLD
def test_update_with_only_tags_bumps_updated_at():
s = _store()
key = s.create(_item())
_backdate(s, key)
s.update(key, tags=["year:2026", "project:pfs"])
assert _updated_at(s, key) != OLD
def test_update_with_only_collections_bumps_updated_at():
s = _store()
key = s.create(_item())
_backdate(s, key)
s.update(key, collections=[])
assert _updated_at(s, key) != OLD
def test_unchanged_upsert_still_does_not_bump():
s = _store()
key, _ = s.upsert_status(_item())
_backdate(s, key)
key2, status = s.upsert_status(_item())
assert (key2, status) == (key, "unchanged")
assert _updated_at(s, key) == OLD