Commit Graph

146 Commits

Author SHA1 Message Date
bvandeusen 8af9f12544 fix(ml): tolerate truncated images in WD14 and SigLIP preprocess
PIL's strict load was raising OSError on images missing the trailing
end-of-stream marker (e.g. '6 bytes not processed' on a JPEG without
its FF D9 EOI), failing the entire ML task for an image the model
could otherwise score fine. Set ImageFile.LOAD_TRUNCATED_IMAGES = True
in both ML modules so a minutely-corrupt tail doesn't block tagging
or embedding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:35:03 -04:00
bvandeusen f3094ec24f fix(autocomplete): parse kind:rest in /api/tags/search
The add-tag input lets the user type 'character:mocha' as a kind shortcut,
but the autocomplete was passing the whole query through to ilike, so
'%character:mocha%' never matched any display_name. Use parse_kind_prefix
to split the prefix into a kind filter, leaving the rest as the search
term. An explicit ?kind= (e.g. fandom picker) still wins. Empty rest
('character:') falls into the no-query branch and shows top characters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 19:53:42 -04:00
bvandeusen 667a70acb9 fix(tags): case-insensitive kind prefix + (Fandom) suffix rescue on add
parse_kind_prefix now lowercases the prefix before checking KNOWN_KINDS,
matching the modal JS which already lowercases before its visibility
check. Without this, typing 'Character:Jinx ...' showed the fandom
picker but the server fell back to kind=user.

Both /tags/add and /api/images/bulk-add-tag now also split a trailing
'(Fandom)' suffix from a typed character name when no explicit fandom
is staged, so a manual entry like 'Character:Jinx (League Of Legends)'
produces a clean (name='Jinx', fandom_id=<league>) row instead of a
malformed character with the suffix baked into the name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:23:43 -04:00
bvandeusen 007592827c fix(suggestions): parse (Fandom) suffix on accept + heal legacy rows
Two-part fix for the reported bug where accepting a character
suggestion named 'Jinx (League Of Legends)' created a single
malformed character tag with fandom_id=NULL and the whole suffix
embedded in the name, and never attached the fandom tag to the
image.

Root cause: WD14 predictions store names like
'jinx_(league_of_legends)'. app.services.tag_suggestions
._canonicalize_wd14_name transforms that to 'Jinx (League Of
Legends)' for character/copyright categories. The suggestion chip
renders that canonicalized string. On ✓, accept_image_suggestion
looked for an existing character with name = 'Jinx (League Of
Legends)' — post-refactor no such row exists (names are bare) —
fell into the 0-candidate branch, and created a fresh malformed
character.

Going forward (main.py):
  accept_image_suggestion's character branch now splits a
  '(Fandom)' suffix off the incoming name before the 0/1/N lookup.
  If it splits, find-or-create the fandom tag, then find-or-create
  a character with (kind='character', name=bare, fandom_id=<f>).
  If no suffix, unchanged 0/1/N behavior.

Backfill for already-corrupted rows (tasks/maintenance.py):
  New _heal_malformed_character_names pass runs before the
  existing sync-fandoms-to-images logic. Finds
  (kind='character', fandom_id IS NULL, name LIKE '% (%)%'),
  parses the suffix via the same regex, and either promotes the
  row to (name=bare, fandom_id=<f>) or merges into an existing
  canonical character. tag_reference_embedding rows (string PK)
  are cascade-renamed alongside. Same auto-merge semantics as
  migration j26042101 Phase 2.

Implementation is all raw SQL to sidestep an ORM autoflush
ordering quirk: ORM-level fandom INSERT + subsequent tag UPDATE
referencing its fresh id hit a transient FK violation in worker
task context. Raw SQL with an explicit commit after fandom
insert avoids the ambiguity.

Verified locally:
  - Fresh accept of 'CanonicalChar (Canonical Fandom)' against a
    pre-existing canonical character: resolved to the canonical
    row, fandom attached to image.
  - Seeded malformed 'LegacyJinx (League Of Legends)' character
    with fandom_id=NULL attached to an image: sync task healed it
    to bare 'LegacyJinx' + fandom_id, attached the fandom row to
    image_tags, final counts {healed:1, skipped:0,
    links_added:1}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:49:23 -04:00
