Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist) #197
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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"> {{ fandomLabel }}</template></span>
|
||||
>→<template v-if="fandomLabel"> {{ 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user