The rail's Suggestions now come from the trained per-concept heads. SuggestionService.for_image scores the image's frozen SigLIP embedding against
every head (heads.score_image) and surfaces concepts above each head's own
suggest threshold; the typed-dropdown's min=0 "show everything" mode maps to a
flat floor so any head-scored concept can still be picked. Already-applied tags
drop; rejected tags stay flagged + reversible (unchanged).
REMOVED from the suggestion path (rule 22, no fallback): the Camie
ImagePrediction candidate/alias/merge pipeline and the per-tag centroid
augmentation, plus the now-dead SuggestionService internals (_load_predictions,
_threshold_for, _settings, self.aliases, self.centroids). Head suggestions are
always canonical tags, so raw_name/via_alias are null/false and the rail's
alias kebab is inert by data (its removal + the Camie ingest-tagger rip are the
flagged follow-up). for_selection (bulk consensus) now aggregates head
suggestions unchanged.
Tests rewritten to the head path: test_ml_suggestions (surfaces/applied/
rejected-reversible/override/no-embedding/no-heads), test_suggestions_bulk
(consensus), test_api_suggestions (get + dropped the Camie-alias roundtrip),
and test_ml_artist_retired (artist not head-eligible via _HEAD_KINDS).
DEPLOY NOTE: after this lands, the rail is empty until you run Train heads
(Settings → Tagging → Concept heads) — deploy, train, then the rail populates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
The UI for the heads subsystem: Settings → Tagging → "Concept heads". Shows
head count, auto-apply-ready count, and last-trained; a Train/Retrain button
(one run at a time, polls while running, surfaces a failed run's error); an
empty state guiding the operator to tag first; and a per-concept table (name,
category, +tags, AP, P, R, auto-apply ⚡) sorted strongest-first so weak/under-
tagged concepts are obvious. Rehydrates status from GET /api/heads on mount so
it survives navigation. Pulls head_min_positives from ML settings for copy.
Slice C (swap the rail's suggestions to heads, remove Camie + centroid) is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
Alphabetize HeadTrainingRun in models/__init__ + maintenance imports (H before
I), and drop the inline comment that split heads.py's import block. Pure import
ordering — no behavior change. (run 1601 lint)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
test_rejected_tag_surfaced_flagged_then_reversible asserted "Rejectme" but an
existing tag keeps its stored name ("rejectme"), so the suggestion's
display_name is lowercase. Match by canonical_tag_id instead (casing-robust).
The feature was correct — only the assertion was wrong (run 1595 integration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
The eval (#1130) proved the frozen-embedding + trained-head spine; this lands
its production form (the first of three slices that make heads the suggestion
source, replacing Camie + centroid).
- tag_head: one logistic-regression head per general/character concept with
enough labelled positives. Weights (pgvector), honest CV-derived suggest
threshold + earned-auto-apply point, and per-concept quality metrics.
- head_training_run: persisted batch lifecycle (mirrors tag_eval_run) so the
admin card shows live + historical status across navigation.
- services/ml/heads.py: TRAIN (sync, ml worker, reuses tag_eval's proven data
loaders + metric math so production heads match measured eval numbers) and
SCORE (async, API worker — numpy via pgvector, no scikit-learn): score one
image's embedding against all heads → the rail's suggestions, cached on
(count, max trained_at) so a retrain invalidates without per-request loads.
- tasks.ml.train_heads (ml queue, commits per head so a kill leaves progress)
+ recover_stalled_head_training_runs sweep + retention(20) + 5-min beat
(rule 89).
- api/heads.py: POST /api/heads/train (one run at a time, 409 guard) + GET
/api/heads (count, graduated, last-trained, running, per-concept table,
recent runs).
- ml_settings: head_min_positives + head_auto_apply_precision, tunable via
/api/ml/settings.
Scoring isn't wired into the rail yet (slice C) and the admin UI is slice B —
this slice makes training + scoring exist and CI-verifiable. 'precision' column
stored as precision_cv (SQL reserved word). Migration 0058.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
A red-✗ dismissal no longer makes the suggestion vanish. The rejected tag
stays in the rail — dimmed, struck-through, with a "rejected" pill and a
one-click undo (↶) in place of the ✗ — so a misclick is recoverable and the
operator can see what they've said no to (operator-asked 2026-06-27).
Backend: SuggestionService.for_image now KEEPS rejected tags, flagged
rejected=True, sorted to the bottom of their category, instead of dropping
them. New AllowlistService.undismiss + POST /suggestions/undismiss clears the
TagSuggestionRejection. Rejected items are still excluded from bulk consensus
(for_selection) and the type-to-add dropdown, whose jobs are unchanged.
Frontend: store.dismiss flags in place (canonical tags) rather than dropping;
new store.undismiss reverts. SuggestionItem renders the rejected state and
swaps ✗→↶; ✓ still accepts (which clears the rejection server-side).
Tests: rejected-surfaced-flagged-then-reversible (service) + undismiss
endpoint idempotency (API).
Completes #1134's reversible-rejection half. Heads-as-suggestion-source is
the remaining piece.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
Replace the single "Accept" pill in the modal Suggestions rail with the
eval card's green ✓ / red ✗ language: ✓ accepts the tag (positive), ✗
dismisses it for this image — which already persists a TagSuggestionRejection
(hard negative the heads train on). The pair occupies ~the footprint of the
old pill, so per-image rejection becomes a one-click peer of accepting
instead of being buried in the kebab.
Dismiss moves off the 3-dot menu, so the kebab now only carries alias
actions and is hidden when none apply (centroid hits with no alias option).
Toward #1134 (native per-image negatives in the rail). The bigger piece —
heads as a suggestion source feeding this panel — is still ahead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
"Keep" on a doubted positive was a no-op, so the same confirmed-correct images
came back in "head doubts" every run (operator-flagged: reinforcement keeps
surfacing the same images). Add tag_positive_confirmation (mirror of
tag_suggestion_rejection): keep → POST /images/<id>/tags/<tag_id>/confirm, and
the eval excludes confirmed positives from the doubts list — exactly as rejected
items already drop out of the suggest list. The tag stays a positive either way
(confirmation is a "reviewed" marker, not a training change).
- model TagPositiveConfirmation + migration 0057; confirm endpoint (idempotent).
- tag_eval: _confirmed_ids + exclude from head_doubts_positive examples.
- store.confirmTag + card "keep" calls it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"head would suggest" drew from the whole negative pool, which INCLUDES the
images the operator rejected. A rejected near-miss (e.g. an orc under "goblin")
is a hard negative that still scores high, so it kept resurfacing as a fresh
suggestion every run (operator-flagged: "same items keep appearing"). Exclude
already-rejected ids from the suggest list — once you've said no, it's gone.
(head doubts = lowest-scoring positives is unchanged; genuinely-hard true
positives legitimately recur there.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two additions driven by "what's the commit threshold?" + "find more tags":
1. High-precision operating point (Bar 4). Per concept, report the threshold that
maximizes recall while holding precision >= a target (default 0.97, configurable
via `precision_target`) — i.e. "could this fire without a human, and how much
would it catch?" `head.auto_apply` = {target, threshold, precision, recall} or
null if the target is unreachable. Surfaced on the card.
2. Server-side concept auto-discovery. `auto_top_n` param unions the explicit
concept list with the N most-tagged general tags (one fast DB query) so the
eval can broaden itself without hand-listing — replaces the slow HTTP directory
paging. Card gains "+ auto-add top-N" and precision-target inputs.
No migration; numpy/sklearn stay lazy. Existing _normalize_params test still
holds (new keys additive; None still falls back to DEFAULT_CONCEPTS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking an example in the maintenance card navigated to /explore/<id> —
heavier than wanted (operator: just want a bigger look). Open the existing
app-wide ImageViewer modal via modal.open(id) instead: bigger image + tags
in place, no navigation away from Settings. The ✓/✗ actions are unaffected
(separate overlay buttons).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the learn-from-tags loop directly on the eval lists (operator-flagged:
no surface to confirm/refine the head's suggestions). Each thumbnail gets a
green ✓ / red ✗ that writes the SAME tables the head trains on:
- suggest + ✓ → apply tag (new positive, POST /images/<id>/tags)
- suggest + ✗ → record rejection (hard negative, suggestions/dismiss)
- doubt + ✗ → remove tag + record rejection (kill bad positive, add negative)
- doubt + ✓ → keep (stays a positive, no write)
Acted thumbs grey out with a badge; re-run to see the head sharpen. Thumb still
links to /explore/<id>. All endpoints already existed — no backend change.
Inline is the starting point; longer-term the modal Suggestions rail gets the
red "No" (negative) so per-image rejection is native there too (next slice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 56px example thumbs were too small to judge a label (operator-flagged).
Bump to 120px and wrap each in a link to /explore/<id> (new tab) so the
"head doubts / would suggest" galleries double as a review-and-fix queue —
click a doubted positive, land on it in Explore, correct the tag, re-run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend for #1130. A maintenance tile in Settings → Tagging:
- Editable concept list + "Run eval" → POST /api/tag-eval (one running at a time).
- Rehydrates on mount via the persisted run (getRun by latest id) and polls while
running — so the report SURVIVES navigation (operator-flagged); the task runs
backend-side regardless and the card reconnects to its row.
- Renders the saved report: per-concept head-vs-centroid metrics table (AP/F1/
precision/recall) with Δ AP, the learning curve (AP @ N positives), and
thumbnail galleries (head-would-suggest / head-doubts-positive) for eyeballing.
Backend: _examples now stores thumbnail_urls (not just ids) so the report is a
self-contained artifact that renders without per-id lookups on reload.
No new top-level surface — slots into the existing maintenance area.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 1 of milestone #114 (tagging v2). Proves the frozen-embedding + trained-
head spine on the operator's own data, reusing the SigLIP embeddings already
stored on image_record — no re-embedding, no GPU.
Per concept: train a logistic-regression HEAD (positives + negatives = explicit
rejections + sampled unlabeled) vs the old single-CENTROID baseline; report
cross-validated precision/recall/AP for both, a LEARNING CURVE (AP/F1 as tagged
positives grow 10→30→100→300), and example image ids (head-would-suggest /
head-doubts-positive) to eyeball.
Persisted so the report SURVIVES navigation (operator-flagged): the run + full
report live in a new tag_eval_run row (mirrors library_audit_run); the admin
card will rehydrate from GET on mount, not transient state.
- models.TagEvalRun + migration 0056; runs on the ml queue (only worker with
numpy/sklearn) — numpy/sklearn lazy-imported so the API can still enqueue.
- services/ml/tag_eval (compute + start helper, one-running guard), tasks.ml
.tag_eval_run, api/tag-eval (POST create, GET history light / detail w/ report).
- recover_stalled_tag_eval_runs sweep + retention (keep last 20) + 5-min beat
(rule 89). scikit-learn added to requirements-ml.
- tests: param normalization + the rehydrate read-path + create/conflict.
Frontend admin card (trigger + render persisted report) follows next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Allowlist / Alias / ImportTask tables scroll their bodies (height=360/480) but
the column headers scrolled away with the rows, so you lost the column labels
(operator-flagged 2026-06-27). Add Vuetify `fixed-header` so the header row
stays pinned while the body scrolls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The panes grid had no explicit row, so the implicit `auto` row sized to its
tallest pane's content. With Provenance + Tags + a long Suggestions list, the
rail outgrew the fixed-height workspace, spilled over and made the WHOLE page
scrollable — showing as a weird empty gap at the top (operator-flagged
2026-06-26). grid-template-rows: minmax(0, 1fr) bounds the row to the container
so each pane's own overflow-y:auto scrolls internally instead. Reset to `none`
in the stacked (<=1100px) layout where the page is meant to scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post title/description frequently names the character, so surface it while
tagging in Explore (operator-asked 2026-06-26). ProvenancePanel gains optional
imageId/image props (default = modal store, so the modal is unchanged) since
provenance is its own system loaded by id; ExploreView renders it above TagPanel
in the right rail, hosted on the anchor. Self-collapses when the image has no
provenance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Explore is a rapid walk-and-tag surface, so focus must keep returning to the tag
input with no extra click (operator-asked 2026-06-26). Two gaps closed:
- Navigation hardening: refocus on every focused-image change (neighbour click,
breadcrumb, Random image, seed) now runs nextTick → requestAnimationFrame, so
it lands AFTER the post-navigation re-render/paint instead of being stolen
back by the neighbour-grid re-render.
- All tag actions refocus, in both Explore and the modal: tag add (existing/new)
and remove now hand focus back like accept-suggestion already did; and the
rename + fandom-assignment dialogs refocus on @after-leave (fires after
Vuetify's own focus-return to the activator, so ours wins).
TagAutocomplete's mobile guard is preserved throughout (no soft-keyboard pop on
touch). Modal behaviour gains the same stickier focus — consistent, low-risk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cluster tag-gap feature's only UI (Explore's TagGapPanel) was removed in the
3-pane rework, leaving the backend that fed it with no caller. Surgical removal:
- drop the POST /api/images/cluster/tag-gaps route (cluster_tag_gaps)
- drop BulkTagService.tag_gaps (+ the now-unused `import math`)
- drop the tag_gaps tests (test_bulk_tag_service, test_api_bulk_tags)
BulkTagService's common_tags / bulk_add / bulk_remove stay — they still back the
gallery bulk editor. Pure deletion, no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The workspace is built for rapid walk-and-tag, but the tag field was only
focused once (TagAutocomplete's on-mount autofocus) — walking to a neighbour
left focus behind, so the operator had to click the field each time
(operator-asked 2026-06-26).
TagPanel now exposes focusTagInput; ExploreView watches the focused image id and
re-focuses the field on seed + every walk via nextTick. Reuses the existing
focus path, so TagAutocomplete's mobile guard (no soft-keyboard pop on touch) is
preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-clarified 2026-06-26: the dimensions/size/type + save (floppy) block
should sit DIRECTLY above the Tags section — i.e. just under Provenance — not at
the very top of the rail. Reorder the rail's main scroll area to Provenance →
ImageMetaBar → TagPanel (Related stays pinned at the bottom).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Right-rail layout fixes (operator-flagged 2026-06-26 — the prior change wasn't
the intended improvement):
- Pin the Related strip to the BOTTOM of the rail: the side becomes a flex
column with a scrolling main area (meta + provenance + tags + suggestions)
and a pinned Related footer (capped at 45% of the rail, scrolls past that).
Related now stays reachable no matter how long Tags/Suggestions run, and
self-collapses (no footer space) when there's nothing to show.
- Remove the 320px suggestions scroll cap (3fcc4ae) — it was a workaround "so
Related stays reachable"; pinning Related is the proper fix, so suggestions
flow in the single main scroll instead of a nested scrollbar.
- Shrink the Download button to a floppy-disk save icon (mdi-content-save); the
meta (dimensions/size/type) + save action now sit as a compact top block
(meta left, icon right). Copy link moves into the adjacent kebab menu.
ImageMetaBar is shared with the Explore center pane, so the compact save
control applies there too (parity). Mobile (<=900px) keeps the single body
scroll — no nested scroll, Related flows at the end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks Explore from "anchor + neighbour grid + cluster tag-gap rail" into a
persistent 3-pane workspace that unfolds the image modal so you can tag while
rabbit-holing (operator concept 2026-06-26):
- LEFT neighbour grid (larger thumbs), click = walk; breadcrumb retained.
- CENTER light viewer — reuses ImageCanvas + ImageMetaBar(:image) for the
focused image; "Open full viewer" still launches the overlay modal.
- RIGHT the modal's TagPanel, hosted on the anchor for modal-parity tagging
(chips, autocomplete, suggestions + Accept, fandom-on-chip, T/"/" focus).
Reuse without destabilising the audited modal store: TagPanel and
SuggestionsPanel gain an optional `host` prop (default = modal store, so the
image modal is unchanged); the explore store implements the same small
tag-CRUD surface (current/currentImageId + reloadTags/addExistingTag/
removeTag/createAndAdd) over the anchor. ImageMetaBar gains an optional
`image` prop for the same reason.
Drops the mass/cluster tagger (TagGapPanel deleted; clusterIds/thumbById
removed) — per-image tagging feeds the per-tag reference-embedding centroid
better than bulk ops.
Nav: keep the Explore tab but bare /explore now SEEDS a random image
(GET /api/showcase?limit=1 → /explore/:id) so the tab kick-starts a rabbit
hole; explicit meta.navOrder pins nav order (Explore after Gallery) since
router.getRoutes() doesn't preserve declaration order.
Note: the backend cluster tag-gaps route/service (#94a) is now frontend-orphaned
— left in place; flag for a separate cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The directory card count regressed to a globally-inflated number (~every
card showed the same ~469): the fandom leg used a doubly-nested correlated
subquery — image_tag.tag_id IN (SELECT member.id WHERE member.fandom_id ==
Tag.id) — whose inner predicate did not correlate the outer Tag, so it
matched EVERY character that has any fandom and counted all their images for
every tag. The gallery scope and cleanup count were unaffected (they pass a
literal tag id, a single-level subquery), which is why only the card diverged
from the gallery.
Rewrite the count as a single-level correlated scalar subquery: join `member`
(the tag applied to the image) and match image_tag.tag_id == Tag.id (direct)
OR member.fandom_id == Tag.id (a character of this fandom). Strengthen the
directory test with a second unrelated fandom/character so a non-correlating
fandom leg fails (count would read 4 instead of 3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fandom owns characters via Tag.fandom_id, but every image<->tag query
went purely through direct image_tag rows, so a fandom only surfaced
images literally tagged with it — images carrying one of its characters
were invisible to its browse count, previews, and gallery filter.
Derive membership at query time instead of materializing fandom rows
(which would drift on every reassign/merge/remove). Add one shared
predicate in tag_query.py — image_in_tag_scope / image_in_any_tag_scope:
an image belongs to a tag if tagged with it directly OR (when the tag is
a fandom) carrying a character whose fandom_id is that tag. The character
leg is empty for non-fandom tags, so it applies uniformly with no kind
branching. Route all read sites through it:
- gallery _apply_scope: include, OR-groups, and symmetric exclude
- directory image_count: correlated COUNT(DISTINCT) scalar subquery
- directory previews: UNION direct + via-character, then ROW_NUMBER<=3
- cleanup count_tag_associations: Tier-B delete prompt now reports a
fandom's true blast radius (was 0 for fandoms with no direct rows)
find_unused_tags already protected fandoms via used_via_fandom; left as is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A swarm overlay-network blip after the :latest redeploy left Redis healthy but
transiently unreachable; a worker starting in that window crash-looped on the
initial broker connect (kombu OperationalError) and needed a manual Redis reset
to recover.
Retry the broker forever on startup + at runtime (broker_connection_max_retries
=None), add redis-transport socket options to the broker (short connect timeout,
TCP keepalive, retry_on_timeout, periodic health check), and mirror the same on
the Redis result backend. Now a transient outage self-heals when overlay routing
returns instead of the worker exiting.
Test pins the key resilience settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
The right rail scrolls as a whole and ProvenancePanel already caps its cards +
attachments, but SuggestionsPanel had no cap — a long suggestion set (the
General bucket runs to dozens) stretched the rail and pushed the Related strip
below the fold. Wrap the suggestion groups in a 320px max-height scroll box
(hairline scrollbar matching the provenance regions), so suggestions scroll
internally and Related stays visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster C frontend, milestone #94.
#94b Explore walk: new /explore/:imageId route + ExploreView + explore store.
Anchor (reuse /api/gallery/image), neighbour grid (reuse /api/gallery/similar,
24), click a neighbour to re-anchor; in-memory breadcrumb that trims on
backtrack (route is the source of truth). Empty/loading/error + no-embedding
states.
#94c tag-gap closing: components/explore/TagGapPanel — fetches
/api/images/cluster/tag-gaps for the anchor+neighbours, a consensus-threshold
slider (default 60%), per gap shows present/total + the missing thumbnails +
'Apply to N missing' → /api/tags/images/bulk/tags (source manual) → re-fetch.
#94d entry points: 'Explore' button in the modal RelatedStrip; the TopNav entry
comes free from the route's meta.title.
#4a metadata HUD + #4b split Download: new modal ImageMetaBar (always-on, above
ProvenancePanel) shows dimensions/size/type and a split Download button
(default Download, chevron → Copy link via utils/clipboard — no clipboard-image,
rule 95).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster C, milestone #94. BulkTagService.tag_gaps(image_ids, threshold) finds
tags applied to >= threshold fraction of a visual neighbour set but not all of
it (the '7 of 10 share Miku; these 3 don't' signal). Each gap carries the
laggard image ids minus any TagSuggestionRejection rows, so apply-to-cluster
never re-proposes a tag a neighbour dismissed. 100%-common tags and <2-image
sets are excluded. New POST /api/images/cluster/tag-gaps.
Tests: consensus found / common excluded / missing ids; rejected laggard
excluded from missing; tag dropped when all laggards rejected; <2 images empty;
route shape + bad input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
The new coverage tests' sequential shas (c{i:063d}) share their first 8 chars,
so deriving the image path from sha[:8] collided on uq_image_record_path. Use
the full sha in the path. Same hardening for test_api_suggestions._img.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster B frontend, milestone #99.
#7c: AllowlistTable gains Applied + Covers columns and a live 'covers ~N at T'
projection as the operator drags a row's threshold (debounced coverage call,
then commits the threshold). allowlist store gains coverage(tagId, threshold)
and refreshes coverage_count after a save.
#7d: suggestions store surfaces a non-blocking toast when accept/alias newly
allowlists a tag — '<verb>: <tag> — allowlisted, auto-applying to ~N images'
(N is the projection; apply runs async). Falls back to the plain toast when
the tag was already allowlisted.
#8b: TagsView merge picker now previews the merge via usePreviewCommit before
committing — shows images moving / already-on-target / series pages / alias-or-
delete / a thumbnail sample, blocks the Merge button on an incompatible
kind/fandom. adminStore.mergeTags gains a dryRun option.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster B, milestone #99. TagService.merge_preview(source, target) computes the
same counts the apply produces (rule 93 parity) without mutating: images_moving
(source links the apply UPDATEs), images_already_on_target (links it drops),
source_total, series_pages, will_alias (_keep_as_alias), a kind/fandom
compatible flag (surfaced, not raised, so the UI can warn), and up to 6
thumbnails of the moving images. The admin /tags/<dest>/merge route gains a
dry_run flag returning the preview JSON.
Tests: preview moving-count == apply merged_count (parity), incompatible flagged
without raising, self/missing raise, admin dry_run returns preview + no mutation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster B, milestone #99. Backend for the allowlist tuning dashboard.
#7a: AllowlistService.coverage(tag_id, threshold) counts distinct images with
a prediction resolving to the tag (raw_name==tag.name OR (raw_name,category) in
the tag's aliases) scoring >= threshold — the gross candidate pool, mirroring
tasks.ml._confidence_for_tag resolution. list_all now carries applied_count
(grouped image_tag count) + coverage_count (at the row's threshold). New
GET /api/tags/<id>/allowlist/coverage?threshold= for the live what-if number.
#7b: /suggestions/accept + /alias return {allowlisted, tag_id, tag_name,
projected_count} (projection at the tag's threshold) instead of 204, so the UI
can show a non-blocking 'auto-applying to ~N images' toast. Apply still runs
async via apply_allowlist_tags — projected_count is an estimate.
Tests: coverage by threshold (direct + alias-with-category), list applied vs
coverage, coverage route (explicit/default/bad threshold), accept/alias payload
(newly-allowlisted vs already-on-list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
Cluster A, milestone #97. Completes the frontend of the structured tag filter
(backend landed in 23fab98).
#6b store: gallery.filter gains tag_or (OR-groups) + tag_exclude; one model,
serialized via tag_or (repeated key) + tag_not across activeFilterParam/
filterToQuery/applyFilterFromQuery/cloneFilter/loadSimilar; _resolveLabels
resolves names for every referenced id. useApi now appends array param values
as a repeated key (tag_or=a&tag_or=b).
#6c light editor (GalleryFilterBar): autocomplete pick → include chip; click a
chip body to flip include↔exclude (exclude = red minus); ✕ removes. An
"N OR-groups" chip + an Advanced button open the builder.
#6d advanced editor (TagQueryBuilder.vue + common/TagPicker.vue): AND-of-OR
group builder + NOT list. Unifies includes+OR-groups into one groups view,
splits back to tag_ids/tag_or on Apply so the URL stays compact. Writes the
same model the light chips edit.
Store serialization round-trip tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX