Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist) #197
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user