feat(suggestions): overlay CCIP character matches onto the rail (#114)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m32s

SuggestionService.for_image now merges CCIP character matches with the SigLIP
head suggestions — they're complementary, not exclusive: CCIP is the identity-
specialized signal but needs a detected figure; the heads work whole-image but
conflate identity with style. Merged by tag: 'both' when they corroborate
(higher score wins), 'ccip' / 'head' otherwise. Cheap when no CCIP vectors exist
yet (match_image returns early without a figure vector), so it's a no-op until
the agent runs. Suggestion.source is now 'head' | 'ccip' | 'both'.

Test: a character with a CCIP reference figure surfaces (source='ccip') on a new
image whose figure matches.

NEXT: the agent container (real CCIP/detector models, hands-on) that produces the
vectors this consumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-29 12:52:24 -04:00
parent d57ca847e7
commit 5faf34a3b5
2 changed files with 62 additions and 9 deletions
+29 -8
View File
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ImageRecord, TagSuggestionRejection from ...models import ImageRecord, TagSuggestionRejection
from ...models.tag import image_tag from ...models.tag import image_tag
from .ccip import match_image as ccip_match_image
from .heads import score_image from .heads import score_image
@@ -27,7 +28,7 @@ class Suggestion:
display_name: str display_name: str
category: str category: str
score: float score: float
source: str # 'head' (Camie 'tagger'/'centroid' sources removed in v2) source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
creates_new_tag: bool creates_new_tag: bool
# raw_name = the booru model vocab key behind this suggestion. It's the key # raw_name = the booru model vocab key behind this suggestion. It's the key
# an alias MUST be stored under (resolution looks up the raw key), so the # an alias MUST be stored under (resolution looks up the raw key), so the
@@ -92,19 +93,39 @@ class SuggestionService:
hits = await score_image( hits = await score_image(
self.session, image_id, threshold_override=threshold_override self.session, image_id, threshold_override=threshold_override
) )
# CCIP character matches OVERLAY the SigLIP character heads — a
# complementary, identity-specialized signal with different failure modes
# (CCIP needs a detected figure; heads work whole-image). Merged by tag:
# 'both' when they corroborate, taking the higher score.
ccip_hits = await ccip_match_image(self.session, image_id)
merged: dict[tuple[str, int], dict] = {}
for h in hits:
merged[(h["category"], h["tag_id"])] = {
"name": h["name"], "score": h["score"], "source": "head",
}
for c in ccip_hits:
key = ("character", c["tag_id"])
ex = merged.get(key)
if ex is not None:
ex["source"] = "both"
ex["score"] = max(ex["score"], c["score"])
else:
merged[key] = {
"name": c["name"], "score": c["score"], "source": "ccip",
}
result = SuggestionList() result = SuggestionList()
for h in hits: for (cat, tag_id), m in merged.items():
tag_id = h["tag_id"]
if tag_id in applied: if tag_id in applied:
continue continue
result.by_category.setdefault(h["category"], []).append( result.by_category.setdefault(cat, []).append(
Suggestion( Suggestion(
canonical_tag_id=tag_id, canonical_tag_id=tag_id,
display_name=h["name"], display_name=m["name"],
category=h["category"], category=cat,
score=h["score"], score=m["score"],
source="head", source=m["source"],
creates_new_tag=False, creates_new_tag=False,
rejected=tag_id in rejected, rejected=tag_id in rejected,
) )
+33 -1
View File
@@ -4,7 +4,7 @@ scikit-learn, ml image only); scoring is numpy-only (available via pgvector)."""
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from backend.app.models import ImageRecord, MLSettings, TagHead, TagKind from backend.app.models import ImageRecord, ImageRegion, MLSettings, TagHead, TagKind
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
from backend.app.services.ml.allowlist import AllowlistService from backend.app.services.ml.allowlist import AllowlistService
from backend.app.services.ml.suggestions import SuggestionService from backend.app.services.ml.suggestions import SuggestionService
@@ -131,3 +131,35 @@ async def test_rejected_tag_surfaced_flagged_then_reversible(db):
sl2 = await SuggestionService(db).for_image(img.id) sl2 = await SuggestionService(db).for_image(img.id)
s2 = next(x for x in sl2.by_category["general"] if x.canonical_tag_id == tag.id) s2 = next(x for x in sl2.by_category["general"] if x.canonical_tag_id == tag.id)
assert s2.rejected is False assert s2.rejected is False
async def _figure(db, image_id, slot):
v = [0.0] * 768
v[slot] = 1.0
db.add(ImageRegion(
image_record_id=image_id, kind="figure",
rx=0.0, ry=0.0, rw=1.0, rh=1.0,
ccip_embedding=v, embedding_version="ccip-test",
))
@pytest.mark.asyncio
async def test_ccip_character_surfaces_in_rail(db):
# A character with a CCIP reference (a tagged figure) is suggested on a new
# image whose figure matches — overlaid into the rail alongside the heads.
raven = await TagService(db).find_or_create("Raven", TagKind.character)
ref = await _img(db, "0" * 64, None) # the operator's tagged example
await _figure(db, ref.id, slot=0)
await db.execute(image_tag.insert().values(
image_record_id=ref.id, tag_id=raven.id, source="manual",
))
query = await _img(db, "1" * 64, None) # untagged, matching figure
await _figure(db, query.id, slot=0)
await db.commit()
sl = await SuggestionService(db).for_image(query.id)
m = next(
c for c in sl.by_category.get("character", [])
if c.canonical_tag_id == raven.id
)
assert m.source == "ccip"