Commit Graph

131 Commits

Author SHA1 Message Date
bvandeusen 03e89416a1 fix(modal): autocomplete + pill list show display_name (fandom suffix)
Two sites in view-modal.js were still calling getTagDisplayName(t.name)
which is a post-refactor passthrough returning the bare name. Both
callers should render display_name so a character with a fandom shows
as 'Ruby Rose (RWBY)' — the whole point of the server sending
display_name alongside name.

Fixes both the autocomplete reveal under the add-tag input and the
sidebar pill list rendered after a successful add/load. bulk-select.js
was already correct (t.display_name || t.name pattern).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 02:38:37 -04:00
bvandeusen 91c349a65b fix(main): list_tags returns ok=true so frontend re-renders pills
Task 12's rewrite of list_tags dropped the ok flag from the response.
loadTags in view-modal.js gates its render on 'if (j.ok)', so every
POST add-tag returned 200, the feedback said 'Tag added', but the
pill list never refreshed because loadTags silently dropped the
response as not-ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:41:42 -04:00
bvandeusen 3d6413c639 fix(modal): autocomplete picks resolve by tag_id, not just by name
Clicking an autocomplete row (or pressing Enter on a highlighted row)
now sends tag_id in the add-tag POST. Server attaches that exact tag,
skipping parse_kind_prefix. Same-name ambiguity — e.g. a character
tag 'Yidhari' and a user tag 'Yidhari' both matching the autocomplete
query 'yid' — now resolves by whichever row the user picked, instead
of silently routing to the user-kind fallback.

Bare free-text input (no click) still goes through parse_kind_prefix
with its kind: shortcut and kind='user' default. The fandom picker
path is untouched.

The click handler also stashes tag_id on the dataset so the keyboard
Enter path on a highlighted row picks up the same resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 18:57:37 -04:00
bvandeusen fc70d56afb fix(migration): drop tag_name_key up front; merge Phase 1 collisions
The original Phase 3 dropped the legacy global UNIQUE(tag.name) at the end,
which meant Phase 1's prefix-strip UPDATEs ran with the old constraint still
in place. On any DB that had a pre-existing bare-name row colliding with the
stripped form of a prefixed row (e.g. 'character:Marcille Donato' stripping
to 'Marcille Donato' where another 'Marcille Donato' already existed), the
migration blew up with UniqueViolation on tag_name_key.

Fix: drop tag_name_key at the very top of upgrade() so both phases operate
against a uniqueness-free table. Add explicit collision handling to Phase 1
(mirrors the existing Phase 2 auto-merge): when the stripped name already
matches an existing row under the shape of the incoming partial-index
(same kind, or same kind+fandom_id for characters), reassign image_tags
onto the winner, cascade-rename tag_reference_embedding, and delete the
loser. Report now shows phase1_merged alongside phase1_renames.

