Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist) #197

Merged
bvandeusen merged 12 commits from dev into main 2026-07-06 21:03:30 -04:00
27 changed files with 806 additions and 72 deletions
@@ -0,0 +1,43 @@
"""stricter auto-apply defaults (milestone 139) — cut auto-apply misfires
head_auto_apply_min_positives 30→50 and ccip_auto_apply_threshold 0.92→0.95
(operator-asked 2026-07-06). The head graduation precision bar stays 0.97 — the
operator confirmed the general-tag confidence was already well tuned; only the
support floor + the CCIP match confidence are raised. The model defaults change
for fresh installs; here we bump the existing singleton row IFF it is still at
the previous default, so a deliberate operator change is NOT clobbered.
Revision ID: 0081
Revises: 0080
Create Date: 2026-07-06
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0081"
down_revision: Union[str, None] = "0080"
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 head_auto_apply_min_positives = 50 "
"WHERE head_auto_apply_min_positives = 30"
)
op.execute(
"UPDATE ml_settings SET ccip_auto_apply_threshold = 0.95 "
"WHERE ccip_auto_apply_threshold = 0.92"
)
def downgrade() -> None:
op.execute(
"UPDATE ml_settings SET head_auto_apply_min_positives = 30 "
"WHERE head_auto_apply_min_positives = 50"
)
op.execute(
"UPDATE ml_settings SET ccip_auto_apply_threshold = 0.92 "
"WHERE ccip_auto_apply_threshold = 0.95"
)
+5
View File
@@ -147,6 +147,11 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
"schedule": 86400.0, # no-op unless ccip_auto_apply_enabled
},
"retract-auto-tags-daily": {
"task": "backend.app.tasks.ml.scheduled_retract_auto_tags",
"schedule": 86400.0, # soft auto-apply: drop auto-tags now below
# their threshold (m139); no-op unless the auto-apply switch is on
},
"snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0,
+6 -2
View File
@@ -63,7 +63,9 @@ class MLSettings(Base):
Boolean, nullable=False, default=True
)
head_auto_apply_min_positives: Mapped[int] = mapped_column(
Integer, nullable=False, default=30
# Support floor raised 30→50 (operator-asked 2026-07-06): a head needs
# more human labels before it may fire without a human.
Integer, nullable=False, default=50
)
# CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75
# over-fired (high-reference characters matched a scatter of images); 0.85
@@ -78,7 +80,9 @@ class MLSettings(Base):
Boolean, nullable=False, default=True
)
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.92
# Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident
# character matches auto-tag.
Float, nullable=False, default=0.95
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds.
+16 -2
View File
@@ -22,7 +22,15 @@ from sqlalchemy import Select, and_, distinct, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
Source,
Tag,
TagPositiveConfirmation,
)
from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
from .pagination import decode_cursor, encode_cursor
from .tag_query import (
@@ -731,8 +739,14 @@ class GalleryService:
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
# modal chip can label it without an N+1 (shared tag_query helpers).
fandom_alias = fandom_join_alias()
# source drives the auto-applied badge; confirmed = operator affirmed the
# tag (positive + retraction-shielded, milestone 139).
confirmed = exists().where(
TagPositiveConfirmation.image_record_id == image_id,
TagPositiveConfirmation.tag_id == Tag.id,
).label("confirmed")
tag_stmt = (
select(*tag_columns(fandom_alias))
select(*tag_columns(fandom_alias), image_tag.c.source, confirmed)
.select_from(
Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id)
+15 -1
View File
@@ -13,7 +13,7 @@ exact CCIP difference metric/threshold gets validated against the model during
the hands-on eval. numpy is imported lazily (API worker has it via pgvector).
"""
from sqlalchemy import func, select
from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
@@ -23,8 +23,10 @@ from ...models import (
MLSettings,
Tag,
TagKind,
TagPositiveConfirmation,
)
from ...models.tag import image_tag
from .training_data import _AUTO_SOURCES
# Cosine-similarity floor to call a figure the same character. The live setting
# (ml_settings.ccip_match_threshold) drives it; this is only the fallback when no
@@ -111,6 +113,17 @@ async def _ref_signature(session: AsyncSession) -> tuple:
return (n_tags, n_regs, max_id, n_hygiene)
def _positive_char_tag():
"""Condition on the joined character image_tag: HUMAN-applied or operator-
confirmed — NOT an unconfirmed auto-apply. Keeps an auto-tagged character from
self-seeding CCIP references, so a ccip_auto misfire can't reinforce itself
(milestone 139) — mirrors the head-training positive exclusion."""
return image_tag.c.source.not_in(_AUTO_SOURCES) | exists().where(
TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id,
TagPositiveConfirmation.tag_id == image_tag.c.tag_id,
)
async def character_references(session: AsyncSession) -> dict[int, list]:
"""Per character-tag CCIP reference vectors: figure/face-region CCIP
embeddings on UNAMBIGUOUS (single-character) images carrying that tag.
@@ -128,6 +141,7 @@ async def character_references(session: AsyncSession) -> dict[int, list]:
)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images()))
@@ -31,9 +31,16 @@ from ...models import (
MLSettings,
Tag,
TagKind,
TagPositiveConfirmation,
)
from ...models.tag import image_tag
from .ccip import _FIGURE_KINDS, _hygiene_tagged_images, _single_character_images
from .ccip import (
_FIGURE_KINDS,
_hygiene_tagged_images,
_l2norm,
_positive_char_tag,
_single_character_images,
)
# Deterministic per-tag capping so a rebuild of an UNCHANGED reference set
# resamples identically (stable prototypes, no churn between refreshes).
@@ -63,7 +70,16 @@ def _global_signature(session: Session) -> str:
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.is_system.is_(True))
).scalar_one()
return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}"
# Character confirmations affect the reference set now that auto-tags only
# seed references once confirmed (milestone 139) — so a confirm must trip the
# gate, or the per-character diff (which reflects it) never runs.
n_conf = session.execute(
select(func.count())
.select_from(TagPositiveConfirmation)
.join(Tag, Tag.id == TagPositiveConfirmation.tag_id)
.where(Tag.kind == TagKind.character)
).scalar_one()
return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}:{n_conf}"
def _current_fingerprints(session: Session) -> dict[int, str]:
@@ -84,6 +100,7 @@ def _current_fingerprints(session: Session) -> dict[int, str]:
)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images()))
@@ -104,6 +121,7 @@ def _rebuild_one(session: Session, tag_id: int, cap: int) -> int:
image_tag.c.image_record_id == ImageRegion.image_record_id,
)
.where(image_tag.c.tag_id == tag_id)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images()))
@@ -173,3 +191,76 @@ def refresh_character_prototypes(
settings.ccip_ref_signature = sig
session.commit()
return {"skipped": False, "rebuilt": rebuilt, "removed": removed}
def retract_auto_applied_ccip(session: Session) -> int:
"""Soft auto-apply for CCIP character tags (milestone 139): re-score every
standing source='ccip_auto' character tag against that character's prototypes
and REMOVE the ones whose best figure match is now BELOW
ccip_auto_apply_threshold. Skips operator-confirmed tags. SILENT — a low score
isn't proof the tag was wrong (that's reserved for an operator removal). No-op
unless ccip_auto_apply_enabled. A character with no prototypes yet, or an image
with no figure vectors, is left alone (can't judge → keep). Returns
n_retracted."""
import numpy as np
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
if not settings.ccip_auto_apply_enabled:
return 0
thr = float(settings.ccip_auto_apply_threshold)
pairs = session.execute(
select(image_tag.c.image_record_id, image_tag.c.tag_id)
.where(image_tag.c.source == "ccip_auto")
).all()
if not pairs:
return 0
confirmed = {
(iid, tid) for iid, tid in session.execute(
select(
TagPositiveConfirmation.image_record_id,
TagPositiveConfirmation.tag_id,
)
).all()
}
# Each involved character's normalized prototype matrix, loaded once.
proto: dict[int, object] = {}
for tid in {tid for _iid, tid in pairs}:
vecs = [
v for (v,) in session.execute(
select(CharacterPrototype.ccip_embedding)
.where(CharacterPrototype.tag_id == tid)
)
]
if vecs:
proto[tid] = _l2norm(
np.vstack([np.asarray(v, dtype=np.float32) for v in vecs]), np
)
retracted = 0
for iid, tid in pairs:
if (iid, tid) in confirmed or tid not in proto:
continue # confirmed / no prototypes
qvecs = [
v for (v,) in session.execute(
select(ImageRegion.ccip_embedding)
.where(ImageRegion.image_record_id == iid)
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
)
]
if not qvecs:
continue # no figure vectors → keep
Q = _l2norm(
np.vstack([np.asarray(v, dtype=np.float32) for v in qvecs]), np
)
if float((Q @ proto[tid].T).max()) < thr:
session.execute(
image_tag.delete()
.where(image_tag.c.image_record_id == iid)
.where(image_tag.c.tag_id == tid)
.where(image_tag.c.source == "ccip_auto")
)
retracted += 1
session.commit()
return retracted
+84 -5
View File
@@ -22,7 +22,7 @@ import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy import delete, exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -35,10 +35,12 @@ from ...models import (
Tag,
TagHead,
TagKind,
TagPositiveConfirmation,
TagSuggestionRejection,
)
from ...models.tag import image_tag
from .training_data import (
_AUTO_SOURCES,
_auto_apply_point,
_hygiene_excluded_ids,
_ids_with_tag,
@@ -137,13 +139,20 @@ def _embedder_version(session: Session) -> str:
def _eligible_tag_ids(session: Session, min_pos: int) -> list[int]:
"""Concept tags (general/character) with >= min_pos labelled images — the
set that gets a head. Counts all sources; source-aware filtering (#1133) is
a separate, optional refinement."""
"""Concept tags (general/character) with >= min_pos POSITIVE images — the set
that gets a head. Counts human-applied + operator-confirmed tags only;
unconfirmed auto-applied predictions do NOT count toward eligibility (they
don't train the head — milestone 139), so a concept can't graduate on its own
guesses."""
confirmed = exists().where(
TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id,
TagPositiveConfirmation.tag_id == image_tag.c.tag_id,
)
rows = session.execute(
select(Tag.id)
.join(image_tag, image_tag.c.tag_id == Tag.id)
.where(Tag.kind.in_(_HEAD_KINDS))
.where(image_tag.c.source.not_in(_AUTO_SOURCES) | confirmed)
.group_by(Tag.id)
.having(func.count(image_tag.c.image_record_id) >= min_pos)
).all()
@@ -180,11 +189,20 @@ def _head_fingerprints(session: Session, tag_ids: list[int]) -> dict[int, str]:
.group_by(TagSuggestionRejection.tag_id)
).all()
rej_map = {t: (c, m) for t, c, m in rej}
# Confirmations promote an auto-applied tag to a positive (milestone 139), so
# a confirm must move the fingerprint too — else a manual Retrain right after
# confirming wouldn't fold the tag in (the nightly full run would).
conf = session.execute(
select(TagPositiveConfirmation.tag_id, func.count())
.where(TagPositiveConfirmation.tag_id.in_(tag_ids))
.group_by(TagPositiveConfirmation.tag_id)
).all()
conf_map = dict(conf)
out = {}
for t in tag_ids:
pc, pm = pos_map.get(t, (0, None))
rc, rm = rej_map.get(t, (0, None))
out[t] = f"{pc}:{pm}:{rc}:{rm}"
out[t] = f"{pc}:{pm}:{rc}:{rm}:{conf_map.get(t, 0)}"
return out
@@ -723,3 +741,64 @@ def auto_apply_sweep(
for h in range(len(rows))
]
return {"n_applied": sum(applied), "concepts": concepts}
def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto'
tag against its CURRENT head and REMOVE the ones now BELOW the head's
auto_apply_threshold — i.e. the head sharpened (or the operator raised the bar)
and no longer supports them. Skips operator-confirmed tags
(TagPositiveConfirmation). SILENT: a low score isn't proof the tag was wrong,
so no hard negative is recorded — that's reserved for an operator removal.
No-op unless head_auto_apply_enabled. Only re-scores the images that ALREADY
carry the auto-tag (bounded), never the whole library. Returns n_retracted."""
import numpy as np
settings = _settings(session)
if not settings.head_auto_apply_enabled:
return 0
heads = session.execute(
select(
TagHead.tag_id, TagHead.weights, TagHead.bias,
TagHead.auto_apply_threshold,
)
.where(TagHead.embedding_version == settings.embedder_model_version)
.where(TagHead.auto_apply_threshold.is_not(None))
).all()
retracted = 0
for tag_id, weights, bias, thr in heads:
auto_ids = [
iid for (iid,) in session.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == tag_id)
.where(image_tag.c.source == "head_auto")
)
]
if not auto_ids:
continue
confirmed = {
iid for (iid,) in session.execute(
select(TagPositiveConfirmation.image_record_id)
.where(TagPositiveConfirmation.tag_id == tag_id)
.where(TagPositiveConfirmation.image_record_id.in_(auto_ids))
)
}
candidates = [i for i in auto_ids if i not in confirmed]
emb = _load_embeddings(session, candidates)
cids = [i for i in candidates if i in emb]
if not cids:
continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
w = np.asarray(weights, dtype=np.float32)
probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias))))
below = [cids[k] for k in np.where(probs < float(thr))[0]]
for iid in below:
session.execute(
image_tag.delete()
.where(image_tag.c.image_record_id == iid)
.where(image_tag.c.tag_id == tag_id)
.where(image_tag.c.source == "head_auto")
)
retracted += 1
session.commit()
return retracted
+27 -2
View File
@@ -17,9 +17,20 @@ from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...models import ImageRecord, Tag, TagSuggestionRejection
from ...models import (
ImageRecord,
Tag,
TagPositiveConfirmation,
TagSuggestionRejection,
)
from ...models.tag import image_tag
# Auto-apply sources whose tags are PROVISIONAL: they never train a head (or seed
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
# auto-applied predictions out of training is what makes them "soft" — a misfire
# can't reinforce itself, so the retraction sweep can actually drop it.
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto")
def _hygiene_excluded_ids(session: Session) -> set[int]:
"""Ids of images carrying ANY system tag (wip / banner / editor
@@ -45,9 +56,23 @@ def _hygiene_excluded_ids(session: Session) -> set[int]:
def _ids_with_tag(session: Session, tag_id: int) -> list[int]:
"""Image ids that count as POSITIVES for this tag's head: human-applied
(manual / accepted) tags PLUS any auto-applied tag the operator explicitly
confirmed (TagPositiveConfirmation). Unconfirmed auto-applied tags are
EXCLUDED — they are provisional and must not train the head that judges
them (milestone 139)."""
confirmed = (
select(TagPositiveConfirmation.image_record_id)
.where(TagPositiveConfirmation.tag_id == tag_id)
)
return [
r[0] for r in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id)
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == tag_id)
.where(
image_tag.c.source.not_in(_AUTO_SOURCES)
| image_tag.c.image_record_id.in_(confirmed)
)
).all()
]
+7
View File
@@ -91,4 +91,11 @@ def serialize_tag(row) -> dict:
"fandom_id": row.fandom_id,
"fandom_name": row.fandom_name,
"is_system": bool(getattr(row, "is_system", False)),
# Applied-tag context: only the image-scoped selects (list_for_image /
# get_image_with_tags) provide these; autocomplete / directory don't →
# default. `source` drives the auto-applied badge; `confirmed` = the
# operator affirmed the tag (a training positive, shielded from the
# retraction sweep — milestone 139).
"source": getattr(row, "source", None),
"confirmed": bool(getattr(row, "confirmed", False)),
}
+15 -2
View File
@@ -9,7 +9,14 @@ from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
from ..models import (
HeadMetric,
Tag,
TagHead,
TagKind,
TagPositiveConfirmation,
image_tag,
)
from .db_helpers import get_or_create
from .tag_query import fandom_join_alias, tag_columns
@@ -288,8 +295,14 @@ class TagService:
character chip with its fandom without an N+1 (mirrors the
autocomplete/directory resolution)."""
fandom_alias = fandom_join_alias()
# source drives the auto-applied badge; confirmed = operator affirmed the
# tag (positive + retraction-shielded, milestone 139).
confirmed = exists().where(
TagPositiveConfirmation.image_record_id == image_id,
TagPositiveConfirmation.tag_id == Tag.id,
).label("confirmed")
stmt = (
select(*tag_columns(fandom_alias))
select(*tag_columns(fandom_alias), image_tag.c.source, confirmed)
.select_from(
Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id)
+20
View File
@@ -592,3 +592,23 @@ def scheduled_ccip_auto_apply() -> str:
applied += 1
session.commit()
return f"applied={applied}"
@celery.task(
name="backend.app.tasks.ml.scheduled_retract_auto_tags",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_retract_auto_tags() -> str:
"""Soft auto-apply (milestone 139): retract standing head_auto/ccip_auto tags
the model no longer supports (score now below the auto-apply threshold),
skipping operator-confirmed ones. Silent (no hard negative). No-op unless the
respective auto-apply switch is on. Returns 'head=N ccip=M'."""
from ..services.ml.character_prototypes import retract_auto_applied_ccip
from ..services.ml.heads import retract_auto_applied_heads
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
n_head = retract_auto_applied_heads(session)
with SessionLocal() as session:
n_ccip = retract_auto_applied_ccip(session)
return f"head={n_head} ccip={n_ccip}"
@@ -1,14 +1,25 @@
<template>
<div class="fc-sgroup">
<button
v-if="collapsible"
class="fc-sgroup__header fc-sgroup__header--btn"
@click="open = !open"
>
<v-icon size="small">{{ open ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
{{ label }} ({{ items.length }})
</button>
<div v-else class="fc-sgroup__header">{{ label }}</div>
<div class="fc-sgroup__header-row">
<button
v-if="collapsible"
class="fc-sgroup__header fc-sgroup__header--btn"
@click="open = !open"
>
<v-icon size="small">{{ open ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
{{ label }} ({{ items.length }})
</button>
<div v-else class="fc-sgroup__header">{{ label }}</div>
<!-- "Confirm what's right, reject the rest": clears every still-unhandled
suggestion in this section at once. Reversible (each stays flagged
rejected with one-click un-reject), so no confirm dialog. -->
<button
v-if="rejectableCount > 0"
class="fc-sgroup__reject-rest" type="button"
:title="`Reject the ${rejectableCount} remaining ${label} suggestion${rejectableCount === 1 ? '' : 's'}`"
@click="$emit('reject-all')"
>Reject rest</button>
</div>
<div v-show="open" class="fc-sgroup__items">
<SuggestionItem
@@ -25,7 +36,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import SuggestionItem from './SuggestionItem.vue'
const props = defineProps({
@@ -34,24 +45,45 @@ const props = defineProps({
collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true }
})
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
const open = ref(props.collapsible ? props.defaultOpen : true)
</script>
<style scoped>
.fc-sgroup { margin-bottom: 10px; }
.fc-sgroup__header-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.fc-sgroup__header {
flex: 1 1 auto; min-width: 0;
font-family: 'Inter', sans-serif;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
margin-bottom: 4px;
}
.fc-sgroup__header--btn {
display: flex; align-items: center; gap: 4px;
background: none; border: none; cursor: pointer;
padding: 0; width: 100%; text-align: left;
padding: 0; text-align: left;
font: inherit; text-transform: uppercase; letter-spacing: 0.06em;
}
/* Section-level "reject the remaining" — subtle until hovered so it doesn't
compete with the per-row verdicts. */
.fc-sgroup__reject-rest {
flex: 0 0 auto;
background: none; border: none; cursor: pointer;
font-family: 'Inter', sans-serif;
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-error));
padding: 2px 5px; border-radius: 4px;
}
.fc-sgroup__reject-rest:hover { background: rgb(var(--v-theme-error), 0.1); }
.fc-sgroup__reject-rest:focus-visible {
outline: 2px solid rgb(var(--v-theme-error)); outline-offset: 1px;
}
</style>
@@ -25,6 +25,7 @@
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('system')"
/>
<SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat"
@@ -32,6 +33,7 @@
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll(cat)"
/>
<SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length"
@@ -39,6 +41,7 @@
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('general')"
/>
</div>
@@ -76,6 +79,16 @@ const emit = defineEmits(['accepted', 'dismissed'])
// re-focus the tag input — same return-to-input behaviour as accept.
function onDismiss (s) { store.dismiss(s); emit('dismissed') }
function onUndismiss (s) { store.undismiss(s); emit('dismissed') }
// Section-level "Reject rest": dismiss every still-unhandled suggestion in the
// category, then return focus to the input like a single reject does.
async function onRejectAll (category) {
try {
await store.dismissRemaining(category)
emit('dismissed')
} catch (e) {
toast({ text: `Reject failed: ${e.message}`, type: 'error' })
}
}
const store = useSuggestionsStore()
const modalStore = useModalStore()
const host = props.host || modalStore
@@ -97,6 +97,7 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import { useInflightToken } from '../../composables/useInflightToken.js'
import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
@@ -183,17 +184,26 @@ const parsed = computed(() => {
const parsedKind = computed(() => parsed.value.kind)
const parsedName = computed(() => parsed.value.name)
// Inflight guard: the debounce only clears the TIMER, so once a fetch has fired
// it still races later ones — a slower earlier-prefix response ("s") could land
// after "sex" and overwrite the dropdown with stale, wrong-prefix matches
// (operator-flagged 2026-07-06). Gate each response on a token so only the latest
// query's results are applied.
const acInflight = useInflightToken()
let debounceId = null
watch(query, () => {
highlight.value = 0
if (debounceId) clearTimeout(debounceId)
acInflight.cancel()
debounceId = setTimeout(async () => {
const q = parsedName.value
if (!q) { hits.value = []; return }
// Autocomplete across ALL kinds. When the user typed a prefix the
// matches list is naturally narrower because the parsed name is
// shorter; we don't filter server-side by kind.
hits.value = await store.autocomplete(q, null, 10)
const t = acInflight.claim()
const res = await store.autocomplete(q, null, 10)
if (t.isCurrent()) hits.value = res
}, 200)
})
+46 -2
View File
@@ -16,8 +16,20 @@
>mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span><span
v-if="unconfirmedAuto" class="fc-tag-chip__auto"
title="Auto-applied — provisional: it won't train the model and can be retracted until you confirm it."
>auto</span>
</v-chip>
<!-- Keep/confirm an auto-applied tag: promotes it to a training positive and
shields it from the retraction sweep (milestone 139). Only shown for
provisional (unconfirmed) auto-tags. -->
<button
v-if="unconfirmedAuto" class="fc-tag-chip__confirm" type="button"
:title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`"
:aria-label="`Confirm ${tag.name}`"
@click.stop="$emit('confirm', tag)"
><v-icon size="14">mdi-check</v-icon></button>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
@@ -43,6 +55,8 @@ import { useTagStore } from '../../stores/tags.js'
import { useApi } from '../../composables/useApi.js'
import KebabMenu from '../common/KebabMenu.vue'
const AUTO_SOURCES = ['head_auto', 'ccip_auto', 'ml_auto']
const props = defineProps({
tag: { type: Object, required: true },
// When set (the tagging panels), hovering the chip asks the backend which crop
@@ -51,11 +65,19 @@ const props = defineProps({
// the hover is inert (no injected target, or no image to ground against).
imageId: { type: Number, default: null },
})
defineEmits(['remove', 'rename', 'set-fandom', 'navigate'])
defineEmits(['remove', 'rename', 'set-fandom', 'navigate', 'confirm'])
const store = useTagStore()
const api = useApi()
// An auto-applied tag the operator hasn't confirmed yet — provisional (milestone
// 139): it doesn't train the model and the retraction sweep can drop it. Shows
// the "auto" badge + a Keep/confirm button. `source`/`confirmed` come from the
// applied-tags payload (list_for_image / get_image_with_tags).
const unconfirmedAuto = computed(() =>
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
)
// #1206 Step 4: applied-tag grounding. `fcSuggestionHover` is provided by the
// image viewer / Explore host (a no-op elsewhere). Applied tags aren't scored
// live, so we fetch the winning region on demand and cache it per (image, tag).
@@ -120,4 +142,26 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
/* "auto" = provisional (auto-applied, unconfirmed). Quiet pill inside the chip. */
.fc-tag-chip__auto {
display: inline-block; vertical-align: middle; margin-left: 4px;
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3);
padding: 0 4px; border-radius: 999px;
}
/* Keep/confirm — a success-tinted check next to a provisional auto-tag. */
.fc-tag-chip__confirm {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
border: none; background: transparent; cursor: pointer;
color: rgb(var(--v-theme-success));
}
.fc-tag-chip__confirm:hover { background: rgb(var(--v-theme-success), 0.14); }
.fc-tag-chip__confirm:focus-visible {
outline: 2px solid rgb(var(--v-theme-success)); outline-offset: 1px;
}
</style>
+15 -1
View File
@@ -6,7 +6,7 @@
v-for="tag in host.current?.tags || []"
:key="tag.id" :tag="tag" :image-id="host.currentImageId"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate"
@navigate="onNavigate" @confirm="onConfirm"
/>
<span v-if="!host.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -65,6 +65,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue'
@@ -83,6 +84,7 @@ const modalStore = useModalStore()
const host = props.host || modalStore
const suggestions = useSuggestionsStore()
const router = useRouter()
const api = useApi()
const errorMsg = ref(null)
const tagInputRef = ref(null)
@@ -106,6 +108,18 @@ defineExpose({ focusTagInput })
// Every tag mutation hands focus back to the input so the operator can keep
// typing the next tag without re-clicking — matches the accept-suggestion flow
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
// Confirm/keep an auto-applied tag (milestone 139): records the affirmation so
// the tag becomes a training positive AND is shielded from the retraction sweep,
// then reloads so the chip drops its "auto" badge + Keep button.
async function onConfirm(tag) {
errorMsg.value = null
try {
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
await host.reloadTags()
}
catch (e) { errorMsg.value = e.message }
}
async function onRemove(tagId) {
errorMsg.value = null
try {
+2 -2
View File
@@ -200,13 +200,13 @@ async function fullImageIds () {
}
async function openModal (imageId) {
modal.open(imageId, { postImageIds: await fullImageIds() })
modal.open(imageId, { playlistIds: await fullImageIds() })
}
async function openModalAtMore () {
const ids = await fullImageIds()
const first = ids[visibleCount.value] ?? ids[0]
if (first != null) modal.open(first, { postImageIds: ids })
if (first != null) modal.open(first, { playlistIds: ids })
}
// --- description "Show more" (text-only, in place, only when truncated) ----
+32 -31
View File
@@ -17,12 +17,13 @@ export const useModalStore = defineStore('modal', () => {
// chip rail. Audit 2026-06-02.
const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard image clicks — the modal is scoped to that post's
// images). When null, prev/next falls back to current.value.neighbors
// (the gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null)
const postImageIndex = ref(0)
// Scoped playlist. When set, prev/next cycles within THIS ordered id array
// the current gallery filter (GalleryView) or a post's images (PostCard) — so
// the modal walks exactly what the user was looking at, not a global order.
// When null, prev/next falls back to current.value.neighbors (the
// /api/gallery/image/<id> global neighbours).
const playlistIds = ref(null)
const playlistIndex = ref(0)
async function open (id, opts = {}) {
// Cancel any in-flight tag mutation or reloadTags from the
@@ -32,13 +33,13 @@ export const useModalStore = defineStore('modal', () => {
current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so
// the next open() from gallery context uses neighbors mode.
if (opts.postImageIds != null) {
postImageIds.value = opts.postImageIds
postImageIndex.value = opts.postImageIds.indexOf(id)
if (postImageIndex.value < 0) postImageIndex.value = 0
} else if (opts.clearPostScope !== false && postImageIds.value != null) {
postImageIds.value = null
postImageIndex.value = 0
if (opts.playlistIds != null) {
playlistIds.value = opts.playlistIds
playlistIndex.value = opts.playlistIds.indexOf(id)
if (playlistIndex.value < 0) playlistIndex.value = 0
} else if (opts.clearPlaylist !== false && playlistIds.value != null) {
playlistIds.value = null
playlistIndex.value = 0
}
const t = inflight.claim()
await run(async () => {
@@ -53,17 +54,17 @@ export const useModalStore = defineStore('modal', () => {
currentImageId.value = null
current.value = null
error.value = null
postImageIds.value = null
postImageIndex.value = 0
playlistIds.value = null
playlistIndex.value = 0
}
async function goPrev () {
if (postImageIds.value != null) {
if (postImageIndex.value > 0) {
const newIdx = postImageIndex.value - 1
const newId = postImageIds.value[newIdx]
postImageIndex.value = newIdx
await open(newId, { postImageIds: postImageIds.value })
if (playlistIds.value != null) {
if (playlistIndex.value > 0) {
const newIdx = playlistIndex.value - 1
const newId = playlistIds.value[newIdx]
playlistIndex.value = newIdx
await open(newId, { playlistIds: playlistIds.value })
}
return
}
@@ -73,12 +74,12 @@ export const useModalStore = defineStore('modal', () => {
}
async function goNext () {
if (postImageIds.value != null) {
if (postImageIndex.value < postImageIds.value.length - 1) {
const newIdx = postImageIndex.value + 1
const newId = postImageIds.value[newIdx]
postImageIndex.value = newIdx
await open(newId, { postImageIds: postImageIds.value })
if (playlistIds.value != null) {
if (playlistIndex.value < playlistIds.value.length - 1) {
const newIdx = playlistIndex.value + 1
const newId = playlistIds.value[newIdx]
playlistIndex.value = newIdx
await open(newId, { playlistIds: playlistIds.value })
}
return
}
@@ -177,19 +178,19 @@ export const useModalStore = defineStore('modal', () => {
const isOpen = computed(() => currentImageId.value !== null)
const canPrev = computed(() => {
if (postImageIds.value != null) return postImageIndex.value > 0
if (playlistIds.value != null) return playlistIndex.value > 0
return current.value?.neighbors?.prev_id != null
})
const canNext = computed(() => {
if (postImageIds.value != null) {
return postImageIndex.value < (postImageIds.value.length - 1)
if (playlistIds.value != null) {
return playlistIndex.value < (playlistIds.value.length - 1)
}
return current.value?.neighbors?.next_id != null
})
return {
currentImageId, current, loading, error,
postImageIds, postImageIndex,
playlistIds, playlistIndex,
isOpen, canPrev, canNext,
open, close, goPrev, goNext,
reloadTags, removeTag, addExistingTag, createAndAdd,
+25 -2
View File
@@ -218,6 +218,29 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
}
// Reject every still-unhandled suggestion in a category in one go ("confirm
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
// persist a rejection and STAY flagged rejected (reversible, one-click
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
// so a big section clears fast.
async function dismissRemaining(category) {
const imageId = currentImageId
if (imageId == null) return
const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
if (!targets.length) return
const canon = targets.filter((s) => s.canonical_tag_id != null)
const raw = targets.filter((s) => s.canonical_tag_id == null)
await Promise.all(canon.map((s) =>
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: s.canonical_tag_id },
})
))
if (currentImageId === imageId) {
canon.forEach((s) => _setRejectedEverywhere(s, true))
raw.forEach((s) => _dropEverywhere(s))
}
}
// Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) {
const imageId = currentImageId
@@ -232,7 +255,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
return {
byCategory, allByCategory, loading, error,
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss,
findPending
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
undismiss, findPending
}
})
+4 -1
View File
@@ -54,7 +54,10 @@ watch(() => route.query, (q) => {
})
function openImage(id) {
modal.open(id)
// Walk the current gallery filter in the modal (#1322) — prev/next moves
// through exactly the filtered set the operator is viewing, not global
// neighbours. Snapshot of the currently-loaded, filtered, ordered ids.
modal.open(id, { playlistIds: store.images.map((i) => i.id) })
}
</script>
+1 -1
View File
@@ -80,6 +80,6 @@ describe('PostCard', () => {
const w = mountComponent(PostCard, { props: { post }, pinia })
await w.find('.fc-post-card__hero').trigger('click')
await flushPromises()
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 11] })
})
})
+32
View File
@@ -141,6 +141,38 @@ async def test_applied_tag_grounding_returns_winning_region(client, db):
assert body["grounding"]["kind"] == "concept"
@pytest.mark.asyncio
async def test_applied_tags_expose_source_and_confirmed(client, db):
# The chip UI needs each applied tag's source (auto vs manual) + confirmed
# state to badge auto-tags and offer Keep/confirm (milestone 139).
from backend.app.models import TagPositiveConfirmation
from backend.app.models.tag import image_tag
img = ImageRecord(
path="/images/srcflag.jpg", sha256="sf" * 32, size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
auto = await TagService(db).find_or_create("autotag", TagKind.general)
manual = await TagService(db).find_or_create("manualtag", TagKind.general)
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=auto.id, source="head_auto"))
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=manual.id, source="manual"))
db.add(TagPositiveConfirmation(image_record_id=img.id, tag_id=manual.id))
await db.commit()
resp = await client.get(f"/api/images/{img.id}/tags")
assert resp.status_code == 200
by_id = {t["id"]: t for t in await resp.get_json()}
assert by_id[auto.id]["source"] == "head_auto"
assert by_id[auto.id]["confirmed"] is False
assert by_id[manual.id]["source"] == "manual"
assert by_id[manual.id]["confirmed"] is True # has a confirmation row
@pytest.mark.asyncio
async def test_applied_tag_grounding_no_head(client, db):
# A tag with no head can't be localized → has_head False, grounding null; the
+22
View File
@@ -12,6 +12,7 @@ from backend.app.models import (
MLSettings,
Tag,
TagKind,
TagPositiveConfirmation,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.character_prototypes import (
@@ -141,6 +142,27 @@ def test_multi_character_image_not_referenced(db_sync):
assert _proto_count(db_sync, daphne.id) == 0
def test_unconfirmed_auto_char_tag_not_referenced(db_sync):
# A single-character image whose ONLY character tag was AUTO-applied
# (unconfirmed) must NOT seed a prototype — else CCIP self-trains on its own
# guess (milestone 139). Confirming it (which trips the global gate) makes it
# a reference.
raven = _char(db_sync, "Raven")
img = _img(db_sync, "z" * 64)
_figure(db_sync, img.id)
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=raven.id, source="ccip_auto",
))
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 0
db_sync.add(TagPositiveConfirmation(image_record_id=img.id, tag_id=raven.id))
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 1
def test_lost_references_are_removed(db_sync):
raven = _char(db_sync, "Raven")
img = _img(db_sync, "e" * 64)
+2 -2
View File
@@ -35,7 +35,7 @@ def _img(db, sha: str, emb) -> ImageRecord:
return img
def _head(db, tag_id: int, slot: int, *, threshold=0.5, n_pos=30):
def _head(db, tag_id: int, slot: int, *, threshold=0.5, n_pos=60):
s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
w = [0.0] * 1152
w[slot] = 1.0
@@ -88,7 +88,7 @@ def test_sweep_dry_run_counts_but_writes_nothing(db_sync):
def test_sweep_skips_under_supported_head(db_sync):
# n_pos below head_auto_apply_min_positives (default 30) → a precise-looking
# n_pos below head_auto_apply_min_positives (default 50) → a precise-looking
# but under-supported head never fires.
img = _img(db_sync, "c" * 64, _emb(0))
tag = Tag(name="weaktag", kind=TagKind.general)
+69
View File
@@ -0,0 +1,69 @@
"""Soft auto-apply (milestone 139): unconfirmed auto-applied tags do NOT train a
head. _ids_with_tag (positives) + _eligible_tag_ids (graduation count) count
human-applied + operator-confirmed tags only. Sklearn-free, so tested via
db_sync."""
import pytest
from backend.app.models import ImageRecord, Tag, TagKind, TagPositiveConfirmation
from backend.app.models.tag import image_tag
from backend.app.services.ml.heads import _eligible_tag_ids
from backend.app.services.ml.training_data import _ids_with_tag
pytestmark = pytest.mark.integration
def _img(db, sha: str) -> ImageRecord:
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
db.flush()
return img
def _tag(db, name: str) -> Tag:
t = Tag(name=name, kind=TagKind.general)
db.add(t)
db.flush()
return t
def _apply(db, image_id: int, tag_id: int, source: str) -> None:
db.execute(image_tag.insert().values(
image_record_id=image_id, tag_id=tag_id, source=source,
))
def test_positives_exclude_unconfirmed_auto(db_sync):
tag = _tag(db_sync, "glasses")
man = _img(db_sync, "a" * 64)
auto = _img(db_sync, "b" * 64)
conf = _img(db_sync, "c" * 64)
acc = _img(db_sync, "d" * 64)
_apply(db_sync, man.id, tag.id, "manual")
_apply(db_sync, auto.id, tag.id, "head_auto") # unconfirmed → excluded
_apply(db_sync, conf.id, tag.id, "head_auto") # confirmed → included
_apply(db_sync, acc.id, tag.id, "ml_accepted")
db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=tag.id))
db_sync.commit()
pos = set(_ids_with_tag(db_sync, tag.id))
assert pos == {man.id, conf.id, acc.id}
assert auto.id not in pos
def test_eligibility_counts_positives_only(db_sync):
# A concept whose only tags are unconfirmed auto-applies does NOT graduate.
tag = _tag(db_sync, "autotag")
for i in range(3):
_apply(db_sync, _img(db_sync, f"e{i}" * 32).id, tag.id, "head_auto")
db_sync.commit()
assert tag.id not in _eligible_tag_ids(db_sync, min_pos=2)
# Two human positives → now eligible at min_pos=2.
for i in range(2):
_apply(db_sync, _img(db_sync, f"h{i}" * 32).id, tag.id, "manual")
db_sync.commit()
assert tag.id in _eligible_tag_ids(db_sync, min_pos=2)
+153
View File
@@ -0,0 +1,153 @@
"""Soft auto-apply (milestone 139): the retraction sweeps drop standing
head_auto/ccip_auto tags now below their threshold, keep the ones still above,
and never touch manual or operator-confirmed tags. Sync + sklearn-free (they
score with STORED weights/vectors), so tested directly via db_sync."""
import pytest
from sqlalchemy import select
from backend.app.models import (
CharacterPrototype,
ImageRecord,
ImageRegion,
MLSettings,
Tag,
TagHead,
TagKind,
TagPositiveConfirmation,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.character_prototypes import retract_auto_applied_ccip
from backend.app.services.ml.heads import retract_auto_applied_heads
pytestmark = pytest.mark.integration
def _emb(slot: int) -> list[float]:
v = [0.0] * 1152
v[slot] = 3.0
return v
def _ccip(slot: int) -> list[float]:
v = [0.0] * 768
v[slot] = 1.0
return v
def _img(db, sha: str, emb=None) -> ImageRecord:
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", siglip_embedding=emb,
)
db.add(img)
db.flush()
return img
def _figure(db, image_id: int, ccip) -> None:
db.add(ImageRegion(
image_record_id=image_id, kind="figure",
rx=0.0, ry=0.0, rw=1.0, rh=1.0,
ccip_embedding=ccip, embedding_version="ccip-test",
))
db.flush()
def _tag(db, name: str, kind: TagKind) -> Tag:
t = Tag(name=name, kind=kind)
db.add(t)
db.flush()
return t
def _apply(db, image_id: int, tag_id: int, source: str) -> None:
db.execute(image_tag.insert().values(
image_record_id=image_id, tag_id=tag_id, source=source,
))
def _version(db) -> str:
return db.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
).scalar_one()
def _head(db, tag_id: int, slot: int, threshold: float, version: str) -> None:
w = [0.0] * 1152
w[slot] = 1.0
db.add(TagHead(
tag_id=tag_id, embedding_version=version, weights=w, bias=0.0,
suggest_threshold=0.5, auto_apply_threshold=threshold,
n_pos=60, n_neg=180, ap=0.9, precision_cv=0.98, recall=0.7,
))
db.flush()
def _has_tag(db, image_id: int, tag_id: int) -> bool:
return db.execute(
select(image_tag.c.tag_id)
.where(image_tag.c.image_record_id == image_id)
.where(image_tag.c.tag_id == tag_id)
).first() is not None
def test_retract_head_auto(db_sync):
ver = _version(db_sync)
tag = _tag(db_sync, "glasses", TagKind.general)
_head(db_sync, tag.id, slot=0, threshold=0.7, version=ver)
hi = _img(db_sync, "a" * 64, _emb(0)) # aligned → ~0.73 ≥ 0.7 → keep
lo = _img(db_sync, "b" * 64, _emb(5)) # orthogonal → 0.5 < 0.7 → retract
man = _img(db_sync, "c" * 64, _emb(5)) # low score but manual → keep
conf = _img(db_sync, "d" * 64, _emb(5)) # low score, head_auto, CONFIRMED → keep
_apply(db_sync, hi.id, tag.id, "head_auto")
_apply(db_sync, lo.id, tag.id, "head_auto")
_apply(db_sync, man.id, tag.id, "manual")
_apply(db_sync, conf.id, tag.id, "head_auto")
db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=tag.id))
db_sync.commit()
assert retract_auto_applied_heads(db_sync) == 1
assert not _has_tag(db_sync, lo.id, tag.id) # retracted (below threshold)
assert _has_tag(db_sync, hi.id, tag.id) # kept (still above)
assert _has_tag(db_sync, man.id, tag.id) # kept (manual, not auto)
assert _has_tag(db_sync, conf.id, tag.id) # kept (operator-confirmed)
def test_retract_head_auto_noop_when_disabled(db_sync):
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
s.head_auto_apply_enabled = False
ver = _version(db_sync)
tag = _tag(db_sync, "glasses", TagKind.general)
_head(db_sync, tag.id, slot=0, threshold=0.7, version=ver)
lo = _img(db_sync, "e" * 64, _emb(5)) # would be below threshold
_apply(db_sync, lo.id, tag.id, "head_auto")
db_sync.commit()
assert retract_auto_applied_heads(db_sync) == 0
assert _has_tag(db_sync, lo.id, tag.id) # switch off → nothing retracted
def test_retract_ccip_auto(db_sync):
char = _tag(db_sync, "Raven", TagKind.character)
db_sync.add(CharacterPrototype(tag_id=char.id, ccip_embedding=_ccip(0)))
hi = _img(db_sync, "f" * 64) # figure matches prototype → keep
lo = _img(db_sync, "g" * 64) # figure orthogonal → retract
conf = _img(db_sync, "h" * 64) # orthogonal, CONFIRMED → keep
man = _img(db_sync, "i" * 64) # orthogonal, manual → keep
_figure(db_sync, hi.id, _ccip(0))
_figure(db_sync, lo.id, _ccip(5))
_figure(db_sync, conf.id, _ccip(5))
_figure(db_sync, man.id, _ccip(5))
_apply(db_sync, hi.id, char.id, "ccip_auto")
_apply(db_sync, lo.id, char.id, "ccip_auto")
_apply(db_sync, conf.id, char.id, "ccip_auto")
_apply(db_sync, man.id, char.id, "manual")
db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=char.id))
db_sync.commit()
assert retract_auto_applied_ccip(db_sync) == 1
assert not _has_tag(db_sync, lo.id, char.id) # retracted (below threshold)
assert _has_tag(db_sync, hi.id, char.id) # kept (match ≥ threshold)
assert _has_tag(db_sync, conf.id, char.id) # kept (operator-confirmed)
assert _has_tag(db_sync, man.id, char.id) # kept (manual, not auto)
+3
View File
@@ -15,6 +15,8 @@ def test_serialize_tag_with_enum_kind():
assert serialize_tag(row) == {
"id": 1, "name": "Sasuke Uchiha", "kind": "character",
"fandom_id": 5, "fandom_name": "Naruto", "is_system": False,
# Applied-tag context defaults for rows without image scope (m139).
"source": None, "confirmed": False,
}
@@ -27,6 +29,7 @@ def test_serialize_tag_with_string_kind_and_no_fandom():
assert serialize_tag(row) == {
"id": 2, "name": "solo", "kind": "general",
"fandom_id": None, "fandom_name": None, "is_system": False,
"source": None, "confirmed": False,
}