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
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/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
-
-
-
+
+
@@ -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..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.
@@ -113,4 +121,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; }
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)
}
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