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:
+208
-14
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user