fix(audit-g5): centroid version DB-as-truth + modal as overlay
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/<slug> 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.
This commit is contained in:
@@ -19,7 +19,6 @@ from ...models import (
|
|||||||
TagReferenceEmbedding,
|
TagReferenceEmbedding,
|
||||||
)
|
)
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .embedder import MODEL_VERSION as SIGLIP_VERSION
|
|
||||||
|
|
||||||
ELIGIBLE_KINDS = {
|
ELIGIBLE_KINDS = {
|
||||||
TagKind.character,
|
TagKind.character,
|
||||||
@@ -46,6 +45,21 @@ class CentroidService:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).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:
|
async def recompute_for_tag(self, tag_id: int) -> bool:
|
||||||
"""Recompute one tag's centroid. Returns True if a centroid was
|
"""Recompute one tag's centroid. Returns True if a centroid was
|
||||||
written, False if skipped (ineligible kind or too few members)."""
|
written, False if skipped (ineligible kind or too few members)."""
|
||||||
@@ -69,19 +83,20 @@ class CentroidService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
|
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
|
||||||
|
model_version = await self._model_version()
|
||||||
|
|
||||||
stmt = insert(TagReferenceEmbedding).values(
|
stmt = insert(TagReferenceEmbedding).values(
|
||||||
tag_id=tag_id,
|
tag_id=tag_id,
|
||||||
embedding=centroid.tolist(),
|
embedding=centroid.tolist(),
|
||||||
reference_count=len(embeddings),
|
reference_count=len(embeddings),
|
||||||
model_version=SIGLIP_VERSION,
|
model_version=model_version,
|
||||||
)
|
)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=["tag_id"],
|
index_elements=["tag_id"],
|
||||||
set_={
|
set_={
|
||||||
"embedding": centroid.tolist(),
|
"embedding": centroid.tolist(),
|
||||||
"reference_count": len(embeddings),
|
"reference_count": len(embeddings),
|
||||||
"model_version": SIGLIP_VERSION,
|
"model_version": model_version,
|
||||||
"updated_at": func.now(),
|
"updated_at": func.now(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,6 +107,7 @@ class CentroidService:
|
|||||||
"""Tag ids whose centroid is stale: member count != reference_count,
|
"""Tag ids whose centroid is stale: member count != reference_count,
|
||||||
OR no centroid row, OR centroid built on a different SigLIP version.
|
OR no centroid row, OR centroid built on a different SigLIP version.
|
||||||
Only considers eligible-kind tags with embeddings present."""
|
Only considers eligible-kind tags with embeddings present."""
|
||||||
|
current_model_version = await self._model_version()
|
||||||
member_counts = (
|
member_counts = (
|
||||||
select(
|
select(
|
||||||
image_tag.c.tag_id.label("tag_id"),
|
image_tag.c.tag_id.label("tag_id"),
|
||||||
@@ -116,7 +132,7 @@ class CentroidService:
|
|||||||
TagReferenceEmbedding.reference_count
|
TagReferenceEmbedding.reference_count
|
||||||
!= member_counts.c.members
|
!= member_counts.c.members
|
||||||
)
|
)
|
||||||
| (TagReferenceEmbedding.model_version != SIGLIP_VERSION)
|
| (TagReferenceEmbedding.model_version != current_model_version)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return list((await self.session.execute(stmt)).scalars().all())
|
return list((await self.session.execute(stmt)).scalars().all())
|
||||||
|
|||||||
+14
-1
@@ -9,7 +9,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from './components/AppShell.vue'
|
import AppShell from './components/AppShell.vue'
|
||||||
import AppSnackbar from './components/AppSnackbar.vue'
|
import AppSnackbar from './components/AppSnackbar.vue'
|
||||||
import ImageViewer from './components/modal/ImageViewer.vue'
|
import ImageViewer from './components/modal/ImageViewer.vue'
|
||||||
@@ -17,9 +19,20 @@ import { useModalStore } from './stores/modal.js'
|
|||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const snackbar = ref(null)
|
const snackbar = ref(null)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Expose snackbar via a simple global so stores can call it without props.
|
// Expose snackbar via a simple global so stores can call it without props.
|
||||||
window.__fcToast = (opts) => snackbar.value?.open(opts)
|
window.__fcToast = (opts) => snackbar.value?.open(opts)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Audit 2026-06-02: the modal is an overlay, not a page. When the
|
||||||
|
// route changes (RouterLink inside the modal, history back/forward,
|
||||||
|
// programmatic push from any view), close the modal so it doesn't
|
||||||
|
// hover over a different route. Watching route.name (not the path)
|
||||||
|
// keeps within-route nav like /artist/foo → /artist/bar from
|
||||||
|
// dismissing the modal mid-browse.
|
||||||
|
watch(() => route.name, () => {
|
||||||
|
if (modal.isOpen) modal.close()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, watch } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useArtistStore } from '../../stores/artist.js'
|
import { useArtistStore } from '../../stores/artist.js'
|
||||||
@@ -28,18 +28,20 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Audit 2026-06-02: same overlay treatment as GalleryView —
|
||||||
|
// honor `?image=N` once for backward-compat, then strip the
|
||||||
|
// query and let the modal live in Pinia-only state.
|
||||||
const initial = parseInt(route.query.image, 10)
|
const initial = parseInt(route.query.image, 10)
|
||||||
if (!isNaN(initial)) modal.open(initial)
|
if (!isNaN(initial)) {
|
||||||
})
|
modal.open(initial)
|
||||||
|
const q = { ...route.query }
|
||||||
watch(() => route.query.image, (q) => {
|
delete q.image
|
||||||
const id = parseInt(q, 10)
|
router.replace({ query: q })
|
||||||
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
}
|
||||||
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function openImage (id) {
|
function openImage (id) {
|
||||||
router.push({ query: { ...route.query, image: id } })
|
modal.open(id)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,20 @@ onMounted(async () => {
|
|||||||
else if (!isNaN(tagId)) store.setTagFilter(tagId)
|
else if (!isNaN(tagId)) store.setTagFilter(tagId)
|
||||||
await store.loadInitial()
|
await store.loadInitial()
|
||||||
await store.loadTimeline()
|
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)
|
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) => {
|
watch(() => route.query.tag_id, (q) => {
|
||||||
@@ -64,19 +75,8 @@ watch(() => route.query.post_id, (q) => {
|
|||||||
store.setPostFilter(isNaN(postId) ? null : postId)
|
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) {
|
function openImage(id) {
|
||||||
router.push({ query: { ...route.query, image: id } })
|
modal.open(id)
|
||||||
}
|
|
||||||
function closeImage() {
|
|
||||||
const q = { ...route.query }
|
|
||||||
delete q.image
|
|
||||||
router.push({ query: q })
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user