bvandeusen 9276b31b99 feat(maintenance): backfill fandom tags onto legacy character attachments
New Celery task sync_character_fandoms_to_images on the maintenance
queue. For every character tag with a non-null fandom_id, runs the
same INSERT ... SELECT ... ON CONFLICT DO NOTHING that set_tag_fandom
already runs inline, attaching the fandom tag to every image that
has the character attached.

Why this exists: migration j26042101 extracted the '(Fandom)' suffix
from pre-refactor character names into tag.fandom_id, but it did NOT
walk image_tags to attach the fandom rows to every image that had
the character. Post-refactor auto-apply (in add_tag /
accept_image_suggestion / set_tag_fandom) only fires on NEW
add/accept events — pre-existing character attachments from before
the migration still show the character pill in the modal but no
fandom pill, because the fandom row was never added to image_tags.

Surfaced by Settings → Maintenance → 'Sync fandoms to images'.
Safe, additive, idempotent. Run once after the migration to close
the gap; subsequent set-fandom operations continue to maintain the
invariant inline.

Verified locally: seeded a character+fandom pair with only the
character attached to image 1; task added the fandom to image_tags;
final state has both rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:58:51 -04:00
bvandeusen 3b472cf519 fix(gallery): infinite scroll + timeline + jump use tag_id
All three API calls (/api/gallery/scroll, /api/gallery/timeline,
/api/gallery/jump) were still sending the legacy ?tag=<name> param.
Task 10's backend switch silently ignored it, so subsequent scroll
loads and timeline navigation fetched unfiltered content on a
tag-filtered gallery page.

Read tag_id from window.location.search at init and cache it in
state.activeTagId. Use it as ?tag_id=<int> on every subsequent
fetch. state.activeTag (the display string) stays for human-facing
uses elsewhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:49:44 -04:00
bvandeusen 7df508d65e fix(modal): scroll highlighted autocomplete row into view on arrow keys
selectAutocompleteItem (main tag input) and highlightFandomItem (fandom
picker) call scrollIntoView({block:'nearest'}) on the newly-highlighted
row. Previously arrowing past the visible slice would move the .selected
class off-screen and leave the user with no visible feedback about
where they were in the list.

block:'nearest' is a no-op when the row is already visible, so in the
common case (first few arrow presses) nothing scrolls. Only scrolls the
minimum distance when the highlighted row has moved outside the
container's viewport.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:52:10 -04:00
bvandeusen 435586388e fix(modal): blocklist offer persists until user acts or image changes
Drops the 8s auto-fade on the post-removal blocklist offer. User
complained it closed too fast to be useful. The offer is already
bounded by image switch (hideBlocklistOffer in updateImage) and
modal close (hideBlocklistOffer in closeModal), so time-based fade
was belt-and-suspenders. Keeps the 2.5s delay after a successful
blocklist so the confirmation text shows before hiding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:41:57 -04:00
bvandeusen e76e94a635 feat(modal): inline blocklist offer after pill × removal
When the user clicks the × on a tag pill, the tag is removed from the
image and a small '/Removed 'X' — [⊘ Blocklist] [×]/' control surfaces
below the tag input. One click on ⊘ Blocklist escalates the removal
to a global blocklist add (which also enqueues the retroactive sweep
Celery task, removing the tag from every other image that has it).
Fades after 8s, hides on image switch or modal close.

Targets the composition-descriptor case: WD14 tags like '1girl',
'breasts', 'simple background' that show up on many images. Without
this, after removing the pill the user has to navigate to Settings →
Maintenance → Suggestion blocklist to add the name, or wait for the
next suggestion chip to appear and use its ⊘. Now it's one click at
the point of removal.

