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
4 changed files with 60 additions and 3 deletions
Showing only changes of commit bae077e323 - Show all commits
+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()))
@@ -38,6 +38,7 @@ from .ccip import (
_FIGURE_KINDS,
_hygiene_tagged_images,
_l2norm,
_positive_char_tag,
_single_character_images,
)
@@ -69,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]:
@@ -90,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()))
@@ -110,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()))
+10 -1
View File
@@ -189,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 = {t: c for t, c in 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
+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)