"""CCIP few-shot character matcher (#114). numpy cosine on stored vectors — no model needed, so it runs in CI with synthetic CCIP vectors.""" import pytest from sqlalchemy import select from backend.app.models import ( CcipPrototypeState, CharacterPrototype, ImageRecord, ImageRegion, TagKind, ) from backend.app.models.tag import image_tag from backend.app.services.ml.ccip import match_image from backend.app.services.tag_service import TagService pytestmark = pytest.mark.integration def _ccip(slot: int) -> list[float]: v = [0.0] * 768 v[slot] = 1.0 return v async def _img(db, sha) -> 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) await db.flush() return img async def _figure(db, image_id, ccip): 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", )) async def _tag_image(db, image_id, tag_id): await db.execute(image_tag.insert().values( image_record_id=image_id, tag_id=tag_id, source="manual", )) @pytest.mark.asyncio async def test_matches_same_character_across_images(db): raven = await TagService(db).find_or_create("Raven", TagKind.character) ref = await _img(db, "a" * 64) # a tagged example = a prototype await _figure(db, ref.id, _ccip(0)) await _tag_image(db, ref.id, raven.id) query = await _img(db, "b" * 64) # untagged, near-identical figure await _figure(db, query.id, _ccip(0)) await db.commit() matches = await match_image(db, query.id) m = next(x for x in matches if x["tag_id"] == raven.id) assert m["source"] == "ccip" and m["category"] == "character" assert m["score"] > 0.9 # #1206: the match grounds to the figure region that matched (hover → the # figure box lights up), so a character suggestion is localized too. assert m["grounding"]["bbox"] == pytest.approx([0.0, 0.0, 1.0, 1.0]) assert m["grounding"]["kind"] == "figure" @pytest.mark.asyncio async def test_matches_from_prototype_store(db): # Once the prototype store is populated (#1317), match_image reads it (the # fast incremental path) instead of rebuilding references on the request — # same match + grounding as the legacy build. raven = await TagService(db).find_or_create("Raven", TagKind.character) db.add(CharacterPrototype(tag_id=raven.id, ccip_embedding=_ccip(0))) db.add(CcipPrototypeState(tag_id=raven.id, fingerprint="1:1")) query = await _img(db, "n" * 64) await _figure(db, query.id, _ccip(0)) # query figure matches the prototype await db.commit() matches = await match_image(db, query.id) m = next(x for x in matches if x["tag_id"] == raven.id) assert m["source"] == "ccip" and m["score"] > 0.9 assert m["grounding"]["kind"] == "figure" @pytest.mark.asyncio async def test_no_match_for_different_character(db): raven = await TagService(db).find_or_create("Raven", TagKind.character) ref = await _img(db, "c" * 64) await _figure(db, ref.id, _ccip(0)) await _tag_image(db, ref.id, raven.id) query = await _img(db, "d" * 64) await _figure(db, query.id, _ccip(5)) # orthogonal → not Raven await db.commit() assert await match_image(db, query.id) == [] @pytest.mark.asyncio async def test_excludes_already_applied_character(db): raven = await TagService(db).find_or_create("Raven", TagKind.character) ref = await _img(db, "e" * 64) await _figure(db, ref.id, _ccip(0)) await _tag_image(db, ref.id, raven.id) query = await _img(db, "f" * 64) await _figure(db, query.id, _ccip(0)) await _tag_image(db, query.id, raven.id) # already tagged → no re-suggest await db.commit() assert all(m["tag_id"] != raven.id for m in await match_image(db, query.id)) @pytest.mark.asyncio async def test_no_figure_vectors_means_no_match(db): query = await _img(db, "g" * 64) await db.commit() assert await match_image(db, query.id) == [] @pytest.mark.asyncio async def test_threshold_gates_borderline_match(db): # A figure ~0.9 cosine from the reference: matched at 0.85, dropped at 0.95. raven = await TagService(db).find_or_create("Raven", TagKind.character) ref = await _img(db, "h" * 64) await _figure(db, ref.id, _ccip(0)) # e0 await _tag_image(db, ref.id, raven.id) near = [0.0] * 768 near[0], near[1] = 0.9, 0.4359 # |·|=1, cos(e0)=0.9 query = await _img(db, "i" * 64) await _figure(db, query.id, near) await db.commit() assert any(m["tag_id"] == raven.id for m in await match_image(db, query.id, 0.85)) assert await match_image(db, query.id, 0.95) == [] @pytest.mark.asyncio async def test_multi_character_image_not_used_as_reference(db): # A figure on a 2-character image is ambiguous (tag is image-level), so it # must NOT seed either character's prototypes — else it'd match both. raven = await TagService(db).find_or_create("Raven", TagKind.character) daphne = await TagService(db).find_or_create("Daphne", TagKind.character) multi = await _img(db, "j" * 64) await _figure(db, multi.id, _ccip(0)) await _tag_image(db, multi.id, raven.id) await _tag_image(db, multi.id, daphne.id) query = await _img(db, "k" * 64) await _figure(db, query.id, _ccip(0)) # identical to the ambiguous figure await db.commit() assert await match_image(db, query.id) == [] # no clean references → nothing @pytest.mark.asyncio async def test_auto_apply_tags_confident_match(db): raven = await TagService(db).find_or_create("Raven", TagKind.character) ref = await _img(db, "l" * 64) await _figure(db, ref.id, _ccip(0)) await _tag_image(db, ref.id, raven.id) # single-character reference query = await _img(db, "m" * 64) await _figure(db, query.id, _ccip(0)) # identical → cosine 1.0 await db.commit() from backend.app.tasks.ml import scheduled_ccip_auto_apply assert "applied=" in scheduled_ccip_auto_apply() # sync task, own session rows = (await db.execute( select(image_tag.c.tag_id, image_tag.c.source).where( image_tag.c.image_record_id == query.id ) )).all() assert (raven.id, "ccip_auto") in [(t, s) for t, s in rows]