Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s

This commit was merged in pull request #54.
This commit is contained in:
2026-06-02 19:09:49 -04:00
9 changed files with 172 additions and 68 deletions
@@ -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"
)
+6 -5
View File
@@ -16,13 +16,14 @@ class MLSettings(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_character: Mapped[float] = mapped_column( 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 # Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
# 0.95 hid most general suggestions. Operator-tunable via Settings → # surfaced too many low-confidence picks; 0.70 keeps the rail
# ML if too noisy. # 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( 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( centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55 Float, nullable=False, default=0.55
+20 -4
View File
@@ -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
View File
@@ -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>
@@ -19,25 +19,34 @@
> >
Accept Accept
</v-btn> </v-btn>
<v-menu> <!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening.
<template #activator="{ props }"> Wrapping in a <span @click.stop> matches the TagPanel chip
<v-btn fix — even though there's no parent click capture here today,
class="fc-suggestion__menu" the wrap is harmless and keeps both kebabs on the same
icon="mdi-dots-vertical" size="small" pattern. Click bubbles from the v-btn opens menu via
variant="outlined" density="compact" activator props bubble continues to span stopPropagation
:aria-label="`More actions for ${suggestion.display_name}`" halts it. -->
v-bind="props" <span class="fc-suggestion__menu-wrap" @click.stop>
/> <v-menu>
</template> <template #activator="{ props }">
<v-list density="compact"> <v-btn
<v-list-item @click="$emit('alias', suggestion)"> class="fc-suggestion__menu"
<v-list-item-title>Treat as alias for</v-list-item-title> icon="mdi-dots-vertical" size="small"
</v-list-item> variant="outlined" density="compact"
<v-list-item @click="$emit('dismiss', suggestion)"> :aria-label="`More actions for ${suggestion.display_name}`"
<v-list-item-title>Dismiss for this image</v-list-item-title> v-bind="props"
</v-list-item> />
</v-list> </template>
</v-menu> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</div> </div>
</template> </template>
@@ -90,6 +99,11 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em; font-size: 12px; letter-spacing: 0.02em;
} }
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }
+22 -13
View File
@@ -10,19 +10,27 @@
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span> {{ tag.name }}<span v-if="tag.fandom_id"></span>
<v-menu> <!-- Operator-flagged 2026-06-02: the previous activator had
<template #activator="{ props: mp }"> `@click.stop` directly on the v-icon, which silently
<v-icon overrode Vuetify's onClick from `v-bind="mp"` — the menu
v-bind="mp" size="x-small" class="ml-1" never opened. Now the v-icon receives the activator
icon="mdi-dots-vertical" @click.stop onClick cleanly, and the wrapping span absorbs the
/> bubbled click so the chip's close button isn't tripped. -->
</template> <span class="kebab-wrap" @click.stop>
<v-list density="compact"> <v-menu>
<v-list-item @click="openRename(tag)"> <template #activator="{ props: mp }">
<v-list-item-title>Rename</v-list-item-title> <v-icon
</v-list-item> v-bind="mp" size="x-small" class="ml-1"
</v-list> icon="mdi-dots-vertical"
</v-menu> />
</template>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</v-chip> </v-chip>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span> <span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div> </div>
@@ -113,4 +121,5 @@ async function onRenamed() {
margin-bottom: 12px; margin-bottom: 12px;
} }
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; }
</style> </style>
+14 -14
View File
@@ -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>
+4 -3
View File
@@ -19,9 +19,10 @@ async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings") resp = await client.get("/api/ml/settings")
assert resp.status_code == 200 assert resp.status_code == 200
body = await resp.get_json() body = await resp.get_json()
# Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95 # Default raised 0.50 → 0.70 on 2026-06-02 (alembic 0033) — 0.50
# hid most general suggestions in the view modal. # was too noisy in practice. The 0.70 default keeps the rail
assert body["suggestion_threshold_general"] == pytest.approx(0.50) # 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. # Retired threshold columns must not appear in the payload.
assert "suggestion_threshold_artist" not in body assert "suggestion_threshold_artist" not in body
assert "suggestion_threshold_copyright" not in body assert "suggestion_threshold_copyright" not in body