Backwards-compatible for DBs where the failing migration already rolled
back — rerunning picks up the fix. The migration's transaction atomicity
means the DB is still in pre-migration state after the earlier failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 17:35:05 -04:00
bvandeusen e2df0aa675 chore: prune stale migration __pycache__ entries
These .pyc files were already deleted on disk; the working tree just
hadn't staged the removals. No behavioral change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 17:27:30 -04:00
bvandeusen 19e1b09f2c chore: final fixes from post-refactor smoke test
artist_gallery redirect and reapply_artist_tags_from_paths both still
built f"artist:{name}" strings. Last stragglers cleaned up; the
dead-code sweep now returns zero prefix-construction hits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:20:53 -04:00
bvandeusen 27609043e8 refactor(js): tag-utils uses display_name and tag_id
getTagDisplayName is now a passthrough; post-refactor names are bare
and callers that need the character+fandom suffix pass tag.display_name
from the server. createTagChipHtml builds ?tag_id=<int> URLs and uses
display_name for the label. bulk-select.js callers pass display_name
directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:19:52 -04:00
bvandeusen c9fce48a39 refactor(js): use display_name from API instead of client-side split
Every user-facing tag rendering now reads tag.display_name from the
server JSON. The client-side split-on-colon fallback is removed.
Series-info link switches from ?tag=<name> to ?tag_id=<int>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:16:38 -04:00
bvandeusen 54c3d96e45 feat(modal): ambiguous-candidate picker + accept payload rename
Accept suggestion requests now send 'name' (matches Task 8 backend)
and preserve source/confidence for feedback logging. New 409 handler
renders an inline picker with each candidate's display_name; user
clicks one, which re-POSTs with explicit tag_id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:14:57 -04:00
bvandeusen 3cd6aa2a6a feat(modal): fandom picker wiring
Reveals the picker on 'character:' prefix; autocomplete via
/api/tags/search?kind=fandom; submits fandom_id (preferred) or
fandom_name alongside the character name. Clears on image switch
and modal close.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:11:47 -04:00
bvandeusen a6bcc1c088 feat(modal): fandom picker markup + styles
HTML/CSS for the inline fandom picker that slides in below the add-tag
input. JS wiring in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:08:14 -04:00
bvandeusen 61f53a1ac1 feat(tags): fandom-less character nudges
Adds a header counter ('⚠ N characters need a fandom') that filters to
?null_fandom=1, plus an inline chip on each qualifying character card.
Backend accepts null_fandom_only in the list query.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:07:17 -04:00
bvandeusen 0cae2b840d refactor(templates): _tag_cards.html and reader.html use tag_id
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:04:25 -04:00
bvandeusen 764092e8dd refactor(templates): _gallery_item.html KIND_EMOJI dict + display_name
Collapses the six-branch prefix-strip expression into a KIND_EMOJI
jinja dict. Anchor hrefs use ?tag_id=.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:59:37 -04:00
bvandeusen 934d6ce111 chore: remove sync_character_fandoms maintenance task
tag.fandom_id is now authoritative. set_tag_fandom auto-applies the
fandom tag inline; add_tag already did. There's no drift to sync on
a schedule anymore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:54:08 -04:00
bvandeusen e5d54dcb89 refactor(suggestions): remove name-parsing band-aids
_FANDOM_SUFFIX_RE, _fandom_suffix_prefixes, and the prefix-suppression
branch in _wd14_suggestions existed to work around the string-embedded
(Fandom) kludge. The bare-name refactor makes them all dead code.
_canonicalize_wd14_name simplifies to a single normalize-or-underscore
swap since no prefix preservation is needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:52:45 -04:00
bvandeusen 59d3c0295e refactor(import): drop artist:/archive: prefix from constructed tag names
Tag.name holds the bare identifier; Tag.kind already carried the role.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:51:04 -04:00
bvandeusen 478d01c5ff refactor(enrichment): post tag name omits post: prefix
The inner platform:artist:id identifier remains as opaque tag.name.
Kind column carries 'post'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:50:45 -04:00
bvandeusen 6c767e2427 refactor(importer): archive tags drop kind prefix
Archive identifier string (artist:archive_name) stays as the tag's
opaque name — the archive kind lives in the kind column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:45:47 -04:00
bvandeusen 7f4b1d3ba7 refactor(main): sweep name.split(':', 1) -> display_name
Every standalone prefix-strip expression in app/main.py replaced with
tag.display_name (user-facing) or tag.name (identifier contexts). The
only remaining colon-splits are inside parse_kind_prefix and tag
suggestions canonicalization.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:44:17 -04:00
bvandeusen 1e5b2c0040 refactor(main): simplify get_tag/list_tags/update_tag
Removes _parse_character_fandom (no longer needed — names are bare).
update_tag respects the new (name, kind[, fandom_id]) uniqueness shape.
get_tag and list_tags return display_name so clients render without
re-parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:43:11 -04:00
bvandeusen 8e6a3d84ba refactor(main): set_tag_fandom is pure fandom_id update
No rename, no retroactive-backfill-in-second-txn. display_name @property
reflects the change instantly. The auto-apply side effect (attach fandom
tag to images with the character) is preserved but simplified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:40:17 -04:00
bvandeusen 1e19048b09 refactor(main): gallery routes use ?tag_id=int
Drops the ?tag=<name> lookup. URLs become unambiguous (distinct
same-name characters each have their own id-keyed URL). Eager-loads
Tag.fandom for the active-tag header rendering.

