feat(vocabulary): backend for native tags and categories (#241)

* feat(tags): keep unused tags in the shared vocabulary

* feat(vocabulary): add bounded tag and category reads

* feat(vocabulary): guard tag create, rename and merge

* feat(vocabulary): preview and apply category consolidation

* refactor(vocabulary): share canonical token hashing

* test(vocabulary): prove retained tags survive restore

* test(vocabulary): cover category paging tie-break and clarify contract docs

* test(vocabulary): assert rejected tag creations write nothing
This commit is contained in:
Vito
2026-10-19 05:47:36 -07:00
committed by GitHub
parent 39dcc40114
commit ab333d3838
14 changed files with 2187 additions and 36 deletions
+63
View File
@@ -181,6 +181,46 @@ class TestTagSuggestions(DatabaseTestCase):
[("asian", 3), ("portrait", 3), ("dog", 2), ("smile", 1), ("snow_day", 1)],
)
def _add_unused(self, *names):
with self.db.model.get_connection() as conn:
for name in names:
conn.execute("INSERT INTO tags(name) VALUES(?)", (name,))
def test_no_context_appends_unused_names_at_zero_after_used(self):
self._add_unused("zz_unused", "asian_unused")
used = [
("asian", 3),
("portrait", 3),
("dog", 2),
("smile", 1),
("snow_day", 1),
]
self.assertEqual(
self._names("", []),
used + [("asian_unused", 0), ("zz_unused", 0)],
)
self.assertEqual(self._names("asian", []), [("asian", 3), ("asian_unused", 0)])
def test_context_suggestions_never_include_unused_names(self):
self._add_unused("zz_unused", "asian_unused")
self.assertEqual(
self._names("", ["asian"]), [("portrait", 2), ("dog", 1), ("smile", 1)]
)
def test_limit_counts_unused_rows_last(self):
self._add_unused("zz_unused", "asian_unused")
self.assertEqual(
self._names("", [], limit=6),
[
("asian", 3),
("portrait", 3),
("dog", 2),
("smile", 1),
("snow_day", 1),
("asian_unused", 0),
],
)
def test_prefix_is_case_insensitive_and_anchored_at_the_start(self):
self.assertEqual(self._names("P", []), [("portrait", 3)])
self.assertEqual(self._names("s", []), [("smile", 1), ("snow_day", 1)])
@@ -203,6 +243,24 @@ class TestTagSuggestions(DatabaseTestCase):
self.assertEqual(self._names("snow_", []), [("snow_day", 1)])
self.assertEqual(self._names("snow%", []), [])
def test_like_wildcards_never_match_unused_names(self):
self._add_unused("snowXday", "snowfall", "plain")
self.assertEqual(self._names("_", []), [])
self.assertEqual(self._names("%", []), [])
self.assertEqual(self._names("snow_", []), [("snow_day", 1)])
self.assertEqual(self._names("snow%", []), [])
self.assertEqual(
self._names("snow", []),
[("snow_day", 1), ("snowfall", 0), ("snowXday", 0)],
)
def test_context_limit_ignores_unused_rows(self):
self._add_unused("aaa_unused", "portrait_unused")
self.assertEqual(
self._names("", ["asian"], limit=2), [("portrait", 2), ("dog", 1)]
)
self.assertEqual(self._names("p", ["asian"], limit=1), [("portrait", 2)])
class TestTagJunctionTables(DatabaseTestCase):
"""Test normalized tag storage via junction tables."""
@@ -218,6 +276,11 @@ class TestTagJunctionTables(DatabaseTestCase):
all_tags = self.db.get_all_tags()
self.assertEqual(sorted(all_tags), ["a", "b", "c"])
def test_get_all_tags_includes_unused_registry_rows(self):
pid = self._save("P1", tags=["a", "b"])
self.db.set_prompt_tags(pid, ["b"])
self.assertEqual(self.db.get_all_tags(), ["a", "b"])
def test_get_tags_with_counts(self):
self._save("P1", tags=["common", "rare"])
self._save("P2", tags=["common"])