feat: ML tag suggestions, character/fandom integrity, underscores, modal polish

Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
This commit is contained in:
2026-04-19 19:50:58 -04:00
parent 69b3ddcbd0
commit 0f35a0c484
37 changed files with 8642 additions and 30 deletions
+208 -14
View File
@@ -1,6 +1,6 @@
# app/main.py
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify, current_app
from sqlalchemy import desc, func, text, extract, and_
from sqlalchemy.orm import joinedload, aliased
@@ -9,6 +9,7 @@ from datetime import datetime
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch, AppSettings
from app import db
from app.utils.tag_names import normalize_display_name
import os
import re
import shutil
@@ -25,8 +26,12 @@ def _parse_character_fandom(name_after_prefix):
def _ensure_fandom_tag(fandom_name):
"""Find or create a fandom tag by name. Returns the Tag object."""
full_name = f"fandom:{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")
@@ -601,6 +606,27 @@ def settings():
return render_template('settings.html', active_tab=tab)
@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'))
@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'))
@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(queue='maintenance')
return redirect(url_for('main.settings', tab='maintenance'))
# ----------------------------
# Tag add/remove endpoints
# ----------------------------
@@ -677,6 +703,114 @@ def list_tags(image_id):
])
# ---------------------------------------------------------------------------
# Tag suggestions (ML-backed)
# ---------------------------------------------------------------------------
@main.get("/image/<int:image_id>/suggestions")
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})
@main.post("/image/<int:image_id>/suggestions/accept")
def accept_image_suggestion(image_id):
from app.models import SuggestionFeedback
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()
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 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
return jsonify({
'ok': True,
'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind},
})
@main.post("/image/<int:image_id>/suggestions/reject")
def reject_image_suggestion(image_id):
from app.models import SuggestionFeedback
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})
@main.post("/image/<int:image_id>/tags/add")
def add_tag(image_id):
"""
@@ -695,8 +829,11 @@ def add_tag(image_id):
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 = name
tag_name = underscores_to_spaces(name)
img = ImageRecord.query.get_or_404(image_id)
tag = Tag.query.filter_by(name=tag_name).first()
@@ -704,16 +841,29 @@ def add_tag(image_id):
fandom_tag = None
if not tag:
tag = Tag(name=tag_name, kind=kind)
# For new character tags, parse and link fandom
# 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()
db.session.add(tag)
db.session.flush()
elif kind == "character" and tag.fandom_id:
fandom_tag = Tag.query.get(tag.fandom_id)
elif tag.kind == "character" and tag.fandom_id:
# Existing character tag with a fandom — grab the fandom tag for auto-apply
fandom_tag = Tag.query.get(tag.fandom_id)
@@ -786,9 +936,10 @@ def set_tag_fandom(tag_id):
fandom_id = request.form.get("fandom_id", type=int)
fandom_name = (request.form.get("fandom_name") or "").strip()
# Get the character's base name (strip existing fandom suffix)
# 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)
@@ -796,8 +947,9 @@ def set_tag_fandom(tag_id):
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_name
fandom_display = fandom_tag.name.split(":", 1)[1]
else:
# Clear fandom
tag.fandom_id = None
@@ -821,6 +973,33 @@ def set_tag_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(
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,
@@ -2112,23 +2291,38 @@ def bulk_add_tag():
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)
# Get or create the tag
tag = Tag.query.filter_by(name=tag_name).first()
fandom_tag = None
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)
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()
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)