Misclick recovery unchanged: the pill × alone only rejects per-image.
The inline offer is opt-in; ignore it or click × to dismiss and the
removal stays scoped to the current image only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:54:19 -04:00
bvandeusen 4b84076540 fix(suggestions): pill × removal also suppresses re-auto-accept
The previous fix only covered the suggestion-chip ✕ flow (which calls
/suggestions/reject). Removing a tag via the pill × in the Tags section
goes through /tags/remove and wrote no feedback row, so the per-image
rejection filter in get_suggestions had nothing to match on — auto-accept
would re-apply the tag on the next modal load.

/tags/remove now writes SuggestionFeedback(decision='rejected',
suggestion_source='manual_removal', confidence=0) alongside the image_tags
delete. The suggestion_source value distinguishes these rows from
chip-reject rejections in the feedback log without complicating the
get_suggestions filter (it matches on decision alone).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:11:21 -04:00
bvandeusen 2347f5eaa2 fix(suggestions): rejected tags stay rejected per image
Adds a per-image rejection filter to get_suggestions so once the user
clicks ✕ on an auto-accepted chip (or rejects a regular suggestion
via the existing ✕ button), that tag name stops reappearing in
suggestions — including auto_accept candidates.

Without this filter, the undo flow was cosmetic: the user's ✕
removed the tag from this image and logged a SuggestionFeedback
decision='rejected' row, but the next modal load re-ran auto-accept
against the same unchanged WD14 predictions and re-attached the same
tag. Rinse, repeat.

Implementation: _rejected_names_for_image(image_id) reads distinct
tag_names from suggestion_feedback where decision='rejected' for
this image. get_suggestions filters merged results against that set
alongside the existing blocklist filter. Both auto-accept candidates
and the three core categories respect the filter.

Tested on image 222 at threshold=0.9: first load auto-applied 23
general tags; rejected '1girl'; second load auto_accepted.count=0,
general suggestions had 10 new items, '1girl' absent from both.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:02:07 -04:00
bvandeusen 2c486b898c fix(modal): provenance chips use tag_id, not legacy tag=<name>
The provenance section's chip hrefs (artist, post) still built
'/gallery?tag=${name}', which the gallery route silently ignores
since Task 10 switched to ?tag_id=<int>. So clicking the artist or
post chip from the modal rendered an unfiltered gallery.

Tag objects from /image/<id>/tags already include 'id', so the fix
is one line: chip.href = '/gallery?tag_id=${t.id}'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:41:13 -04:00
bvandeusen 5b7e033d0c feat(suggestions): auto-accept threshold + retroactive blocklist sweep
Two capabilities that work together to turn the suggestion system from
a 'review every noisy chip' UX into a 'curated tags only, configurable
auto-apply' UX for a solo-user library.

Auto-accept threshold
  Service (tag_suggestions.py):
    - New default auto_accept_general_threshold = 0.95 in _DEFAULTS
    - get_suggestions splits general-category hits at/above the threshold
      into a separate auto_accept_candidates list; the core 'character'/
      'copyright'/'general' keys stay the same shape for existing callers
    - get_bulk_suggestions ignores the new key (no side effects in bulk)
  Route (main.py GET /image/<id>/suggestions):
    - For each candidate: find-or-create Tag(kind='user', name=name), attach
      to image, log SuggestionFeedback(source='wd14'|etc, decision='accepted')
    - Response adds 'auto_accepted: [{id, name, display_name, kind,
      confidence, source}, ...]' so the modal can review + undo
  Config endpoints (main.py):
    GET  /api/suggestions/config/auto-accept-threshold
    POST /api/suggestions/config/auto-accept-threshold {threshold}
    Values > 1.0 effectively disable the feature
  UI (view-modal.js):
    - New renderAutoAccepted block at top of suggestions section with
      green-tinted chips carrying ✕ (undo for this image, logs rejection)
      and ⊘ (blocklist + remove from all images)
    - loadSuggestions refreshes the tag pill list when auto_accepted has
      items so the user sees them in the Tags section too
  Settings:
    - Maintenance tab gains a number input + save button backed by
      /api/suggestions/config/auto-accept-threshold

