fix(bib): tag/collection changes stamp updated_at (refs #615)
This commit is contained in:
@@ -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]]:
|
||||||
|
|||||||
98
tests/bib/test_store_updated_at.py
Normal file
98
tests/bib/test_store_updated_at.py
Normal 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
|
||||||
Reference in New Issue
Block a user