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) }