+ Run inference on all images missing predictions or embeddings for the current model versions.
+ Resumable and safe to re-run. Backfill enqueues work on the ml queue; monitor progress in the ml-worker logs.
+
+
+
+
+ Recompute centroids for all eligible tags (character, fandom, and general/topic) that have at least
+ the minimum number of reference images. Run this after the initial backfill, or any time you've
+ applied many tags manually and want the suggestions to catch up.
+
+
+
+
+ Attach each character's fandom to every image already tagged with that character.
+ Additive only — existing fandom tags are never removed. Run after you've set a
+ fandom on an existing character, or whenever you suspect drift.
+
+
+
diff --git a/app/utils/tag_names.py b/app/utils/tag_names.py
new file mode 100644
index 0000000..66e4d47
--- /dev/null
+++ b/app/utils/tag_names.py
@@ -0,0 +1,49 @@
+"""Normalization helpers for Tag display names.
+
+Keeps the first letter of every whitespace-separated word upper-case and
+preserves internal punctuation. Called from every character/fandom write
+path so typos like "misty" don't accumulate alongside "Misty".
+"""
+
+
+def underscores_to_spaces(s: str) -> str:
+ """Replace every underscore with a space.
+
+ Used by general/null-kind write paths and as a pre-step inside
+ normalize_display_name so WD14's 'misty_ketchum' and a hand-typed
+ 'Misty Ketchum' collapse onto the same tag name.
+
+ Examples:
+ "big_hair" -> "big hair"
+ "long_blue_hair" -> "long blue hair"
+ "" -> ""
+ """
+ return s.replace('_', ' ') if s else s
+
+
+def normalize_display_name(s: str) -> str:
+ """Title-case each whitespace-separated word. Preserves internal punctuation.
+
+ Underscores are converted to spaces first so WD14-style names normalize
+ identically to hand-typed names.
+
+ Examples:
+ "misty" -> "Misty"
+ "misty ketchum" -> "Misty Ketchum"
+ "misty_ketchum" -> "Misty Ketchum"
+ "o'brien" -> "O'brien"
+ "mary-kate" -> "Mary-kate"
+ "" -> ""
+ " " -> " " (only whitespace — returned unchanged)
+ """
+ if not s:
+ return s
+ s = underscores_to_spaces(s)
+ parts = s.split(' ')
+ out: list[str] = []
+ for p in parts:
+ if p == '':
+ out.append(p)
+ else:
+ out.append(p[:1].upper() + p[1:])
+ return ' '.join(out)
diff --git a/docker-compose.yml b/docker-compose.yml
index 459649e..952b351 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,7 +15,7 @@ services:
# PostgreSQL database
postgres:
- image: postgres:16-alpine
+ image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${DB_USER:-imagerepo}
POSTGRES_PASSWORD: ${DB_PASS:-postgres}
@@ -105,6 +105,37 @@ services:
condition: service_healthy
restart: unless-stopped
+ # ML inference worker (CPU-only): WD14 tagging + SigLIP embeddings
+ # Handles: ml queue
+ ml-worker:
+ build:
+ context: .
+ dockerfile: Dockerfile.ml
+ command: celery -A app.celery_app:celery worker --loglevel=info -Q ml --concurrency=1
+ environment:
+ - TZ=${TZ:-America/New_York}
+ - DB_USER=${DB_USER:-imagerepo}
+ - DB_PASS=${DB_PASS:-postgres}
+ - DB_HOST=postgres
+ - DB_PORT=5432
+ - DB_NAME=${DB_NAME:-imagerepo}
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ - ML_MODEL_DIR=/models
+ volumes:
+ - ./imagerepo/images:/images:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ deploy:
+ resources:
+ limits:
+ cpus: '4.0'
+ memory: 8G
+ restart: unless-stopped
+
volumes:
redis_data:
postgres_data:
diff --git a/docs/superpowers/plans/2026-04-18-clickable-post-tag.md b/docs/superpowers/plans/2026-04-18-clickable-post-tag.md
new file mode 100644
index 0000000..0b9e078
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-18-clickable-post-tag.md
@@ -0,0 +1,318 @@
+# Clickable Post Tag Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** In the gallery/showcase image modal, move the system-generated `post` tag out of the editable tag list into a dedicated, visually distinct provenance section whose chip links to the tag-filtered gallery view.
+
+**Architecture:** Frontend-only (Jinja template + vanilla JS + CSS). No backend or API changes — the existing `/image//tags` and `/api/post-metadata/by-tag-name/` endpoints cover everything. Tags returned by the image-tags endpoint are partitioned client-side into editable vs. provenance lists and rendered into separate containers.
+
+**Tech Stack:** Jinja2 templates, vanilla JavaScript, single CSS file (`app/static/style.css`)
+
+**Spec:** `docs/superpowers/specs/2026-04-18-clickable-post-tag-design.md`
+
+**Testing:** This repo has no automated test harness for the frontend. Verification is a manual browser check at the end of the plan. Each earlier task ends with a commit; the browser check is gated to after all code is in place.
+
+---
+
+## File Map
+
+| File | Task(s) | What changes |
+|------|---------|--------------|
+| `app/templates/_gallery_modal.html` | 1 | New `modal-provenance-section` between Series and Tags sections |
+| `app/static/style.css` | 2 | `.modal-provenance-section`, `.provenance-chip`, platform accent selectors |
+| `app/static/js/view-modal.js` | 3 | Cache provenance DOM refs; partition tags in `loadTags`; add `renderProvenance`, `getProvenanceDisplayName`, `upgradeProvenanceChip`; clear provenance in `closeModal` |
+
+---
+
+## Task 1: Add provenance section markup to the modal
+
+**Files:**
+- Modify: `app/templates/_gallery_modal.html` (insert between line 48 and line 50)
+
+### Step 1.1: Open the modal template
+
+- [ ] Open `app/templates/_gallery_modal.html`.
+- [ ] Locate the end of the Series section — the `` on line 48 that closes `modal-series-section`, immediately followed by `` on line 50.
+
+### Step 1.2: Insert the provenance section between Series and Tags
+
+- [ ] Insert this block on a new line between the closing `` of the Series section and the `` comment:
+
+```html
+
+
+
+
+
+```
+
+The `style="display: none;"` matches the inline-style pattern already used by `modalSeriesInfo` and `modalAddToSeries` in this same file (per the project's "inline style over CSS class toggling" convention from commit `e37ce27`).
+
+### Step 1.3: Verify the insertion
+
+- [ ] Run: `grep -n "modalProvenanceSection\|modal-tags-section" app/templates/_gallery_modal.html`
+- [ ] Expected: `modalProvenanceSection` appears on an earlier line than `modal-tags-section`.
+
+### Step 1.4: Commit
+
+```bash
+git add app/templates/_gallery_modal.html
+git commit -m "add empty provenance section to gallery modal sidebar"
+```
+
+---
+
+## Task 2: Add CSS for provenance section and chip
+
+**Files:**
+- Modify: `app/static/style.css` (append new block)
+
+### Step 2.1: Find the end of the modal sidebar / tag chip section in the stylesheet
+
+- [ ] Open `app/static/style.css`.
+- [ ] Run: `grep -n "Tag chips in modal - with remove button" app/static/style.css`
+- [ ] Navigate to that block (around line 816). Scroll to the end of the rule block that styles `.tag-editor .tag-chip` — the last closing `}` of that rule group.
+
+### Step 2.2: Append the provenance CSS
+
+- [ ] Immediately after that closing `}`, append:
+
+```css
+
+/* Modal provenance section — system-generated origin tags (post; future: source/archive) */
+.modal-provenance-section .provenance-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.provenance-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 2px 8px 2px 10px;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text-dim);
+ text-decoration: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-left-width: 2px;
+ border-left-color: rgba(255, 255, 255, 0.4);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font: 12px/1.6 system-ui, sans-serif;
+ max-width: clamp(8rem, 22vw, 14rem);
+ transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
+}
+
+.provenance-chip:hover {
+ border-color: rgba(255, 255, 255, 0.35);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text);
+}
+
+.provenance-chip[data-platform="patreon"] {
+ border-left-color: #f96854;
+}
+.provenance-chip[data-platform="subscribestar"] {
+ border-left-color: #009cde;
+}
+.provenance-chip[data-platform="hentaifoundry"] {
+ border-left-color: #c8232c;
+}
+
+.provenance-chip .provenance-arrow {
+ opacity: 0.6;
+ font-size: 0.85em;
+}
+```
+
+### Step 2.3: Commit
+
+```bash
+git add app/static/style.css
+git commit -m "style provenance chip with platform-tinted ghost treatment"
+```
+
+---
+
+## Task 3: Partition tags and render the provenance chip in view-modal.js
+
+**Files:**
+- Modify: `app/static/js/view-modal.js`
+
+This task has several edits inside one file. Make them in order; commit once at the end.
+
+### Step 3.1: Cache the new DOM references
+
+- [ ] Open `app/static/js/view-modal.js`.
+- [ ] Find the block that caches tag editor elements (around lines 11-16), beginning with `const tagEditor = document.getElementById('modalTagEditor');`.
+- [ ] Immediately after the line `const tagActionFeedback = document.getElementById('tagActionFeedback');`, insert:
+
+```javascript
+ const provenanceSection = document.getElementById('modalProvenanceSection');
+ const provenanceList = document.getElementById('modalProvenanceList');
+```
+
+### Step 3.2: Add the provenance display-name helper
+
+- [ ] Find the `renderTags` function (starts around line 85).
+- [ ] Immediately **above** `function renderTags(tags) {`, insert this helper:
+
+```javascript
+ function getProvenanceDisplayName(name) {
+ // Post tag names are "post:platform:artist:id". Strip the "post:" prefix only.
+ if (name.startsWith('post:')) return name.slice(5);
+ return name;
+ }
+
+```
+
+### Step 3.3: Add renderProvenance and upgradeProvenanceChip
+
+- [ ] Directly **below** the existing `renderTags` function (after its closing `}` around line 96), insert:
+
+```javascript
+
+ function renderProvenance(tags) {
+ if (!provenanceSection || !provenanceList) return;
+ provenanceList.innerHTML = '';
+ if (!tags || tags.length === 0) {
+ provenanceSection.style.display = 'none';
+ return;
+ }
+ tags.forEach(t => {
+ const chip = document.createElement('a');
+ chip.className = 'provenance-chip';
+ chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
+ chip.title = t.name;
+ chip.dataset.tagName = t.name;
+ const placeholder = escapeHtml(getProvenanceDisplayName(t.name));
+ chip.innerHTML = `📌 ${placeholder}↗`;
+ provenanceList.appendChild(chip);
+ upgradeProvenanceChip(chip, t.name);
+ });
+ provenanceSection.style.display = '';
+ }
+
+ async function upgradeProvenanceChip(chip, tagName) {
+ try {
+ const r = await fetch(`/api/post-metadata/by-tag-name/${encodeURIComponent(tagName)}`);
+ const j = await r.json();
+ if (!j.ok || !j.metadata) return;
+ const pm = j.metadata;
+ if (pm.platform) chip.dataset.platform = pm.platform;
+ const label = pm.artist || pm.title;
+ if (label) {
+ const labelEl = chip.querySelector('.provenance-label');
+ if (labelEl) labelEl.textContent = label;
+ }
+ } catch {
+ // Fetch failed — chip keeps its placeholder label and neutral accent.
+ }
+ }
+```
+
+### Step 3.4: Partition tags in loadTags
+
+- [ ] Find the current `loadTags` function (around lines 97-106):
+
+```javascript
+ async function loadTags(imageId) {
+ if (!imageId) { renderTags([]); return; }
+ try {
+ const r = await fetch(`/image/${imageId}/tags`);
+ const j = await r.json();
+ if (j.ok) renderTags(j.tags);
+ } catch {
+ // ignore
+ }
+ }
+```
+
+- [ ] Replace it with:
+
+```javascript
+ async function loadTags(imageId) {
+ if (!imageId) { renderTags([]); renderProvenance([]); return; }
+ try {
+ const r = await fetch(`/image/${imageId}/tags`);
+ const j = await r.json();
+ if (j.ok) {
+ const all = j.tags || [];
+ const postTags = all.filter(t => t.kind === 'post');
+ const editableTags = all.filter(t => t.kind !== 'post');
+ renderTags(editableTags);
+ renderProvenance(postTags);
+ }
+ } catch {
+ // ignore
+ }
+ }
+```
+
+### Step 3.5: Clear provenance on modal close
+
+- [ ] Find the `closeModal` function (around lines 615-634).
+- [ ] Locate the line `renderTags([]);` inside it (around line 625).
+- [ ] Immediately **after** that line, add:
+
+```javascript
+ renderProvenance([]);
+```
+
+### Step 3.6: Commit
+
+```bash
+git add app/static/js/view-modal.js
+git commit -m "split post tags into provenance section in image modal"
+```
+
+---
+
+## Task 4: Manual browser verification
+
+**No code changes. This is a gate before declaring the feature done.**
+
+### Step 4.1: Start the app
+
+- [ ] Follow whatever start procedure this project uses locally (e.g., `docker compose up` or `python run.py` — consult `README.md` / `entrypoint.sh` if unsure).
+- [ ] Open the showcase in a browser.
+
+### Step 4.2: Verify with an image that has a post tag
+
+- [ ] In the Tags nav, pick any post-backed image — or open the showcase and click any image known to come from an imported post (Patreon / SubscribeStar / HentaiFoundry).
+- [ ] Confirm:
+ - A new chip-only section appears in the modal sidebar, **above** the editable tag list and **below** the Series section.
+ - The chip displays 📌, a text label, and a trailing ↗.
+ - The chip's **left border** is tinted with the platform color (orange / teal / red).
+ - The label starts as the raw `platform:artist:id` placeholder, then updates to the artist (or title, if no artist) once the platform metadata request resolves.
+ - The post tag is **not** present in the editable tag list below.
+ - Hovering the chip brightens the border and background.
+ - Clicking the chip navigates to `/gallery?tag=` and the gallery page shows the post's title/description/source URL block.
+
+### Step 4.3: Verify with an image that has NO post tag
+
+- [ ] Open an image with no post tag (e.g., a manually uploaded image without import metadata).
+- [ ] Confirm:
+ - The provenance section is **not visible** — no empty heading, no empty chip row, no stray divider.
+ - The editable tag list still renders unchanged.
+
+### Step 4.4: Verify the metadata-fetch failure path (optional smoke test)
+
+- [ ] In DevTools, block requests to `/api/post-metadata/by-tag-name/*` and reopen the modal on a post-backed image.
+- [ ] Confirm the chip renders with the neutral-grey left border (no platform tint) and the raw `platform:artist:id` label, and clicking it still navigates to the filtered gallery.
+
+### Step 4.5: Navigate between images with arrow keys
+
+- [ ] From a post-backed image modal, press the right-arrow key to advance through several images of mixed types (with and without post tags).
+- [ ] Confirm the provenance section shows and hides correctly per image, with no stale chip left over from the previous image.
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: placement between Series and Tags ✓ (Task 1), hidden when empty ✓ (Task 3.3, 3.4, 3.5), platform-tinted ghost chip ✓ (Task 2), click → filtered gallery ✓ (Task 3.3), partition so post tag is removed from editable list ✓ (Task 3.4), placeholder + metadata upgrade ✓ (Task 3.3), fallback accent on fetch failure ✓ (Task 4.4 verifies).
+- No placeholders in the plan: every code step shows the full snippet to insert; no "similar to" references.
+- Type consistency: `renderProvenance`, `upgradeProvenanceChip`, `getProvenanceDisplayName` are named consistently across steps; DOM ids (`modalProvenanceSection`, `modalProvenanceList`) match between template and JS.
diff --git a/docs/superpowers/plans/2026-04-18-tag-suggestions.md b/docs/superpowers/plans/2026-04-18-tag-suggestions.md
new file mode 100644
index 0000000..c0828b6
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-18-tag-suggestions.md
@@ -0,0 +1,1825 @@
+# Tag Suggestions Implementation Plan (Scan + Modal Phase)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the ML-backed tag suggestion pipeline (WD14 tagger + SigLIP embeddings) and surface suggestions in the image modal with accept/reject controls. Multi-edit gallery suggestions are deferred.
+
+**Architecture:** A new `ml-worker` Celery service runs CPU-only inference on both models, writing predictions and embeddings to pgvector-backed tables. Suggestion generation happens on-read in the Flask web process. The modal loads suggestions alongside existing tag lists and lets the user apply a hybrid vocabulary policy: auto-create character/fandom/rating tags on accept, keep general tags curated.
+
+**Tech Stack:** Python 3.12, Flask, SQLAlchemy + pgvector, PostgreSQL 16, Celery 5 + Redis, ONNX Runtime (CPU), PyTorch (CPU) + Transformers for SigLIP, Pillow, NumPy, Alembic, vanilla JS, Jinja2.
+
+**Parent spec:** `docs/superpowers/specs/2026-04-18-tag-suggestions-integration.md` (which layers on `docs/superpowers/tag-suggestion-system-spec.md`).
+
+**Testing:** This repo has no automated test harness. Per-task verification is structural (grep / read) and a manual inference smoke test for ML tasks; end-to-end verification is a gated manual browser check at the very end.
+
+---
+
+## File Map
+
+| File | Task | Responsibility |
+|------|------|----------------|
+| `requirements.txt` | 1 | Add `pgvector` so the web process can read `Vector` columns |
+| `migrations/versions/_add_tag_suggestions.py` | 1 | Enable `vector` extension, create 5 tables, HNSW index, seed config |
+| `app/models.py` | 2 | Append 5 SQLAlchemy models backing the new tables |
+| `app/ml/__init__.py` | 3 | Mark package |
+| `app/ml/wd14.py` | 3 | Load WD14 ONNX, preprocess, run inference |
+| `app/ml/siglip.py` | 3 | Load SigLIP via transformers, preprocess, run inference |
+| `requirements-ml.txt` | 4 | ML-worker-only Python deps |
+| `scripts/download_models.py` | 4 | Pre-fetches model weights at image build time |
+| `Dockerfile.ml` | 4 | Build ml-worker image with CPU ONNX Runtime, PyTorch CPU, models baked in |
+| `app/tasks/ml.py` | 5 | Celery tasks: `tag_and_embed`, `backfill`, `recompute_centroid` |
+| `docker-compose.yml` | 6 | Add `ml-worker` service on the `ml` queue |
+| `app/celery_app.py` | 6 | Route `app.tasks.ml.*` to the `ml` queue |
+| `app/tasks/import_file.py` | 7 | Chain `tag_and_embed` after ImageRecord commit |
+| `app/services/__init__.py` | 8 | Mark package |
+| `app/services/tag_suggestions.py` | 8 | Generate merged suggestion list on read |
+| `app/main.py` | 9, 11 | Three suggestion endpoints + backfill trigger endpoint |
+| `app/templates/_gallery_modal.html` | 10 | Add suggestions section markup |
+| `app/static/style.css` | 10 | Suggestion section + chip styles |
+| `app/static/js/view-modal.js` | 10 | `loadSuggestions`, `acceptSuggestion`, `rejectSuggestion`, lifecycle hooks |
+| `app/templates/_settings_maintenance.html` (or equivalent) | 11 | "Run ML backfill" button |
+
+---
+
+## Task 1: Database — pgvector extension, 5 tables, seed config
+
+**Files:**
+- Modify: `requirements.txt`
+- Create: `migrations/versions/_add_tag_suggestions.py`
+
+### Step 1.1: Add pgvector to base requirements
+
+- [ ] Open `requirements.txt`.
+- [ ] At the end of the file, add:
+
+```
+pgvector>=0.2.5
+```
+
+### Step 1.2: Rebuild the web image so pgvector is installed
+
+- [ ] Run: `docker compose build web worker scheduler`
+- [ ] Expected: build completes; `pgvector` pulls in successfully.
+
+### Step 1.3: Generate the migration skeleton
+
+- [ ] Run: `docker compose run --rm web flask db revision -m "add tag suggestions"`
+- [ ] Expected: a new file appears in `migrations/versions/` with a generated revision id. Note the path.
+
+### Step 1.4: Replace the migration body
+
+- [ ] Open the newly generated migration file.
+- [ ] Replace its `upgrade()` and `downgrade()` with:
+
+```python
+"""add tag suggestions
+
+Revision ID:
+Revises:
+Create Date:
+"""
+from alembic import op
+import sqlalchemy as sa
+from pgvector.sqlalchemy import Vector
+
+
+# revision identifiers, used by Alembic.
+revision = ''
+down_revision = ''
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("CREATE EXTENSION IF NOT EXISTS vector")
+
+ op.create_table(
+ 'image_tag_prediction',
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('image_id', sa.Integer(), nullable=False),
+ sa.Column('tag_name', sa.Text(), nullable=False),
+ sa.Column('tag_category', sa.Text(), nullable=False),
+ sa.Column('confidence', sa.Float(), nullable=False),
+ sa.Column('model_version', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE'),
+ )
+ op.create_index('idx_tag_predictions_image', 'image_tag_prediction', ['image_id'])
+ op.create_index('idx_tag_predictions_tag', 'image_tag_prediction', ['tag_name'])
+ op.create_index('idx_tag_predictions_model', 'image_tag_prediction', ['model_version'])
+
+ op.create_table(
+ 'image_embedding',
+ sa.Column('image_id', sa.Integer(), nullable=False),
+ sa.Column('model_version', sa.Text(), nullable=False),
+ sa.Column('embedding', Vector(1152), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.PrimaryKeyConstraint('image_id', 'model_version'),
+ sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE'),
+ )
+ op.execute(
+ "CREATE INDEX idx_embeddings_hnsw ON image_embedding "
+ "USING hnsw (embedding vector_cosine_ops)"
+ )
+
+ op.create_table(
+ 'character_reference_embedding',
+ sa.Column('character_tag', sa.Text(), nullable=False),
+ sa.Column('model_version', sa.Text(), nullable=False),
+ sa.Column('centroid', Vector(1152), nullable=False),
+ sa.Column('reference_count', sa.Integer(), nullable=False),
+ sa.Column('computed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.PrimaryKeyConstraint('character_tag', 'model_version'),
+ )
+
+ op.create_table(
+ 'suggestion_feedback',
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('image_id', sa.Integer(), nullable=False),
+ sa.Column('tag_name', sa.Text(), nullable=False),
+ sa.Column('suggestion_source', sa.Text(), nullable=False),
+ sa.Column('confidence', sa.Float(), nullable=False),
+ sa.Column('decision', sa.Text(), nullable=False),
+ sa.Column('decided_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], ondelete='CASCADE'),
+ )
+ op.create_index('idx_feedback_image', 'suggestion_feedback', ['image_id'])
+ op.create_index('idx_feedback_tag', 'suggestion_feedback', ['tag_name'])
+
+ op.create_table(
+ 'tag_suggestion_config',
+ sa.Column('key', sa.Text(), primary_key=True),
+ sa.Column('value', sa.Text(), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ )
+
+ # Seed default thresholds
+ op.bulk_insert(
+ sa.table(
+ 'tag_suggestion_config',
+ sa.column('key', sa.Text()),
+ sa.column('value', sa.Text()),
+ sa.column('description', sa.Text()),
+ ),
+ [
+ {'key': 'threshold_general', 'value': '0.35', 'description': 'Min confidence for general tag suggestions'},
+ {'key': 'threshold_character_wd14', 'value': '0.75', 'description': 'Min confidence for WD14 character tags'},
+ {'key': 'threshold_copyright', 'value': '0.5', 'description': 'Min confidence for copyright/fandom tags'},
+ {'key': 'threshold_rating', 'value': '0.5', 'description': 'Min confidence for rating tags'},
+ {'key': 'threshold_meta', 'value': '0.5', 'description': 'Min confidence for meta tags (omitted from modal)'},
+ {'key': 'threshold_embedding_character', 'value': '0.85', 'description': 'Min cosine similarity for character via embeddings'},
+ {'key': 'min_reference_images_per_character', 'value': '5', 'description': 'Characters with fewer confirmed refs are excluded from embedding suggestions'},
+ {'key': 'centroid_recompute_delta', 'value': '3', 'description': 'Recompute character centroid after N new confirmed references'},
+ {'key': 'wd14_model_version', 'value': 'wd-eva02-large-tagger-v3', 'description': 'Current WD14 model version'},
+ {'key': 'siglip_model_version', 'value': 'siglip-so400m-patch14-384', 'description': 'Current SigLIP embedding model version'},
+ ],
+ )
+
+
+def downgrade():
+ op.drop_table('tag_suggestion_config')
+ op.drop_index('idx_feedback_tag', table_name='suggestion_feedback')
+ op.drop_index('idx_feedback_image', table_name='suggestion_feedback')
+ op.drop_table('suggestion_feedback')
+ op.drop_table('character_reference_embedding')
+ op.execute("DROP INDEX IF EXISTS idx_embeddings_hnsw")
+ op.drop_table('image_embedding')
+ op.drop_index('idx_tag_predictions_model', table_name='image_tag_prediction')
+ op.drop_index('idx_tag_predictions_tag', table_name='image_tag_prediction')
+ op.drop_index('idx_tag_predictions_image', table_name='image_tag_prediction')
+ op.drop_table('image_tag_prediction')
+ op.execute("DROP EXTENSION IF EXISTS vector")
+```
+
+### Step 1.5: Apply the migration
+
+- [ ] Run: `docker compose run --rm web flask db upgrade`
+- [ ] Expected: output ends with `Running upgrade -> , add tag suggestions` and no error.
+
+### Step 1.6: Structural verification
+
+- [ ] Run: `docker compose exec postgres psql -U imagerepo -d imagerepo -c "\dt"`
+- [ ] Expected: the five new tables (`image_tag_prediction`, `image_embedding`, `character_reference_embedding`, `suggestion_feedback`, `tag_suggestion_config`) appear in the list.
+- [ ] Run: `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT count(*) FROM tag_suggestion_config"`
+- [ ] Expected: count is `10`.
+
+### Step 1.7: Commit
+
+```bash
+git add requirements.txt migrations/versions/
+git commit -m "feat(db): add pgvector + tag-suggestion tables + seed thresholds"
+```
+
+---
+
+## Task 2: SQLAlchemy models for the new tables
+
+**Files:**
+- Modify: `app/models.py` (append at end)
+
+### Step 2.1: Append the new models
+
+- [ ] Open `app/models.py`.
+- [ ] At the end of the file, append:
+
+```python
+from pgvector.sqlalchemy import Vector
+
+
+class ImageTagPrediction(db.Model):
+ __tablename__ = "image_tag_prediction"
+ id = db.Column(db.Integer, primary_key=True)
+ image_id = db.Column(db.Integer, db.ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, index=True)
+ tag_name = db.Column(db.Text, nullable=False, index=True)
+ tag_category = db.Column(db.Text, nullable=False) # general/character/copyright/rating/meta
+ confidence = db.Column(db.Float, nullable=False)
+ model_version = db.Column(db.Text, nullable=False, index=True)
+ created_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+
+
+class ImageEmbedding(db.Model):
+ __tablename__ = "image_embedding"
+ image_id = db.Column(db.Integer, db.ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True)
+ model_version = db.Column(db.Text, primary_key=True)
+ embedding = db.Column(Vector(1152), nullable=False)
+ created_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+
+
+class CharacterReferenceEmbedding(db.Model):
+ __tablename__ = "character_reference_embedding"
+ character_tag = db.Column(db.Text, primary_key=True)
+ model_version = db.Column(db.Text, primary_key=True)
+ centroid = db.Column(Vector(1152), nullable=False)
+ reference_count = db.Column(db.Integer, nullable=False)
+ computed_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+
+
+class SuggestionFeedback(db.Model):
+ __tablename__ = "suggestion_feedback"
+ id = db.Column(db.Integer, primary_key=True)
+ image_id = db.Column(db.Integer, db.ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, index=True)
+ tag_name = db.Column(db.Text, nullable=False, index=True)
+ suggestion_source = db.Column(db.Text, nullable=False) # wd14 | embedding_similarity
+ confidence = db.Column(db.Float, nullable=False)
+ decision = db.Column(db.Text, nullable=False) # accepted | rejected
+ decided_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+
+
+class TagSuggestionConfig(db.Model):
+ __tablename__ = "tag_suggestion_config"
+ key = db.Column(db.Text, primary_key=True)
+ value = db.Column(db.Text, nullable=False)
+ description = db.Column(db.Text, nullable=True)
+```
+
+### Step 2.2: Verify the web process still imports cleanly
+
+- [ ] Run: `docker compose run --rm web python -c "from app.models import ImageTagPrediction, ImageEmbedding, CharacterReferenceEmbedding, SuggestionFeedback, TagSuggestionConfig; print('ok')"`
+- [ ] Expected: prints `ok` with no exceptions.
+
+### Step 2.3: Commit
+
+```bash
+git add app/models.py
+git commit -m "feat(models): add SQLAlchemy models for tag suggestions"
+```
+
+---
+
+## Task 3: ML inference wrappers (WD14 + SigLIP)
+
+**Files:**
+- Create: `app/ml/__init__.py`
+- Create: `app/ml/wd14.py`
+- Create: `app/ml/siglip.py`
+
+Both wrappers load lazily on first call and cache the session/model at module scope. They expect model files to live under `$ML_MODEL_DIR` (default `/models/`), which the ml-worker Dockerfile populates.
+
+### Step 3.1: Create the package
+
+- [ ] Create `app/ml/__init__.py` with contents:
+
+```python
+"""Machine-learning inference wrappers used by the ml-worker."""
+```
+
+### Step 3.2: Write the WD14 wrapper
+
+- [ ] Create `app/ml/wd14.py` with contents:
+
+```python
+"""WD14 EVA02-Large tagger (ONNX CPU inference)."""
+from __future__ import annotations
+import csv
+import os
+from typing import Iterable
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image
+
+MODEL_VERSION = os.environ.get('WD14_MODEL_VERSION', 'wd-eva02-large-tagger-v3')
+_MODEL_DIR = os.environ.get('ML_MODEL_DIR', '/models')
+_WD14_DIR = os.path.join(_MODEL_DIR, 'wd14')
+_MODEL_PATH = os.path.join(_WD14_DIR, 'model.onnx')
+_TAGS_PATH = os.path.join(_WD14_DIR, 'selected_tags.csv')
+
+# WD14 selected_tags.csv uses Danbooru category ids:
+# 0=general, 1=artist, 3=copyright, 4=character, 5=meta, 9=rating
+_CATEGORY_MAP = {0: 'general', 1: 'artist', 3: 'copyright', 4: 'character', 5: 'meta', 9: 'rating'}
+
+_session: ort.InferenceSession | None = None
+_tag_meta: list[dict] | None = None
+_input_name: str | None = None
+_output_name: str | None = None
+_input_size: int = 448
+
+
+def _load() -> None:
+ global _session, _tag_meta, _input_name, _output_name, _input_size
+ if _session is not None:
+ return
+
+ tag_meta: list[dict] = []
+ with open(_TAGS_PATH, newline='') as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ tag_meta.append({
+ 'name': row['name'],
+ 'category': _CATEGORY_MAP.get(int(row['category']), 'unknown'),
+ })
+ _tag_meta = tag_meta
+
+ session = ort.InferenceSession(
+ _MODEL_PATH,
+ providers=['CPUExecutionProvider'],
+ )
+ _session = session
+ _input_name = session.get_inputs()[0].name
+ _output_name = session.get_outputs()[0].name
+ # Input shape is usually [batch, H, W, 3] NHWC; pick the spatial dim
+ input_shape = session.get_inputs()[0].shape
+ for dim in input_shape:
+ if isinstance(dim, int) and dim > 1:
+ _input_size = dim
+ break
+
+
+def _preprocess(image_path: str) -> np.ndarray:
+ img = Image.open(image_path)
+ if img.mode != 'RGBA':
+ img = img.convert('RGBA')
+ # Composite onto white background so transparency doesn't bias the model
+ bg = Image.new('RGBA', img.size, (255, 255, 255, 255))
+ bg.paste(img, mask=img.split()[3] if img.mode == 'RGBA' else None)
+ img = bg.convert('RGB')
+
+ w, h = img.size
+ side = max(w, h)
+ square = Image.new('RGB', (side, side), (255, 255, 255))
+ square.paste(img, ((side - w) // 2, (side - h) // 2))
+ square = square.resize((_input_size, _input_size), Image.BICUBIC)
+
+ arr = np.array(square, dtype=np.float32)
+ # WD14 was trained on BGR
+ arr = arr[:, :, ::-1]
+ return arr[np.newaxis, :, :, :] # NHWC
+
+
+def infer(image_path: str) -> list[dict]:
+ """Run WD14 on one image. Returns a list of {name, category, confidence}."""
+ _load()
+ x = _preprocess(image_path)
+ out = _session.run([_output_name], {_input_name: x})[0][0]
+ results: list[dict] = []
+ for idx, score in enumerate(out):
+ meta = _tag_meta[idx]
+ results.append({
+ 'name': meta['name'],
+ 'category': meta['category'],
+ 'confidence': float(score),
+ })
+ return results
+
+
+def infer_filtered(image_path: str, min_any: float = 0.05) -> list[dict]:
+ """Same as infer() but drops tags below a floor to keep DB rows reasonable."""
+ return [r for r in infer(image_path) if r['confidence'] >= min_any]
+```
+
+### Step 3.3: Write the SigLIP wrapper
+
+- [ ] Create `app/ml/siglip.py` with contents:
+
+```python
+"""SigLIP SO400M image-embedding wrapper (PyTorch CPU)."""
+from __future__ import annotations
+import os
+
+import numpy as np
+import torch
+from PIL import Image
+from transformers import AutoModel, AutoProcessor
+
+MODEL_NAME = os.environ.get('SIGLIP_MODEL_NAME', 'google/siglip-so400m-patch14-384')
+MODEL_VERSION = os.environ.get('SIGLIP_MODEL_VERSION', 'siglip-so400m-patch14-384')
+_CACHE_DIR = os.path.join(os.environ.get('ML_MODEL_DIR', '/models'), 'siglip')
+
+_model = None
+_processor = None
+
+
+def _load() -> None:
+ global _model, _processor
+ if _model is not None:
+ return
+ _processor = AutoProcessor.from_pretrained(MODEL_NAME, cache_dir=_CACHE_DIR)
+ model = AutoModel.from_pretrained(MODEL_NAME, cache_dir=_CACHE_DIR)
+ model.eval()
+ _model = model
+
+
+def infer(image_path: str) -> np.ndarray:
+ """Return a 1152-dim float32 numpy embedding for the image."""
+ _load()
+ img = Image.open(image_path).convert('RGB')
+ with torch.no_grad():
+ inputs = _processor(images=img, return_tensors='pt')
+ features = _model.get_image_features(**inputs) # shape [1, 1152]
+ return features[0].numpy().astype(np.float32)
+```
+
+### Step 3.4: Structural check
+
+- [ ] Run: `grep -n "def infer" app/ml/wd14.py app/ml/siglip.py`
+- [ ] Expected: `infer` (and for wd14, `infer_filtered`) appear in both files.
+
+### Step 3.5: Commit
+
+```bash
+git add app/ml/__init__.py app/ml/wd14.py app/ml/siglip.py
+git commit -m "feat(ml): add WD14 and SigLIP inference wrappers"
+```
+
+---
+
+## Task 4: ml-worker Dockerfile, requirements, and model-download script
+
+**Files:**
+- Create: `requirements-ml.txt`
+- Create: `scripts/download_models.py`
+- Create: `Dockerfile.ml`
+
+### Step 4.1: Create the ML requirements list
+
+- [ ] Create `requirements-ml.txt` with contents:
+
+```
+# Base app deps (mirrors requirements.txt so the ml-worker can import app code)
+Flask
+Flask-SQLAlchemy
+Flask-Migrate
+psycopg2-binary
+pillow
+exifread
+imagehash
+gunicorn
+celery[redis]>=5.3.0
+redis>=5.0.0
+pgvector>=0.2.5
+
+# ML-specific deps (CPU-only)
+numpy>=1.26
+onnxruntime>=1.17 # CPU build; do not install onnxruntime-gpu here
+transformers>=4.40
+huggingface_hub>=0.22
+```
+
+Note: `torch` CPU is installed separately in the Dockerfile via PyTorch's CPU wheel index, to avoid pulling the CUDA build.
+
+### Step 4.2: Create the model-download script
+
+- [ ] Create `scripts/download_models.py` with contents:
+
+```python
+"""Pre-fetch WD14 and SigLIP model files into $ML_MODEL_DIR at image build time."""
+from __future__ import annotations
+import os
+import sys
+
+from huggingface_hub import hf_hub_download, snapshot_download
+
+MODEL_DIR = os.environ.get('ML_MODEL_DIR', '/models')
+WD14_REPO = os.environ.get('WD14_REPO', 'SmilingWolf/wd-eva02-large-tagger-v3')
+SIGLIP_REPO = os.environ.get('SIGLIP_REPO', 'google/siglip-so400m-patch14-384')
+
+
+def download_wd14() -> None:
+ target = os.path.join(MODEL_DIR, 'wd14')
+ os.makedirs(target, exist_ok=True)
+ hf_hub_download(repo_id=WD14_REPO, filename='model.onnx', local_dir=target, local_dir_use_symlinks=False)
+ hf_hub_download(repo_id=WD14_REPO, filename='selected_tags.csv', local_dir=target, local_dir_use_symlinks=False)
+ print(f"WD14 downloaded to {target}")
+
+
+def download_siglip() -> None:
+ target = os.path.join(MODEL_DIR, 'siglip')
+ os.makedirs(target, exist_ok=True)
+ snapshot_download(
+ repo_id=SIGLIP_REPO,
+ local_dir=target,
+ local_dir_use_symlinks=False,
+ allow_patterns=[
+ 'config.json',
+ 'preprocessor_config.json',
+ 'tokenizer_config.json',
+ 'tokenizer.json',
+ 'special_tokens_map.json',
+ 'spiece.model',
+ 'model.safetensors',
+ 'pytorch_model.bin',
+ ],
+ )
+ print(f"SigLIP downloaded to {target}")
+
+
+if __name__ == '__main__':
+ download_wd14()
+ download_siglip()
+ sys.exit(0)
+```
+
+### Step 4.3: Create the ml-worker Dockerfile
+
+- [ ] Create `Dockerfile.ml` with contents:
+
+```dockerfile
+# syntax=docker/dockerfile:1
+FROM python:3.12-slim AS base
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ ML_MODEL_DIR=/models
+
+# System deps: Pillow / image handling + psycopg2 build deps (binary wheel usually fine but libpq is needed at runtime on slim)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libgl1 \
+ libglib2.0-0 \
+ libpq5 \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Install PyTorch CPU wheel first from its dedicated index to avoid pulling CUDA libs
+RUN pip install --no-cache-dir \
+ --index-url https://download.pytorch.org/whl/cpu \
+ torch==2.3.1
+
+# Install ML requirements
+COPY requirements-ml.txt /app/requirements-ml.txt
+RUN pip install --no-cache-dir -r requirements-ml.txt
+
+# Download models at build time so container starts fast and has no network dependency
+COPY scripts/download_models.py /app/scripts/download_models.py
+RUN mkdir -p ${ML_MODEL_DIR} && python /app/scripts/download_models.py
+
+# Copy application code (needed so Celery can import app.tasks.ml and app.ml)
+COPY app /app/app
+
+# Celery needs these env vars set at runtime via docker-compose.
+CMD ["celery", "-A", "app.celery_app:celery", "worker", "--loglevel=info", "-Q", "ml", "--concurrency=1"]
+```
+
+### Step 4.4: Build the ml-worker image
+
+- [ ] Run: `docker build -f Dockerfile.ml -t imagerepo-ml-worker:dev .`
+- [ ] Expected: build succeeds. Expect it to take 10-20 minutes the first time because of model downloads (~2 GB). Image size will be large.
+
+### Step 4.5: Smoke-test the wrappers inside the image
+
+- [ ] Run (adjust path if your dev image fixtures live elsewhere):
+
+```bash
+docker run --rm -v /path/to/a/real/image.jpg:/tmp/test.jpg \
+ imagerepo-ml-worker:dev \
+ python -c "from app.ml import wd14, siglip; r=wd14.infer('/tmp/test.jpg'); print(len(r), sorted(r, key=lambda x:-x['confidence'])[:5]); e=siglip.infer('/tmp/test.jpg'); print(e.shape, e.dtype)"
+```
+
+- [ ] Expected: WD14 prints a count (~10k) and the top-5 tags with descending confidence; SigLIP prints `(1152,) float32`.
+- [ ] If WD14's input tensor name doesn't match, re-check `session.get_inputs()[0].name` — the wrapper already uses it dynamically, so this should Just Work.
+
+### Step 4.6: Commit
+
+```bash
+git add requirements-ml.txt scripts/download_models.py Dockerfile.ml
+git commit -m "feat(ml): add ml-worker Dockerfile, requirements, and model downloader"
+```
+
+---
+
+## Task 5: Celery tasks — tag_and_embed, backfill, recompute_centroid
+
+**Files:**
+- Create: `app/tasks/ml.py`
+
+### Step 5.1: Write the Celery task module
+
+- [ ] Create `app/tasks/ml.py` with contents:
+
+```python
+"""ML inference Celery tasks (WD14 tagging, SigLIP embedding, centroid recompute)."""
+from __future__ import annotations
+import logging
+import os
+import time
+
+import numpy as np
+from sqlalchemy import and_, func
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+
+from app.celery_app import celery
+from app import db
+from app.models import (
+ ImageRecord,
+ Tag,
+ ImageTagPrediction,
+ ImageEmbedding,
+ CharacterReferenceEmbedding,
+ TagSuggestionConfig,
+ image_tags,
+)
+
+log = logging.getLogger('celery.tasks.ml')
+
+# Minimum raw WD14 confidence to bother storing. Below this, rows are noise.
+WD14_STORE_FLOOR = float(os.environ.get('WD14_STORE_FLOOR', '0.05'))
+
+
+def _config_value(key: str, default: str) -> str:
+ row = TagSuggestionConfig.query.filter_by(key=key).first()
+ return row.value if row else default
+
+
+@celery.task(bind=True, name='app.tasks.ml.tag_and_embed',
+ max_retries=2, default_retry_delay=60,
+ soft_time_limit=120, time_limit=180)
+def tag_and_embed(self, image_id: int):
+ """Run WD14 + SigLIP on one image and persist predictions and embedding."""
+ from app.ml import wd14, siglip # lazy import so web process never loads torch
+
+ image = ImageRecord.query.get(image_id)
+ if image is None:
+ log.warning(f"tag_and_embed: image {image_id} not found")
+ return {'status': 'missing'}
+
+ if not os.path.exists(image.filepath):
+ log.warning(f"tag_and_embed: file missing at {image.filepath}")
+ return {'status': 'file_missing'}
+
+ try:
+ t0 = time.time()
+ raw = wd14.infer_filtered(image.filepath, min_any=WD14_STORE_FLOOR)
+ embedding = siglip.infer(image.filepath)
+ t_inf = time.time() - t0
+
+ # Remove any pre-existing predictions/embedding for this image+model_version pair
+ db.session.query(ImageTagPrediction).filter(
+ ImageTagPrediction.image_id == image_id,
+ ImageTagPrediction.model_version == wd14.MODEL_VERSION,
+ ).delete(synchronize_session=False)
+ db.session.query(ImageEmbedding).filter(
+ ImageEmbedding.image_id == image_id,
+ ImageEmbedding.model_version == siglip.MODEL_VERSION,
+ ).delete(synchronize_session=False)
+
+ for pred in raw:
+ db.session.add(ImageTagPrediction(
+ image_id=image_id,
+ tag_name=pred['name'],
+ tag_category=pred['category'],
+ confidence=pred['confidence'],
+ model_version=wd14.MODEL_VERSION,
+ ))
+
+ db.session.add(ImageEmbedding(
+ image_id=image_id,
+ model_version=siglip.MODEL_VERSION,
+ embedding=embedding.tolist(),
+ ))
+
+ db.session.commit()
+ log.info(f"tag_and_embed: image {image_id} done in {t_inf:.2f}s ({len(raw)} predictions)")
+ return {'status': 'ok', 'predictions': len(raw), 'duration_s': t_inf}
+ except Exception as e:
+ db.session.rollback()
+ log.error(f"tag_and_embed failed for image {image_id}: {e}", exc_info=True)
+ raise self.retry(exc=e)
+
+
+@celery.task(bind=True, name='app.tasks.ml.backfill',
+ soft_time_limit=None, time_limit=None)
+def backfill(self, batch_size: int = 50, pause_seconds: float = 0.5):
+ """Enqueue tag_and_embed for every image missing predictions or embeddings for the current model versions.
+
+ Resumable by construction: the query re-runs on each batch and only picks up images still without rows.
+ """
+ from app.ml.wd14 import MODEL_VERSION as WD14_VER
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+
+ enqueued_total = 0
+ while True:
+ # Images missing a prediction row OR an embedding row for current model versions
+ q = (
+ db.session.query(ImageRecord.id)
+ .outerjoin(
+ ImageTagPrediction,
+ and_(
+ ImageTagPrediction.image_id == ImageRecord.id,
+ ImageTagPrediction.model_version == WD14_VER,
+ ),
+ )
+ .outerjoin(
+ ImageEmbedding,
+ and_(
+ ImageEmbedding.image_id == ImageRecord.id,
+ ImageEmbedding.model_version == SIGLIP_VER,
+ ),
+ )
+ .filter(
+ (ImageTagPrediction.image_id.is_(None))
+ | (ImageEmbedding.image_id.is_(None))
+ )
+ .order_by(ImageRecord.id)
+ .limit(batch_size)
+ )
+ ids = [row[0] for row in q.all()]
+ if not ids:
+ break
+ for image_id in ids:
+ tag_and_embed.apply_async(args=[image_id], queue='ml')
+ enqueued_total += len(ids)
+ log.info(f"backfill: enqueued batch of {len(ids)} (total {enqueued_total})")
+ time.sleep(pause_seconds)
+ log.info(f"backfill: complete, enqueued {enqueued_total} images")
+ return {'status': 'ok', 'enqueued': enqueued_total}
+
+
+@celery.task(bind=True, name='app.tasks.ml.recompute_centroid',
+ soft_time_limit=60, time_limit=120)
+def recompute_centroid(self, character_tag_name: str):
+ """Recompute the mean embedding for a character tag from all its currently-associated images."""
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+
+ tag = Tag.query.filter_by(name=character_tag_name, kind='character').first()
+ if tag is None:
+ log.warning(f"recompute_centroid: character tag {character_tag_name!r} not found")
+ return {'status': 'missing_tag'}
+
+ rows = (
+ db.session.query(ImageEmbedding.embedding)
+ .join(image_tags, image_tags.c.image_id == ImageEmbedding.image_id)
+ .filter(image_tags.c.tag_id == tag.id)
+ .filter(ImageEmbedding.model_version == SIGLIP_VER)
+ .all()
+ )
+ if not rows:
+ log.info(f"recompute_centroid: no embeddings yet for {character_tag_name}")
+ return {'status': 'no_embeddings'}
+
+ vectors = np.array([np.array(r[0], dtype=np.float32) for r in rows])
+ centroid = vectors.mean(axis=0)
+
+ stmt = pg_insert(CharacterReferenceEmbedding).values(
+ character_tag=character_tag_name,
+ model_version=SIGLIP_VER,
+ centroid=centroid.tolist(),
+ reference_count=len(rows),
+ computed_at=func.now(),
+ )
+ stmt = stmt.on_conflict_do_update(
+ index_elements=['character_tag', 'model_version'],
+ set_={
+ 'centroid': stmt.excluded.centroid,
+ 'reference_count': stmt.excluded.reference_count,
+ 'computed_at': func.now(),
+ },
+ )
+ db.session.execute(stmt)
+ db.session.commit()
+ log.info(f"recompute_centroid: {character_tag_name} -> n={len(rows)}")
+ return {'status': 'ok', 'reference_count': len(rows)}
+```
+
+### Step 5.2: Structural check
+
+- [ ] Run: `grep -nE "^@celery\.task|def tag_and_embed|def backfill|def recompute_centroid" app/tasks/ml.py`
+- [ ] Expected: four decorator/function matches for the three tasks.
+
+### Step 5.3: Commit
+
+```bash
+git add app/tasks/ml.py
+git commit -m "feat(ml): add Celery tasks for tagging, embedding, backfill, centroid recompute"
+```
+
+---
+
+## Task 6: Wire ml-worker service + queue routing
+
+**Files:**
+- Modify: `docker-compose.yml`
+- Modify: `app/celery_app.py`
+
+### Step 6.1: Route the ml tasks to the ml queue
+
+- [ ] Open `app/celery_app.py`.
+- [ ] Locate the `task_routes` dict (around line 62).
+- [ ] Add an entry for the ml tasks. After the line:
+
+```python
+ 'app.tasks.sidecar.*': {'queue': 'sidecar'},
+```
+
+insert:
+
+```python
+
+ # ML inference tasks - handled by ml-worker
+ 'app.tasks.ml.*': {'queue': 'ml'},
+```
+
+### Step 6.2: Add the ml-worker service to docker-compose.yml
+
+- [ ] Open `docker-compose.yml`.
+- [ ] After the `scheduler` service block and before the `volumes:` block, insert:
+
+```yaml
+ # ML inference worker (CPU-only): WD14 tagging + SigLIP embeddings
+ # Handles: ml queue
+ ml-worker:
+ build:
+ context: .
+ dockerfile: Dockerfile.ml
+ command: celery -A app.celery_app:celery worker --loglevel=info -Q ml --concurrency=1
+ environment:
+ - TZ=${TZ:-America/New_York}
+ - DB_USER=${DB_USER:-imagerepo}
+ - DB_PASS=${DB_PASS:-postgres}
+ - DB_HOST=postgres
+ - DB_PORT=5432
+ - DB_NAME=${DB_NAME:-imagerepo}
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ - ML_MODEL_DIR=/models
+ volumes:
+ - ./imagerepo/images:/images:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ deploy:
+ resources:
+ limits:
+ cpus: '4.0'
+ memory: 8G
+ restart: unless-stopped
+```
+
+### Step 6.3: Verify config parses
+
+- [ ] Run: `docker compose config > /dev/null`
+- [ ] Expected: no output and exit code 0.
+- [ ] Run: `grep -nE "app\.tasks\.ml|ml-worker:" app/celery_app.py docker-compose.yml`
+- [ ] Expected: the ml route appears in `app/celery_app.py`, and the `ml-worker:` service key appears in `docker-compose.yml`.
+
+### Step 6.4: Bring up the ml-worker
+
+- [ ] Run: `docker compose up -d ml-worker`
+- [ ] Run: `docker compose logs --tail=20 ml-worker`
+- [ ] Expected: Celery banner lines show it connected to Redis and is listening on the `ml` queue. No tracebacks.
+
+### Step 6.5: Commit
+
+```bash
+git add docker-compose.yml app/celery_app.py
+git commit -m "feat(infra): add ml-worker service and route ml tasks to ml queue"
+```
+
+---
+
+## Task 7: Chain tag_and_embed from the import pipeline
+
+**Files:**
+- Modify: `app/tasks/import_file.py`
+
+### Step 7.1: Insert the chain call after ImageRecord commit
+
+- [ ] Open `app/tasks/import_file.py`.
+- [ ] Find the block in `import_media_file` that currently queues the sidecar metadata task (around lines 361-365). It looks like:
+
+```python
+ # Queue sidecar metadata enrichment
+ # Uses archive sidecar as fallback for files from archives
+ sidecar_path = _find_best_sidecar(src_path, archive_path, archive_sidecar_path)
+ if sidecar_path:
+ apply_sidecar_metadata.delay(record.id, sidecar_path)
+```
+
+- [ ] Immediately **after** that block, add:
+
+```python
+
+ # Queue ML inference (WD14 tagging + SigLIP embedding)
+ try:
+ from app.tasks.ml import tag_and_embed
+ tag_and_embed.apply_async(args=[record.id], queue='ml')
+ except Exception as ml_err:
+ # Never let an enqueue failure roll back an otherwise-successful import.
+ log.warning(f"Could not enqueue tag_and_embed for image {record.id}: {ml_err}")
+```
+
+### Step 7.2: Structural check
+
+- [ ] Run: `grep -n "tag_and_embed" app/tasks/import_file.py`
+- [ ] Expected: two hits (the import and the `apply_async` call).
+
+### Step 7.3: Commit
+
+```bash
+git add app/tasks/import_file.py
+git commit -m "feat(import): chain tag_and_embed after ImageRecord commit"
+```
+
+---
+
+## Task 8: Suggestion-generation service (read path)
+
+**Files:**
+- Create: `app/services/__init__.py`
+- Create: `app/services/tag_suggestions.py`
+
+### Step 8.1: Create the services package
+
+- [ ] Create `app/services/__init__.py` with contents:
+
+```python
+"""Read-path service modules called from Flask routes."""
+```
+
+### Step 8.2: Write the suggestion-generation service
+
+- [ ] Create `app/services/tag_suggestions.py` with contents:
+
+```python
+"""Compute merged tag suggestions for an image on demand.
+
+Sources:
+ - WD14 predictions filtered by per-category thresholds
+ - Embedding similarity vs. character centroids (for character tags only)
+
+The result is grouped by category and annotated with whether each tag already
+exists in the Tag table (the modal dims disallowed auto-creations).
+"""
+from __future__ import annotations
+from typing import Iterable
+
+from sqlalchemy import select
+
+from app import db
+from app.models import (
+ ImageTagPrediction,
+ ImageEmbedding,
+ CharacterReferenceEmbedding,
+ TagSuggestionConfig,
+ ImageRecord,
+ Tag,
+ image_tags,
+)
+
+# Config keys with safe defaults (used if the row is missing from tag_suggestion_config).
+_DEFAULTS = {
+ 'threshold_general': 0.35,
+ 'threshold_character_wd14': 0.75,
+ 'threshold_copyright': 0.5,
+ 'threshold_rating': 0.5,
+ 'threshold_meta': 0.5,
+ 'threshold_embedding_character': 0.85,
+ 'min_reference_images_per_character': 5.0,
+ 'wd14_model_version': 'wd-eva02-large-tagger-v3',
+ 'siglip_model_version': 'siglip-so400m-patch14-384',
+}
+
+
+def _config() -> dict:
+ rows = TagSuggestionConfig.query.all()
+ cfg = dict(_DEFAULTS)
+ for r in rows:
+ try:
+ cfg[r.key] = float(r.value)
+ except ValueError:
+ cfg[r.key] = r.value
+ return cfg
+
+
+def _category_threshold(category: str, cfg: dict) -> float:
+ return {
+ 'general': cfg['threshold_general'],
+ 'character': cfg['threshold_character_wd14'],
+ 'copyright': cfg['threshold_copyright'],
+ 'rating': cfg['threshold_rating'],
+ 'meta': cfg['threshold_meta'],
+ }.get(category, 1.01) # unknown → effectively disabled
+
+
+def _existing_tag_names_for_image(image_id: int) -> set[str]:
+ rows = (
+ db.session.query(Tag.name)
+ .join(image_tags, image_tags.c.tag_id == Tag.id)
+ .filter(image_tags.c.image_id == image_id)
+ .all()
+ )
+ return {row[0] for row in rows}
+
+
+def _existing_tag_names() -> set[str]:
+ return {row[0] for row in db.session.query(Tag.name).all()}
+
+
+def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]:
+ wd14_ver = cfg.get('wd14_model_version', _DEFAULTS['wd14_model_version'])
+ preds = (
+ ImageTagPrediction.query
+ .filter_by(image_id=image_id, model_version=wd14_ver)
+ .all()
+ )
+ out: list[dict] = []
+ for p in preds:
+ threshold = _category_threshold(p.tag_category, cfg)
+ if p.confidence < threshold:
+ continue
+ if p.tag_name in already:
+ continue
+ out.append({
+ 'name': p.tag_name,
+ 'category': p.tag_category,
+ 'confidence': p.confidence,
+ 'source': 'wd14',
+ })
+ return out
+
+
+def _embedding_character_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]:
+ siglip_ver = cfg.get('siglip_model_version', _DEFAULTS['siglip_model_version'])
+ min_refs = int(cfg.get('min_reference_images_per_character', 5))
+ threshold = cfg.get('threshold_embedding_character', 0.85)
+
+ image_emb = (
+ ImageEmbedding.query
+ .filter_by(image_id=image_id, model_version=siglip_ver)
+ .first()
+ )
+ if image_emb is None:
+ return []
+
+ # pgvector cosine distance; similarity = 1 - distance
+ distance = CharacterReferenceEmbedding.centroid.cosine_distance(image_emb.embedding)
+ rows = (
+ db.session.query(
+ CharacterReferenceEmbedding.character_tag,
+ CharacterReferenceEmbedding.reference_count,
+ distance.label('distance'),
+ )
+ .filter(CharacterReferenceEmbedding.model_version == siglip_ver)
+ .filter(CharacterReferenceEmbedding.reference_count >= min_refs)
+ .order_by(distance)
+ .limit(20)
+ .all()
+ )
+ out: list[dict] = []
+ for tag_name, ref_count, dist in rows:
+ similarity = 1.0 - float(dist)
+ if similarity < threshold:
+ continue
+ if tag_name in already:
+ continue
+ out.append({
+ 'name': tag_name,
+ 'category': 'character',
+ 'confidence': similarity,
+ 'source': 'embedding_similarity',
+ })
+ return out
+
+
+def _merge(wd14: list[dict], embedding: list[dict]) -> list[dict]:
+ by_name: dict[str, dict] = {}
+ for s in wd14 + embedding:
+ existing = by_name.get(s['name'])
+ if existing is None or s['confidence'] > existing['confidence']:
+ by_name[s['name']] = s
+ return list(by_name.values())
+
+
+def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
+ """Return suggestions grouped by category for one image.
+
+ Shape:
+ {
+ 'character': [{name, confidence, source, exists_in_db}, ...],
+ 'copyright': [...],
+ 'rating': [...],
+ 'general': [...],
+ }
+ Ordered by confidence desc within each category. 'meta' is omitted.
+ """
+ if ImageRecord.query.get(image_id) is None:
+ return {'character': [], 'copyright': [], 'rating': [], 'general': []}
+
+ cfg = _config()
+ already = _existing_tag_names_for_image(image_id)
+
+ merged = _merge(
+ _wd14_suggestions(image_id, cfg, already),
+ _embedding_character_suggestions(image_id, cfg, already),
+ )
+
+ existing_all = _existing_tag_names()
+
+ grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'rating': [], 'general': []}
+ for s in merged:
+ if s['category'] not in grouped:
+ continue # meta / artist / unknown are dropped
+ s['exists_in_db'] = s['name'] in existing_all
+ grouped[s['category']].append(s)
+
+ for cat in grouped:
+ grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
+ grouped[cat] = grouped[cat][:top_k_per_category]
+
+ return grouped
+```
+
+### Step 8.3: Structural check
+
+- [ ] Run: `grep -n "def get_suggestions" app/services/tag_suggestions.py`
+- [ ] Expected: exactly one match on a line near the bottom.
+
+### Step 8.4: Commit
+
+```bash
+git add app/services/__init__.py app/services/tag_suggestions.py
+git commit -m "feat(services): add tag-suggestion generation service"
+```
+
+---
+
+## Task 9: Flask endpoints for suggestions (GET / accept / reject)
+
+**Files:**
+- Modify: `app/main.py`
+
+### Step 9.1: Identify an appropriate insertion point
+
+- [ ] Open `app/main.py`.
+- [ ] Run: `grep -n "def .*tags\|@app.route.*/image" app/main.py`
+- [ ] Locate the existing `/image//tags` handler. Insert the new routes next to it (directly below is fine).
+
+### Step 9.2: Insert the three endpoints
+
+- [ ] Directly below the existing `/image//tags` handler, paste:
+
+```python
+# ---------------------------------------------------------------------------
+# Tag suggestions (ML-backed)
+# ---------------------------------------------------------------------------
+
+_WD14_CATEGORY_TO_KIND = {
+ 'character': 'character',
+ 'copyright': 'fandom', # ImageRepo uses 'fandom' for IP/franchise
+ 'rating': 'rating',
+}
+
+
+@app.route('/image//suggestions', methods=['GET'])
+def get_image_suggestions(image_id):
+ from app.services.tag_suggestions import get_suggestions
+ suggestions = get_suggestions(image_id)
+ return jsonify({'ok': True, 'suggestions': suggestions})
+
+
+@app.route('/image//suggestions/accept', methods=['POST'])
+def accept_image_suggestion(image_id):
+ from app.models import (
+ ImageRecord, Tag, SuggestionFeedback,
+ )
+ payload = request.get_json(force=True, silent=True) or {}
+ tag_name = (payload.get('tag_name') or '').strip()
+ source = payload.get('source') or ''
+ category = payload.get('category') or ''
+ confidence = float(payload.get('confidence') or 0.0)
+ if not tag_name or not source or not category:
+ return jsonify({'ok': False, 'error': 'missing_fields'}), 400
+
+ image = ImageRecord.query.get(image_id)
+ if image is None:
+ return jsonify({'ok': False, 'error': 'image_not_found'}), 404
+
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ # Hybrid policy: auto-create for character/copyright/rating; refuse general.
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+ if kind is None:
+ return jsonify({'ok': False, 'error': 'unknown_general_tag'}), 400
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush() # assign tag.id
+
+ if tag not in image.tags:
+ image.tags.append(tag)
+
+ db.session.add(SuggestionFeedback(
+ image_id=image_id,
+ tag_name=tag_name,
+ suggestion_source=source,
+ confidence=confidence,
+ decision='accepted',
+ ))
+ db.session.commit()
+
+ # Schedule centroid recompute if this was a character and delta is exceeded.
+ if tag.kind == 'character':
+ from app.models import CharacterReferenceEmbedding, TagSuggestionConfig, image_tags
+ from app.tasks.ml import recompute_centroid
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+
+ delta_cfg = TagSuggestionConfig.query.filter_by(key='centroid_recompute_delta').first()
+ delta_threshold = int(delta_cfg.value) if delta_cfg else 3
+
+ current_count = (
+ db.session.query(db.func.count(image_tags.c.image_id))
+ .filter(image_tags.c.tag_id == tag.id)
+ .scalar()
+ ) or 0
+ ref = (
+ CharacterReferenceEmbedding.query
+ .filter_by(character_tag=tag_name, model_version=SIGLIP_VER)
+ .first()
+ )
+ last_count = ref.reference_count if ref else 0
+ if current_count - last_count >= delta_threshold:
+ try:
+ recompute_centroid.apply_async(args=[tag_name], queue='ml')
+ except Exception:
+ # Don't fail the accept if enqueue fails
+ pass
+
+ return jsonify({
+ 'ok': True,
+ 'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind},
+ })
+
+
+@app.route('/image//suggestions/reject', methods=['POST'])
+def reject_image_suggestion(image_id):
+ from app.models import SuggestionFeedback, ImageRecord
+ payload = request.get_json(force=True, silent=True) or {}
+ tag_name = (payload.get('tag_name') or '').strip()
+ source = payload.get('source') or ''
+ confidence = float(payload.get('confidence') or 0.0)
+ if not tag_name or not source:
+ return jsonify({'ok': False, 'error': 'missing_fields'}), 400
+
+ if ImageRecord.query.get(image_id) is None:
+ return jsonify({'ok': False, 'error': 'image_not_found'}), 404
+
+ db.session.add(SuggestionFeedback(
+ image_id=image_id,
+ tag_name=tag_name,
+ suggestion_source=source,
+ confidence=confidence,
+ decision='rejected',
+ ))
+ db.session.commit()
+ return jsonify({'ok': True})
+```
+
+Note: if `app/main.py` uses a blueprint rather than `@app.route`, adapt the decorator accordingly (e.g. `@bp.route(...)`). The `grep` in Step 9.1 reveals which pattern the existing `/image//tags` handler uses; follow it exactly.
+
+### Step 9.3: Verify the routes register
+
+- [ ] Run: `docker compose up -d web && docker compose exec web python -c "from app import app; print('\n'.join(sorted(str(r) for r in app.url_map.iter_rules() if 'suggestion' in str(r))))"`
+- [ ] Expected: three rules printed — the GET, accept POST, and reject POST.
+
+### Step 9.4: Commit
+
+```bash
+git add app/main.py
+git commit -m "feat(api): add GET/accept/reject endpoints for tag suggestions"
+```
+
+---
+
+## Task 10: Modal UI — template + CSS + JS
+
+**Files:**
+- Modify: `app/templates/_gallery_modal.html`
+- Modify: `app/static/style.css`
+- Modify: `app/static/js/view-modal.js`
+
+### Step 10.1: Add the suggestions section to the modal template
+
+- [ ] Open `app/templates/_gallery_modal.html`.
+- [ ] Run: `grep -n "SECTION: Tags\|modal-tags-section" app/templates/_gallery_modal.html`
+- [ ] Locate the closing `` of the Tags section (the sibling block below the existing `` comment).
+- [ ] Immediately after that closing `` (and before the next section or sidebar-wrapper close), insert:
+
+```html
+
+
+ Run inference on all images missing predictions or embeddings for the current model versions.
+ Resumable and safe to re-run. Backfill enqueues work on the ml queue; monitor progress in the ml-worker logs.
+
+
+
+```
+
+### Step 11.3: Add the backfill-trigger route
+
+- [ ] Open `app/main.py` (or the blueprint file that hosts settings routes — follow existing settings-route locations).
+- [ ] Near the existing maintenance routes, add:
+
+```python
+@app.route('/settings/maintenance/ml-backfill', methods=['POST'])
+def trigger_ml_backfill():
+ from app.tasks.ml import backfill
+ backfill.apply_async(queue='ml')
+ # Redirect back to the Maintenance tab. Adjust endpoint name if needed.
+ return redirect(url_for('settings', tab='maintenance'))
+```
+
+Note: the redirect target must match whatever endpoint and query/path pattern the existing Settings → Maintenance tab uses. Run `grep -n "def settings\|/settings\b" app/main.py` if unsure; use the same endpoint.
+
+### Step 11.4: Structural check
+
+- [ ] Run: `grep -n "trigger_ml_backfill" app/main.py app/templates/`
+- [ ] Expected: the function appears in `main.py`; `url_for('trigger_ml_backfill')` appears in the template.
+
+### Step 11.5: Commit
+
+```bash
+git add app/main.py app/templates/
+git commit -m "feat(settings): add ML backfill trigger to Maintenance tab"
+```
+
+---
+
+## Task 12: Manual browser verification
+
+**No code changes.** This is the gate before declaring the feature done.
+
+### Step 12.1: Bring up the full stack
+
+- [ ] Run: `docker compose up -d`
+- [ ] Run: `docker compose ps`
+- [ ] Expected: all services (`web`, `worker`, `scheduler`, `ml-worker`, `postgres`, `redis`) are Up / healthy.
+- [ ] Run: `docker compose logs --tail=30 ml-worker`
+- [ ] Expected: Celery banner, no tracebacks.
+
+### Step 12.2: Kick off backfill against existing images
+
+- [ ] Open Settings → Maintenance in a browser. Click **Run ML backfill**.
+- [ ] Run: `docker compose logs -f ml-worker | head -30`
+- [ ] Expected: you see tasks being received and completed (~1-2s each). Leave backfill running.
+
+### Step 12.3: Verify suggestions in the modal (post-backfill, ≥ few images processed)
+
+- [ ] Check a processed image exists:
+ `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT image_id, COUNT(*) FROM image_tag_prediction GROUP BY image_id LIMIT 5"`.
+- [ ] Open one of those images in the gallery modal.
+- [ ] Confirm the Suggestions section appears below Tags with chips grouped by category and descending confidence.
+- [ ] Hover a chip — the ✕ becomes visible; cursor shows pointer.
+- [ ] Click `+` on a plausible character/general suggestion; confirm:
+ - Chip animates out.
+ - The accepted tag appears in the editable Tags list.
+ - The gallery thumbnail (if visible) gets its tag-overlay refreshed.
+ - `tagActionFeedback` shows `Added `.
+- [ ] Click `✕` on any remaining suggestion; confirm chip animates out and `Rejected ` is shown.
+- [ ] Check feedback rows: `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT decision, suggestion_source, tag_name FROM suggestion_feedback ORDER BY decided_at DESC LIMIT 5"`.
+- [ ] Expected: recent `accepted` and `rejected` rows matching your clicks.
+
+### Step 12.4: Verify the auto-create hybrid policy
+
+- [ ] Find (or craft) a suggestion whose tag does NOT already exist in your DB — e.g. a character WD14 knows that you haven't tagged yet. Accept it.
+- [ ] Run: `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT id, name, kind FROM tag WHERE name="`.
+- [ ] Expected: a new row exists with the correct kind (`character`, `fandom`, or `rating`).
+- [ ] Find a general-category suggestion that does NOT exist in the DB. It should render dimmed with the `+` disabled. Confirm you cannot accept it from the UI.
+
+### Step 12.5: Verify per-upload chaining
+
+- [ ] Drop a new image into the import watch directory (or trigger your normal import path).
+- [ ] After import completes, run: `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT COUNT(*) FROM image_tag_prediction WHERE image_id="`.
+- [ ] Expected: within ~5-10s of import, the count is > 0.
+- [ ] Same for `image_embedding`: count is exactly 1.
+
+### Step 12.6: Verify centroid recompute
+
+- [ ] Accept a character suggestion on several images (enough to exceed `centroid_recompute_delta=3`).
+- [ ] Watch ml-worker logs: `docker compose logs -f ml-worker`.
+- [ ] Expected: a `recompute_centroid` task fires; on completion, check:
+ `docker compose exec postgres psql -U imagerepo -d imagerepo -c "SELECT character_tag, reference_count, computed_at FROM character_reference_embedding ORDER BY computed_at DESC LIMIT 5"`.
+- [ ] Expected: a row for the character with the current reference count.
+
+### Step 12.7: Verify arrow-key navigation in the modal
+
+- [ ] Use right-arrow to navigate between images.
+- [ ] Expected: suggestions re-load per image; no stale chips carry over.
+
+### Step 12.8: Verify backfill resumability
+
+- [ ] Stop the ml-worker mid-backfill: `docker compose stop ml-worker`.
+- [ ] Restart: `docker compose up -d ml-worker`.
+- [ ] Click **Run ML backfill** again in Settings → Maintenance.
+- [ ] Expected: it picks up exactly the images still missing predictions/embeddings. Rows counts in both tables climb monotonically.
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check (against `2026-04-18-tag-suggestions-integration.md`):**
+
+- §1 System layout → Tasks 4, 6.
+- §2 Database → Task 1 (migration, pgvector), Task 2 (models).
+- §3 Task flow → Task 5 (three tasks), Task 7 (chaining), accept-side centroid trigger in Task 9.
+- §4 Web integration → Task 8 (service), Task 9 (routes), Task 10 (modal UI).
+- §5 Hybrid vocabulary → Task 9 `_WD14_CATEGORY_TO_KIND` + general-tag guard, Task 10 chip-dim behavior.
+- §6 Scope — in-scope items all mapped; deferred items intentionally not in the plan.
+- §7 Verification → Task 12.
+
+**Placeholder scan:** No "TBD" / "implement later" / hand-wavy "add error handling" steps. Every file gets a concrete code block. Tensor-name lookups in the WD14 wrapper are dynamic, so no guessed string constants.
+
+**Type consistency:** Function/method names match across tasks: `tag_and_embed`, `backfill`, `recompute_centroid`, `get_suggestions`, `loadSuggestions`, `renderSuggestions`, `acceptSuggestion`, `rejectSuggestion`, `clearSuggestions`, `_WD14_CATEGORY_TO_KIND`. DOM ids match between template (`modalSuggestionsSection`, `modalSuggestionsList`, `suggestionsRefreshBtn`) and JS.
+
+**Known risks to watch at implementation time:**
+
+- ONNX Runtime input tensor shape/name varies by model revision. The wrapper uses `session.get_inputs()[0].name` + shape introspection to stay robust — but if SmilingWolf republishes with a different preprocessing convention (e.g. 512-px instead of 448), preprocessing may need a tweak.
+- SigLIP's `get_image_features` signature is stable across recent `transformers` versions but should be re-checked if `transformers>=4.40` is not available.
+- If `app/main.py` uses blueprints instead of `@app.route`, Task 9 and 11 decorators adapt trivially; the spec leaves that flexibility intentional.
+- On first run the ml-worker image build downloads ~2 GB of models; this is a one-time cost.
diff --git a/docs/superpowers/plans/2026-04-19-character-tag-integrity.md b/docs/superpowers/plans/2026-04-19-character-tag-integrity.md
new file mode 100644
index 0000000..ac90956
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-19-character-tag-integrity.md
@@ -0,0 +1,1045 @@
+# Character-Tag Integrity Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Guarantee that every image tagged with a character whose tag has a `fandom_id` also has the fandom tag attached, and enforce first-letter-of-each-word capitalization on all character and fandom display names.
+
+**Architecture:** A single `normalize_display_name` helper is called from every character/fandom write path. `set_tag_fandom` runs an additive backfill in a second transaction. A new `sync_character_fandoms` Celery maintenance task (`maintenance` queue) sweeps drift on demand. A one-time Alembic migration renames existing lowercase tags and merges duplicates.
+
+**Tech Stack:** Flask + SQLAlchemy + Postgres 16 + pgvector + Alembic (Flask-Migrate) + Celery 5.
+
+**Current alembic head:** `g26041901` — the new migration's `down_revision` is this value.
+
+---
+
+## File Structure
+
+New:
+- `app/utils/tag_names.py` — normalization helper; one function.
+- `app/tasks/maintenance.py` — new module for non-ML maintenance Celery tasks.
+- `migrations/versions/h26041901_normalize_character_fandom_tag_names.py` — one-time rename + merge.
+
+Modified:
+- `app/main.py` — call normalizer from write paths, add backfill to `set_tag_fandom`, add `trigger_sync_character_fandoms` route.
+- `app/celery_app.py` — add `app.tasks.maintenance` to `include=` list and route task to `maintenance` queue.
+- `app/templates/settings.html` — add third Maintenance button.
+
+---
+
+## Task 1: Normalization helper module
+
+**Files:**
+- Create: `app/utils/tag_names.py`
+
+- [ ] **Step 1: Create the helper module**
+
+Write this exact file to `app/utils/tag_names.py`:
+
+```python
+"""Normalization helpers for Tag display names.
+
+Keeps the first letter of every whitespace-separated word upper-case and
+preserves internal punctuation. Called from every character/fandom write
+path so typos like "misty" don't accumulate alongside "Misty".
+"""
+
+
+def normalize_display_name(s: str) -> str:
+ """Title-case each whitespace-separated word. Preserves internal punctuation.
+
+ Examples:
+ "misty" -> "Misty"
+ "misty ketchum" -> "Misty Ketchum"
+ "o'brien" -> "O'brien"
+ "mary-kate" -> "Mary-kate"
+ "" -> ""
+ " " -> " " (only whitespace — returned unchanged)
+ """
+ if not s:
+ return s
+ parts = s.split(' ')
+ out: list[str] = []
+ for p in parts:
+ if p == '':
+ out.append(p)
+ else:
+ out.append(p[:1].upper() + p[1:])
+ return ' '.join(out)
+```
+
+- [ ] **Step 2: Verify the module imports cleanly**
+
+Run:
+
+```bash
+docker compose exec -T web python -c "from app.utils.tag_names import normalize_display_name; print(normalize_display_name('misty ketchum'))"
+```
+
+Expected output: `Misty Ketchum`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/utils/tag_names.py
+git commit -m "feat(tags): add normalize_display_name helper"
+```
+
+---
+
+## Task 2: Apply normalization at every character/fandom write path
+
+**Files:**
+- Modify: `app/main.py`
+
+- [ ] **Step 1: Import the helper**
+
+Find the imports near the top of `app/main.py` (around lines 1–16). Add to the existing imports (one new line near the other `from app...` imports):
+
+```python
+from app.utils.tag_names import normalize_display_name
+```
+
+- [ ] **Step 2: Normalize inside `_ensure_fandom_tag`**
+
+Find (around `app/main.py:27`):
+
+```python
+def _ensure_fandom_tag(fandom_name):
+ """Find or create a fandom tag by name. Returns the Tag object."""
+ full_name = f"fandom:{fandom_name}"
+ fandom_tag = Tag.query.filter_by(name=full_name).first()
+ if not fandom_tag:
+ fandom_tag = Tag(name=full_name, kind="fandom")
+ db.session.add(fandom_tag)
+ db.session.flush()
+ return fandom_tag
+```
+
+Replace with:
+
+```python
+def _ensure_fandom_tag(fandom_name):
+ """Find or create a fandom tag by name. Returns the Tag object.
+
+ Normalizes the display name so callers don't have to remember.
+ """
+ normalized = normalize_display_name(fandom_name.strip())
+ full_name = f"fandom:{normalized}"
+ fandom_tag = Tag.query.filter_by(name=full_name).first()
+ if not fandom_tag:
+ fandom_tag = Tag(name=full_name, kind="fandom")
+ db.session.add(fandom_tag)
+ db.session.flush()
+ return fandom_tag
+```
+
+- [ ] **Step 3: Normalize inside `add_tag` when creating a new character tag**
+
+Find the block inside `add_tag` (around `app/main.py:830-840`):
+
+```python
+ if not tag:
+ tag = Tag(name=tag_name, kind=kind)
+ # For new character tags, parse and link fandom
+ if kind == "character":
+ char_part = tag_name.split(":", 1)[1]
+ char_name, fandom_name = _parse_character_fandom(char_part)
+ if fandom_name:
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ tag.fandom_id = fandom_tag.id
+ db.session.add(tag)
+ db.session.flush()
+```
+
+Replace with:
+
+```python
+ if not tag:
+ # For new character tags, parse + normalize + link fandom
+ if kind == "character":
+ char_part = tag_name.split(":", 1)[1]
+ char_name, fandom_name = _parse_character_fandom(char_part)
+ norm_char = normalize_display_name(char_name)
+ if fandom_name:
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ tag_name = f"character:{norm_char} ({fandom_tag.name.split(':', 1)[1]})"
+ else:
+ tag_name = f"character:{norm_char}"
+ elif kind == "fandom":
+ fandom_part = tag_name.split(":", 1)[1]
+ tag_name = f"fandom:{normalize_display_name(fandom_part)}"
+ # Look up again under the normalized name — an existing row may match.
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if not tag:
+ tag = Tag(name=tag_name, kind=kind)
+ if kind == "character" and fandom_name:
+ tag.fandom_id = fandom_tag.id
+ db.session.add(tag)
+ db.session.flush()
+ elif kind == "character" and tag.fandom_id:
+ fandom_tag = Tag.query.get(tag.fandom_id)
+```
+
+*Why the second lookup:* after normalization, the rewritten `tag_name` may already exist (e.g. user typed `character:misty` but `character:Misty` is already in the DB). We re-query so we reuse that row instead of crashing on the unique constraint. The `elif` branch mirrors the existing "existing char with fandom" branch so `fandom_tag` gets populated for the auto-apply step below.
+
+- [ ] **Step 4: Normalize inside `add_bulk_tag`**
+
+Find the analogous block in `add_bulk_tag` (around `app/main.py:2240-2257`). It mirrors `add_tag` structure. Replace the create block:
+
+```python
+ tag = Tag(name=tag_name, kind=kind)
+ if kind == 'character':
+ char_part = tag_name.split(':', 1)[1]
+ char_name, fandom_name = _parse_character_fandom(char_part)
+ if fandom_name:
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ tag.fandom_id = fandom_tag.id
+ db.session.add(tag)
+ db.session.flush()
+ elif tag.kind == 'character' and tag.fandom_id:
+ fandom_tag = Tag.query.get(tag.fandom_id)
+```
+
+With:
+
+```python
+ if kind == 'character':
+ char_part = tag_name.split(':', 1)[1]
+ char_name, fandom_name = _parse_character_fandom(char_part)
+ norm_char = normalize_display_name(char_name)
+ if fandom_name:
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ tag_name = f"character:{norm_char} ({fandom_tag.name.split(':', 1)[1]})"
+ else:
+ tag_name = f"character:{norm_char}"
+ elif kind == 'fandom':
+ fandom_part = tag_name.split(':', 1)[1]
+ tag_name = f"fandom:{normalize_display_name(fandom_part)}"
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if not tag:
+ tag = Tag(name=tag_name, kind=kind)
+ if kind == 'character' and fandom_name:
+ tag.fandom_id = fandom_tag.id
+ db.session.add(tag)
+ db.session.flush()
+ elif tag.kind == 'character' and tag.fandom_id:
+ fandom_tag = Tag.query.get(tag.fandom_id)
+ elif tag.kind == 'character' and tag.fandom_id:
+ fandom_tag = Tag.query.get(tag.fandom_id)
+```
+
+The outermost `elif` handles the case where `tag` was already found on the first lookup (pre-normalization) and has an existing fandom — preserved from the original code.
+
+- [ ] **Step 5: Normalize inside `set_tag_fandom`**
+
+Find (around `app/main.py:913-946`) the block that reassembles the character name:
+
+```python
+ # Get the character's base name (strip existing fandom suffix)
+ char_part = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
+ char_base, _ = _parse_character_fandom(char_part)
+
+ if fandom_id:
+ fandom_tag = Tag.query.get(fandom_id)
+ if not fandom_tag or fandom_tag.kind != "fandom":
+ return jsonify(ok=False, error="Invalid fandom tag"), 400
+ fandom_display = fandom_tag.name.split(":", 1)[1] if ":" in fandom_tag.name else fandom_tag.name
+ elif fandom_name:
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ fandom_display = fandom_name
+ else:
+ # Clear fandom
+ tag.fandom_id = None
+ tag.name = f"character:{char_base}"
+ db.session.commit()
+ return jsonify(ok=True, tag={
+ "id": tag.id,
+ "name": tag.name,
+ "kind": tag.kind,
+ "fandom_id": None,
+ "fandom_name": None
+ })
+
+ # Check for name conflict before renaming
+ new_name = f"character:{char_base} ({fandom_display})"
+ conflict = Tag.query.filter(Tag.name == new_name, Tag.id != tag_id).first()
+ if conflict:
+ return jsonify(ok=False, error=f"A tag named '{new_name}' already exists"), 409
+
+ tag.fandom_id = fandom_tag.id
+ tag.name = new_name
+ db.session.commit()
+```
+
+Replace with:
+
+```python
+ # Get the character's base name (strip existing fandom suffix) and normalize it.
+ char_part = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
+ char_base, _ = _parse_character_fandom(char_part)
+ char_base = normalize_display_name(char_base)
+
+ if fandom_id:
+ fandom_tag = Tag.query.get(fandom_id)
+ if not fandom_tag or fandom_tag.kind != "fandom":
+ return jsonify(ok=False, error="Invalid fandom tag"), 400
+ fandom_display = fandom_tag.name.split(":", 1)[1] if ":" in fandom_tag.name else fandom_tag.name
+ elif fandom_name:
+ # _ensure_fandom_tag normalizes internally.
+ fandom_tag = _ensure_fandom_tag(fandom_name)
+ fandom_display = fandom_tag.name.split(":", 1)[1]
+ else:
+ # Clear fandom
+ tag.fandom_id = None
+ tag.name = f"character:{char_base}"
+ db.session.commit()
+ return jsonify(ok=True, tag={
+ "id": tag.id,
+ "name": tag.name,
+ "kind": tag.kind,
+ "fandom_id": None,
+ "fandom_name": None
+ })
+
+ # Check for name conflict before renaming
+ new_name = f"character:{char_base} ({fandom_display})"
+ conflict = Tag.query.filter(Tag.name == new_name, Tag.id != tag_id).first()
+ if conflict:
+ return jsonify(ok=False, error=f"A tag named '{new_name}' already exists"), 409
+
+ tag.fandom_id = fandom_tag.id
+ tag.name = new_name
+ db.session.commit()
+```
+
+*Backfill is added in Task 3.* Task 2 only handles normalization.
+
+- [ ] **Step 6: Normalize inside `accept_image_suggestion`**
+
+Find the auto-create block (around `app/main.py:726-732`):
+
+```python
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ # Auto-create in all categories. General maps to kind=None.
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush() # assign tag.id
+```
+
+Replace with:
+
+```python
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ # Auto-create in all categories. General maps to kind=None.
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+ # Normalize character/fandom display names so WD14 auto-accepts stay consistent.
+ if kind in ('character', 'fandom') and ':' in tag_name:
+ prefix, rest = tag_name.split(':', 1)
+ tag_name = f"{prefix}:{normalize_display_name(rest)}"
+ # The normalized name may already exist — reuse if so.
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush() # assign tag.id
+```
+
+- [ ] **Step 7: Verify imports and sanity-check**
+
+```bash
+docker compose exec -T web python -c "
+from app import create_app
+app = create_app()
+print('import ok')
+"
+```
+
+Expected: `import ok`. Any traceback means a typo — fix and rerun.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(tags): normalize character/fandom display names on every write path"
+```
+
+---
+
+## Task 3: Retroactive fandom backfill on `set_tag_fandom`
+
+**Files:**
+- Modify: `app/main.py`
+
+- [ ] **Step 1: Add the backfill step after the rename commit**
+
+Find the end of `set_tag_fandom` (the block just after the main `db.session.commit()` that finalizes the rename, around the last `return jsonify(ok=True, tag={...})` in the function body, at or near `app/main.py:948-954`):
+
+```python
+ tag.fandom_id = fandom_tag.id
+ tag.name = new_name
+ db.session.commit()
+
+ return jsonify(ok=True, tag={
+ "id": tag.id,
+ "name": tag.name,
+ "kind": tag.kind,
+ "fandom_id": tag.fandom_id,
+ "fandom_name": fandom_display
+ })
+```
+
+Replace with:
+
+```python
+ tag.fandom_id = fandom_tag.id
+ tag.name = new_name
+ db.session.commit()
+
+ # Retroactive backfill: every image already tagged with this character should
+ # also have the fandom tag attached. Runs in a second transaction so a failed
+ # backfill doesn't roll back the rename.
+ try:
+ db.session.execute(
+ db.text("""
+ INSERT INTO image_tags (image_id, tag_id)
+ SELECT it.image_id, :fandom_id
+ FROM image_tags it
+ WHERE it.tag_id = :char_id
+ AND NOT EXISTS (
+ SELECT 1 FROM image_tags it2
+ WHERE it2.image_id = it.image_id
+ AND it2.tag_id = :fandom_id
+ )
+ """),
+ {'char_id': tag.id, 'fandom_id': fandom_tag.id},
+ )
+ db.session.commit()
+ except Exception as e:
+ db.session.rollback()
+ # Rename already committed; log and continue. Sweep button recovers.
+ current_app.logger.warning(
+ "set_tag_fandom backfill failed for tag %s -> fandom %s: %s",
+ tag.id, fandom_tag.id, e,
+ )
+
+ return jsonify(ok=True, tag={
+ "id": tag.id,
+ "name": tag.name,
+ "kind": tag.kind,
+ "fandom_id": tag.fandom_id,
+ "fandom_name": fandom_display
+ })
+```
+
+- [ ] **Step 2: Verify `current_app` is importable from `app/main.py`**
+
+Run:
+
+```bash
+grep -n "current_app\|from flask import" app/main.py | head -5
+```
+
+If `current_app` is not already in the `from flask import (...)` tuple near the top of the file, add it. Typical existing line looks like `from flask import Blueprint, render_template, request, jsonify, abort, redirect, url_for, send_file`; append `current_app`.
+
+- [ ] **Step 3: Sanity-check the app still boots**
+
+```bash
+docker compose exec -T web python -c "from app import create_app; create_app(); print('ok')"
+```
+
+Expected: `ok`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(tags): backfill fandom to existing character-tagged images on set-fandom"
+```
+
+---
+
+## Task 4: Create `sync_character_fandoms` Celery maintenance task
+
+**Files:**
+- Create: `app/tasks/maintenance.py`
+- Modify: `app/celery_app.py`
+
+- [ ] **Step 1: Create the maintenance tasks module**
+
+Write to `app/tasks/maintenance.py`:
+
+```python
+"""Maintenance-queue Celery tasks. Lightweight, user-triggered, non-ML."""
+import logging
+from celery import shared_task
+
+from app import db
+from app.models import Tag
+
+log = logging.getLogger(__name__)
+
+
+@shared_task(
+ name='app.tasks.maintenance.sync_character_fandoms',
+ soft_time_limit=120,
+ time_limit=180,
+)
+def sync_character_fandoms():
+ """For every character tag with a non-null fandom_id, attach the fandom
+ tag to every image tagged with the character but not the fandom.
+
+ Additive only — never removes fandom tags.
+
+ Returns a summary dict with counts.
+ """
+ chars = (
+ Tag.query
+ .filter_by(kind='character')
+ .filter(Tag.fandom_id.isnot(None))
+ .all()
+ )
+ total_added = 0
+ failed = 0
+ for char in chars:
+ try:
+ result = db.session.execute(
+ db.text("""
+ INSERT INTO image_tags (image_id, tag_id)
+ SELECT it.image_id, :fandom_id
+ FROM image_tags it
+ WHERE it.tag_id = :char_id
+ AND NOT EXISTS (
+ SELECT 1 FROM image_tags it2
+ WHERE it2.image_id = it.image_id
+ AND it2.tag_id = :fandom_id
+ )
+ """),
+ {'char_id': char.id, 'fandom_id': char.fandom_id},
+ )
+ total_added += result.rowcount or 0
+ db.session.commit()
+ except Exception as e:
+ db.session.rollback()
+ failed += 1
+ log.warning(
+ "sync_character_fandoms: char tag %s (%s) failed: %s",
+ char.id, char.name, e,
+ )
+ log.info(
+ "sync_character_fandoms: scanned %d characters, added %d image↔fandom links, %d failed",
+ len(chars), total_added, failed,
+ )
+ return {
+ 'characters_scanned': len(chars),
+ 'links_added': total_added,
+ 'characters_failed': failed,
+ }
+```
+
+- [ ] **Step 2: Register the module in the Celery include list**
+
+Find in `app/celery_app.py` (around lines 36-43):
+
+```python
+ include=[
+ 'app.tasks.scan',
+ 'app.tasks.import_file',
+ 'app.tasks.thumbnail',
+ 'app.tasks.sidecar',
+ 'app.tasks.ml',
+ ]
+```
+
+Replace with:
+
+```python
+ include=[
+ 'app.tasks.scan',
+ 'app.tasks.import_file',
+ 'app.tasks.thumbnail',
+ 'app.tasks.sidecar',
+ 'app.tasks.ml',
+ 'app.tasks.maintenance',
+ ]
+```
+
+- [ ] **Step 3: Route the new task to the `maintenance` queue**
+
+Find in `app/celery_app.py` (around lines 63-81) the `task_routes` dict. The `maintenance` queue already exists. Append one entry inside the existing `'app.tasks.scan.*'` maintenance entries block, keeping the file's existing style:
+
+Find:
+
+```python
+ # Maintenance tasks - handled by scheduler (periodic/lightweight)
+ 'app.tasks.scan.recover_interrupted_tasks': {'queue': 'maintenance'},
+ 'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
+ 'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
+ 'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
+```
+
+Replace with:
+
+```python
+ # Maintenance tasks - handled by scheduler (periodic/lightweight)
+ 'app.tasks.scan.recover_interrupted_tasks': {'queue': 'maintenance'},
+ 'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
+ 'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
+ 'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
+ 'app.tasks.maintenance.sync_character_fandoms': {'queue': 'maintenance'},
+```
+
+- [ ] **Step 4: Sanity-check import and registration**
+
+```bash
+docker compose exec -T web python -c "
+from app.tasks.maintenance import sync_character_fandoms
+print('task name:', sync_character_fandoms.name)
+"
+```
+
+Expected: `task name: app.tasks.maintenance.sync_character_fandoms`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/tasks/maintenance.py app/celery_app.py
+git commit -m "feat(tasks): add sync_character_fandoms maintenance task"
+```
+
+---
+
+## Task 5: Add sweep route + Settings maintenance button
+
+**Files:**
+- Modify: `app/main.py`
+- Modify: `app/templates/settings.html`
+
+- [ ] **Step 1: Add the trigger route**
+
+Find the existing `trigger_recompute_all_centroids` route (added in the prior feature; it sits right after `trigger_ml_backfill`). Immediately after it, add:
+
+```python
+@main.post('/settings/maintenance/sync-character-fandoms')
+def trigger_sync_character_fandoms():
+ from app.tasks.maintenance import sync_character_fandoms
+ sync_character_fandoms.apply_async()
+ return redirect(url_for('main.settings', tab='maintenance'))
+```
+
+Note: no `queue='...'` arg; the task routes itself to `maintenance` via `celery_app.py`.
+
+- [ ] **Step 2: Add the Settings → Maintenance button**
+
+Find the existing `
+```
+
+- [ ] **Step 3: Verify the route is registered**
+
+```bash
+docker compose exec -T web python -c "
+from app import create_app
+app = create_app()
+print([r for r in app.url_map.iter_rules() if 'sync-character-fandoms' in r.rule])
+"
+```
+
+Expected: one `Rule` entry for `/settings/maintenance/sync-character-fandoms`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/main.py app/templates/settings.html
+git commit -m "feat(settings): Sync character fandoms maintenance button + route"
+```
+
+---
+
+## Task 6: One-time migration — rename and merge
+
+**Files:**
+- Create: `migrations/versions/h26041901_normalize_character_fandom_tag_names.py`
+
+- [ ] **Step 1: Write the migration file**
+
+Write to `migrations/versions/h26041901_normalize_character_fandom_tag_names.py`:
+
+```python
+"""Normalize character and fandom tag display names; merge case-only duplicates.
+
+Revision ID: h26041901
+Revises: g26041901
+Create Date: 2026-04-19
+
+Upgrade: title-cases the display-name portion of every character:* and fandom:*
+tag. When two rows of the same kind collapse to the same name, merges image_tags,
+tag.fandom_id references, and tag_reference_embedding rows into the already-
+correctly-cased target ("winner") and deletes the "loser" tag row.
+
+Downgrade: raises. Merges are destructive; reverting would require restoring
+from a pre-migration backup.
+"""
+from alembic import op
+
+
+revision = 'h26041901'
+down_revision = 'g26041901'
+branch_labels = None
+depends_on = None
+
+
+def _normalize_display(s: str) -> str:
+ """Mirror of app.utils.tag_names.normalize_display_name. Inlined here so
+ the migration doesn't import app code (Alembic runs without the Flask app)."""
+ if not s:
+ return s
+ out = []
+ for p in s.split(' '):
+ if p == '':
+ out.append(p)
+ else:
+ out.append(p[:1].upper() + p[1:])
+ return ' '.join(out)
+
+
+def _compute_new_name(old_name: str, kind: str) -> str:
+ """Given a full 'prefix:display' name, return the normalized full name."""
+ if ':' not in old_name:
+ return old_name
+ prefix, rest = old_name.split(':', 1)
+ if kind == 'character':
+ # Split off "(Fandom)" suffix if present, normalize each part.
+ if '(' in rest and rest.rstrip().endswith(')'):
+ paren_idx = rest.rfind('(')
+ base = rest[:paren_idx].rstrip()
+ fandom_part = rest[paren_idx+1:-1] # strip outer parens
+ base = _normalize_display(base)
+ fandom_part = _normalize_display(fandom_part)
+ return f"{prefix}:{base} ({fandom_part})"
+ return f"{prefix}:{_normalize_display(rest)}"
+ elif kind == 'fandom':
+ return f"{prefix}:{_normalize_display(rest)}"
+ return old_name
+
+
+def upgrade():
+ conn = op.get_bind()
+ # Work on a snapshot; we mutate the tag table as we iterate.
+ rows = conn.execute(
+ op.get_context().bind.dialect.paramstyle and
+ __import__('sqlalchemy').text(
+ "SELECT id, name, kind FROM tag WHERE kind IN ('character', 'fandom') ORDER BY id"
+ )
+ ).fetchall()
+
+ renames = 0
+ merges = 0
+ for row in rows:
+ tag_id = row[0]
+ old_name = row[1]
+ kind = row[2]
+ new_name = _compute_new_name(old_name, kind)
+ if new_name == old_name:
+ continue
+
+ # Re-check that this tag still exists (may have been deleted as a loser in
+ # an earlier merge within this loop).
+ still_exists = conn.execute(
+ __import__('sqlalchemy').text("SELECT id FROM tag WHERE id = :id"),
+ {'id': tag_id},
+ ).fetchone()
+ if not still_exists:
+ continue
+
+ # Look for a collision: same target name + same kind + different id.
+ target = conn.execute(
+ __import__('sqlalchemy').text(
+ "SELECT id, name FROM tag WHERE name = :new_name AND kind = :kind AND id != :tag_id"
+ ),
+ {'new_name': new_name, 'kind': kind, 'tag_id': tag_id},
+ ).fetchone()
+
+ if target is None:
+ # No collision — plain rename.
+ conn.execute(
+ __import__('sqlalchemy').text("UPDATE tag SET name = :new_name WHERE id = :tag_id"),
+ {'new_name': new_name, 'tag_id': tag_id},
+ )
+ renames += 1
+ else:
+ # Merge: winner = target (already correctly cased), loser = current row.
+ winner_id = target[0]
+ winner_name = target[1] # already == new_name
+ loser_id = tag_id
+ loser_name = old_name
+
+ # 1. Reassign image_tags, dedup into winner.
+ conn.execute(
+ __import__('sqlalchemy').text("""
+ INSERT INTO image_tags (image_id, tag_id)
+ SELECT DISTINCT image_id, :winner_id
+ FROM image_tags
+ WHERE tag_id = :loser_id
+ ON CONFLICT DO NOTHING
+ """),
+ {'winner_id': winner_id, 'loser_id': loser_id},
+ )
+ conn.execute(
+ __import__('sqlalchemy').text("DELETE FROM image_tags WHERE tag_id = :loser_id"),
+ {'loser_id': loser_id},
+ )
+
+ # 2. Reassign any fandom_id FKs pointing at the loser.
+ conn.execute(
+ __import__('sqlalchemy').text(
+ "UPDATE tag SET fandom_id = :winner_id WHERE fandom_id = :loser_id"
+ ),
+ {'winner_id': winner_id, 'loser_id': loser_id},
+ )
+
+ # 3. tag_reference_embedding: the table is keyed by (tag_name, model_version).
+ # For each model_version where only the loser has a row, rename; where
+ # both have rows, keep the winner's and delete the loser's.
+ conn.execute(
+ __import__('sqlalchemy').text("""
+ UPDATE tag_reference_embedding
+ SET tag_name = :winner_name
+ WHERE tag_name = :loser_name
+ AND NOT EXISTS (
+ SELECT 1 FROM tag_reference_embedding w
+ WHERE w.tag_name = :winner_name
+ AND w.model_version = tag_reference_embedding.model_version
+ )
+ """),
+ {'winner_name': winner_name, 'loser_name': loser_name},
+ )
+ conn.execute(
+ __import__('sqlalchemy').text(
+ "DELETE FROM tag_reference_embedding WHERE tag_name = :loser_name"
+ ),
+ {'loser_name': loser_name},
+ )
+
+ # 4. Delete the loser tag row.
+ conn.execute(
+ __import__('sqlalchemy').text("DELETE FROM tag WHERE id = :loser_id"),
+ {'loser_id': loser_id},
+ )
+ merges += 1
+
+ print(f"normalize_character_fandom_tag_names: {renames} renames, {merges} merges")
+
+
+def downgrade():
+ raise NotImplementedError(
+ "h26041901 normalizes tag names and merges case-only duplicates. "
+ "Merges are destructive and cannot be reversed automatically. "
+ "Restore from a pre-migration backup if you need to roll back."
+ )
+```
+
+*Note the repeated `__import__('sqlalchemy').text(...)` — this avoids adding a top-level `from sqlalchemy import text` that would clutter the diff. If you prefer, you can add `from sqlalchemy import text` at the top and use `text(...)` directly — the effect is identical.*
+
+- [ ] **Step 2: Run the migration**
+
+```bash
+docker compose exec -T web flask db upgrade
+```
+
+Expected output includes a line like `normalize_character_fandom_tag_names: N renames, M merges`. Also `INFO [alembic.runtime.migration] Running upgrade g26041901 -> h26041901`.
+
+- [ ] **Step 3: Verify dev DB state**
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "SELECT name, kind FROM tag WHERE kind IN ('character','fandom') ORDER BY kind, name"
+```
+
+Expected: every displayed name starts each whitespace-separated word with an upper-case letter. In the current dev DB, `character:emelie` should now be `character:Emelie`.
+
+- [ ] **Step 4: Verify no orphan references**
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+SELECT 'orphan image_tags' AS what, COUNT(*) FROM image_tags it LEFT JOIN tag t ON t.id = it.tag_id WHERE t.id IS NULL
+UNION ALL
+SELECT 'orphan fandom_id refs', COUNT(*) FROM tag WHERE fandom_id IS NOT NULL AND fandom_id NOT IN (SELECT id FROM tag)
+"
+```
+
+Expected: both counts `0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add migrations/versions/h26041901_normalize_character_fandom_tag_names.py
+git commit -m "feat(migration): normalize character/fandom names, merge case-only duplicates"
+```
+
+---
+
+## Task 7: Rebuild + smoke tests
+
+**Files:**
+- None modified unless smoke tests reveal bugs.
+
+- [ ] **Step 1: Rebuild web + scheduler containers**
+
+The scheduler container holds the `maintenance` queue worker, so it must be rebuilt along with web.
+
+```bash
+docker compose up -d --build web scheduler
+```
+
+Expected: both containers rebuild and start cleanly.
+
+- [ ] **Step 2: Verify scheduler registered the new task**
+
+```bash
+docker compose logs scheduler --tail 80 2>&1 | grep "sync_character_fandoms"
+```
+
+Expected: one line ` . app.tasks.maintenance.sync_character_fandoms` in the task listing.
+
+- [ ] **Step 3: Verify the web container is up**
+
+```bash
+docker compose logs web --tail 10 2>&1 | grep -iE "listening|ready|error|traceback" | head -5
+```
+
+Expected: a `Listening at: http://0.0.0.0:5000` line with no errors.
+
+- [ ] **Step 4: Smoke-test — create a character with a lowercase fandom**
+
+In the browser (or via curl):
+
+```bash
+# Pick any valid image_id from your dev DB.
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -tAc "SELECT id FROM image_record LIMIT 1"
+# Use that id below.
+curl -s -X POST "http://localhost:5000/image//tags/add" \
+ -d "name=character:pikachu (pokemon)"
+```
+
+Then verify:
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+SELECT name, kind, fandom_id FROM tag WHERE name ILIKE 'character:pikachu%' OR name ILIKE 'fandom:pokemon'
+"
+```
+
+Expected: two rows — `character:Pikachu (Pokemon)` (with `fandom_id` set) and `fandom:Pokemon` (kind='fandom'). Both capitalized despite lowercase input.
+
+- [ ] **Step 5: Smoke-test — retroactive backfill on `set_tag_fandom`**
+
+Create a character tag with no fandom, attach to 2 images, then set its fandom and verify both images get the fandom:
+
+```bash
+# Pick two image ids.
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -tAc "SELECT id FROM image_record LIMIT 2"
+# Call them IMG1 and IMG2.
+
+# Add the character to both (no fandom yet).
+curl -s -X POST "http://localhost:5000/image//tags/add" -d "name=character:TestKid"
+curl -s -X POST "http://localhost:5000/image//tags/add" -d "name=character:TestKid"
+
+# Get the character tag id.
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -tAc "SELECT id FROM tag WHERE name = 'character:TestKid'"
+# Call it CHAR_ID.
+
+# Set a fandom on it.
+curl -s -X POST "http://localhost:5000/api/tag//set-fandom" -d "fandom_name=TestShow"
+
+# Verify both images now have fandom:TestShow.
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+SELECT i.id, t.name FROM image_record i
+JOIN image_tags it ON it.image_id = i.id
+JOIN tag t ON t.id = it.tag_id
+WHERE i.id IN (, ) AND t.kind = 'fandom'
+"
+```
+
+Expected: two rows, both with `fandom:TestShow`.
+
+- [ ] **Step 6: Smoke-test — sweep button is idempotent**
+
+Click "Sync character fandoms to images" from Settings → Maintenance (or POST to the URL directly). Watch the scheduler logs:
+
+```bash
+docker compose logs -f scheduler 2>&1 | grep sync_character_fandoms
+```
+
+Expected: `sync_character_fandoms: scanned N characters, added 0 image↔fandom links, 0 failed` (or similar — zero links added because Step 5 already synced them).
+
+- [ ] **Step 7: Smoke-test — sweep fixes manual drift**
+
+Remove the fandom from one of the images manually:
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+DELETE FROM image_tags WHERE image_id = AND tag_id = (SELECT id FROM tag WHERE name = 'fandom:TestShow')
+"
+```
+
+Click the sweep button again. Verify:
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+SELECT 1 FROM image_tags it JOIN tag t ON t.id = it.tag_id
+WHERE it.image_id = AND t.name = 'fandom:TestShow'
+"
+```
+
+Expected: one row (link restored). Scheduler log shows `links_added: 1`.
+
+- [ ] **Step 8: Clean up test data (optional)**
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "
+DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name IN ('character:TestKid (TestShow)', 'character:TestKid', 'fandom:TestShow', 'character:Pikachu (Pokemon)', 'fandom:Pokemon'));
+DELETE FROM tag WHERE name IN ('character:TestKid (TestShow)', 'character:TestKid', 'fandom:TestShow', 'character:Pikachu (Pokemon)', 'fandom:Pokemon');
+"
+```
+
+- [ ] **Step 9: Commit any fix-ups from smoke tests**
+
+If the smoke tests uncovered bugs, fix them in the relevant task's files and commit separately. If everything passed, there's nothing to commit.
+
+---
+
+## Self-Review Checklist
+
+- Spec coverage:
+ - [x] `normalize_display_name` helper → Task 1
+ - [x] Helper applied to `_ensure_fandom_tag`, `add_tag`, `add_bulk_tag`, `set_tag_fandom`, `accept_image_suggestion` → Task 2
+ - [x] Retroactive backfill on `set_tag_fandom` (second transaction, additive) → Task 3
+ - [x] `sync_character_fandoms` Celery task on `maintenance` queue → Task 4
+ - [x] Sweep route + Settings button → Task 5
+ - [x] One-time migration with rename + merge (image_tags, fandom_id, tag_reference_embedding) → Task 6
+ - [x] `downgrade()` raises `NotImplementedError` → Task 6
+ - [x] Manual smoke tests → Task 7
+
+- Type/name consistency: helper is `normalize_display_name` everywhere; task name `app.tasks.maintenance.sync_character_fandoms` matches between module, routing entry, trigger endpoint, and scheduler log.
+
+- Placeholders: none. ``, ``, ``, `` in Task 7 smoke tests are per-environment substitution values the operator fills in from the preceding query, not unspecified content.
+
+---
+
+## Notes for the implementer
+
+- **Alembic migration style:** the existing migrations in this repo use `sqlalchemy.text(...)` for raw SQL. You may prefer adding `from sqlalchemy import text` at the top of the migration and using `text(...)` directly instead of the `__import__('sqlalchemy').text(...)` idiom shown above. Either works.
+- **Frontend cache-busting:** hard-refresh the browser after the rebuild so the maintenance page picks up the new button.
+- **Tests:** the project has no automated test suite; validation is Task 7's manual smoke-test script.
diff --git a/docs/superpowers/plans/2026-04-19-tag-underscores-and-modal-polish.md b/docs/superpowers/plans/2026-04-19-tag-underscores-and-modal-polish.md
new file mode 100644
index 0000000..4c17568
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-19-tag-underscores-and-modal-polish.md
@@ -0,0 +1,1007 @@
+# Tag Underscores + Modal Polish Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Translate WD14 underscored names (`big_hair`) to spaces at display, persist, and migration layers; make suggestion-accept refresh the tag list the same way manual add does; tint accept/reject chip buttons green/red persistently.
+
+**Architecture:** One helper (`underscores_to_spaces`) added to `app/utils/tag_names.py`; `normalize_display_name` calls it internally so character/fandom paths collapse `misty_ketchum` → `Misty Ketchum`. WD14 names are canonicalized in two places: `app/services/tag_suggestions.py` rewrites them at emit so the modal sees `big hair`, and `app/main.py:accept_image_suggestion` re-applies the same transform before DB write. General (null-kind) gets underscore→space only; case preserved. One-time Alembic migration `i26041901` does the same rename-or-merge sweep as `h26041901`, extended to cover null-kind rows. Modal JS awaits `loadTags` before the chip animates out, mirroring manual-add timing. CSS base rule tints ✓ green / ✕ red; hover intensifies.
+
+**Tech Stack:** Python 3.12, Flask, SQLAlchemy, Alembic (via Flask-Migrate), Postgres 16, pgvector, vanilla JS, CSS. No JS build step.
+
+**Testing philosophy:** This codebase has no automated test infrastructure — the prior `h26041901` feature used manual dev-DB verification. This plan follows the same approach: each task ends with a concrete manual check against the running dev stack, then a commit. No pytest/jest scaffolding.
+
+---
+
+## File Structure
+
+**Modify:**
+- `app/utils/tag_names.py` — add `underscores_to_spaces`, update `normalize_display_name` to call it.
+- `app/services/tag_suggestions.py` — move `_WD14_CATEGORY_TO_KIND` here, add `_canonicalize_wd14_name`, apply to `_wd14_suggestions` output + `already` set.
+- `app/main.py` — drop local `_WD14_CATEGORY_TO_KIND`, import from service; rewrite `accept_image_suggestion` name-canonicalization block; add underscore→space in `add_tag` and `bulk_add_tag` user/no-prefix paths.
+- `app/static/js/view-modal.js` — rewrite `acceptSuggestion` for refresh parity; simplify `animateChipOut` signature.
+- `app/static/style.css` — add persistent color rules for `.sugg-accept` and `.sugg-reject`; hover intensifies.
+
+**Create:**
+- `migrations/versions/i26041901_normalize_underscores_and_general_tags.py` — one-time rename-or-merge across character/fandom/null kinds.
+
+---
+
+## Task 1: Extend normalization helpers
+
+**Files:**
+- Modify: `app/utils/tag_names.py`
+
+- [ ] **Step 1: Update the module**
+
+Replace the entire contents of `app/utils/tag_names.py` with:
+
+```python
+"""Normalization helpers for Tag display names.
+
+Keeps the first letter of every whitespace-separated word upper-case and
+preserves internal punctuation. Called from every character/fandom write
+path so typos like "misty" don't accumulate alongside "Misty".
+"""
+
+
+def underscores_to_spaces(s: str) -> str:
+ """Replace every underscore with a space.
+
+ Used by general/null-kind write paths and as a pre-step inside
+ normalize_display_name so WD14's 'misty_ketchum' and a hand-typed
+ 'Misty Ketchum' collapse onto the same tag name.
+
+ Examples:
+ "big_hair" -> "big hair"
+ "long_blue_hair" -> "long blue hair"
+ "" -> ""
+ """
+ return s.replace('_', ' ') if s else s
+
+
+def normalize_display_name(s: str) -> str:
+ """Title-case each whitespace-separated word. Preserves internal punctuation.
+
+ Underscores are converted to spaces first so WD14-style names normalize
+ identically to hand-typed names.
+
+ Examples:
+ "misty" -> "Misty"
+ "misty ketchum" -> "Misty Ketchum"
+ "misty_ketchum" -> "Misty Ketchum"
+ "o'brien" -> "O'brien"
+ "mary-kate" -> "Mary-kate"
+ "" -> ""
+ " " -> " " (only whitespace — returned unchanged)
+ """
+ if not s:
+ return s
+ s = underscores_to_spaces(s)
+ parts = s.split(' ')
+ out: list[str] = []
+ for p in parts:
+ if p == '':
+ out.append(p)
+ else:
+ out.append(p[:1].upper() + p[1:])
+ return ' '.join(out)
+```
+
+- [ ] **Step 2: Manually verify in a Python shell**
+
+Run:
+```bash
+cd /home/bvandeusen/Nextcloud/Projects/ImageRepo
+python3 -c "
+from app.utils.tag_names import underscores_to_spaces, normalize_display_name
+assert underscores_to_spaces('big_hair') == 'big hair'
+assert underscores_to_spaces('') == ''
+assert normalize_display_name('misty_ketchum') == 'Misty Ketchum'
+assert normalize_display_name('misty') == 'Misty'
+assert normalize_display_name('o\\'brien') == 'O\\'brien'
+assert normalize_display_name('') == ''
+print('OK')
+"
+```
+Expected: `OK` printed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/utils/tag_names.py
+git commit -m "feat(tags): add underscores_to_spaces helper, fold into normalize_display_name"
+```
+
+---
+
+## Task 2: Move `_WD14_CATEGORY_TO_KIND` into the service module
+
+**Files:**
+- Modify: `app/services/tag_suggestions.py` (add constant near top)
+- Modify: `app/main.py:710-713` (remove local copy, import from service)
+
+- [ ] **Step 1: Add the constant to `app/services/tag_suggestions.py`**
+
+In `app/services/tag_suggestions.py`, insert this block immediately after `ELIGIBLE_CENTROID_KINDS = ('character', 'fandom', None)` (around line 28):
+
+```python
+# Maps the WD14 prediction category ('character', 'copyright') to the kind used
+# in the Tag table. 'general' maps to None and is handled separately at accept
+# time. Lives here (not in main) so the canonicalization helper below can use it
+# without a circular import.
+_WD14_CATEGORY_TO_KIND = {
+ 'character': 'character',
+ 'copyright': 'fandom',
+}
+```
+
+- [ ] **Step 2: Remove the old constant from `app/main.py:710-713`**
+
+Delete these four lines from `app/main.py`:
+
+```python
+_WD14_CATEGORY_TO_KIND = {
+ 'character': 'character',
+ 'copyright': 'fandom', # ImageRepo uses 'fandom' for IP/franchise
+}
+```
+
+- [ ] **Step 3: Update the one reference in `accept_image_suggestion`**
+
+Find the line in `app/main.py` (around line 741):
+
+```python
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+```
+
+Change it to import from the service:
+
+```python
+ from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+```
+
+(The inline import matches the existing pattern in this function — surrounding code already does `from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS` inline.)
+
+- [ ] **Step 4: Verify the app still starts**
+
+Run:
+```bash
+cd /home/bvandeusen/Nextcloud/Projects/ImageRepo
+docker compose up -d web
+sleep 3
+docker compose logs --tail=20 web
+```
+Expected: no ImportError; `web` reports "Running on …" or similar ready line.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/services/tag_suggestions.py app/main.py
+git commit -m "refactor(tags): move _WD14_CATEGORY_TO_KIND into tag_suggestions service"
+```
+
+---
+
+## Task 3: WD14 name canonicalization at emit
+
+**Files:**
+- Modify: `app/services/tag_suggestions.py` (new helper + integrate into `_wd14_suggestions` + `_existing_tag_names_for_image`)
+
+- [ ] **Step 1: Add the helper**
+
+In `app/services/tag_suggestions.py`, insert this function immediately after the `_WD14_CATEGORY_TO_KIND` constant:
+
+```python
+def _canonicalize_wd14_name(raw: str, category: str) -> str:
+ """Rewrite a raw WD14 tag name to the canonical form ImageRepo persists.
+
+ - 'character' / 'copyright': title-case the display portion.
+ - everything else ('general', 'meta', unknown): underscore-to-space only,
+ preserving the user's casing for general topics.
+ """
+ from app.utils.tag_names import normalize_display_name, underscores_to_spaces
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+ if ':' in raw:
+ prefix, rest = raw.split(':', 1)
+ transform = normalize_display_name if kind in ('character', 'fandom') else underscores_to_spaces
+ return f"{prefix}:{transform(rest)}"
+ return normalize_display_name(raw) if kind in ('character', 'fandom') else underscores_to_spaces(raw)
+```
+
+- [ ] **Step 2: Canonicalize WD14 output names**
+
+In the same file, replace the `_wd14_suggestions` function (lines 77-97) with:
+
+```python
+def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]:
+ wd14_ver = cfg.get('wd14_model_version', _DEFAULTS['wd14_model_version'])
+ preds = (
+ ImageTagPrediction.query
+ .filter_by(image_id=image_id, model_version=wd14_ver)
+ .all()
+ )
+ out: list[dict] = []
+ for p in preds:
+ threshold = _category_threshold(p.tag_category, cfg)
+ if p.confidence < threshold:
+ continue
+ canonical = _canonicalize_wd14_name(p.tag_name, p.tag_category)
+ if canonical in already:
+ continue
+ out.append({
+ 'name': canonical,
+ 'category': p.tag_category,
+ 'confidence': p.confidence,
+ 'source': 'wd14',
+ })
+ return out
+```
+
+Two changes: (1) name is rewritten before the `already` check and before the dict is built; (2) the `already` set is assumed to already contain canonical names — see next step.
+
+- [ ] **Step 3: Canonicalize the "already attached" set**
+
+In the same file, replace `_existing_tag_names_for_image` (lines 63-70) with:
+
+```python
+def _existing_tag_names_for_image(image_id: int) -> set[str]:
+ """Names of tags already attached to this image.
+
+ Names are returned as-stored; since every write path canonicalizes, any
+ row in the DB is already in canonical form. WD14 output is canonicalized
+ before lookup, so equality works.
+ """
+ rows = (
+ db.session.query(Tag.name)
+ .join(image_tags, image_tags.c.tag_id == Tag.id)
+ .filter(image_tags.c.image_id == image_id)
+ .all()
+ )
+ return {row[0] for row in rows}
+```
+
+No behavioral change yet — the comment documents the invariant that Task 4 and Task 7 (migration) establish. Once the migration runs, all existing rows have canonical names and this function needs no runtime transform.
+
+- [ ] **Step 4: Manually verify at the running service**
+
+Pick one image that has WD14 predictions with underscored names. Run:
+
+```bash
+cd /home/bvandeusen/Nextcloud/Projects/ImageRepo
+docker compose exec db psql -U postgres imagerepo -c \
+ "SELECT image_id, tag_name FROM image_tag_prediction WHERE tag_name LIKE '%\\_%' ESCAPE '\\' LIMIT 3"
+```
+Pick an `image_id` from the output. Then:
+
+```bash
+curl -s http://localhost:5000/image//suggestions | python3 -m json.tool | grep -E '"name"' | head -20
+```
+
+Expected: no `_` characters in any `"name"` field. Names with spaces only.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/services/tag_suggestions.py
+git commit -m "feat(suggestions): canonicalize WD14 names (underscore→space) at emit"
+```
+
+---
+
+## Task 4: Accept-endpoint canonicalization
+
+**Files:**
+- Modify: `app/main.py:723-796` (`accept_image_suggestion`)
+
+- [ ] **Step 1: Rewrite the name-canonicalization block**
+
+In `app/main.py`, locate `accept_image_suggestion` (around line 723). The current body (lines 727-752) reads:
+
+```python
+ payload = request.get_json(force=True, silent=True) or {}
+ tag_name = (payload.get('tag_name') or '').strip()
+ source = payload.get('source') or ''
+ category = payload.get('category') or ''
+ confidence = float(payload.get('confidence') or 0.0)
+ if not tag_name or not source or not category:
+ return jsonify({'ok': False, 'error': 'missing_fields'}), 400
+
+ image = ImageRecord.query.get(image_id)
+ if image is None:
+ return jsonify({'ok': False, 'error': 'image_not_found'}), 404
+
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ # Auto-create in all categories. General maps to kind=None.
+ kind = _WD14_CATEGORY_TO_KIND.get(category)
+ # Normalize character/fandom display names so WD14 auto-accepts stay consistent.
+ if kind in ('character', 'fandom') and ':' in tag_name:
+ prefix, rest = tag_name.split(':', 1)
+ tag_name = f"{prefix}:{normalize_display_name(rest)}"
+ # The normalized name may already exist — reuse if so.
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush()
+```
+
+Replace those lines with:
+
+```python
+ from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name
+ payload = request.get_json(force=True, silent=True) or {}
+ tag_name = (payload.get('tag_name') or '').strip()
+ source = payload.get('source') or ''
+ category = payload.get('category') or ''
+ confidence = float(payload.get('confidence') or 0.0)
+ if not tag_name or not source or not category:
+ return jsonify({'ok': False, 'error': 'missing_fields'}), 400
+
+ image = ImageRecord.query.get(image_id)
+ if image is None:
+ return jsonify({'ok': False, 'error': 'image_not_found'}), 404
+
+ # Canonicalize before lookup so an already-attached "big hair" matches a
+ # freshly-POSTed "big_hair" (defensive — the client should already send canonical).
+ tag_name = _canonicalize_wd14_name(tag_name, category)
+
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ kind = _WD14_CATEGORY_TO_KIND.get(category) # None for 'general'
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush()
+```
+
+Key changes: (1) inline import both helpers at function top; (2) canonicalize once, up front; (3) kind derivation moves before `Tag(...)` — no double-lookup, no redundant normalization.
+
+- [ ] **Step 2: Remove the now-unused inline import in the centroid-recompute block**
+
+Still in `accept_image_suggestion` (around line 766), the existing inline `from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS` line stays — it's used by the centroid gate below. No change needed; just confirm it's still present after the Step 1 edit.
+
+- [ ] **Step 3: Test the happy path end-to-end**
+
+Open the modal for an image with a WD14 underscored suggestion. Click ✓. Then:
+
+```bash
+docker compose exec db psql -U postgres imagerepo -c \
+ "SELECT name, kind FROM tag ORDER BY id DESC LIMIT 5"
+```
+Expected: the new row's `name` has no underscores (e.g., `big hair`, not `big_hair`).
+
+- [ ] **Step 4: Test idempotency — accept the same tag twice**
+
+In the modal, click ✓ on another underscored suggestion. Verify the chip animates out and the tag appears in the attached-tags list. Refresh the modal; the tag should not re-appear as a suggestion (because `already` canonicalization works).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(tags): canonicalize suggestion-accept tag names via service helper"
+```
+
+---
+
+## Task 5: Write-path underscore stripping in `add_tag`
+
+**Files:**
+- Modify: `app/main.py:823-910` (`add_tag`)
+
+- [ ] **Step 1: Inspect the current function for the insertion point**
+
+Open `app/main.py` at `add_tag` (line 823). The current prefix-split block (lines 837-843) reads:
+
+```python
+ if ":" in name:
+ kind = name.split(":", 1)[0]
+ tag_name = name
+ else:
+ kind = "user"
+ tag_name = name
+```
+
+- [ ] **Step 2: Add underscore stripping to the user path**
+
+Replace the block above with:
+
+```python
+ if ":" in name:
+ kind = name.split(":", 1)[0]
+ tag_name = name
+ else:
+ # No prefix — treat as a user-typed general tag. Strip underscores so
+ # WD14-style names pasted into the add box converge with our convention.
+ from app.utils.tag_names import underscores_to_spaces
+ kind = "user"
+ tag_name = underscores_to_spaces(name)
+```
+
+Note: this only affects the user/no-prefix case. Character and fandom paths are already fully covered because `normalize_display_name` now strips underscores internally (Task 1).
+
+- [ ] **Step 3: Manually verify**
+
+In the modal, type `big_hair` (no colon) into the add-tag input and click Add. Then:
+
+```bash
+docker compose exec db psql -U postgres imagerepo -c \
+ "SELECT name, kind FROM tag WHERE name ILIKE 'big%' ORDER BY id DESC LIMIT 3"
+```
+Expected: a row with `name = 'big hair'`, `kind = 'user'`. No `big_hair` row.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(tags): strip underscores on user add-tag path"
+```
+
+---
+
+## Task 6: Write-path underscore stripping in `bulk_add_tag`
+
+**Files:**
+- Modify: `app/main.py:2279-2329` (`bulk_add_tag`)
+
+- [ ] **Step 1: Add underscore stripping to the kind-detection block**
+
+In `app/main.py`, locate `bulk_add_tag` (line 2279). The current block (lines 2296-2300) reads:
+
+```python
+ # Determine tag kind from name
+ if ':' in tag_name:
+ kind = tag_name.split(':', 1)[0]
+ else:
+ kind = 'user'
+```
+
+Replace with:
+
+```python
+ # Determine tag kind from name
+ if ':' in tag_name:
+ kind = tag_name.split(':', 1)[0]
+ else:
+ # No prefix — treat as a user-typed general tag. Strip underscores so
+ # WD14-style names pasted into the bulk box converge with our convention.
+ from app.utils.tag_names import underscores_to_spaces
+ kind = 'user'
+ tag_name = underscores_to_spaces(tag_name)
+```
+
+Character and fandom paths are already covered by the existing `normalize_display_name` calls (lines 2310, 2318) that now strip underscores internally.
+
+- [ ] **Step 2: Manually verify via the bulk endpoint**
+
+Pick two image IDs from the DB:
+
+```bash
+docker compose exec db psql -U postgres imagerepo -c "SELECT id FROM image_record LIMIT 2"
+```
+
+Call the endpoint with an underscored name:
+
+```bash
+curl -s -X POST http://localhost:5000/api/images/bulk-add-tag \
+ -H 'Content-Type: application/json' \
+ -d '{"image_ids":[,], "tag_name":"long_hair"}'
+```
+
+Then:
+
+```bash
+docker compose exec db psql -U postgres imagerepo -c \
+ "SELECT name, kind FROM tag WHERE name ILIKE 'long%' ORDER BY id DESC LIMIT 3"
+```
+Expected: a row with `name = 'long hair'`, `kind = 'user'`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(tags): strip underscores on bulk add-tag path"
+```
+
+---
+
+## Task 7: One-time migration `i26041901`
+
+**Files:**
+- Create: `migrations/versions/i26041901_normalize_underscores_and_general_tags.py`
+
+- [ ] **Step 1: Confirm the current head revision**
+
+```bash
+cd /home/bvandeusen/Nextcloud/Projects/ImageRepo
+docker compose exec web alembic heads
+```
+
+Expected: `h26041901 (head)`. If it differs, stop and ask the plan author — the migration's `down_revision` must match the actual current head.
+
+- [ ] **Step 2: Create the migration file**
+
+Create `migrations/versions/i26041901_normalize_underscores_and_general_tags.py` with this content:
+
+```python
+"""Strip underscores from character/fandom/null-kind tag display names and merge
+case-only duplicates that collapse onto the same canonical name.
+
+Revision ID: i26041901
+Revises: h26041901
+Create Date: 2026-04-19
+
+Upgrade: for every row in `tag` with kind in ('character', 'fandom', NULL),
+compute the canonical name (character/fandom: title-case + underscore→space;
+null: underscore→space only). Rename survivors. On collision, merge image_tags,
+fandom_id references, and tag_reference_embedding rows into the already-
+correctly-named winner and delete the loser.
+
+Downgrade: raises. Merges are destructive; reverting requires a pre-migration
+backup.
+"""
+from alembic import op
+from sqlalchemy import text
+
+
+revision = 'i26041901'
+down_revision = 'h26041901'
+branch_labels = None
+depends_on = None
+
+
+def _underscores_to_spaces(s: str) -> str:
+ """Mirror of app.utils.tag_names.underscores_to_spaces. Inlined so the
+ migration doesn't import app code (Alembic runs without the Flask app)."""
+ return s.replace('_', ' ') if s else s
+
+
+def _normalize_display(s: str) -> str:
+ """Mirror of app.utils.tag_names.normalize_display_name."""
+ if not s:
+ return s
+ s = _underscores_to_spaces(s)
+ out = []
+ for p in s.split(' '):
+ if p == '':
+ out.append(p)
+ else:
+ out.append(p[:1].upper() + p[1:])
+ return ' '.join(out)
+
+
+def _compute_new_name(old_name: str, kind) -> str:
+ """Return the canonical full name for a given (name, kind) pair.
+
+ kind == 'character': title-case base + (optional fandom suffix).
+ kind == 'fandom': title-case the display portion after the prefix.
+ kind IS NULL: underscore→space only; case preserved.
+ """
+ if kind == 'character':
+ if ':' not in old_name:
+ return old_name
+ prefix, rest = old_name.split(':', 1)
+ if '(' in rest and rest.rstrip().endswith(')'):
+ paren_idx = rest.rfind('(')
+ base = rest[:paren_idx].rstrip()
+ fandom_part = rest[paren_idx+1:-1].strip()
+ return f"{prefix}:{_normalize_display(base)} ({_normalize_display(fandom_part)})"
+ return f"{prefix}:{_normalize_display(rest)}"
+ elif kind == 'fandom':
+ if ':' not in old_name:
+ return old_name
+ prefix, rest = old_name.split(':', 1)
+ return f"{prefix}:{_normalize_display(rest)}"
+ elif kind is None:
+ # Null-kind (general): keep case, just strip underscores. Split off any
+ # prefix defensively — historical rows may or may not carry one.
+ if ':' in old_name:
+ prefix, rest = old_name.split(':', 1)
+ return f"{prefix}:{_underscores_to_spaces(rest)}"
+ return _underscores_to_spaces(old_name)
+ return old_name
+
+
+def upgrade():
+ conn = op.get_bind()
+ rows = conn.execute(
+ text("""
+ SELECT id, name, kind FROM tag
+ WHERE kind IN ('character', 'fandom') OR kind IS NULL
+ ORDER BY id
+ """)
+ ).fetchall()
+
+ renames = 0
+ merges = 0
+ for row in rows:
+ tag_id = row[0]
+ old_name = row[1]
+ kind = row[2]
+ new_name = _compute_new_name(old_name, kind)
+ if new_name == old_name:
+ continue
+
+ # Re-check that this tag still exists (may have been deleted as a loser
+ # in an earlier merge within this loop).
+ still_exists = conn.execute(
+ text("SELECT id FROM tag WHERE id = :id"),
+ {'id': tag_id},
+ ).fetchone()
+ if not still_exists:
+ continue
+
+ # Collision check: same target name + same kind (NULL-safe) + different id.
+ if kind is None:
+ target = conn.execute(
+ text("SELECT id, name FROM tag WHERE name = :new_name AND kind IS NULL AND id != :tag_id"),
+ {'new_name': new_name, 'tag_id': tag_id},
+ ).fetchone()
+ else:
+ target = conn.execute(
+ text("SELECT id, name FROM tag WHERE name = :new_name AND kind = :kind AND id != :tag_id"),
+ {'new_name': new_name, 'kind': kind, 'tag_id': tag_id},
+ ).fetchone()
+
+ if target is None:
+ conn.execute(
+ text("UPDATE tag SET name = :new_name WHERE id = :tag_id"),
+ {'new_name': new_name, 'tag_id': tag_id},
+ )
+ renames += 1
+ else:
+ winner_id = target[0]
+ winner_name = target[1]
+ loser_id = tag_id
+ loser_name = old_name
+
+ # 1. Reassign image_tags into winner.
+ conn.execute(
+ text("""
+ INSERT INTO image_tags (image_id, tag_id)
+ SELECT DISTINCT image_id, :winner_id
+ FROM image_tags
+ WHERE tag_id = :loser_id
+ ON CONFLICT DO NOTHING
+ """),
+ {'winner_id': winner_id, 'loser_id': loser_id},
+ )
+ conn.execute(
+ text("DELETE FROM image_tags WHERE tag_id = :loser_id"),
+ {'loser_id': loser_id},
+ )
+
+ # 2. Reassign fandom_id FKs pointing at the loser.
+ conn.execute(
+ text("UPDATE tag SET fandom_id = :winner_id WHERE fandom_id = :loser_id"),
+ {'winner_id': winner_id, 'loser_id': loser_id},
+ )
+
+ # 3. tag_reference_embedding: keep winner's row per model_version;
+ # otherwise rename loser's row to the winner.
+ conn.execute(
+ text("""
+ UPDATE tag_reference_embedding
+ SET tag_name = :winner_name
+ WHERE tag_name = :loser_name
+ AND NOT EXISTS (
+ SELECT 1 FROM tag_reference_embedding w
+ WHERE w.tag_name = :winner_name
+ AND w.model_version = tag_reference_embedding.model_version
+ )
+ """),
+ {'winner_name': winner_name, 'loser_name': loser_name},
+ )
+ conn.execute(
+ text("DELETE FROM tag_reference_embedding WHERE tag_name = :loser_name"),
+ {'loser_name': loser_name},
+ )
+
+ # 4. Delete the loser tag row.
+ conn.execute(
+ text("DELETE FROM tag WHERE id = :loser_id"),
+ {'loser_id': loser_id},
+ )
+ merges += 1
+
+ print(f"normalize_underscores_and_general_tags: {renames} renames, {merges} merges")
+
+
+def downgrade():
+ raise NotImplementedError(
+ "i26041901 canonicalizes tag names and merges duplicates that collapse. "
+ "Merges are destructive and cannot be reversed automatically. "
+ "Restore from a pre-migration backup if you need to roll back."
+ )
+```
+
+- [ ] **Step 3: Seed test data before the migration**
+
+Run this to create a controlled test: one underscored-only-loser-collides-with-winner case, one pure-rename case, plus a null-kind rename:
+
+```bash
+docker compose exec db psql -U postgres imagerepo <<'SQL'
+-- Collision case: winner 'big hair' exists, loser 'big_hair' will merge in.
+INSERT INTO tag (name, kind) VALUES ('big hair', NULL) ON CONFLICT DO NOTHING;
+INSERT INTO tag (name, kind) VALUES ('big_hair', NULL);
+
+-- Pure rename case: no collision.
+INSERT INTO tag (name, kind) VALUES ('fandom:trigun_stampede', 'fandom');
+
+-- Null-kind pure rename: underscores survive as spaces, case preserved.
+INSERT INTO tag (name, kind) VALUES ('long_blue_sky', NULL);
+SQL
+```
+
+Record the `id`s for later verification:
+
+```bash
+docker compose exec db psql -U postgres imagerepo -c \
+ "SELECT id, name, kind FROM tag WHERE name IN ('big hair','big_hair','fandom:trigun_stampede','long_blue_sky') ORDER BY id"
+```
+
+Attach the loser `big_hair` to one real image so the merge has something to move. Pick any image id:
+
+```bash
+docker compose exec db psql -U postgres imagerepo <<'SQL'
+INSERT INTO image_tags (image_id, tag_id)
+SELECT (SELECT id FROM image_record LIMIT 1), id
+FROM tag WHERE name = 'big_hair';
+SQL
+```
+
+- [ ] **Step 4: Run the migration**
+
+```bash
+docker compose exec web alembic upgrade head
+```
+
+Expected output includes: `normalize_underscores_and_general_tags: 2 renames, 1 merges`. (Two renames: `fandom:trigun_stampede` → `fandom:Trigun Stampede` and `long_blue_sky` → `long blue sky`. One merge: `big_hair` into `big hair`.)
+
+- [ ] **Step 5: Verify post-migration state**
+
+```bash
+docker compose exec db psql -U postgres imagerepo <<'SQL'
+-- Expected: exactly one 'big hair' row, no 'big_hair'.
+SELECT name, kind FROM tag WHERE name ILIKE 'big%' AND kind IS NULL;
+-- Expected: 'fandom:Trigun Stampede' present, 'fandom:trigun_stampede' absent.
+SELECT name, kind FROM tag WHERE name ILIKE 'fandom:trigun%';
+-- Expected: 'long blue sky' present.
+SELECT name, kind FROM tag WHERE name = 'long blue sky';
+-- Expected: the image previously tagged with 'big_hair' now has 'big hair'.
+SELECT it.image_id, t.name FROM image_tags it JOIN tag t ON t.id = it.tag_id
+WHERE t.name IN ('big hair', 'big_hair');
+SQL
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add migrations/versions/i26041901_normalize_underscores_and_general_tags.py
+git commit -m "feat(migrations): i26041901 normalize underscores + general tags with merge"
+```
+
+---
+
+## Task 8: Modal JS — accept refresh parity
+
+**Files:**
+- Modify: `app/static/js/view-modal.js:195-233` (`animateChipOut`, `acceptSuggestion`)
+
+- [ ] **Step 1: Simplify `animateChipOut`**
+
+In `app/static/js/view-modal.js`, replace the `animateChipOut` function (lines 195-201) with:
+
+```javascript
+ function animateChipOut(chip) {
+ chip.classList.add('leaving');
+ setTimeout(() => { chip.remove(); }, 200);
+ }
+```
+
+(Removes the `onDone` callback — no callers need it after Task 8 completes.)
+
+- [ ] **Step 2: Rewrite `acceptSuggestion`**
+
+Still in `view-modal.js`, replace `acceptSuggestion` (lines 203-233) with:
+
+```javascript
+ async function acceptSuggestion(chip, imageId) {
+ if (chip.dataset.disabled === 'true') return;
+ chip.dataset.disabled = 'true';
+ chip.style.pointerEvents = 'none';
+ try {
+ const r = await fetch(`/image/${imageId}/suggestions/accept`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tag_name: chip.dataset.tagName,
+ source: chip.dataset.source,
+ category: chip.dataset.category,
+ confidence: Number(chip.dataset.confidence) || 0,
+ }),
+ });
+ const j = await r.json();
+ if (!j.ok) {
+ chip.dataset.disabled = 'false';
+ chip.style.pointerEvents = '';
+ if (tagActionFeedback) {
+ tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
+ }
+ return;
+ }
+ if (typeof loadTags === 'function') await loadTags(imageId);
+ if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.name}`;
+ if (typeof window.refreshItemTags === 'function') window.refreshItemTags(imageId);
+ animateChipOut(chip);
+ } catch {
+ chip.dataset.disabled = 'false';
+ chip.style.pointerEvents = '';
+ }
+ }
+```
+
+Key changes vs. current code: (1) `chip.dataset.disabled` guards double-click; (2) `await loadTags(imageId)` runs before feedback text and animation, mirroring manual-add timing (`addTag` at line 601); (3) chip animation no longer gates the data refresh.
+
+- [ ] **Step 3: Update the one other caller of `animateChipOut` — `rejectSuggestion`**
+
+Still in `view-modal.js`, `rejectSuggestion` (around line 235) currently calls `animateChipOut(chip, () => { ... })`. Replace those lines (currently 247-249) with:
+
+```javascript
+ animateChipOut(chip);
+ if (tagActionFeedback) tagActionFeedback.textContent = `Rejected ${chip.dataset.tagName}`;
+```
+
+(The feedback text was the only thing the callback did. Moving it out keeps the same ordering relative to the user's perception — the chip fades while the message appears.)
+
+- [ ] **Step 4: Manually verify accept timing**
+
+In a browser, open DevTools → Network tab. Open an image modal with suggestions. Click ✓ on one. Watch the Network panel:
+
+Expected sequence:
+1. `POST /image//suggestions/accept` → 200
+2. `GET /image//tags` → 200 (the `loadTags` call)
+3. Chip's leaving animation plays and chip disappears
+
+The attached-tags section should show the new tag *before* the chip disappears (because `await loadTags` runs before `animateChipOut`).
+
+- [ ] **Step 5: Verify duplicate-click protection**
+
+Rapid double-click ✓. Check Network: only one `POST /accept` fires.
+
+- [ ] **Step 6: Verify error path**
+
+Temporarily edit `accept_image_suggestion` in `app/main.py` to unconditionally return `jsonify({'ok': False, 'error': 'test'}), 400` at the top. Reload the page, click ✓. Verify: the chip stays on screen, its pointer events re-enable, feedback text says "Couldn't accept: test". Then revert the temporary change — do NOT commit the test stub.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/static/js/view-modal.js
+git commit -m "feat(ui): suggestion accept awaits loadTags, mirrors manual-add timing"
+```
+
+---
+
+## Task 9: CSS — persistent green/red chip buttons
+
+**Files:**
+- Modify: `app/static/style.css:998-1006`
+
+- [ ] **Step 1: Replace the button color rules**
+
+In `app/static/style.css`, locate the two `:hover` blocks at lines 998-1006:
+
+```css
+.suggestion-chip .sugg-btn.sugg-accept:hover {
+ color: #8be78b;
+ border-color: rgba(139, 231, 139, 0.6);
+ background: rgba(139, 231, 139, 0.08);
+}
+.suggestion-chip .sugg-btn.sugg-reject:hover {
+ color: #e78b8b;
+ border-color: rgba(231, 139, 139, 0.6);
+ background: rgba(231, 139, 139, 0.08);
+}
+```
+
+Replace with:
+
+```css
+.suggestion-chip .sugg-btn.sugg-accept {
+ color: #8be78b;
+ border-color: rgba(139, 231, 139, 0.35);
+}
+.suggestion-chip .sugg-btn.sugg-accept:hover {
+ background: rgba(139, 231, 139, 0.12);
+ border-color: rgba(139, 231, 139, 0.6);
+}
+.suggestion-chip .sugg-btn.sugg-reject {
+ color: #e78b8b;
+ border-color: rgba(231, 139, 139, 0.35);
+}
+.suggestion-chip .sugg-btn.sugg-reject:hover {
+ background: rgba(231, 139, 139, 0.12);
+ border-color: rgba(231, 139, 139, 0.6);
+}
+```
+
+Rationale: the base `.sugg-btn` rule already covers layout + transparent background. The new accept/reject base rules add the semantic color; hover intensifies background + border.
+
+- [ ] **Step 2: Manually verify**
+
+Hard-refresh the browser (Ctrl-Shift-R) to bust CSS cache. Open an image modal with suggestions. Without hovering, verify:
+- ✓ buttons show a green glyph and a soft green border.
+- ✕ buttons show a red glyph and a soft red border.
+- Hovering intensifies both.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/style.css
+git commit -m "feat(ui): persistent green/red tint on suggestion accept/reject buttons"
+```
+
+---
+
+## Task 10: End-to-end smoke test
+
+- [ ] **Step 1: Full-flow test on a WD14-predicted image**
+
+Pick an image that has underscored WD14 predictions (any image already processed by `tag_and_embed`). In the browser:
+
+1. Open the modal.
+2. Confirm: every suggestion chip's name is underscore-free (e.g., `long hair`, not `long_hair`).
+3. Confirm: ✓ is green-tinted, ✕ is red-tinted, both without hover.
+4. Click ✓ on one suggestion.
+5. Confirm: attached-tags list updates before chip animation finishes; feedback text "Added " shows.
+6. Confirm: refreshing the page and re-opening the modal no longer shows that suggestion.
+7. In DB: `SELECT name FROM tag WHERE id = (SELECT tag_id FROM image_tags WHERE image_id = ORDER BY tag_id DESC LIMIT 1)` — expect no underscores.
+
+- [ ] **Step 2: Rollback of test seed data (if present from Task 7)**
+
+Clean up any leftover test-only tags from the Task 7 seed step. Inspect and delete as needed:
+
+```bash
+docker compose exec db psql -U postgres imagerepo <<'SQL'
+-- Only delete if they came from test seeding. Check what is attached first.
+SELECT t.id, t.name, t.kind, COUNT(it.image_id) AS attached_count
+FROM tag t LEFT JOIN image_tags it ON it.tag_id = t.id
+WHERE t.name IN ('big hair', 'long blue sky', 'fandom:Trigun Stampede')
+GROUP BY t.id, t.name, t.kind;
+SQL
+```
+
+If any of these were only attached by the Task 7 seed (and the user doesn't want to keep them), delete:
+
+```bash
+docker compose exec db psql -U postgres imagerepo <<'SQL'
+DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name IN ('big hair', 'long blue sky', 'fandom:Trigun Stampede'));
+DELETE FROM tag WHERE name IN ('big hair', 'long blue sky', 'fandom:Trigun Stampede');
+SQL
+```
+
+Skip this step entirely if the user's real library already contained any of those names pre-migration.
+
+- [ ] **Step 3: Final commit (if anything uncommitted remains)**
+
+```bash
+git status
+```
+If clean, nothing to do. Plan complete.
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check:**
+- WD14 underscore→space at emit → Task 3 ✓
+- Write-path canonicalization (character/fandom via `normalize_display_name`) → Task 1 (the helper change itself covers both kinds in `add_tag`/`bulk_add_tag`/`set_tag_fandom`/`accept`) ✓
+- General (null) underscore→space on write paths → Tasks 4 (accept), 5 (add_tag), 6 (bulk_add_tag) ✓
+- Migration `i26041901` rename-or-merge across three kinds → Task 7 ✓
+- Accept refresh parity with manual add → Task 8 ✓
+- Persistent green/red chip buttons → Task 9 ✓
+- `_WD14_CATEGORY_TO_KIND` relocated to service (per spec) → Task 2 ✓
+
+**No `set_tag_fandom` task** — the spec doesn't name it as a write path that needs new code. `set_tag_fandom` already routes through `normalize_display_name` (from the prior character-tag-integrity feature), which now strips underscores automatically via the Task 1 change. No explicit edit required.
+
+**No test files** — matches project convention (see the parent feature `h26041901` and the character-tag integrity spec, both of which used manual verification only).
diff --git a/docs/superpowers/plans/2026-04-19-user-tag-embedding-suggestions.md b/docs/superpowers/plans/2026-04-19-user-tag-embedding-suggestions.md
new file mode 100644
index 0000000..db21444
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-19-user-tag-embedding-suggestions.md
@@ -0,0 +1,1052 @@
+# User-Tag Embedding Suggestions Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extend SigLIP centroid-based tag suggestions from `character` only to `character` + `fandom` + null (general/topic) kinds, and drop `rating` from the suggestion UI entirely.
+
+**Architecture:** Rename `character_reference_embedding` table to `tag_reference_embedding`, add `tag_kind` column, and thread that kind through the recompute task, suggestion service, and accept endpoint. Add a batch "recompute all eligible centroids" task and expose it in Settings → Maintenance.
+
+**Tech Stack:** Flask + SQLAlchemy + Alembic migrations, Postgres 16 + pgvector, Celery 5 + Redis, SigLIP SO400M embeddings.
+
+**Spec:** `docs/superpowers/specs/2026-04-19-user-tag-embedding-suggestions-design.md`
+
+---
+
+## File Structure
+
+Files created:
+
+- `migrations/versions/g26041901_rename_character_to_tag_reference.py` — Alembic migration (rename table/column, add `tag_kind`, rename/drop config rows).
+
+Files modified (one responsibility each):
+
+- `app/models.py:250-256` — rename `CharacterReferenceEmbedding` class → `TagReferenceEmbedding`, rename column `character_tag` → `tag_name`, add `tag_kind`.
+- `app/services/tag_suggestions.py` — defines `ELIGIBLE_CENTROID_KINDS`, rewrites `_embedding_character_suggestions` as `_embedding_tag_suggestions`, drops `rating` from grouped output, renames config keys.
+- `app/tasks/ml.py:146-189` — rewrite `recompute_centroid` to accept any eligible kind, add new `recompute_all_centroids` batch task.
+- `app/main.py:691-772` — drop `rating` from `_WD14_CATEGORY_TO_KIND`, extend centroid trigger to all eligible kinds, add `trigger_recompute_all_centroids` route.
+- `app/static/js/view-modal.js:88-117` — drop `rating` from `SUGGESTION_CATEGORY_ORDER` / `SUGGESTION_CATEGORY_LABEL`.
+- `app/templates/settings.html:440-449` — add "Recompute all centroids" button next to existing ML backfill form.
+
+---
+
+## Task 1: Database migration — rename table, add tag_kind, clean config
+
+**Files:**
+- Create: `migrations/versions/g26041901_rename_character_to_tag_reference.py`
+- Reference: `migrations/versions/0017871d2221_add_tag_suggestions.py` (prior migration, down_revision target)
+
+Check current alembic head first to confirm the `down_revision`:
+
+- [ ] **Step 1: Confirm the alembic revision chain**
+
+```bash
+docker compose exec web alembic heads
+```
+
+Expected: `0017871d2221 (head)` (this is the tag-suggestions migration that created the table we're renaming).
+
+- [ ] **Step 2: Create the migration file**
+
+Write `migrations/versions/g26041901_rename_character_to_tag_reference.py`:
+
+```python
+"""rename character_reference_embedding to tag_reference_embedding and add tag_kind
+
+Revision ID: g26041901
+Revises: 0017871d2221
+Create Date: 2026-04-19
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = 'g26041901'
+down_revision = '0017871d2221'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Rename table
+ op.rename_table('character_reference_embedding', 'tag_reference_embedding')
+
+ # Rename column
+ op.alter_column('tag_reference_embedding', 'character_tag', new_column_name='tag_name')
+
+ # Add tag_kind column (nullable — null = general/topic tag)
+ op.add_column(
+ 'tag_reference_embedding',
+ sa.Column('tag_kind', sa.Text(), nullable=True),
+ )
+
+ # Every pre-existing row was written by the character-only path, so tag_kind='character'.
+ op.execute("UPDATE tag_reference_embedding SET tag_kind = 'character'")
+
+ # Rename config keys. Postgres-safe single UPDATE per key.
+ op.execute(
+ "UPDATE tag_suggestion_config "
+ "SET key = 'threshold_embedding' "
+ "WHERE key = 'threshold_embedding_character'"
+ )
+ op.execute(
+ "UPDATE tag_suggestion_config "
+ "SET key = 'min_reference_images' "
+ "WHERE key = 'min_reference_images_per_character'"
+ )
+
+ # Drop the rating threshold — rating is no longer surfaced in the UI.
+ op.execute("DELETE FROM tag_suggestion_config WHERE key = 'threshold_rating'")
+
+
+def downgrade():
+ # Restore threshold_rating seed with original default.
+ op.execute(
+ "INSERT INTO tag_suggestion_config (key, value, description) "
+ "VALUES ('threshold_rating', '0.5', 'Min confidence for rating tags') "
+ "ON CONFLICT (key) DO NOTHING"
+ )
+
+ # Revert config key renames.
+ op.execute(
+ "UPDATE tag_suggestion_config "
+ "SET key = 'min_reference_images_per_character' "
+ "WHERE key = 'min_reference_images'"
+ )
+ op.execute(
+ "UPDATE tag_suggestion_config "
+ "SET key = 'threshold_embedding_character' "
+ "WHERE key = 'threshold_embedding'"
+ )
+
+ # Drop tag_kind column.
+ op.drop_column('tag_reference_embedding', 'tag_kind')
+
+ # Revert column and table renames.
+ op.alter_column('tag_reference_embedding', 'tag_name', new_column_name='character_tag')
+ op.rename_table('tag_reference_embedding', 'character_reference_embedding')
+```
+
+- [ ] **Step 3: Run the migration against the dev DB**
+
+```bash
+docker compose exec web alembic upgrade head
+```
+
+Expected output: `Running upgrade 0017871d2221 -> g26041901, rename character_reference_embedding to tag_reference_embedding and add tag_kind`.
+
+- [ ] **Step 4: Verify the schema changed**
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "\d tag_reference_embedding"
+```
+
+Expected: table `tag_reference_embedding` with columns `tag_name`, `tag_kind`, `model_version`, `centroid`, `reference_count`, `computed_at`. Primary key `(tag_name, model_version)`.
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "SELECT tag_name, tag_kind FROM tag_reference_embedding LIMIT 5"
+```
+
+Expected: any pre-existing rows show `tag_kind = 'character'`.
+
+```bash
+docker compose exec -T postgres psql -U imagerepo -d imagerepo -c "SELECT key FROM tag_suggestion_config ORDER BY key"
+```
+
+Expected: includes `threshold_embedding`, `min_reference_images`; does **not** include `threshold_embedding_character`, `min_reference_images_per_character`, or `threshold_rating`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add migrations/versions/g26041901_rename_character_to_tag_reference.py
+git commit -m "feat(db): rename character_reference_embedding → tag_reference_embedding + tag_kind"
+```
+
+---
+
+## Task 2: Update the SQLAlchemy model
+
+**Files:**
+- Modify: `app/models.py:250-256`
+
+- [ ] **Step 1: Replace the model class**
+
+Find this block in `app/models.py`:
+
+```python
+class CharacterReferenceEmbedding(db.Model):
+ __tablename__ = "character_reference_embedding"
+ character_tag = db.Column(db.Text, primary_key=True)
+ model_version = db.Column(db.Text, primary_key=True)
+ centroid = db.Column(Vector(1152), nullable=False)
+ reference_count = db.Column(db.Integer, nullable=False)
+ computed_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+```
+
+Replace with:
+
+```python
+class TagReferenceEmbedding(db.Model):
+ __tablename__ = "tag_reference_embedding"
+ tag_name = db.Column(db.Text, primary_key=True)
+ model_version = db.Column(db.Text, primary_key=True)
+ tag_kind = db.Column(db.Text, nullable=True)
+ centroid = db.Column(Vector(1152), nullable=False)
+ reference_count = db.Column(db.Integer, nullable=False)
+ computed_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
+```
+
+- [ ] **Step 2: Verify no other import references the old class name**
+
+```bash
+grep -rn "CharacterReferenceEmbedding" app/
+```
+
+Expected output: references only from `app/models.py` (the class definition, now renamed) and these files which we'll update in later tasks:
+- `app/tasks/ml.py` (Task 3)
+- `app/services/tag_suggestions.py` (Task 4)
+- `app/main.py` (Task 5)
+
+If any **other** file references `CharacterReferenceEmbedding`, stop and add it to the fix list.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/models.py
+git commit -m "feat(models): rename CharacterReferenceEmbedding → TagReferenceEmbedding"
+```
+
+---
+
+## Task 3: Rewrite `recompute_centroid` task for any eligible kind + add batch task
+
+**Files:**
+- Modify: `app/tasks/ml.py:146-189` (replace entire `recompute_centroid` function)
+- Modify: `app/tasks/ml.py` (add new `recompute_all_centroids` task at end of file)
+
+- [ ] **Step 1: Update imports**
+
+At the top of `app/tasks/ml.py`, find:
+
+```python
+from app.models import (
+ ImageRecord,
+ Tag,
+ ImageTagPrediction,
+ ImageEmbedding,
+ CharacterReferenceEmbedding,
+ TagSuggestionConfig,
+ image_tags,
+)
+```
+
+Replace `CharacterReferenceEmbedding` with `TagReferenceEmbedding`:
+
+```python
+from app.models import (
+ ImageRecord,
+ Tag,
+ ImageTagPrediction,
+ ImageEmbedding,
+ TagReferenceEmbedding,
+ TagSuggestionConfig,
+ image_tags,
+)
+```
+
+- [ ] **Step 2: Replace `recompute_centroid` with the kind-agnostic version**
+
+Find the existing function (lines ~146-189) and replace with:
+
+```python
+@celery.task(bind=True, name='app.tasks.ml.recompute_centroid',
+ soft_time_limit=60, time_limit=120)
+def recompute_centroid(self, tag_name: str):
+ """Recompute the mean embedding for an eligible tag from its currently-associated images.
+
+ Eligible tag kinds are defined by ELIGIBLE_CENTROID_KINDS in
+ app.services.tag_suggestions. Tags of any other kind return early with
+ status='ineligible_kind' and do not write a centroid row.
+ """
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+ from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS
+
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if tag is None:
+ log.warning(f"recompute_centroid: tag {tag_name!r} not found")
+ return {'status': 'missing_tag'}
+
+ if tag.kind not in ELIGIBLE_CENTROID_KINDS:
+ log.info(f"recompute_centroid: tag {tag_name!r} has ineligible kind {tag.kind!r}")
+ return {'status': 'ineligible_kind'}
+
+ rows = (
+ db.session.query(ImageEmbedding.embedding)
+ .join(image_tags, image_tags.c.image_id == ImageEmbedding.image_id)
+ .filter(image_tags.c.tag_id == tag.id)
+ .filter(ImageEmbedding.model_version == SIGLIP_VER)
+ .all()
+ )
+ if not rows:
+ log.info(f"recompute_centroid: no embeddings yet for {tag_name}")
+ return {'status': 'no_embeddings'}
+
+ vectors = np.array([np.array(r[0], dtype=np.float32) for r in rows])
+ centroid = vectors.mean(axis=0)
+
+ stmt = pg_insert(TagReferenceEmbedding).values(
+ tag_name=tag_name,
+ tag_kind=tag.kind,
+ model_version=SIGLIP_VER,
+ centroid=centroid.tolist(),
+ reference_count=len(rows),
+ computed_at=func.now(),
+ )
+ stmt = stmt.on_conflict_do_update(
+ index_elements=['tag_name', 'model_version'],
+ set_={
+ 'tag_kind': stmt.excluded.tag_kind,
+ 'centroid': stmt.excluded.centroid,
+ 'reference_count': stmt.excluded.reference_count,
+ 'computed_at': func.now(),
+ },
+ )
+ db.session.execute(stmt)
+ db.session.commit()
+ log.info(f"recompute_centroid: {tag_name} (kind={tag.kind}) -> n={len(rows)}")
+ return {'status': 'ok', 'reference_count': len(rows), 'tag_kind': tag.kind}
+```
+
+- [ ] **Step 3: Add the batch task at end of `app/tasks/ml.py`**
+
+Append:
+
+```python
+@celery.task(bind=True, name='app.tasks.ml.recompute_all_centroids',
+ soft_time_limit=None, time_limit=None)
+def recompute_all_centroids(self):
+ """Enqueue recompute_centroid for every eligible tag with enough reference images.
+
+ Uses a single aggregate query to find tags with >= min_reference_images applied
+ images, then enqueues one recompute_centroid task per tag on the ml queue.
+ """
+ from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS
+
+ min_refs_row = TagSuggestionConfig.query.filter_by(key='min_reference_images').first()
+ min_refs = int(min_refs_row.value) if min_refs_row else 5
+
+ # Build an IS NULL / IN filter that covers ELIGIBLE_CENTROID_KINDS including None.
+ kinds_not_null = [k for k in ELIGIBLE_CENTROID_KINDS if k is not None]
+ allow_null = None in ELIGIBLE_CENTROID_KINDS
+
+ kind_filter = Tag.kind.in_(kinds_not_null)
+ if allow_null:
+ kind_filter = kind_filter | Tag.kind.is_(None)
+
+ rows = (
+ db.session.query(Tag.name, func.count(image_tags.c.image_id).label('n'))
+ .join(image_tags, image_tags.c.tag_id == Tag.id)
+ .filter(kind_filter)
+ .group_by(Tag.name)
+ .having(func.count(image_tags.c.image_id) >= min_refs)
+ .all()
+ )
+
+ enqueued = 0
+ for tag_name, n in rows:
+ recompute_centroid.apply_async(args=[tag_name], queue='ml')
+ enqueued += 1
+ log.info(f"recompute_all_centroids: enqueued {enqueued} tags (min_refs={min_refs})")
+ return {'status': 'ok', 'enqueued': enqueued, 'min_refs': min_refs}
+```
+
+- [ ] **Step 4: Register the new task in celery_app.py includes**
+
+Check `app/celery_app.py` has `'app.tasks.ml'` in the `include=` list. It should already be present from the prior feature — if missing, add it. Run:
+
+```bash
+grep -n "app.tasks.ml" app/celery_app.py
+```
+
+Expected: at least one line showing `'app.tasks.ml'` in the `include=[...]` tuple. If absent, add it.
+
+- [ ] **Step 5: Rebuild the ml-worker so the new task registers**
+
+```bash
+docker compose up -d --build ml-worker
+```
+
+- [ ] **Step 6: Verify new task appears in the worker task list**
+
+```bash
+docker compose logs ml-worker --tail 50 2>&1 | grep -E "recompute_all_centroids|recompute_centroid"
+```
+
+Expected: both `app.tasks.ml.recompute_centroid` and `app.tasks.ml.recompute_all_centroids` appear in the `[tasks]` listing.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/tasks/ml.py
+git commit -m "feat(ml): kind-aware recompute_centroid + recompute_all_centroids batch task"
+```
+
+---
+
+## Task 4: Update suggestion service — eligible kinds, drop rating group
+
+**Files:**
+- Modify: `app/services/tag_suggestions.py`
+
+- [ ] **Step 1: Update imports + add eligible-kinds constant at the top**
+
+Find the imports near the top of `app/services/tag_suggestions.py`:
+
+```python
+from app.models import (
+ ImageTagPrediction,
+ ImageEmbedding,
+ CharacterReferenceEmbedding,
+ TagSuggestionConfig,
+ ImageRecord,
+ Tag,
+ image_tags,
+)
+```
+
+Replace with (swap class name):
+
+```python
+from app.models import (
+ ImageTagPrediction,
+ ImageEmbedding,
+ TagReferenceEmbedding,
+ TagSuggestionConfig,
+ ImageRecord,
+ Tag,
+ image_tags,
+)
+```
+
+Then below the imports, add the constant:
+
+```python
+# Tag kinds that receive embedding-similarity suggestions. None = general/topic tags.
+# Adding 'artist' or 'series' here enables them with no other code changes.
+ELIGIBLE_CENTROID_KINDS = ('character', 'fandom', None)
+```
+
+- [ ] **Step 2: Rename config keys in `_DEFAULTS`**
+
+Find:
+
+```python
+_DEFAULTS = {
+ 'threshold_general': 0.35,
+ 'threshold_character_wd14': 0.75,
+ 'threshold_copyright': 0.5,
+ 'threshold_rating': 0.5,
+ 'threshold_meta': 0.5,
+ 'threshold_embedding_character': 0.85,
+ 'min_reference_images_per_character': 5.0,
+ 'wd14_model_version': 'wd-eva02-large-tagger-v3',
+ 'siglip_model_version': 'siglip-so400m-patch14-384',
+}
+```
+
+Replace with:
+
+```python
+_DEFAULTS = {
+ 'threshold_general': 0.35,
+ 'threshold_character_wd14': 0.75,
+ 'threshold_copyright': 0.5,
+ 'threshold_meta': 0.5,
+ 'threshold_embedding': 0.85,
+ 'min_reference_images': 5.0,
+ 'wd14_model_version': 'wd-eva02-large-tagger-v3',
+ 'siglip_model_version': 'siglip-so400m-patch14-384',
+}
+```
+
+- [ ] **Step 3: Replace `_embedding_character_suggestions` with `_embedding_tag_suggestions`**
+
+Find the existing function:
+
+```python
+def _embedding_character_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]:
+ siglip_ver = cfg.get('siglip_model_version', _DEFAULTS['siglip_model_version'])
+ min_refs = int(cfg.get('min_reference_images_per_character', 5))
+ threshold = cfg.get('threshold_embedding_character', 0.85)
+
+ image_emb = (
+ ImageEmbedding.query
+ .filter_by(image_id=image_id, model_version=siglip_ver)
+ .first()
+ )
+ if image_emb is None:
+ return []
+
+ # pgvector cosine distance; similarity = 1 - distance
+ distance = CharacterReferenceEmbedding.centroid.cosine_distance(image_emb.embedding)
+ rows = (
+ db.session.query(
+ CharacterReferenceEmbedding.character_tag,
+ CharacterReferenceEmbedding.reference_count,
+ distance.label('distance'),
+ )
+ .filter(CharacterReferenceEmbedding.model_version == siglip_ver)
+ .filter(CharacterReferenceEmbedding.reference_count >= min_refs)
+ .order_by(distance)
+ .limit(20)
+ .all()
+ )
+ out: list[dict] = []
+ for tag_name, ref_count, dist in rows:
+ similarity = 1.0 - float(dist)
+ if similarity < threshold:
+ continue
+ if tag_name in already:
+ continue
+ out.append({
+ 'name': tag_name,
+ 'category': 'character',
+ 'confidence': similarity,
+ 'source': 'embedding_similarity',
+ })
+ return out
+```
+
+Replace with:
+
+```python
+# Maps tag kind (as stored on Tag and TagReferenceEmbedding) to the display
+# category used in the modal UI's grouped suggestions.
+_KIND_TO_DISPLAY_CATEGORY = {
+ 'character': 'character',
+ 'fandom': 'copyright',
+ None: 'general',
+}
+
+
+def _embedding_tag_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]:
+ siglip_ver = cfg.get('siglip_model_version', _DEFAULTS['siglip_model_version'])
+ min_refs = int(cfg.get('min_reference_images', 5))
+ threshold = cfg.get('threshold_embedding', 0.85)
+
+ image_emb = (
+ ImageEmbedding.query
+ .filter_by(image_id=image_id, model_version=siglip_ver)
+ .first()
+ )
+ if image_emb is None:
+ return []
+
+ # pgvector cosine distance; similarity = 1 - distance
+ distance = TagReferenceEmbedding.centroid.cosine_distance(image_emb.embedding)
+ rows = (
+ db.session.query(
+ TagReferenceEmbedding.tag_name,
+ TagReferenceEmbedding.tag_kind,
+ TagReferenceEmbedding.reference_count,
+ distance.label('distance'),
+ )
+ .filter(TagReferenceEmbedding.model_version == siglip_ver)
+ .filter(TagReferenceEmbedding.reference_count >= min_refs)
+ .order_by(distance)
+ .limit(40)
+ .all()
+ )
+ out: list[dict] = []
+ for tag_name, tag_kind, ref_count, dist in rows:
+ similarity = 1.0 - float(dist)
+ if similarity < threshold:
+ continue
+ if tag_name in already:
+ continue
+ category = _KIND_TO_DISPLAY_CATEGORY.get(tag_kind)
+ if category is None and tag_kind is not None:
+ # Unknown kind — skip defensively. (Should never happen because
+ # recompute_centroid only writes eligible kinds.)
+ continue
+ if tag_kind is None:
+ category = 'general'
+ out.append({
+ 'name': tag_name,
+ 'category': category,
+ 'confidence': similarity,
+ 'source': 'embedding_similarity',
+ })
+ return out
+```
+
+- [ ] **Step 4: Update `get_suggestions` call site + drop `rating` group**
+
+Find:
+
+```python
+def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
+ """Return suggestions grouped by category for one image.
+
+ Shape:
+ {
+ 'character': [{name, confidence, source, exists_in_db}, ...],
+ 'copyright': [...],
+ 'rating': [...],
+ 'general': [...],
+ }
+ Ordered by confidence desc within each category. 'meta' is omitted.
+ """
+ if ImageRecord.query.get(image_id) is None:
+ return {'character': [], 'copyright': [], 'rating': [], 'general': []}
+
+ cfg = _config()
+ already = _existing_tag_names_for_image(image_id)
+
+ merged = _merge(
+ _wd14_suggestions(image_id, cfg, already),
+ _embedding_character_suggestions(image_id, cfg, already),
+ )
+
+ existing_all = _existing_tag_names()
+
+ grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'rating': [], 'general': []}
+ for s in merged:
+ if s['category'] not in grouped:
+ continue # meta / artist / unknown are dropped
+ s['exists_in_db'] = s['name'] in existing_all
+ grouped[s['category']].append(s)
+
+ for cat in grouped:
+ grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
+ grouped[cat] = grouped[cat][:top_k_per_category]
+
+ return grouped
+```
+
+Replace with:
+
+```python
+def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
+ """Return suggestions grouped by category for one image.
+
+ Shape:
+ {
+ 'character': [{name, confidence, source, exists_in_db}, ...],
+ 'copyright': [...],
+ 'general': [...],
+ }
+ Ordered by confidence desc within each category. 'meta' and 'rating' are omitted.
+ """
+ if ImageRecord.query.get(image_id) is None:
+ return {'character': [], 'copyright': [], 'general': []}
+
+ cfg = _config()
+ already = _existing_tag_names_for_image(image_id)
+
+ merged = _merge(
+ _wd14_suggestions(image_id, cfg, already),
+ _embedding_tag_suggestions(image_id, cfg, already),
+ )
+
+ existing_all = _existing_tag_names()
+
+ grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
+ for s in merged:
+ if s['category'] not in grouped:
+ continue # meta / rating / artist / unknown are dropped
+ s['exists_in_db'] = s['name'] in existing_all
+ grouped[s['category']].append(s)
+
+ for cat in grouped:
+ grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
+ grouped[cat] = grouped[cat][:top_k_per_category]
+
+ return grouped
+```
+
+- [ ] **Step 5: Update `_category_threshold` to drop rating**
+
+Find:
+
+```python
+def _category_threshold(category: str, cfg: dict) -> float:
+ return {
+ 'general': cfg['threshold_general'],
+ 'character': cfg['threshold_character_wd14'],
+ 'copyright': cfg['threshold_copyright'],
+ 'rating': cfg['threshold_rating'],
+ 'meta': cfg['threshold_meta'],
+ }.get(category, 1.01) # unknown → effectively disabled
+```
+
+Replace with:
+
+```python
+def _category_threshold(category: str, cfg: dict) -> float:
+ return {
+ 'general': cfg['threshold_general'],
+ 'character': cfg['threshold_character_wd14'],
+ 'copyright': cfg['threshold_copyright'],
+ 'meta': cfg['threshold_meta'],
+ }.get(category, 1.01) # unknown/rating → effectively disabled (not surfaced)
+```
+
+- [ ] **Step 6: Verify no stale `_embedding_character_suggestions` / `CharacterReferenceEmbedding` references**
+
+```bash
+grep -n "_embedding_character_suggestions\|CharacterReferenceEmbedding" app/services/tag_suggestions.py
+```
+
+Expected: no output.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/services/tag_suggestions.py
+git commit -m "feat(suggestions): kind-aware embedding suggestions, drop rating group"
+```
+
+---
+
+## Task 5: Update accept endpoint — drop rating mapping, extend centroid trigger
+
+**Files:**
+- Modify: `app/main.py:691-772`
+
+- [ ] **Step 1: Update `_WD14_CATEGORY_TO_KIND` — drop rating**
+
+Find:
+
+```python
+_WD14_CATEGORY_TO_KIND = {
+ 'character': 'character',
+ 'copyright': 'fandom', # ImageRepo uses 'fandom' for IP/franchise
+ 'rating': 'rating',
+}
+```
+
+Replace with:
+
+```python
+_WD14_CATEGORY_TO_KIND = {
+ 'character': 'character',
+ 'copyright': 'fandom', # ImageRepo uses 'fandom' for IP/franchise
+}
+```
+
+- [ ] **Step 2: Extend the centroid-recompute trigger to all eligible kinds**
+
+Find this block inside `accept_image_suggestion`:
+
+```python
+ # Schedule centroid recompute if this was a character and delta is exceeded.
+ if tag.kind == 'character':
+ from app.models import CharacterReferenceEmbedding, TagSuggestionConfig
+ from app.tasks.ml import recompute_centroid
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+
+ delta_cfg = TagSuggestionConfig.query.filter_by(key='centroid_recompute_delta').first()
+ delta_threshold = int(delta_cfg.value) if delta_cfg else 3
+
+ current_count = (
+ db.session.query(db.func.count(image_tags.c.image_id))
+ .filter(image_tags.c.tag_id == tag.id)
+ .scalar()
+ ) or 0
+ ref = (
+ CharacterReferenceEmbedding.query
+ .filter_by(character_tag=tag_name, model_version=SIGLIP_VER)
+ .first()
+ )
+ last_count = ref.reference_count if ref else 0
+ if current_count - last_count >= delta_threshold:
+ try:
+ recompute_centroid.apply_async(args=[tag_name], queue='ml')
+ except Exception:
+ # Don't fail the accept if enqueue fails
+ pass
+```
+
+Replace with:
+
+```python
+ # Schedule centroid recompute if this tag's kind is eligible and delta is exceeded.
+ from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS
+ if tag.kind in ELIGIBLE_CENTROID_KINDS:
+ from app.models import TagReferenceEmbedding, TagSuggestionConfig
+ from app.tasks.ml import recompute_centroid
+ from app.ml.siglip import MODEL_VERSION as SIGLIP_VER
+
+ delta_cfg = TagSuggestionConfig.query.filter_by(key='centroid_recompute_delta').first()
+ delta_threshold = int(delta_cfg.value) if delta_cfg else 3
+
+ current_count = (
+ db.session.query(db.func.count(image_tags.c.image_id))
+ .filter(image_tags.c.tag_id == tag.id)
+ .scalar()
+ ) or 0
+ ref = (
+ TagReferenceEmbedding.query
+ .filter_by(tag_name=tag_name, model_version=SIGLIP_VER)
+ .first()
+ )
+ last_count = ref.reference_count if ref else 0
+ if current_count - last_count >= delta_threshold:
+ try:
+ recompute_centroid.apply_async(args=[tag_name], queue='ml')
+ except Exception:
+ # Don't fail the accept if enqueue fails
+ pass
+```
+
+- [ ] **Step 3: Add the `trigger_recompute_all_centroids` route**
+
+Find the existing `trigger_ml_backfill` function (around line 604):
+
+```python
+@main.post('/settings/maintenance/ml-backfill')
+def trigger_ml_backfill():
+ from app.tasks.ml import backfill
+ backfill.apply_async(queue='ml')
+ return redirect(url_for('main.settings', tab='maintenance'))
+```
+
+Immediately after it, add:
+
+```python
+@main.post('/settings/maintenance/recompute-centroids')
+def trigger_recompute_all_centroids():
+ from app.tasks.ml import recompute_all_centroids
+ recompute_all_centroids.apply_async(queue='ml')
+ return redirect(url_for('main.settings', tab='maintenance'))
+```
+
+- [ ] **Step 4: Verify no stale references**
+
+```bash
+grep -n "CharacterReferenceEmbedding\|'rating':\s*'rating'" app/main.py
+```
+
+Expected: no output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(accept): extend centroid trigger to eligible kinds, drop rating mapping"
+```
+
+---
+
+## Task 6: Update modal UI — drop rating group
+
+**Files:**
+- Modify: `app/static/js/view-modal.js:88-117`
+
+- [ ] **Step 1: Find and update `SUGGESTION_CATEGORY_ORDER` and `SUGGESTION_CATEGORY_LABEL`**
+
+Find:
+
+```javascript
+ const SUGGESTION_CATEGORY_ORDER = ['character', 'copyright', 'rating', 'general'];
+ const SUGGESTION_CATEGORY_LABEL = {
+ character: 'Characters',
+ copyright: 'Fandoms',
+ rating: 'Rating',
+ general: 'General',
+ };
+```
+
+Replace with:
+
+```javascript
+ const SUGGESTION_CATEGORY_ORDER = ['character', 'copyright', 'general'];
+ const SUGGESTION_CATEGORY_LABEL = {
+ character: 'Characters',
+ copyright: 'Fandoms',
+ general: 'General',
+ };
+```
+
+(If the exact labels in your file differ, keep the existing labels and only drop the `rating` entry + its position in the order array.)
+
+- [ ] **Step 2: Verify no other rating references remain in the JS**
+
+```bash
+grep -n "rating" app/static/js/view-modal.js
+```
+
+Expected: no output. If any references remain (e.g. styling hooks), they should be removed too since the category will never arrive from the server after Task 4.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/js/view-modal.js
+git commit -m "feat(modal): drop rating group from suggestions UI"
+```
+
+---
+
+## Task 7: Add Settings → Maintenance button for recompute-all-centroids
+
+**Files:**
+- Modify: `app/templates/settings.html:440-449`
+
+- [ ] **Step 1: Add the new form next to the existing backfill form**
+
+Find:
+
+```html
+
+
ML tag suggestions
+
+ Run inference on all images missing predictions or embeddings for the current model versions.
+ Resumable and safe to re-run. Backfill enqueues work on the ml queue; monitor progress in the ml-worker logs.
+
+ Run inference on all images missing predictions or embeddings for the current model versions.
+ Resumable and safe to re-run. Backfill enqueues work on the ml queue; monitor progress in the ml-worker logs.
+
+
+
+
+ Recompute centroids for all eligible tags (character, fandom, and general/topic) that have at least
+ the minimum number of reference images. Run this after the initial backfill, or any time you've
+ applied many tags manually and want the suggestions to catch up.
+