Retroactive blocklist sweep
  New Celery task app.tasks.maintenance.sweep_blocklisted_tag_from_images
  on the maintenance queue. Enqueued automatically when a name is added
  via POST /api/suggestions/blocklist or appears new in the bulk replace.
  Task body: finds kind='user' Tag matching the name, deletes its
  image_tags rows, deletes the Tag itself. Scope limited to kind='user'
  so deliberate character/fandom/artist tags sharing a blocklisted name
  aren't silently destroyed.

Verification (local dev):
  - Threshold GET/POST round-trip correct (default 0.95, persisted in
    tag_suggestion_config)
  - Image 633 at threshold=0.9: 4 general tags auto-applied, each with
    a SuggestionFeedback row, returned in auto_accepted
  - Blocklist add of 'sweeptestonly' with an attached test tag: Redis
    maintenance queue depth = 1, task body removes 1 image_tags row +
    the Tag row
  - get_bulk_suggestions still works (auto_accept_candidates key skipped)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:23:25 -04:00
bvandeusen a510665a17 feat(suggestions): user-managed blocklist for noisy auto-tags
Adds a tag_suggestion_blocklist table + service-layer filter so the
user can permanently suppress specific canonical tag names from the
suggestion stream (WD14 anatomy/composition tags like '1girl',
'breasts', 'nipples' that aren't useful for the discoverability use
case this app targets).

Data model
  - migration k26042201: tag_suggestion_blocklist(name TEXT PK, created_at)
  - model TagSuggestionBlocklistEntry

Service
  - tag_suggestions._blocklisted_names() snapshots the current set
  - get_suggestions filters merged results before grouping, so both
    WD14- and embedding-sourced suggestions respect the blocklist
  - get_bulk_suggestions inherits the filter via its per-image call
    to get_suggestions

API
  - GET  /api/suggestions/blocklist           -> {ok, names}
  - POST /api/suggestions/blocklist           -> add one
  - POST /api/suggestions/blocklist/delete    -> remove one
  - POST /api/suggestions/blocklist/bulk      -> replace the whole list
    (backs the settings textarea save button)

UI
  - modal suggestion chip gets a third action button (⊘) alongside
    accept (✓) / reject (✕). Clicking it adds the name to the
    blocklist, logs a rejection for ML feedback on this image, and
    sweeps every chip on the page carrying that same name.
  - Settings -> Maintenance -> Suggestion blocklist section with a
    monospaced textarea (one name per line) + Save. Loads current
    entries on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:41:44 -04:00
bvandeusen 3f11dcdc6c fix(modal,tags): five UX follow-ups
1. Hide 'archive' kind from /tags default view. Archive tags only
   appear when the user explicitly requests them via ?kind=archive.
   _get_tags_with_previews gains a default_excluded_kinds tuple that
   applies when no explicit kind filter is set.

2. Hide the 'Add' submit button on desktop (>=768px). Enter in the
   tag input (and in the fandom picker when no row is highlighted)
   already submits; the button is only needed for mobile virtual
   keyboards where Enter-to-submit is unreliable.

3. Fandom picker keyboard parity: ArrowUp/Down navigate rows, Enter
   picks a highlighted row or (if no row is highlighted) submits the
   whole add-tag form. Escape clears the highlight. Mirrors the main
   tagInput's keyboard block.

4. Tag editor merge bug: the edit modal populated nameInput.value
   with data.tag.display_name — for a character with a fandom, that
   value is 'Name (Fandom)'. Saving without stripping the suffix
   routed to a rename that never collided with the canonical bare
   name, so the merge offer never fired. Now uses data.tag.name (the
   bare form). The (Fandom) display is still shown in the fandom
   input which is rendered separately.

5. Fandom picker styling: match the main tag-form input — same
   padding, border, background, focus state. Drop the separate
   'Fandom' label; the placeholder 'Fandom (optional)' now carries
   the label role.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:28:18 -04:00
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