feat(suggestions): heads are the suggestion source — Camie + centroid removed (#114 C)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
CI / frontend-build (push) Successful in 17s

The rail's Suggestions now come from the trained per-concept heads. SuggestionService.for_image scores the image's frozen SigLIP embedding against
every head (heads.score_image) and surfaces concepts above each head's own
suggest threshold; the typed-dropdown's min=0 "show everything" mode maps to a
flat floor so any head-scored concept can still be picked. Already-applied tags
drop; rejected tags stay flagged + reversible (unchanged).

REMOVED from the suggestion path (rule 22, no fallback): the Camie
ImagePrediction candidate/alias/merge pipeline and the per-tag centroid
augmentation, plus the now-dead SuggestionService internals (_load_predictions,
_threshold_for, _settings, self.aliases, self.centroids). Head suggestions are
always canonical tags, so raw_name/via_alias are null/false and the rail's
alias kebab is inert by data (its removal + the Camie ingest-tagger rip are the
flagged follow-up). for_selection (bulk consensus) now aggregates head
suggestions unchanged.

Tests rewritten to the head path: test_ml_suggestions (surfaces/applied/
rejected-reversible/override/no-embedding/no-heads), test_suggestions_bulk
(consensus), test_api_suggestions (get + dropped the Camie-alias roundtrip),
and test_ml_artist_retired (artist not head-eligible via _HEAD_KINDS).

DEPLOY NOTE: after this lands, the rail is empty until you run Train heads
(Settings → Tagging → Concept heads) — deploy, train, then the rail populates.

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-28 11:20:11 -04:00
parent 06d5e83da4
commit ca1c17446c
6 changed files with 222 additions and 497 deletions
+9 -4
View File
@@ -287,10 +287,14 @@ async def _current_heads(session: AsyncSession, embedding_version: str):
return loaded
async def score_image(session: AsyncSession, image_id: int) -> list[dict]:
async def score_image(
session: AsyncSession, image_id: int, threshold_override: float | None = None,
) -> list[dict]:
"""Suggestions for one image from the trained heads: [{tag_id, name,
category, score}], score >= each head's suggest_threshold, ranked. Empty if
the image has no embedding or no heads exist yet."""
category, score}], ranked. A concept surfaces when its score clears the
head's own suggest_threshold — or, when threshold_override is given (the
typed-dropdown "show everything" mode), that flat floor instead (0 → every
head). Empty if the image has no embedding or no heads exist yet."""
import numpy as np
img = await session.get(ImageRecord, image_id)
@@ -307,7 +311,8 @@ async def score_image(session: AsyncSession, image_id: int) -> list[dict]:
probs = 1.0 / (1.0 + np.exp(-z))
out = []
for i, p in enumerate(probs):
if p >= heads["thr"][i]:
cut = threshold_override if threshold_override is not None else heads["thr"][i]
if p >= cut:
m = heads["meta"][i]
out.append({
"tag_id": m["tag_id"],