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
+60
View File
@@ -1,3 +1,5 @@
from pgvector.sqlalchemy import Vector
from . import db
# tag to object relationship table
@@ -218,3 +220,61 @@ class ImportTask(db.Model):
db.Index('ix_import_task_status_type', 'status', 'task_type'),
db.Index('ix_import_task_batch_status', 'batch_id', 'status'),
)
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)
tag_name = db.Column(db.Text, nullable=False)
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)
created_at = db.Column(db.DateTime(timezone=True), server_default=db.func.now(), nullable=False)
__table_args__ = (
db.Index('idx_tag_predictions_image', 'image_id'),
db.Index('idx_tag_predictions_tag', 'tag_name'),
db.Index('idx_tag_predictions_model', 'model_version'),
)
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 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)
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)
tag_name = db.Column(db.Text, nullable=False)
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)
__table_args__ = (
db.Index('idx_feedback_image', 'image_id'),
db.Index('idx_feedback_tag', 'tag_name'),
)
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)