From ecac6c4bda83f882b040f9d30ee096e112843c7b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:28:57 -0400 Subject: [PATCH 1/5] fix(audit-g5): centroid version DB-as-truth + modal as overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last two findings from the 2026-06-02 audit (G5.1 + G5.4). G5.1 — Centroid version no longer drifts: CentroidService now reads MLSettings.embedder_model_version (the DB row tag_and_embed already writes from) for both the centroid model- version stamp and the drift-detection comparison. Previously the centroid sites imported MODEL_VERSION from env, so the version stamped on centroids could disagree with the version stamped on the embeddings they were built from. By construction those now match, so list_drifted won't silently miss the env-vs-DB drift case. embedder.py keeps MODEL_VERSION as an env-driven constant for the actual model loader — that's a different concern (which weights are loaded) from the version-stamp that gets persisted alongside data. G5.4 — Modal is a Pinia-only overlay: The previous URL↔modal sync in GalleryView and ArtistGalleryTab leaked the modal across route changes (RouterLink to /artist/ left the modal mounted on top of the new route) and re-opened it on history back/forward with stale ?image=N entries. Now: openImage() just calls modal.open(id) — no URL push. GalleryView's dead closeImage helper is deleted. A route.name watcher in App.vue closes the modal whenever the route changes, which auto-fixes RouterLink-in-modal and back/forward. Backward-compat: ?image=N is still honored on initial mount as a one-shot deep-link opener, then router.replace strips the query so the URL doesn't re-trigger and no extra history entry is added. Existing bookmarks / shared URLs keep working; new opens stay Pinia-only. --- backend/app/services/ml/centroids.py | 24 +++++++++++++--- frontend/src/App.vue | 15 +++++++++- .../components/artist/ArtistGalleryTab.vue | 20 +++++++------ frontend/src/views/GalleryView.vue | 28 +++++++++---------- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/backend/app/services/ml/centroids.py b/backend/app/services/ml/centroids.py index 6a21d77..e18907f 100644 --- a/backend/app/services/ml/centroids.py +++ b/backend/app/services/ml/centroids.py @@ -19,7 +19,6 @@ from ...models import ( TagReferenceEmbedding, ) from ...models.tag import image_tag -from .embedder import MODEL_VERSION as SIGLIP_VERSION ELIGIBLE_KINDS = { TagKind.character, @@ -46,6 +45,21 @@ class CentroidService: ) ).scalar_one() + async def _model_version(self) -> str: + """Audit 2026-06-02: SigLIP model-version stamp comes from the + DB row, not the env constant. tag_and_embed (tasks/ml.py:110) + already reads from MLSettings.embedder_model_version, so by + sourcing centroid stamps + drift checks from the same row, we + eliminate the silent-drift case the audit flagged. env + SIGLIP_MODEL_VERSION still drives which model embedder.py + loads at runtime; the version stamp is purely the operator- + controlled identifier.""" + return ( + await self.session.execute( + select(MLSettings.embedder_model_version).where(MLSettings.id == 1) + ) + ).scalar_one() + async def recompute_for_tag(self, tag_id: int) -> bool: """Recompute one tag's centroid. Returns True if a centroid was written, False if skipped (ineligible kind or too few members).""" @@ -69,19 +83,20 @@ class CentroidService: return False centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32) + model_version = await self._model_version() stmt = insert(TagReferenceEmbedding).values( tag_id=tag_id, embedding=centroid.tolist(), reference_count=len(embeddings), - model_version=SIGLIP_VERSION, + model_version=model_version, ) stmt = stmt.on_conflict_do_update( index_elements=["tag_id"], set_={ "embedding": centroid.tolist(), "reference_count": len(embeddings), - "model_version": SIGLIP_VERSION, + "model_version": model_version, "updated_at": func.now(), }, ) @@ -92,6 +107,7 @@ class CentroidService: """Tag ids whose centroid is stale: member count != reference_count, OR no centroid row, OR centroid built on a different SigLIP version. Only considers eligible-kind tags with embeddings present.""" + current_model_version = await self._model_version() member_counts = ( select( image_tag.c.tag_id.label("tag_id"), @@ -116,7 +132,7 @@ class CentroidService: TagReferenceEmbedding.reference_count != member_counts.c.members ) - | (TagReferenceEmbedding.model_version != SIGLIP_VERSION) + | (TagReferenceEmbedding.model_version != current_model_version) ) ) return list((await self.session.execute(stmt)).scalars().all()) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5cfbdda..67062ad 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,7 +9,9 @@ diff --git a/frontend/src/components/artist/ArtistGalleryTab.vue b/frontend/src/components/artist/ArtistGalleryTab.vue index 0cc6b7c..f0d2a70 100644 --- a/frontend/src/components/artist/ArtistGalleryTab.vue +++ b/frontend/src/components/artist/ArtistGalleryTab.vue @@ -11,7 +11,7 @@ diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue index da45c33..aa10623 100644 --- a/frontend/src/views/GalleryView.vue +++ b/frontend/src/views/GalleryView.vue @@ -47,9 +47,20 @@ onMounted(async () => { else if (!isNaN(tagId)) store.setTagFilter(tagId) await store.loadInitial() await store.loadTimeline() - // Open modal if URL has ?image=N + // Audit 2026-06-02: modal is now a Pinia-only overlay (see + // G5.4 design). The previous URL↔modal sync leaked the modal + // across route changes and re-opened it on history back/forward. + // We still honor `?image=N` as a one-shot deep-link opener so + // existing bookmarks / shared URLs keep working; we strip the + // query immediately via router.replace so the URL doesn't + // re-trigger and no history entry is added. const initial = parseInt(route.query.image, 10) - if (!isNaN(initial)) modal.open(initial) + if (!isNaN(initial)) { + modal.open(initial) + const q = { ...route.query } + delete q.image + router.replace({ query: q }) + } }) watch(() => route.query.tag_id, (q) => { @@ -64,19 +75,8 @@ watch(() => route.query.post_id, (q) => { store.setPostFilter(isNaN(postId) ? null : postId) }) -watch(() => route.query.image, (q) => { - const id = parseInt(q, 10) - if (!isNaN(id) && id !== modal.currentImageId) modal.open(id) - else if (isNaN(id) && modal.currentImageId !== null) modal.close() -}) - function openImage(id) { - router.push({ query: { ...route.query, image: id } }) -} -function closeImage() { - const q = { ...route.query } - delete q.image - router.push({ query: q }) + modal.open(id) } -- 2.52.0 From 1fd594baaff72aa3852b1349b452c7c7de942f47 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:38:12 -0400 Subject: [PATCH 2/5] =?UTF-8?q?chore(ml):=20suggestion=5Fthreshold=20defau?= =?UTF-8?q?lt=200.50=20=E2=86=92=200.70?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) surfaces too many low-confidence picks in the modal's Suggestions rail. 0.70 keeps the rail signal-rich while still showing more than the original 0.95 (which hid almost everything). Alembic 0033 updates the singleton row conditionally — only rows still at the old 0.50 default flip to 0.70. Operators who tuned to some other value via Settings → ML keep their pick. Settings UI already exposes both sliders (MLThresholdSliders.vue), so further tuning continues to work without a deploy. --- .../0033_suggestion_threshold_default_070.py | 48 +++++++++++++++++++ backend/app/models/ml_settings.py | 11 +++-- 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/0033_suggestion_threshold_default_070.py diff --git a/alembic/versions/0033_suggestion_threshold_default_070.py b/alembic/versions/0033_suggestion_threshold_default_070.py new file mode 100644 index 0000000..652cf44 --- /dev/null +++ b/alembic/versions/0033_suggestion_threshold_default_070.py @@ -0,0 +1,48 @@ +"""suggestion_threshold default 0.50 → 0.70 + +Revision ID: 0033 +Revises: 0032 +Create Date: 2026-06-02 + +Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) is +too noisy in practice; raise to 0.70 for both suggestion categories. + +Only conditionally updates singletons whose current value is still the +2026-06-01 default (0.50). Operators who deliberately tuned their row +to some other value (0.55, 0.65, 0.80, etc. via the Settings UI) keep +their pick — the migration only catches the unchanged-default case. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0033" +down_revision: Union[str, None] = "0032" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "UPDATE ml_settings " + "SET suggestion_threshold_character = 0.70 " + "WHERE id = 1 AND suggestion_threshold_character = 0.50" + ) + op.execute( + "UPDATE ml_settings " + "SET suggestion_threshold_general = 0.70 " + "WHERE id = 1 AND suggestion_threshold_general = 0.50" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE ml_settings " + "SET suggestion_threshold_character = 0.50 " + "WHERE id = 1 AND suggestion_threshold_character = 0.70" + ) + op.execute( + "UPDATE ml_settings " + "SET suggestion_threshold_general = 0.50 " + "WHERE id = 1 AND suggestion_threshold_general = 0.70" + ) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index de4e476..5c3e858 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -16,13 +16,14 @@ class MLSettings(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) suggestion_threshold_character: Mapped[float] = mapped_column( - Float, nullable=False, default=0.50 + Float, nullable=False, default=0.70 ) - # Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that - # 0.95 hid most general suggestions. Operator-tunable via Settings → - # ML if too noisy. + # Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50 + # surfaced too many low-confidence picks; 0.70 keeps the rail + # signal-rich while still surfacing more than the original 0.95 + # which hid almost everything. Operator-tunable via Settings → ML. suggestion_threshold_general: Mapped[float] = mapped_column( - Float, nullable=False, default=0.50 + Float, nullable=False, default=0.70 ) centroid_similarity_threshold: Mapped[float] = mapped_column( Float, nullable=False, default=0.55 -- 2.52.0 From 8326e5447a2ea42c741bcfaccc6726e8489a9ce8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:48:32 -0400 Subject: [PATCH 3/5] fix(modal): kebab menus weren't opening (chip + suggestion rows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-06-02. Both kebab activators were broken: - TagPanel chip kebab had `@click.stop` directly on the v-icon activator. In Vue 3, an explicit @click on the same element as `v-bind="props"` overrides the spread onClick — so Vuetify's activator handler never fired. Menu never opened. - SuggestionItem kebab didn't have @click.stop, but for consistency and to make both kebabs follow the same shape, wrap it too. The fix: each kebab is now wrapped in a ``. The v-icon / v-btn receives Vuetify's onClick cleanly and opens the menu; the bubbling click then reaches the span where stopPropagation absorbs it before it can affect a parent (the v-chip's close button in TagPanel's case). --- .../src/components/modal/SuggestionItem.vue | 52 ++++++++++++------- frontend/src/components/modal/TagPanel.vue | 1 + 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/modal/SuggestionItem.vue b/frontend/src/components/modal/SuggestionItem.vue index 9517649..1a0c541 100644 --- a/frontend/src/components/modal/SuggestionItem.vue +++ b/frontend/src/components/modal/SuggestionItem.vue @@ -19,25 +19,34 @@ > Accept - - - - - Treat as alias for… - - - Dismiss for this image - - - + + + + + + + Treat as alias for… + + + Dismiss for this image + + + + @@ -90,6 +99,11 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) .fc-suggestion__accept :deep(.v-btn__content) { font-size: 12px; letter-spacing: 0.02em; } +.fc-suggestion__menu-wrap { + flex: 0 0 auto; + display: inline-flex; + align-items: center; +} .fc-suggestion__menu { flex: 0 0 auto; } diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index da79fe5..e6c49e0 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -113,4 +113,5 @@ async function onRenamed() { margin-bottom: 12px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } +.kebab-wrap { display: inline-flex; align-items: center; } -- 2.52.0 From 0fbb19dc244d20156e2a934e876725d833749ef2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:48:53 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(modal):=20TagPanel=20kebab=20=E2=80=94?= =?UTF-8?q?=20apply=20the=20wrapping-span=20fix=20that=20the=20prior=20com?= =?UTF-8?q?mit=20missed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (8326e54) updated the CSS but the structural edit to the v-menu wrapper didn't take. Re-applying so the chip kebab actually opens. --- frontend/src/components/modal/TagPanel.vue | 34 +++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index e6c49e0..9453592 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -10,19 +10,27 @@ > {{ iconFor(tag.kind) }} {{ tag.name }} - - - - - Rename… - - - + + + + + + + Rename… + + + + No tags yet. -- 2.52.0 From b181d779fe5498b61cd70d9cba4b56f1a37160b4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:49:24 -0400 Subject: [PATCH 5/5] fix(test): default suggestion_threshold_general now 0.70 (alembic 0033) --- tests/test_api_ml_admin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py index 547e78e..1692743 100644 --- a/tests/test_api_ml_admin.py +++ b/tests/test_api_ml_admin.py @@ -19,9 +19,10 @@ async def test_get_and_patch_settings(client): resp = await client.get("/api/ml/settings") assert resp.status_code == 200 body = await resp.get_json() - # Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95 - # hid most general suggestions in the view modal. - assert body["suggestion_threshold_general"] == pytest.approx(0.50) + # Default raised 0.50 → 0.70 on 2026-06-02 (alembic 0033) — 0.50 + # was too noisy in practice. The 0.70 default keeps the rail + # signal-rich without hiding everything like the original 0.95. + assert body["suggestion_threshold_general"] == pytest.approx(0.70) # Retired threshold columns must not appear in the payload. assert "suggestion_threshold_artist" not in body assert "suggestion_threshold_copyright" not in body -- 2.52.0