Applied to all four gallery routes: /gallery, /api/gallery/scroll,
/api/gallery/timeline, /api/gallery/jump — they all shared the same
name-keyed filter pattern. (Plan named only the first two; extended
to all four for behavioral consistency so partial JS migration to
tag_id doesn't silently drop filter on the sub-APIs.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:38:22 -04:00
bvandeusen 51c13129dd refactor(main): search_tags JOINs fandom; matches on display CONCAT
Post-refactor tag.name is bare. Autocomplete needs to match the
display form (name + ' (' + fandom.name + ')') so users can still
find 'Ruby Rose (RWBY)' by typing 'rwby'. Switched to
contains_eager(Tag.fandom, alias=f) to eliminate the duplicate
join that caused a Postgres GROUP BY error in the no-query path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:24:24 -04:00
bvandeusen bdd69c0a07 refactor(main): accept_image_suggestion 0/1/N candidate resolution
Replaces the ilike-suffix fallback with explicit bare-name character
matching: 0 candidates -> create null-fandom, 1 -> attach, N -> 409
with candidate list for frontend disambiguation. Accepts explicit
tag_id to complete the disambiguation round-trip.

Preserves existing behavior on the accept side:
  - defensive _canonicalize_wd14_name on the boundary
  - SuggestionFeedback row logged on every accepted decision
    (including the explicit_tag_id path). Ambiguous 409 does not
    log — no decision has been made yet.
  - conditional centroid recompute enqueue for eligible kinds

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:21:47 -04:00
bvandeusen d9081917ba refactor(main): bulk_add_tag uses parse_kind_prefix; bare storage
Mirrors add_tag's new shape. Removes inline prefix-parsing, returns
display_name in the response.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:44:06 -04:00
bvandeusen d89f910a6d refactor(main): add_tag parses prefix at boundary; bare storage
Accepts 'character:Saber' user shortcut, parses via parse_kind_prefix,
stores tag.name bare. Character path accepts fandom_id (preferred) or
fandom_name for fandom association. Returns display_name in the
response so the client can render pills without a second round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:47:53 -04:00
bvandeusen a283b97176 refactor(main): _ensure_fandom_tag stores bare names
Post-migration tag.name no longer embeds 'fandom:' prefix. Helper now
creates/finds by (kind='fandom', name=bare).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:33:31 -04:00
bvandeusen 1a83c634c8 feat(models): Tag.__table_args__ matches j26042101 indices
Move __table_args__ below column declarations to match the convention
used by SeriesPage, ImportTask, ImageTagPrediction, SuggestionFeedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 12:57:58 -04:00
bvandeusen 97ad60b973 feat(models): Tag.__table_args__ matches j26042101 indices
Drop unique=True on Tag.name (the migration replaced UNIQUE(name) with
four partial indices). Declare the partial indices in __table_args__ so
Alembic autogenerate stops seeing drift on future schema changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:08:24 -04:00
bvandeusen c7e2605a10 feat(migration): bare tag names; fandom_id authoritative
Three-phase Alembic migration j26042101 strips 'kind:' prefix from every
non-user tag, extracts character '(Fandom)' suffix into fandom_id with
auto-merge on duplicates, and swaps UNIQUE(name) for four partial indices
keyed on (name, kind) with special handling for the (character, fandom_id)
combination.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:39:36 -04:00
bvandeusen b9104f9caa feat(utils): add parse_kind_prefix helper
Owns the 'character:Saber' user-input shortcut parser in one place.
Invoked by add_tag, bulk_add_tag, and the migration's sanity check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:36:20 -04:00
bvandeusen 7330a9f8f5 feat(models): add Tag.display_name @property
Single source of truth for human-readable tag rendering. Falls back to
tag.name when no fandom is attached, preserving pre-migration data shapes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:18:41 -04:00
bvandeusen 0669098e80 docs: fix docker service name in refactor plan (db → postgres)
Plan used 'db' service name but docker-compose.yml uses 'postgres'.
Correcting pre-flight to avoid tripping every implementer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:15:01 -04:00
bvandeusen 22a0be4e05 docs: add character-fandom association refactor implementation plan
26-task plan covering bare-name migration, partial unique indices,
display_name property, add-tag UX with inline fandom picker, and
full code/template/JS sweep. Adapted for manual verification (no
test suite in project).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 23:09:08 -04:00
bvandeusen 191a1329c3 docs: character-fandom association refactor spec (wide scope)
Replaces the string-embedded `character:Name (Fandom)` naming scheme with
structural storage: bare name in tag.name, kind in kind column only,
fandom association via fandom_id. Extends to all tag kinds — the kind
prefix (`artist:`, `fandom:`, etc.) is stripped from tag.name repo-wide,
making tag.name authoritative display text and tag.kind the sole source
of truth for kind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:51:17 -04:00
bvandeusen 0257a79a15 fix(suggestions): resolve bare WD14 character names to existing "(Fandom)" tags
WD14 emits bare names (e.g. "Ruby Rose") while curated character/fandom
tags carry a fandom suffix ("Ruby Rose (RWBY)"). Two gaps caused a
duplicate, fandom-less tag to be created when users accepted a WD14
suggestion for an image that already had the curated version:

- Suggestion compute: bare character/copyright suggestions are now
  suppressed when the image already carries a `Name (Fandom)` variant,
  so the duplicate chip no longer surfaces.
- Accept endpoint: if the exact-name lookup misses for character/fandom
  kinds, we try a `Name (%)`-scoped lookup and attach the existing tag
  only when exactly one candidate matches. Zero or multiple matches
  fall through to prior behavior to avoid silently guessing across
  ambiguous fandoms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 20:30:49 -04:00
bvandeusen 03184e7f1c refactor(modal): reserved inline area for add-tag autocomplete
Replace the floating autocomplete popup with a reserved ~5-row inline
area below the add-tag input. It never covers provenance or suggestion
chips — they stay visible side-by-side with the autocomplete list while
the user types. The area scrolls when more matches are loaded, and is
prefilled with top tags on every image load so it's never empty.

The base .tag-autocomplete class (still used by bulk-select and the
fandom picker) keeps its floating behavior; the modal opts in to the
new inline layout via an added .tag-autocomplete-inline modifier.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:03:48 -04:00
bvandeusen 38942c211d fix(ml): install ffmpeg in ml-worker image for video frame sampling
extract_video_frames() calls ffprobe/ffmpeg, which were only installed
in the web Dockerfile. Videos route through the ml-worker for WD14 +
SigLIP inference, so the tool needs to exist there too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:48:54 -04:00
bvandeusen 40010202b2 fix(modal): reposition tag autocomplete when suggestions load
Suggestions arrive after the modal opens, so the autocomplete dropdown
positioned itself downward before the suggestions section was visible
and stayed there — covering the chips until the input lost and regained
focus. Re-run positionAutocomplete() after renderSuggestions so it flips
above the input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 08:21:52 -04:00
bvandeusen 4539f0e2bf docs: README covers video ML sampling + bulk consensus suggestions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 18:42:32 -04:00
bvandeusen 3944605d33 feat(bulk): consensus ML suggestions + frontend polish
Bulk editor now loads consensus ML suggestions across the current
selection via a new POST /api/suggestions/bulk endpoint, powered by
get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if
it was suggested for or already applied to >= 80% of the selection;
coverage counts include images that already have the tag so a near-
universal tag isn't penalized for dropping out of suggestion lists.
Accept-only chips (no reject) match the rest of the suggestions UX.

Frontend polish in the same commit:
- Showcase session dedup (exclude seen ids, reset on second lap) and
  aspect-ratio-aware placeholder heights for better column balancing
- Modal touch-swipe navigation on the image wrapper, auto-focus of the
  tag input on desktop (>=768px), and autocomplete flip-up when the
  dropdown would cover the Suggestions section
- Settings template inline styles replaced with utility classes
- Inline series-editor script extracted to gallery-series-editor.js

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 18:39:47 -04:00
bvandeusen bc6b0a4a58 feat(ml): video tagging + embedding via frame sampling
Videos now route through a 10-frame sampling branch (configurable via
VIDEO_ML_FRAMES env) instead of the previous unsupported_format early
return. WD14 predictions are aggregated by max-confidence per (name,
category) across frames so sparse signals aren't diluted; SigLIP
embeddings are mean-pooled for a representative shot. Also generates a
fallback thumbnail when the record is missing one, and removes the
video_filter from backfill so videos get enqueued.

Celery soft/hard limits bumped to 240s/360s to accommodate 10x inference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 18:39:33 -04:00
bvandeusen ac9b5cbc06 revert: remove modal tag-input auto-focus
The restore in fb925f5 was based on an observation of an older running
image, not a real regression against current main. User doesn't want the
auto-focus behavior; removing it so the modal opens without moving focus.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 00:13:37 -04:00
bvandeusen 9a0ac85f8c fix(ml): skip videos in tag_and_embed and backfill
WD14 and SigLIP are image-only. The current backfill enqueues every
ImageRecord row, and tag_and_embed retries 3 times (~3 min) on each
video before giving up, blocking the serialized ml queue. Worse,
videos never get a prediction row, so every subsequent backfill
re-enqueues them.

- tag_and_embed: early return 'unsupported_format' for video
  extensions, no retry.
- backfill: filter the outerjoin query to exclude video extensions
  via VIDEO_EXTS from app.utils.image_importer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:31:50 -04:00
bvandeusen 70957a307c docs: README matches current stack and covers ML suggestions
Quick Start had drifted: postgres:16-alpine instead of pgvector/pg16,
a single celery-worker + celery-beat layout instead of the actual
worker / scheduler / ml-worker split, and no mention of the ML
tag-suggestion surface. Updates the compose example to mirror the
real docker-compose.yml, adds ml-worker env vars and /models volume
docs, and documents the ML maintenance tools in Settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:18:31 -04:00
bvandeusen fb925f5e6a feat: ml-worker self-heals models; restore modal tag-input auto-focus
ml-worker now fetches WD14 + SigLIP on container start via an entrypoint
script that calls huggingface_hub with a populated local_dir. HF does a
content-based integrity check, so subsequent starts are a no-op. Transient
HF outages warn and continue when a local copy is already present. This
drops the baked-in model layer (~4 GB) from the image so it pushes cleanly
through registry proxies and no longer bloats every tagged release.

view-modal.js restores the desktop-only auto-focus of the tag input on
openModal (with touch-device guard). The block existed in an earlier
main commit (0bf1961) but was absent from the post-corruption
consolidation commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:12:43 -04:00
bvandeusen 0f35a0c484 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
2026-04-19 19:50:58 -04:00
bvandeusen 69b3ddcbd0 untrack .claude/settings.local.json (covered by .gitignore)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:00:02 -04:00
bvandeusen 52d783546e replace client-side tab toggling with server-side tab routing
Each settings tab is now a separate page load via ?tab= query param.
Flask renders only the active panel - no CSS or JS visibility logic needed.
Tab bar uses <a> links instead of buttons. Eliminates the display:none
cascade issue that was preventing Import and Maintenance tabs from showing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:06:11 -04:00