Files
FabledCurator/backend/app/services/ml/suggestions.py
T
bvandeusen a444cf82d1
CI / backend-lint-and-test (push) Successful in 36s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m45s
refactor(tags): unify suggestion source — one canonical DB-tag dropdown, drop dead raw/alias machinery (#154)
Every tag suggestion is a canonical DB tag now (tagging-v2 #114: heads + CCIP
score EXISTING concept tags). The pre-heads apparatus for model-predicted tags
that didn't exist in the DB — creates_new_tag / raw_name / via_alias, the
/suggestions/alias endpoint + add_alias_and_accept, AliasPickerDialog, and the
store's aliasAccept/removeAlias — was dead and is removed.

The type-to-add dropdown was TWO row sources (server autocomplete + the image's
ML suggestions) merged with a dedup that dropped the %-bearing suggestion row
when the debounced server hit landed — the operator's "confidence % flickers
then vanishes". Now it's ONE list of DB-tag matches, each annotated with the
model's confidence (join by canonical_tag_id) when the tag was scored for this
image. No dedup, no flicker; picking a suggested tag still records acceptance
via TagPanel.findPending.

Single per-image fetch: score_image now reports above_threshold per row
(computed vs the head's own suggest cut, separate from the inclusion floor), so
the rail makes ONE min=0 request and derives the panel (above_threshold) and the
dropdown (all, text-filtered) client-side — the two /suggestions calls collapse
to one. Manual "Create 'X' as <kind>" (novel typed names) is unchanged; the
alias table + tag-side alias admin + auto-apply alias matching are untouched.

Tests: gate/serializer assertions updated (above_threshold; dropped dead-field
+ alias-endpoint checks); frontend spec seeds via the single load and covers the
byCategory/aboveByCategory split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
2026-07-10 15:22:51 -04:00

228 lines
9.7 KiB
Python

"""The suggestion read-path: trained HEADS score one image's frozen embedding
into alias-resolved, category-grouped, ranked suggestions.
Tagging-v2 (#114): suggestions now come from the per-concept heads that LEARN
from the operator's tags (services/ml/heads.py) — the Camie prediction source
and the per-tag SigLIP centroid have been REMOVED. A head exists only for an
existing concept tag, so every suggestion is a canonical tag (no raw model key,
no alias remap, no creates-new). Rejected tags stay in the list FLAGGED (not
dropped) so the rail can show + reverse a dismissal.
"""
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ImageRecord, TagSuggestionRejection
from ...models.tag import image_tag
from .ccip import match_image as ccip_match_image
from .heads import score_image
@dataclass(frozen=True)
class Suggestion:
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
# cases are gone — a suggestion always maps to a real tag id.
canonical_tag_id: int
display_name: str
category: str
score: float
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
# above_threshold = the score cleared the head's own suggest cut (or the
# system floor). The Suggestions PANEL shows only these; the typed-tag
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each
# matching row with its score, so a low-confidence concept can still be typed
# and picked. CCIP character matches are always above their match threshold.
above_threshold: bool
# rejected = the operator dismissed this tag for this image (a stored
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
# operator-asked 2026-06-27) instead of silently vanishing or re-suggesting.
rejected: bool = False
# grounding = the crop region that produced this suggestion (#1206):
# {bbox:[x,y,w,h] normalized, kind, detector}. None when the whole-image
# vector won (not localized) or for a CCIP-only hit (figure grounding TBD).
# Lets the rail highlight the exact region on hover.
grounding: dict | None = None
@dataclass
class SuggestionList:
by_category: dict[str, list[Suggestion]] = field(default_factory=dict)
class SuggestionService:
def __init__(self, session: AsyncSession):
self.session = session
async def for_image(
self, image_id: int, threshold_override: float | None = None,
) -> SuggestionList:
"""Head-scored suggestions for one image, grouped by category and ranked.
Each trained head scores the image's frozen embedding; a concept surfaces
when its score clears the head's own suggest threshold. threshold_override
(used by the typed tag-input dropdown's "show everything" mode) replaces
that per-head cut with a flat floor (0 → every head), so a low-scoring
concept can still be typed + picked in canonical formatting.
Already-applied tags are dropped; rejected tags stay FLAGGED and sink to
the bottom of their category so a dismissal is visible + reversible."""
img = await self.session.get(ImageRecord, image_id)
if img is None:
return SuggestionList()
applied = set(
(
await self.session.execute(
select(image_tag.c.tag_id).where(
image_tag.c.image_record_id == image_id
)
)
).scalars().all()
)
rejected = set(
(
await self.session.execute(
select(TagSuggestionRejection.tag_id).where(
TagSuggestionRejection.image_record_id == image_id
)
)
).scalars().all()
)
hits = await score_image(
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",
"above_threshold": h["above_threshold"],
"grounding": h.get("grounding"),
}
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"])
# CCIP only returns matches above its own threshold, so a CCIP
# corroboration always makes the merged suggestion above-threshold.
ex["above_threshold"] = True
# Keep the head's localized crop if it had one; else fall back to
# the CCIP figure so a corroborated character still grounds (#1206).
ex["grounding"] = ex.get("grounding") or c.get("grounding")
else:
merged[key] = {
"name": c["name"], "score": c["score"], "source": "ccip",
"above_threshold": True,
"grounding": c.get("grounding"),
}
result = SuggestionList()
for (cat, tag_id), m in merged.items():
if tag_id in applied:
continue
result.by_category.setdefault(cat, []).append(
Suggestion(
canonical_tag_id=tag_id,
display_name=m["name"],
category=cat,
score=m["score"],
source=m["source"],
above_threshold=m["above_threshold"],
rejected=tag_id in rejected,
grounding=m.get("grounding"),
)
)
for cat in result.by_category:
# Live suggestions first (by score), rejected ones sink to the
# bottom of the category — visible for recovery, out of the way.
result.by_category[cat].sort(key=lambda s: (s.rejected, -s.score))
return result
async def for_selection(
self,
image_ids: list[int],
threshold: float = 0.8,
top_k: int = 10,
) -> dict[str, list[dict]]:
"""Consensus suggestions across image_ids. A tag is included iff it
was suggested for (or already applied to) >= threshold fraction of
the selection AND was acceptable on >= 1 image. Confidence is the
mean over images where it was suggested. Aggregated by
canonical_tag_id (every suggestion is a canonical tag now)."""
if not image_ids:
return {}
threshold = min(1.0, max(0.0, threshold))
total = len(image_ids)
stats: dict[int, dict] = {}
for image_id in image_ids:
sl = await self.for_image(image_id)
for category, items in sl.by_category.items():
for s in items:
# for_image keeps rejected tags (flagged) for the rail;
# bulk consensus must still ignore them — a tag dismissed on
# an image isn't a suggestion for that image.
if s.rejected:
continue
st = stats.get(s.canonical_tag_id)
if st is None:
st = {
"tag_id": s.canonical_tag_id,
"name": s.display_name,
"category": category,
"source": s.source,
"suggested_count": 0,
"sum_score": 0.0,
}
stats[s.canonical_tag_id] = st
st["suggested_count"] += 1
st["sum_score"] += s.score
rows = (
await self.session.execute(
select(
image_tag.c.image_record_id, image_tag.c.tag_id
).where(image_tag.c.image_record_id.in_(image_ids))
)
).all()
applied_by_tag: dict[int, set[int]] = {}
for iid, tid in rows:
applied_by_tag.setdefault(tid, set()).add(iid)
result: dict[str, list[dict]] = {}
for st in stats.values():
existing_count = len(applied_by_tag.get(st["tag_id"], set()))
covered = st["suggested_count"] + existing_count
coverage = covered / total
if coverage < threshold or st["suggested_count"] < 1:
continue
result.setdefault(st["category"], []).append(
{
"canonical_tag_id": st["tag_id"],
"name": st["name"],
"category": st["category"],
"confidence": round(
st["sum_score"] / st["suggested_count"], 4
),
"coverage": round(coverage, 4),
"covered_count": covered,
"source": st["source"],
}
)
for cat in result:
result[cat].sort(key=lambda x: x["confidence"], reverse=True)
result[cat] = result[cat][:top_k]
return result