diff --git a/backend/app/services/ml/ccip.py b/backend/app/services/ml/ccip.py index bf09a09..833b55c 100644 --- a/backend/app/services/ml/ccip.py +++ b/backend/app/services/ml/ccip.py @@ -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())) diff --git a/backend/app/services/ml/character_prototypes.py b/backend/app/services/ml/character_prototypes.py index 602d4ec..7a4802a 100644 --- a/backend/app/services/ml/character_prototypes.py +++ b/backend/app/services/ml/character_prototypes.py @@ -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())) diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 592a471..38b8eaf 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -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 diff --git a/tests/test_character_prototypes.py b/tests/test_character_prototypes.py index 6842053..be5bd04 100644 --- a/tests/test_character_prototypes.py +++ b/tests/test_character_prototypes.py @@ -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)