From 23fab983a0989265b33a1eb5d9fe311f9d19788e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:11:42 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(gallery):=20tag=E2=86=92gallery=20nav?= =?UTF-8?q?=20from=20modal=20chips=20(#5)=20+=20OR/exclude=20tag=20scope?= =?UTF-8?q?=20(#6a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster A, milestone #97. #5: clicking an image-modal tag chip's body now closes the modal and opens the gallery filtered for that one tag (fresh filter); ✕/kebab stay as the explicit remove/rename controls. #6a (backend of OR/exclude filtering): gallery_service._apply_scope gains a structured tag model — tag_or_groups (AND-of-OR: one EXISTS(tag_id IN group) per group) + tag_exclude (NOT EXISTS(tag_id IN exclude)) — layered additively on the existing tag_ids AND path so cursors/facets/deep-links are untouched. Threaded through scroll/timeline/jump_cursor/facets/similar + facets common dict; _require_single_filter rejects post_id combined with OR/exclude. API parses tag_or (repeatable → one OR-group each) + tag_not (csv exclude). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- backend/app/api/gallery.py | 30 ++++++-- backend/app/services/gallery_service.py | 75 ++++++++++++++++--- frontend/src/components/modal/TagChip.vue | 13 +++- frontend/src/components/modal/TagPanel.vue | 11 +++ tests/test_api_gallery.py | 62 ++++++++++++++- tests/test_gallery_service.py | 87 ++++++++++++++++++++++ 6 files changed, 258 insertions(+), 20 deletions(-) diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 4e35bab..678ab4a 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -37,16 +37,30 @@ def _parse_filters(): """Parse the composable gallery filters from query args, returning ``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates. - `tag_id` accepts a single id or a comma-separated list (AND); `media` is - image|video; `sort` is newest|oldest; `platform` selects one platform - (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are boolean - flags; `date_from`/`date_to` are inclusive calendar-day bounds (date_to is - widened by a day so the whole day is covered by the service's half-open - `< date_to`).""" + The structured tag filter (#6) is AND-of-OR plus exclusions: + - `tag_id` accepts a single id or a comma-separated list — all ANDed + (the include common case; back-compat). + - `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and + the image must match at least one tag from EACH group (groups ANDed). + - `tag_not` is a comma-separated exclude list (image must carry none). + + `media` is image|video; `sort` is newest|oldest; `platform` selects one + platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are + boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds + (date_to is widened by a day so the whole day is covered by the service's + half-open `< date_to`).""" tag_raw = request.args.get("tag_id") tag_ids = ( [int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None ) or None + tag_or_groups = [ + grp for raw in request.args.getlist("tag_or") + if (grp := [int(x) for x in raw.split(",") if x.strip()]) + ] or None + not_raw = request.args.get("tag_not") + tag_exclude = ( + [int(x) for x in not_raw.split(",") if x.strip()] if not_raw else None + ) or None post_id_raw = request.args.get("post_id") post_id = int(post_id_raw) if post_id_raw else None artist_id_raw = request.args.get("artist_id") @@ -64,7 +78,9 @@ def _parse_filters(): date_to += timedelta(days=1) # inclusive of the date_to calendar day filters = { "tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id, - "media_type": media_type, "platform": platform, + "media_type": media_type, + "tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude, + "platform": platform, "untagged": untagged, "no_artist": no_artist, "date_from": date_from, "date_to": date_to, } diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index ac5a975..0618d75 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -136,11 +136,15 @@ def image_url(path: str) -> str: return f"/images/{quote(rel, safe='/')}" -def _require_single_filter(tag_ids, post_id, artist_id) -> None: +def _require_single_filter( + tag_ids, post_id, artist_id, tag_or_groups=None, tag_exclude=None, +) -> None: """post_id is the post-detail view — it can't combine with the - composable filters. tag_ids + artist_id (+ media_type) compose freely - (AND).""" - if post_id is not None and (tag_ids or artist_id is not None): + composable filters. tag_ids / tag_or_groups / tag_exclude + artist_id + (+ media_type) compose freely (AND).""" + if post_id is not None and ( + tag_ids or artist_id is not None or tag_or_groups or tag_exclude + ): raise ValueError( "post_id cannot be combined with tag or artist filters" ) @@ -148,6 +152,7 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None: def _apply_scope( stmt, *, tag_ids, post_id, artist_id, media_type, + tag_or_groups=None, tag_exclude=None, platform=None, untagged=False, no_artist=False, date_from=None, date_to=None, ): @@ -158,7 +163,14 @@ def _apply_scope( be present on `stmt` (the artist/platform paths alias Post/Source inside their own EXISTS). - - tag_ids: image must carry ALL of them — one correlated EXISTS per tag. + Tag filtering is one structured model (#6): AND-of-OR plus exclusions. + - tag_ids: image must carry ALL of them — one correlated EXISTS per tag + (the AND-of-singletons "include" common case; light editor + back-compat). + - tag_or_groups: list of OR-groups; the image must carry AT LEAST ONE tag + from EACH group — one EXISTS(tag_id IN group) per group, AND'd across + groups. (advanced editor) + - tag_exclude: image must carry NONE of these — a single NOT EXISTS(tag_id + IN exclude). (light "exclude" chips + advanced NOT) - post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded by _require_single_filter). - media_type: 'image' | 'video' narrows by mime prefix. @@ -175,6 +187,22 @@ def _apply_scope( image_tag.c.tag_id == tid, ) ) + for group in tag_or_groups or []: + if not group: + continue # an empty OR-group would match nothing; treat as absent + stmt = stmt.where( + exists().where( + image_tag.c.image_record_id == ImageRecord.id, + image_tag.c.tag_id.in_(group), + ) + ) + if tag_exclude: + stmt = stmt.where( + ~exists().where( + image_tag.c.image_record_id == ImageRecord.id, + image_tag.c.tag_id.in_(tag_exclude), + ) + ) prov = _provenance_clause(post_id, artist_id) if prov is not None: stmt = stmt.where(prov) @@ -296,6 +324,8 @@ class GalleryService: artist_id: int | None = None, media_type: str | None = None, sort: str = "newest", + tag_or_groups: list[list[int]] | None = None, + tag_exclude: list[int] | None = None, platform: str | None = None, untagged: bool = False, no_artist: bool = False, @@ -304,7 +334,9 @@ class GalleryService: ) -> GalleryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") - _require_single_filter(tag_ids, post_id, artist_id) + _require_single_filter( + tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, + ) eff = _effective_date_col() stmt = select(ImageRecord, Post.post_date, eff.label("eff")) @@ -312,6 +344,7 @@ class GalleryService: stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, platform=platform, untagged=untagged, no_artist=no_artist, date_from=date_from, date_to=date_to, ) @@ -359,6 +392,8 @@ class GalleryService: post_id: int | None = None, artist_id: int | None = None, media_type: str | None = None, + tag_or_groups: list[list[int]] | None = None, + tag_exclude: list[int] | None = None, platform: str | None = None, untagged: bool = False, no_artist: bool = False, @@ -372,10 +407,13 @@ class GalleryService: year_col, month_col, func.count(ImageRecord.id).label("cnt") ) stmt = _outer_join_primary_post(stmt) - _require_single_filter(tag_ids, post_id, artist_id) + _require_single_filter( + tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, + ) stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, platform=platform, untagged=untagged, no_artist=no_artist, date_from=date_from, date_to=date_to, ) @@ -387,6 +425,8 @@ class GalleryService: self, year: int, month: int, tag_ids: list[int] | None = None, post_id: int | None = None, artist_id: int | None = None, media_type: str | None = None, sort: str = "newest", + tag_or_groups: list[list[int]] | None = None, + tag_exclude: list[int] | None = None, platform: str | None = None, untagged: bool = False, no_artist: bool = False, date_from: datetime | None = None, date_to: datetime | None = None, @@ -403,10 +443,13 @@ class GalleryService: extract("month", eff) == month, ) stmt = _outer_join_primary_post(stmt) - _require_single_filter(tag_ids, post_id, artist_id) + _require_single_filter( + tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, + ) stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=post_id, artist_id=artist_id, media_type=media_type, + tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, platform=platform, untagged=untagged, no_artist=no_artist, date_from=date_from, date_to=date_to, ) @@ -427,7 +470,10 @@ class GalleryService: async def facets( self, *, tag_ids: list[int] | None = None, post_id: int | None = None, artist_id: int | None = None, - media_type: str | None = None, platform: str | None = None, + media_type: str | None = None, + tag_or_groups: list[list[int]] | None = None, + tag_exclude: list[int] | None = None, + platform: str | None = None, untagged: bool = False, no_artist: bool = False, date_from: datetime | None = None, date_to: datetime | None = None, ) -> GalleryFacets: @@ -437,10 +483,13 @@ class GalleryService: No outer join is needed — every clause is a correlated EXISTS or a column predicate on ImageRecord. """ - _require_single_filter(tag_ids, post_id, artist_id) + _require_single_filter( + tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, + ) common = { "tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id, "media_type": media_type, + "tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude, } # total — the full active filter (the headline result count). @@ -515,7 +564,10 @@ class GalleryService: async def similar( self, image_id: int, limit: int = 100, *, tag_ids: list[int] | None = None, artist_id: int | None = None, - media_type: str | None = None, platform: str | None = None, + media_type: str | None = None, + tag_or_groups: list[list[int]] | None = None, + tag_exclude: list[int] | None = None, + platform: str | None = None, untagged: bool = False, no_artist: bool = False, date_from: datetime | None = None, date_to: datetime | None = None, ) -> list[GalleryImage] | None: @@ -547,6 +599,7 @@ class GalleryService: stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=None, artist_id=artist_id, media_type=media_type, + tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, platform=platform, untagged=untagged, no_artist=no_artist, date_from=date_from, date_to=date_to, ) diff --git a/frontend/src/components/modal/TagChip.vue b/frontend/src/components/modal/TagChip.vue index 1dc5699..2f7011f 100644 --- a/frontend/src/components/modal/TagChip.vue +++ b/frontend/src/components/modal/TagChip.vue @@ -3,6 +3,10 @@ {{ iconFor(tag.kind) }} @@ -30,7 +34,7 @@ import { useTagStore } from '../../stores/tags.js' import KebabMenu from '../common/KebabMenu.vue' const props = defineProps({ tag: { type: Object, required: true } }) -defineEmits(['remove', 'rename', 'set-fandom']) +defineEmits(['remove', 'rename', 'set-fandom', 'navigate']) const store = useTagStore() @@ -53,6 +57,13 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' } diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js index 1be0c31..76cb4f7 100644 --- a/frontend/src/composables/useApi.js +++ b/frontend/src/composables/useApi.js @@ -15,7 +15,16 @@ async function request(method, url, { body, params, signal } = {}) { if (params) { const search = new URLSearchParams() for (const [k, v] of Object.entries(params)) { - if (v !== undefined && v !== null) search.set(k, String(v)) + if (v === undefined || v === null) continue + // Array values serialize as a REPEATED key (k=a&k=b) — e.g. the + // gallery's tag_or OR-groups, read server-side via request.args.getlist. + if (Array.isArray(v)) { + for (const item of v) { + if (item !== undefined && item !== null) search.append(k, String(item)) + } + } else { + search.set(k, String(v)) + } } const qs = search.toString() if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js index bad0d8b..a0599bf 100644 --- a/frontend/src/stores/gallery.js +++ b/frontend/src/stores/gallery.js @@ -24,6 +24,10 @@ export const useGalleryStore = defineStore('gallery', () => { const filter = ref({ tag_ids: [], artist_id: null, media_type: null, sort: 'newest', post_id: null, + // #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND + // "include" singletons (light editor + back-compat); tag_or is a list of + // OR-groups (each group ORs, groups AND); tag_exclude is the NOT set. + tag_or: [], tag_exclude: [], // Phase-2 faceted refine params. platform: null, untagged: false, no_artist: false, date_from: null, date_to: null, @@ -102,6 +106,9 @@ export const useGalleryStore = defineStore('gallery', () => { const f = filter.value const params = { similar_to: f.similar_to, limit: 100 } if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',') + const orParam = _orGroupsParam(f.tag_or) + if (orParam.length) params.tag_or = orParam + if (f.tag_exclude.length) params.tag_not = f.tag_exclude.join(',') if (f.artist_id) params.artist_id = f.artist_id if (f.media_type) params.media = f.media_type if (f.platform) params.platform = f.platform @@ -142,6 +149,9 @@ export const useGalleryStore = defineStore('gallery', () => { if (filter.value.post_id) return { post_id: filter.value.post_id } const p = {} if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',') + const orParam = _orGroupsParam(filter.value.tag_or) + if (orParam.length) p.tag_or = orParam // array → repeated tag_or= key + if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',') if (filter.value.artist_id) p.artist_id = filter.value.artist_id if (filter.value.media_type) p.media = filter.value.media_type if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort @@ -177,6 +187,8 @@ export const useGalleryStore = defineStore('gallery', () => { async function applyFilterFromQuery(q) { filter.value = { tag_ids: _parseIds(q.tag_id), + tag_or: _parseGroups(q.tag_or), + tag_exclude: _parseIds(q.tag_not), artist_id: _toId(q.artist_id), media_type: ['image', 'video'].includes(q.media) ? q.media : null, sort: q.sort === 'oldest' ? 'oldest' : 'newest', @@ -211,6 +223,14 @@ export const useGalleryStore = defineStore('gallery', () => { if (!raw) return [] return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0) } + // tag_or arrives from the URL as a string (one group) or string[] (many); + // each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping + // empties so a stray `tag_or=` can't become a match-nothing group. + function _parseGroups(raw) { + if (raw == null) return [] + const values = Array.isArray(raw) ? raw : [raw] + return values.map(_parseIds).filter((g) => g.length) + } // Pre-seed a label so a freshly-picked chip shows its name without a // round-trip; the bar calls these before pushing the new URL. @@ -218,7 +238,14 @@ export const useGalleryStore = defineStore('gallery', () => { function noteArtistLabel(name) { artistLabel.value = name || null } async function _resolveLabels() { - for (const id of filter.value.tag_ids) { + // Every tag id referenced anywhere in the structured filter needs a chip + // label — includes, every OR-group member, and excludes. + const allTagIds = new Set([ + ...filter.value.tag_ids, + ...filter.value.tag_or.flat(), + ...filter.value.tag_exclude, + ]) + for (const id of allTagIds) { if (tagLabels.value[id]) continue try { const t = await api.get(`/api/tags/${id}`) @@ -252,7 +279,10 @@ export const useGalleryStore = defineStore('gallery', () => { // the exclusive post-detail view. export function cloneFilter(f) { return { - tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type, + tag_ids: [...f.tag_ids], + tag_or: (f.tag_or || []).map((g) => [...g]), // deep-clone the groups + tag_exclude: [...(f.tag_exclude || [])], + artist_id: f.artist_id, media_type: f.media_type, sort: f.sort, platform: f.platform, untagged: f.untagged, no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to, similar_to: f.similar_to, @@ -262,6 +292,9 @@ export function cloneFilter(f) { export function filterToQuery(f) { const q = {} if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',') + const orParam = _orGroupsParam(f.tag_or) + if (orParam.length) q.tag_or = orParam // array → repeated query key + if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',') if (f.artist_id) q.artist_id = String(f.artist_id) if (f.media_type) q.media = f.media_type if (f.sort && f.sort !== 'newest') q.sort = f.sort @@ -274,6 +307,15 @@ export function filterToQuery(f) { return q } +// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'], +// dropping empty groups. Shared by the store's activeFilterParam and the +// router-facing filterToQuery so both write tag_or the same way. +function _orGroupsParam(groups) { + return (groups || []) + .map((g) => (g || []).join(',')) + .filter((s) => s.length) +} + function mergeGroups(existing, incoming) { // Merge sequential groups with the same (year, month) instead of duplicating. const merged = [...existing] diff --git a/frontend/test/gallery.spec.js b/frontend/test/gallery.spec.js index 93ce5c6..ce533e3 100644 --- a/frontend/test/gallery.spec.js +++ b/frontend/test/gallery.spec.js @@ -144,6 +144,40 @@ describe('gallery store: composable filter', () => { expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false) }) + it('parses tag_or OR-groups + tag_not and forwards them (incl. repeated tag_or)', async () => { + const s = useGalleryStore() + const urls = [] + stubFetch((url) => { + urls.push(url) + if (url.includes('/api/tags/')) { + return { status: 200, body: { id: 1, name: 'X', kind: 'general' } } + } + return { status: 200, body: EMPTY } + }) + // tag_or arrives as an array (two groups); tag_not as a csv exclude list. + await s.applyFilterFromQuery({ tag_or: ['1,2', '3'], tag_not: '4,5' }) + expect(s.filter.tag_or).toEqual([[1, 2], [3]]) + expect(s.filter.tag_exclude).toEqual([4, 5]) + const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll'))) + expect(scroll).toContain('tag_or=1,2') // first OR-group + expect(scroll).toContain('tag_or=3') // second OR-group (repeated key) + expect(scroll).toContain('tag_not=4,5') + }) + + it('normalizes a single-string tag_or and drops empty groups', async () => { + const s = useGalleryStore() + stubFetch((url) => { + if (url.includes('/api/tags/')) { + return { status: 200, body: { id: 1, name: 'X', kind: 'general' } } + } + return { status: 200, body: EMPTY } + }) + await s.applyFilterFromQuery({ tag_or: '7,8' }) // single string → one group + expect(s.filter.tag_or).toEqual([[7, 8]]) + await s.applyFilterFromQuery({ tag_or: ['', '9'] }) // empty group dropped + expect(s.filter.tag_or).toEqual([[9]]) + }) + it('loadInitial issues exactly one scroll request at the initial limit', async () => { const s = useGalleryStore() const urls = [] @@ -181,6 +215,26 @@ describe('filterToQuery / cloneFilter', () => { expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined() }) + it('serializes tag_or OR-groups (repeated key) + tag_not, dropping empties', () => { + const q = filterToQuery({ + tag_ids: [], tag_or: [[1, 2], [3], []], tag_exclude: [4, 5], + }) + expect(q.tag_or).toEqual(['1,2', '3']) // empty group dropped → array + expect(q.tag_not).toBe('4,5') + const empty = filterToQuery({ tag_ids: [], tag_or: [], tag_exclude: [] }) + expect(empty.tag_or).toBeUndefined() + expect(empty.tag_not).toBeUndefined() + }) + + it('cloneFilter deep-clones OR-groups + exclude', () => { + const orig = { tag_ids: [], tag_or: [[1, 2]], tag_exclude: [3] } + const c = cloneFilter(orig) + c.tag_or[0].push(9) + c.tag_exclude.push(8) + expect(orig.tag_or).toEqual([[1, 2]]) // group detached + expect(orig.tag_exclude).toEqual([3]) // exclude detached + }) + it('cloneFilter copies refine fields and detaches tag_ids', () => { const orig = { tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest', -- 2.52.0 From e206778a5c9aad6fbbb82ce920857e46ac7f8429 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:34:21 -0400 Subject: [PATCH 3/8] feat(allowlist): coverage projection + applied-count + post-accept projection (#7a/#7b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- backend/app/api/allowlist.py | 25 ++++++++ backend/app/api/suggestions.py | 38 ++++++++++-- backend/app/services/ml/allowlist.py | 88 ++++++++++++++++++++++++---- tests/test_api_allowlist.py | 49 +++++++++++++++- tests/test_api_suggestions.py | 33 +++++++++-- tests/test_ml_allowlist.py | 66 ++++++++++++++++++++- 6 files changed, 275 insertions(+), 24 deletions(-) diff --git a/backend/app/api/allowlist.py b/backend/app/api/allowlist.py index 53f38bb..31241de 100644 --- a/backend/app/api/allowlist.py +++ b/backend/app/api/allowlist.py @@ -20,12 +20,37 @@ async def list_allowlist(): "tag_name": r.tag_name, "tag_kind": r.tag_kind, "min_confidence": r.min_confidence, + "applied_count": r.applied_count, + "coverage_count": r.coverage_count, } for r in rows ] ) +@allowlist_bp.route("/tags//allowlist/coverage", methods=["GET"]) +async def coverage(tag_id: int): + """Live "at threshold T, a sweep would cover ~N images" projection for the + allowlist tuning dashboard. Defaults to the tag's stored threshold.""" + raw = request.args.get("threshold") + async with get_session() as session: + svc = AllowlistService(session) + if raw is not None: + try: + threshold = float(raw) + except ValueError: + return jsonify({"error": "threshold must be a float"}), 400 + if not (0 < threshold <= 1): + return jsonify({"error": "threshold must be in (0, 1]"}), 400 + else: + row = await session.get(TagAllowlist, tag_id) + if row is None: + return jsonify({"error": "not on allowlist"}), 404 + threshold = row.min_confidence + count = await svc.coverage(tag_id, threshold) + return jsonify({"count": count, "threshold": threshold}) + + @allowlist_bp.route("/tags//allowlist", methods=["GET"]) async def get_one(tag_id: int): async with get_session() as session: diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index a8a9cf4..46fa2e5 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -3,12 +3,31 @@ from quart import Blueprint, jsonify, request from ..extensions import get_session +from ..models import Tag, TagAllowlist from ..services.ml.allowlist import AllowlistService from ..services.ml.suggestions import SuggestionService suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") +async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict: + """Shape the accept/alias response. When accepting newly allowlists a tag, + include the coverage PROJECTION (at the tag's threshold) so the UI can show + a non-blocking "auto-applying to ~N images" toast — the actual apply runs + async via apply_allowlist_tags, so this is an estimate, not a post-hoc + count (#7).""" + payload = {"allowlisted": newly_added} + if newly_added: + tag = await session.get(Tag, tag_id) + row = await session.get(TagAllowlist, tag_id) + payload["tag_id"] = tag_id + payload["tag_name"] = tag.name if tag is not None else None + payload["projected_count"] = await svc.coverage( + tag_id, row.min_confidence if row is not None else 0.90, + ) + return payload + + @suggestions_bp.route("/images//suggestions", methods=["GET"]) async def get_suggestions(image_id: int): # ?min= overrides the configured per-category thresholds so the typed @@ -60,13 +79,15 @@ async def accept_suggestion(image_id: int): return jsonify({"error": "tag_id required"}), 400 tag_id = body["tag_id"] async with get_session() as session: - newly_added = await AllowlistService(session).accept(image_id, tag_id) + svc = AllowlistService(session) + newly_added = await svc.accept(image_id, tag_id) + payload = await _accept_payload(session, svc, newly_added, tag_id) await session.commit() if newly_added: from ..tasks.ml import apply_allowlist_tags apply_allowlist_tags.delay(tag_id=tag_id) - return "", 204 + return jsonify(payload) @suggestions_bp.route( @@ -77,19 +98,24 @@ async def alias_suggestion(image_id: int): required = {"alias_string", "alias_category", "canonical_tag_id"} if not body or not required.issubset(body): return jsonify({"error": f"required: {sorted(required)}"}), 400 + canonical_tag_id = body["canonical_tag_id"] async with get_session() as session: - newly_added = await AllowlistService(session).add_alias_and_accept( + svc = AllowlistService(session) + newly_added = await svc.add_alias_and_accept( image_id, body["alias_string"], body["alias_category"], - body["canonical_tag_id"], + canonical_tag_id, + ) + payload = await _accept_payload( + session, svc, newly_added, canonical_tag_id, ) await session.commit() if newly_added: from ..tasks.ml import apply_allowlist_tags - apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"]) - return "", 204 + apply_allowlist_tags.delay(tag_id=canonical_tag_id) + return jsonify(payload) @suggestions_bp.route( diff --git a/backend/app/services/ml/allowlist.py b/backend/app/services/ml/allowlist.py index 08a6ac7..8f7d14e 100644 --- a/backend/app/services/ml/allowlist.py +++ b/backend/app/services/ml/allowlist.py @@ -5,11 +5,18 @@ image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection. from collections.abc import Sequence from dataclasses import dataclass -from sqlalchemy import delete, select +from sqlalchemy import and_, delete, distinct, func, or_, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession -from ...models import MLSettings, Tag, TagAllowlist, TagSuggestionRejection +from ...models import ( + ImagePrediction, + MLSettings, + Tag, + TagAlias, + TagAllowlist, + TagSuggestionRejection, +) from ...models.tag import image_tag from .aliases import AliasService @@ -20,6 +27,8 @@ class AllowlistRow: tag_name: str tag_kind: str min_confidence: float + applied_count: int # image_tag rows currently carrying this tag + coverage_count: int # images a sweep WOULD cover at min_confidence class AllowlistService: @@ -116,6 +125,44 @@ class AllowlistService: delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id) ) + async def _coverage_match(self, tag: Tag): + """The predicate over image_prediction rows that resolve to `tag`, + mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose + raw_name equals the tag name (any category), OR an alias maps + (raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause. + """ + alias_rows = ( + await self.session.execute( + select(TagAlias.alias_string, TagAlias.alias_category).where( + TagAlias.canonical_tag_id == tag.id + ) + ) + ).all() + name_clause = ImagePrediction.raw_name == tag.name + alias_clauses = [ + and_( + ImagePrediction.raw_name == a, + ImagePrediction.category == c, + ) + for a, c in alias_rows + ] + return or_(name_clause, *alias_clauses) if alias_clauses else name_clause + + async def coverage(self, tag_id: int, threshold: float) -> int: + """How many distinct images a sweep WOULD cover for this tag at + `threshold`: images with a resolving prediction scoring >= threshold. + The gross candidate pool (NOT minus already-applied/rejected) — it's + the tuning signal for "lower the threshold and ~N more images qualify". + """ + tag = await self.session.get(Tag, tag_id) + if tag is None: + return 0 + match = await self._coverage_match(tag) + stmt = select( + func.count(distinct(ImagePrediction.image_record_id)) + ).where(ImagePrediction.score >= threshold, match) + return (await self.session.execute(stmt)).scalar_one() + async def list_all(self) -> Sequence[AllowlistRow]: stmt = ( select( @@ -128,12 +175,33 @@ class AllowlistService: .order_by(Tag.name.asc()) ) rows = (await self.session.execute(stmt)).all() - return [ - AllowlistRow( - tag_id=r[0], - tag_name=r[1], - tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]), - min_confidence=r[3], + tag_ids = [r[0] for r in rows] + + # Applied counts in ONE grouped query (vs N per-row counts). + applied: dict[int, int] = {} + if tag_ids: + applied = dict( + ( + await self.session.execute( + select(image_tag.c.tag_id, func.count()) + .where(image_tag.c.tag_id.in_(tag_ids)) + .group_by(image_tag.c.tag_id) + ) + ).all() ) - for r in rows - ] + + result = [] + for r in rows: + # Coverage is per-tag (alias set differs); allowlist is small. + cov = await self.coverage(r[0], r[3]) + result.append( + AllowlistRow( + tag_id=r[0], + tag_name=r[1], + tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]), + min_confidence=r[3], + applied_count=applied.get(r[0], 0), + coverage_count=cov, + ) + ) + return result diff --git a/tests/test_api_allowlist.py b/tests/test_api_allowlist.py index 3b4f45b..01539ae 100644 --- a/tests/test_api_allowlist.py +++ b/tests/test_api_allowlist.py @@ -1,6 +1,6 @@ import pytest -from backend.app.models import TagAllowlist, TagKind +from backend.app.models import ImagePrediction, ImageRecord, TagAllowlist, TagKind from backend.app.services.tag_service import TagService pytestmark = pytest.mark.integration @@ -39,3 +39,50 @@ async def test_patch_rejects_out_of_range(client, db): f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 1.5} ) assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_coverage_endpoint(client, db): + tag = await TagService(db).find_or_create("Cover", TagKind.general) + db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.90)) + for i, score in enumerate((0.95, 0.60)): + img = ImageRecord( + path=f"/images/cov{i}.jpg", sha256=f"cv{i:062d}", size_bytes=1, + mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + db.add(img) + await db.flush() + db.add(ImagePrediction( + image_record_id=img.id, raw_name="Cover", + category="general", score=score, + )) + await db.commit() + + # Explicit threshold. + resp = await client.get( + f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.90" + ) + assert resp.status_code == 200 + assert (await resp.get_json())["count"] == 1 + # Lower what-if threshold widens coverage. + resp = await client.get( + f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.50" + ) + assert (await resp.get_json())["count"] == 2 + # No threshold → uses the stored min_confidence (0.90). + resp = await client.get(f"/api/tags/{tag.id}/allowlist/coverage") + body = await resp.get_json() + assert body["count"] == 1 + assert body["threshold"] == pytest.approx(0.90) + + +@pytest.mark.asyncio +async def test_coverage_rejects_bad_threshold(client, db): + tag = await TagService(db).find_or_create("Cover2", TagKind.general) + db.add(TagAllowlist(tag_id=tag.id)) + await db.commit() + resp = await client.get( + f"/api/tags/{tag.id}/allowlist/coverage?threshold=2.0" + ) + assert resp.status_code == 400 diff --git a/tests/test_api_suggestions.py b/tests/test_api_suggestions.py index 84b95b0..36c0f89 100644 --- a/tests/test_api_suggestions.py +++ b/tests/test_api_suggestions.py @@ -14,11 +14,11 @@ def eager(): celery.conf.task_always_eager = False -async def _img(db, preds): +async def _img(db, preds, sha="s" * 64): from tests._prediction_helpers import seed_predictions img = ImageRecord( - path="/images/s.jpg", sha256="s" * 64, size_bytes=1, + path=f"/images/{sha[:8]}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", ) @@ -57,7 +57,31 @@ async def test_accept_then_applied(client, db): resp = await client.post( f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id} ) - assert resp.status_code == 204 + assert resp.status_code == 200 + body = await resp.get_json() + # #7b: a fresh accept newly-allowlists → projection payload for the toast. + assert body["allowlisted"] is True + assert body["tag_id"] == tag.id + assert body["tag_name"] == "AcceptMe" + assert "projected_count" in body + + +@pytest.mark.asyncio +async def test_accept_already_allowlisted_reports_not_new(client, db): + img1 = await _img(db, {}, sha="c" * 64) + img2 = await _img(db, {}, sha="d" * 64) + tag = await TagService(db).find_or_create("Twice", TagKind.character) + await db.commit() + first = await client.post( + f"/api/images/{img1.id}/suggestions/accept", json={"tag_id": tag.id} + ) + assert (await first.get_json())["allowlisted"] is True + second = await client.post( + f"/api/images/{img2.id}/suggestions/accept", json={"tag_id": tag.id} + ) + body = await second.get_json() + assert body["allowlisted"] is False # already on the allowlist + assert "projected_count" not in body @pytest.mark.asyncio @@ -127,7 +151,8 @@ async def test_alias_roundtrip_resolves_by_raw_key(client, db): "canonical_tag_id": canonical.id, }, ) - assert resp.status_code == 204 + assert resp.status_code == 200 + assert (await resp.get_json())["allowlisted"] is True # (b) A DIFFERENT image with the same prediction now resolves via the alias # (image A's tag is applied, so it's filtered there). Had the alias been diff --git a/tests/test_ml_allowlist.py b/tests/test_ml_allowlist.py index ad3298f..3d853b1 100644 --- a/tests/test_ml_allowlist.py +++ b/tests/test_ml_allowlist.py @@ -1,7 +1,13 @@ import pytest from sqlalchemy import select -from backend.app.models import TagAllowlist, TagKind, TagSuggestionRejection +from backend.app.models import ( + ImagePrediction, + TagAlias, + TagAllowlist, + TagKind, + TagSuggestionRejection, +) from backend.app.models.tag import image_tag from backend.app.services.ml.allowlist import AllowlistService from backend.app.services.tag_service import TagService @@ -9,10 +15,10 @@ from backend.app.services.tag_service import TagService pytestmark = pytest.mark.integration -async def _make_image(db): +async def _make_image(db, sha: str = "x" * 64): from backend.app.models import ImageRecord img = ImageRecord( - path="/images/x.jpg", sha256="x" * 64, size_bytes=1, + path=f"/images/{sha[:8]}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", ) @@ -21,6 +27,14 @@ async def _make_image(db): return img +async def _add_pred(db, image_id, raw_name, score, category="general"): + db.add(ImagePrediction( + image_record_id=image_id, raw_name=raw_name, + category=category, score=score, + )) + await db.flush() + + @pytest.mark.asyncio async def test_accept_applies_and_allowlists(db): img = await _make_image(db) @@ -106,6 +120,52 @@ async def test_update_threshold_and_remove(db): assert await db.get(TagAllowlist, tag.id) is None +@pytest.mark.asyncio +async def test_coverage_by_threshold_direct_name(db): + tag = await TagService(db).find_or_create("Cov", TagKind.general) + svc = AllowlistService(db) + for i, score in enumerate((0.95, 0.80, 0.60)): + img = await _make_image(db, sha=f"c{i:063d}") + await _add_pred(db, img.id, "Cov", score) + assert await svc.coverage(tag.id, 0.90) == 1 + assert await svc.coverage(tag.id, 0.70) == 2 + assert await svc.coverage(tag.id, 0.50) == 3 + + +@pytest.mark.asyncio +async def test_coverage_via_alias_respects_category(db): + tag = await TagService(db).find_or_create("Aliased", TagKind.character) + db.add(TagAlias( + alias_string="model_key", alias_category="character", + canonical_tag_id=tag.id, + )) + await db.flush() + svc = AllowlistService(db) + hit = await _make_image(db, sha=f"a{0:063d}") + await _add_pred(db, hit.id, "model_key", 0.92, category="character") + # Same alias string but wrong category must NOT resolve to the tag. + miss = await _make_image(db, sha=f"a{1:063d}") + await _add_pred(db, miss.id, "model_key", 0.99, category="general") + assert await svc.coverage(tag.id, 0.90) == 1 + + +@pytest.mark.asyncio +async def test_list_all_reports_applied_and_coverage(db): + tag = await TagService(db).find_or_create("Both", TagKind.general) + svc = AllowlistService(db) + applied_img = await _make_image(db, sha=f"b{0:063d}") + await svc.accept(applied_img.id, tag.id) # applies + allowlists + await _add_pred(db, applied_img.id, "Both", 0.95) + # A second image only has a qualifying prediction (covered, not applied). + cov_img = await _make_image(db, sha=f"b{1:063d}") + await _add_pred(db, cov_img.id, "Both", 0.95) + + rows = await svc.list_all() + row = next(r for r in rows if r.tag_id == tag.id) + assert row.applied_count == 1 # only the accepted image + assert row.coverage_count == 2 # both have a ≥threshold pred + + @pytest.mark.asyncio async def test_update_threshold_clamped_to_store_floor(db): # A min_confidence below the store floor (default 0.70) is clamped up — -- 2.52.0 From 71277143168dc719c90162aea491bf642a7422b1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:37:11 -0400 Subject: [PATCH 4/8] feat(tags): non-mutating merge preview + admin dry_run (#8a) 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//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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- backend/app/api/admin.py | 24 +++++++ backend/app/services/tag_service.py | 107 ++++++++++++++++++++++++++++ tests/test_api_admin.py | 35 +++++++++ tests/test_tag_merge.py | 42 +++++++++++ 4 files changed, 208 insertions(+) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index b2a9709..53781a9 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -171,6 +171,30 @@ async def tag_merge(dest_id: int): if not isinstance(source_id, int) or source_id == dest_id: return _bad("invalid_source_id", detail="source_id must be int and differ from dest") + # dry_run: non-mutating preview (counts + sample) so the operator can + # confirm the target before the irreversible merge (#8, rule 93 parity). + if body.get("dry_run"): + async with get_session() as session: + try: + p = await TagService(session).merge_preview( + source_id=source_id, target_id=dest_id, + ) + except TagValidationError as exc: + return _bad("tag_not_found", status=404, detail=str(exc)) + return jsonify({ + "preview": { + "source_id": p.source_id, "source_name": p.source_name, + "target_id": p.target_id, "target_name": p.target_name, + "compatible": p.compatible, + "images_moving": p.images_moving, + "images_already_on_target": p.images_already_on_target, + "source_total": p.source_total, + "series_pages": p.series_pages, + "will_alias": p.will_alias, + "sample_thumbnails": p.sample_thumbnails, + }, + }) + async with get_session() as session: try: result = await TagService(session).merge( diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 93b3ce7..495875a 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -79,6 +79,23 @@ class MergeResult: source_deleted: bool +@dataclass(frozen=True) +class MergePreview: + """Non-mutating projection of what merge(source→target) would do (#8). + Shares the apply's predicates (rule 93) so the preview can't drift.""" + source_id: int + source_name: str + target_id: int + target_name: str + compatible: bool # same kind + fandom — would apply succeed? + images_moving: int # source links that move (image lacks target) + images_already_on_target: int # source links dropped (image has target) + source_total: int # images carrying source + series_pages: int # series pages repointed + will_alias: bool # source name kept as a protective alias + sample_thumbnails: list[str] # a few of the moving images + + class TagService: def __init__(self, session: AsyncSession): self.session = session @@ -376,6 +393,96 @@ class TagService: await self.session.flush() return tag + async def merge_preview( + self, source_id: int, target_id: int + ) -> MergePreview: + """Read-only dry-run of merge(source→target): the same counts the apply + would produce, plus a thumbnail sample of the images that move. Raises + TagValidationError only for missing/self-merge (so the UI can render a + kind/fandom-mismatch warning rather than a hard error).""" + if source_id == target_id: + raise TagValidationError("Cannot merge a tag into itself") + source = await self.session.get(Tag, source_id) + target = await self.session.get(Tag, target_id) + if source is None or target is None: + raise TagValidationError("Tag not found") + + compatible = source.kind == target.kind and ( + (source.fandom_id or 0) == (target.fandom_id or 0) + ) + + # Mirrors _repoint_image_tags: links whose image already has target are + # dropped (already_on_target); the rest move. + target_images = select(image_tag.c.image_record_id).where( + image_tag.c.tag_id == target_id + ) + moving = await self.session.scalar( + select(func.count()) + .select_from(image_tag) + .where( + image_tag.c.tag_id == source_id, + image_tag.c.image_record_id.notin_(target_images), + ) + ) + already = await self.session.scalar( + select(func.count()) + .select_from(image_tag) + .where( + image_tag.c.tag_id == source_id, + image_tag.c.image_record_id.in_(target_images), + ) + ) + + from ..models.series_page import SeriesPage + + series_pages = await self.session.scalar( + select(func.count()) + .select_from(SeriesPage) + .where(SeriesPage.series_tag_id == source_id) + ) + + will_alias = await self._keep_as_alias(source_id) + sample = await self._merge_sample_thumbnails(source_id, target_images) + + return MergePreview( + source_id=source_id, + source_name=source.name, + target_id=target_id, + target_name=target.name, + compatible=compatible, + images_moving=moving or 0, + images_already_on_target=already or 0, + source_total=(moving or 0) + (already or 0), + series_pages=series_pages or 0, + will_alias=will_alias, + sample_thumbnails=sample, + ) + + async def _merge_sample_thumbnails( + self, source_id: int, target_images + ) -> list[str]: + """Up to 6 thumbnails of the images that WOULD move (carry source, not + target) — a sanity check that the merge targets the right content.""" + from ..models import ImageRecord + from .gallery_service import thumbnail_url + + rows = ( + await self.session.execute( + select( + ImageRecord.thumbnail_path, + ImageRecord.sha256, + ImageRecord.mime, + ) + .join(image_tag, image_tag.c.image_record_id == ImageRecord.id) + .where( + image_tag.c.tag_id == source_id, + image_tag.c.image_record_id.notin_(target_images), + ) + .limit(6) + ) + ).all() + return [thumbnail_url(tp, sha, mime) for tp, sha, mime in rows] + async def merge(self, source_id: int, target_id: int) -> MergeResult: """Transactionally repoint every FK from source→target, optionally keep source's name as a tagger alias, delete source. Atomic: any diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 5071661..0ad963a 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -270,6 +270,41 @@ async def test_tag_merge_succeeds_for_same_kind(client, db): assert body["result"]["target_id"] == dest_id +@pytest.mark.asyncio +async def test_tag_merge_dry_run_previews_without_mutating(client, db): + from backend.app.models import ImageRecord + from backend.app.models.tag import image_tag + + src = Tag(name="dry-src", kind=TagKind.general) + dest = Tag(name="dry-dest", kind=TagKind.general) + db.add_all([src, dest]) + await db.flush() + img = ImageRecord( + path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1, + mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + db.add(img) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=src.id, source="manual", + )) + src_id, dest_id = src.id, dest.id + await db.commit() + + resp = await client.post( + f"/api/admin/tags/{dest_id}/merge", + json={"source_id": src_id, "dry_run": True}, + ) + assert resp.status_code == 200 + p = (await resp.get_json())["preview"] + assert p["compatible"] is True + assert p["images_moving"] == 1 + assert p["source_total"] == 1 + # No mutation: source still exists. + assert await db.get(Tag, src_id) is not None + + @pytest.mark.asyncio async def test_tag_merge_rejects_self_merge(client, db): t = Tag(name="x", kind=TagKind.general) diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index 03a7fce..d99ec44 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -56,6 +56,48 @@ async def test_merge_conflict_is_a_tag_validation_error(db): assert issubclass(TagMergeConflict, TagValidationError) +@pytest.mark.asyncio +async def test_merge_preview_matches_apply(db): + # #8 rule-93 parity: the preview's moving-count is exactly what the apply + # moves, and already-on-target links are the ones dropped. + svc = TagService(db) + target = await svc.find_or_create("MTarget", TagKind.general) + source = await svc.find_or_create("MSource", TagKind.general) + a, b, c = await _img(db), await _img(db), await _img(db) + for img in (a, b, c): + await svc.add_to_image(img, source.id, source="manual") + await svc.add_to_image(a, target.id, source="manual") # a already on target + + preview = await svc.merge_preview(source.id, target.id) + assert preview.compatible is True + assert preview.images_moving == 2 # b, c + assert preview.images_already_on_target == 1 # a + assert preview.source_total == 3 + assert preview.will_alias is False # purely manual + + result = await svc.merge(source.id, target.id) + assert result.merged_count == preview.images_moving # parity + + +@pytest.mark.asyncio +async def test_merge_preview_flags_incompatible_without_raising(db): + svc = TagService(db) + target = await svc.find_or_create("CharT", TagKind.character) + source = await svc.find_or_create("GenS", TagKind.general) + preview = await svc.merge_preview(source.id, target.id) + assert preview.compatible is False # kind mismatch surfaced, not raised + + +@pytest.mark.asyncio +async def test_merge_preview_self_and_missing_raise(db): + svc = TagService(db) + t = await svc.find_or_create("Solo", TagKind.general) + with pytest.raises(TagValidationError): + await svc.merge_preview(t.id, t.id) + with pytest.raises(TagValidationError): + await svc.merge_preview(t.id, 999999) + + @pytest.mark.asyncio async def test_will_alias_true_when_machine_sourced(db): svc = TagService(db) -- 2.52.0 From e49cea3eba7033777e1075451b152d2b507db011 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:41:09 -0400 Subject: [PATCH 5/8] feat(tagging): allowlist tuning dashboard + post-accept toast + merge preview UI (#7c/#7d/#8b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — ': — 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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- .../components/settings/AllowlistTable.vue | 95 +++++++++++++++---- frontend/src/stores/admin.js | 5 +- frontend/src/stores/allowlist.js | 18 +++- frontend/src/stores/suggestions.js | 30 ++++-- frontend/src/views/TagsView.vue | 95 +++++++++++++++++-- 5 files changed, 204 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/settings/AllowlistTable.vue b/frontend/src/components/settings/AllowlistTable.vue index b33b2a3..a198424 100644 --- a/frontend/src/components/settings/AllowlistTable.vue +++ b/frontend/src/components/settings/AllowlistTable.vue @@ -2,33 +2,60 @@ - + + diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 86cd059..496398b 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -65,10 +65,11 @@ export const useAdminStore = defineStore('admin', () => { return _guard(() => api.delete(`/api/admin/tags/${tagId}`)) } - function mergeTags(destId, sourceId) { + // dryRun → {preview: {...}} (non-mutating counts + sample); else {result}. + function mergeTags(destId, sourceId, { dryRun = false } = {}) { return _guard(() => api.post( `/api/admin/tags/${destId}/merge`, - { body: { source_id: sourceId } }, + { body: { source_id: sourceId, dry_run: dryRun } }, )) } diff --git a/frontend/src/stores/allowlist.js b/frontend/src/stores/allowlist.js index 78db284..5d730f1 100644 --- a/frontend/src/stores/allowlist.js +++ b/frontend/src/stores/allowlist.js @@ -18,7 +18,21 @@ export const useAllowlistStore = defineStore('allowlist', () => { body: { min_confidence: minConfidence } }) const r = rows.value.find(x => x.tag_id === tagId) - if (r) r.min_confidence = minConfidence + if (r) { + r.min_confidence = minConfidence + // The committed threshold changed the covered pool — refresh the row's + // coverage so the table stays truthful after a save. + try { r.coverage_count = (await coverage(tagId, minConfidence)).count } + catch { /* leave the stale count rather than blank it */ } + } + } + + // Live "at threshold T, a sweep would cover ~N images" projection for the + // tuning dashboard. Returns { count, threshold }. + async function coverage(tagId, threshold) { + return api.get(`/api/tags/${tagId}/allowlist/coverage`, { + params: { threshold } + }) } async function remove(tagId) { @@ -26,5 +40,5 @@ export const useAllowlistStore = defineStore('allowlist', () => { rows.value = rows.value.filter(x => x.tag_id !== tagId) } - return { rows, loading, load, updateThreshold, remove } + return { rows, loading, load, updateThreshold, coverage, remove } }) diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 344b1f5..36c926a 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -100,7 +100,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { }) tagId = created.id } - await api.post(`/api/images/${imageId}/suggestions/accept`, { + const res = await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) // Only drop from THIS image's category list — if the user navigated, @@ -108,10 +108,23 @@ export const useSuggestionsStore = defineStore('suggestions', () => { if (currentImageId === imageId) { _dropEverywhere(suggestion) } - toast({ - text: `Tagged: ${suggestion.display_name}`, - type: 'success' - }) + _acceptToast('Tagged', suggestion.display_name, res) + } + + // One non-blocking toast for accept/alias. When the accept newly allowlisted + // the tag, surface the coverage PROJECTION (#7) so the operator sees the + // auto-apply reach without a blocking pre-accept preview — the apply itself + // runs async, hence "~N". + function _acceptToast(verb, displayName, res) { + if (res?.allowlisted) { + const n = res.projected_count + toast({ + text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`, + type: 'success' + }) + } else { + toast({ text: `${verb}: ${displayName}`, type: 'success' }) + } } async function aliasAccept(suggestion, canonicalTagId) { @@ -123,7 +136,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { // reappearing unaliased. raw_name is null only for centroid hits, which // can't be aliased (the UI hides the action for them). const aliasString = suggestion.raw_name ?? suggestion.display_name - await api.post(`/api/images/${imageId}/suggestions/alias`, { + const res = await api.post(`/api/images/${imageId}/suggestions/alias`, { body: { alias_string: aliasString, alias_category: suggestion.category, @@ -133,10 +146,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { if (currentImageId === imageId) { _dropEverywhere(suggestion) } - toast({ - text: `Aliased & tagged: ${suggestion.display_name}`, - type: 'success' - }) + _acceptToast('Aliased & tagged', suggestion.display_name, res) } // Remove the alias behind an aliased suggestion (the raw prediction reverts to diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue index 1bbae8b..387d9dc 100644 --- a/frontend/src/views/TagsView.vue +++ b/frontend/src/views/TagsView.vue @@ -45,10 +45,9 @@ Merge “{{ mergeSource?.name }}” into… -

- Pick the target tag. Source tag will be deleted; all its - image associations will move to the target. Must be same - kind ({{ mergeSource?.kind }}). +

+ Pick the target tag. Source tag's image associations move to the + target. Must be same kind ({{ mergeSource?.kind }}).

+ + {{ adminStore.lastError }} + + +
+
+ + Checking what would move… +
+
+

+ Moves {{ mergePreview.images_moving }} + image(s) onto {{ mergePreview.target_name }}. + + ({{ mergePreview.images_already_on_target }} already on it.) + +

+

+ {{ mergePreview.series_pages }} series page(s) repointed. +

+

+ + +

+ Different kind or fandom — this merge would be rejected. +
+ +
+
+
Cancel Merge + >Merge{{ mergePreview ? ` ${mergePreview.images_moving} image(s)` : '' }}
@@ -114,6 +157,7 @@ import { useAdminStore } from '../stores/admin.js' import { useApi } from '../composables/useApi.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js' +import { usePreviewCommit } from '../composables/usePreviewCommit.js' import TagCard from '../components/discovery/TagCard.vue' import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue' @@ -207,10 +251,32 @@ const mergeHits = ref([]) const mergeLoading = ref(false) let mergeDebounce = null +// #8: dry-run preview (counts + sample of what moves) before the irreversible +// merge — same usePreviewCommit primitive the maintenance tiles use. +const { + previewData: mergePreview, previewing: mergePreviewing, committing: merging, + runPreview: runMergePreview, runCommit: runMergeCommit, +} = usePreviewCommit({ + preview: async () => + (await adminStore.mergeTags( + mergeTargetId.value, mergeSource.value.id, { dryRun: true }, + )).preview, + commit: () => adminStore.mergeTags( + mergeTargetId.value, mergeSource.value.id, { dryRun: false }, + ), +}) + +// Re-preview whenever the chosen target changes (and clear it when unset). +watch(mergeTargetId, (id) => { + if (id && mergeSource.value) runMergePreview() + else mergePreview.value = null +}) + function onMergeWith(card) { mergeSource.value = card mergeTargetId.value = null mergeHits.value = [] + mergePreview.value = null mergePickerOpen.value = true } @@ -234,8 +300,9 @@ function onMergeSearch(q) { async function onMergeConfirm() { if (!mergeSource.value || !mergeTargetId.value) return + if (!mergePreview.value || !mergePreview.value.compatible) return try { - await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id) + await runMergeCommit() mergePickerOpen.value = false store.reset() } catch { @@ -282,4 +349,18 @@ async function onDeleteTagConfirm() { .fc-tags__sentinel { display: flex; justify-content: center; padding: 32px 0; min-height: 60px; } +.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } +.fc-merge-preview { + padding: 10px 12px; + border: 1px solid rgba(var(--v-theme-on-surface), 0.12); + border-radius: 10px; + background: rgba(var(--v-theme-on-surface), 0.03); +} +.fc-merge-thumbs { + display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; +} +.fc-merge-thumbs img { + width: 56px; height: 56px; object-fit: cover; border-radius: 6px; + background: rgb(var(--v-theme-surface-light)); +} -- 2.52.0 From 0cd2f391ee2dbbd4a2af5cef7444ec20bf23533a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:42:21 -0400 Subject: [PATCH 6/8] test(allowlist): unique image paths in coverage tests (CI fix) 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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- tests/test_api_suggestions.py | 2 +- tests/test_ml_allowlist.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_api_suggestions.py b/tests/test_api_suggestions.py index 36c0f89..2b1b425 100644 --- a/tests/test_api_suggestions.py +++ b/tests/test_api_suggestions.py @@ -18,7 +18,7 @@ async def _img(db, preds, sha="s" * 64): from tests._prediction_helpers import seed_predictions img = ImageRecord( - path=f"/images/{sha[:8]}.jpg", sha256=sha, size_bytes=1, + path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", ) diff --git a/tests/test_ml_allowlist.py b/tests/test_ml_allowlist.py index 3d853b1..198d171 100644 --- a/tests/test_ml_allowlist.py +++ b/tests/test_ml_allowlist.py @@ -18,7 +18,9 @@ pytestmark = pytest.mark.integration async def _make_image(db, sha: str = "x" * 64): from backend.app.models import ImageRecord img = ImageRecord( - path=f"/images/{sha[:8]}.jpg", sha256=sha, size_bytes=1, + # Full sha in the path — the first 8 chars collide for sequential + # shas like c{i:063d}, and path is UNIQUE (uq_image_record_path). + path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", ) -- 2.52.0 From 0ecd1ce4f1a1d92e03687cc32a1f25e63ea645da Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 02:02:28 -0400 Subject: [PATCH 7/8] feat(explore): cluster-consensus tag-gaps service + route (#94a) 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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- backend/app/api/tags.py | 21 ++++++ backend/app/services/bulk_tag_service.py | 81 ++++++++++++++++++++++++ tests/test_api_bulk_tags.py | 39 ++++++++++++ tests/test_bulk_tag_service.py | 56 ++++++++++++++++ 4 files changed, 197 insertions(+) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 0556ed9..31aee4e 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -320,6 +320,27 @@ async def common_tags(): return jsonify({"tags": tags}) +@tags_bp.route("/images/cluster/tag-gaps", methods=["POST"]) +async def cluster_tag_gaps(): + """Cluster-consensus tag gaps for a visual neighbour set (#94 Explore): + tags on >= threshold of the images but not all. Each gap carries the + laggard image ids (minus rejections) for apply-to-cluster.""" + body = await request.get_json() + ids, err = _parse_bulk_ids(body) + if err: + return err + try: + threshold = float(body.get("threshold", 0.6)) + except (TypeError, ValueError): + threshold = 0.6 + threshold = min(1.0, max(0.0, threshold)) + async with get_session() as session: + gaps = await BulkTagService(session).tag_gaps(ids, threshold) + return jsonify( + {"gaps": gaps, "total": len(set(ids)), "threshold": threshold} + ) + + @tags_bp.route("/images/bulk/tags", methods=["POST"]) async def bulk_add_tag(): body = await request.get_json() diff --git a/backend/app/services/bulk_tag_service.py b/backend/app/services/bulk_tag_service.py index b573ef9..57d673c 100644 --- a/backend/app/services/bulk_tag_service.py +++ b/backend/app/services/bulk_tag_service.py @@ -1,5 +1,7 @@ """Bulk tag operations over a set of images (Core, set-based, atomic).""" +import math + from sqlalchemy import and_, func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -13,6 +15,85 @@ class BulkTagService: def __init__(self, session: AsyncSession): self.session = session + async def tag_gaps( + self, image_ids: list[int], threshold: float + ) -> list[dict]: + """Cluster-consensus tag gaps (#94 Explore): tags applied to at least + `threshold` fraction of the image set but NOT all of it — the + "7 of these 10 share Hatsune Miku; these 3 don't" signal. + + For each gap, `missing_image_ids` are the set members that LACK the tag + and have NOT rejected it (TagSuggestionRejection) — so apply-to-cluster + never re-proposes a tag a neighbor explicitly dismissed. A tag on every + image is "common", not a gap; a tag on fewer than 2 isn't consensus. + """ + ids = list({int(i) for i in image_ids}) + if len(ids) < 2: + return [] + n = len(ids) + min_present = max(2, math.ceil(threshold * n)) + if min_present >= n: + return [] # threshold so high only a 100%-common tag could qualify + + cnt = func.count(func.distinct(image_tag.c.image_record_id)) + gap_rows = ( + await self.session.execute( + select(Tag.id, Tag.name, Tag.kind) + .join(image_tag, image_tag.c.tag_id == Tag.id) + .where(image_tag.c.image_record_id.in_(ids)) + .group_by(Tag.id, Tag.name, Tag.kind) + .having(and_(cnt >= min_present, cnt < n)) + .order_by(cnt.desc(), Tag.name.asc()) + ) + ).all() + if not gap_rows: + return [] + gap_ids = [r.id for r in gap_rows] + + present: dict[int, set[int]] = {} + for tid, iid in ( + await self.session.execute( + select(image_tag.c.tag_id, image_tag.c.image_record_id).where( + image_tag.c.tag_id.in_(gap_ids), + image_tag.c.image_record_id.in_(ids), + ) + ) + ).all(): + present.setdefault(tid, set()).add(iid) + + rejected: dict[int, set[int]] = {} + for tid, iid in ( + await self.session.execute( + select( + TagSuggestionRejection.tag_id, + TagSuggestionRejection.image_record_id, + ).where( + TagSuggestionRejection.tag_id.in_(gap_ids), + TagSuggestionRejection.image_record_id.in_(ids), + ) + ) + ).all(): + rejected.setdefault(tid, set()).add(iid) + + id_set = set(ids) + gaps = [] + for r in gap_rows: + have = present.get(r.id, set()) + missing = id_set - have - rejected.get(r.id, set()) + if not missing: + continue # every laggard rejected it — nothing to apply + gaps.append( + { + "tag_id": r.id, + "name": r.name, + "kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind), + "present_count": len(have), + "total": n, + "missing_image_ids": sorted(missing), + } + ) + return gaps + async def common_tags(self, image_ids: list[int]) -> list[dict]: """Tags present on EVERY image in image_ids.""" if not image_ids: diff --git a/tests/test_api_bulk_tags.py b/tests/test_api_bulk_tags.py index 5c3c6c7..7f6e0db 100644 --- a/tests/test_api_bulk_tags.py +++ b/tests/test_api_bulk_tags.py @@ -87,3 +87,42 @@ async def test_bulk_remove_route_requires_ids(client): "/api/images/bulk/tags/remove", json={"tag_id": 1} ) assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_cluster_tag_gaps_requires_list(client): + resp = await client.post("/api/images/cluster/tag-gaps", json={}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_cluster_tag_gaps_route_reports_gap(client, db): + from backend.app.models import ImageRecord, TagKind + from backend.app.services.tag_service import TagService + + tags = TagService(db) + gap = await tags.find_or_create("GapRoute", TagKind.general) + imgs = [] + for i in range(4): + rec = ImageRecord( + path=f"/tmp/gaproute_{i}.png", sha256=f"g{i:063d}", + size_bytes=1, mime="image/png", origin="uploaded", + ) + db.add(rec) + await db.flush() + imgs.append(rec.id) + for i in imgs[:3]: + await tags.add_to_image(i, gap.id) # on 3/4 → a gap at 0.6 + await db.commit() + + resp = await client.post( + "/api/images/cluster/tag-gaps", + json={"image_ids": imgs, "threshold": 0.6}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["total"] == 4 + assert body["threshold"] == 0.6 + g = next(x for x in body["gaps"] if x["tag_id"] == gap.id) + assert g["present_count"] == 3 + assert g["missing_image_ids"] == [imgs[3]] diff --git a/tests/test_bulk_tag_service.py b/tests/test_bulk_tag_service.py index cb8d1c5..4cd9024 100644 --- a/tests/test_bulk_tag_service.py +++ b/tests/test_bulk_tag_service.py @@ -120,3 +120,59 @@ async def test_bulk_remove_rejection_is_idempotent(db): # Second remove: nothing to delete, rejection already present, no error. removed = await svc.bulk_remove([i1], tag.id) assert removed == 0 + + +@pytest.mark.asyncio +async def test_tag_gaps_finds_consensus_excludes_common(db): + tags = TagService(db) + gap = await tags.find_or_create("Miku", TagKind.character) + common = await tags.find_or_create("Solo", TagKind.general) + imgs = [await _img(db) for _ in range(5)] + for i in imgs: + await tags.add_to_image(i, common.id) # on all 5 → not a gap + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/5 → a gap + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + assert [g["tag_id"] for g in gaps] == [gap.id] # the common tag is excluded + g = gaps[0] + assert g["present_count"] == 4 + assert g["total"] == 5 + assert g["missing_image_ids"] == [imgs[4]] + + +@pytest.mark.asyncio +async def test_tag_gaps_excludes_rejected_laggard(db): + tags = TagService(db) + gap = await tags.find_or_create("Rin", TagKind.character) + imgs = [await _img(db) for _ in range(6)] + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/6 (min_present=ceil(3.6)=4) + # imgs[4], imgs[5] lack it; imgs[4] explicitly rejected it. + db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) + await db.flush() + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + g = next(x for x in gaps if x["tag_id"] == gap.id) + assert g["missing_image_ids"] == [imgs[5]] # rejected laggard excluded + + +@pytest.mark.asyncio +async def test_tag_gaps_drops_tag_when_all_laggards_rejected(db): + tags = TagService(db) + gap = await tags.find_or_create("Len", TagKind.character) + imgs = [await _img(db) for _ in range(5)] + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/5, sole laggard imgs[4] + db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) + await db.flush() + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + assert all(g["tag_id"] != gap.id for g in gaps) # nothing to apply → dropped + + +@pytest.mark.asyncio +async def test_tag_gaps_needs_at_least_two_images(db): + svc = BulkTagService(db) + assert await svc.tag_gaps([], 0.6) == [] + assert await svc.tag_gaps([await _img(db)], 0.6) == [] -- 2.52.0 From 7b712920a4eef56b061e330a9e1fe650847c7b4f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 02:08:34 -0400 Subject: [PATCH 8/8] =?UTF-8?q?feat(explore):=20Explore=20view=20+=20tag-g?= =?UTF-8?q?ap=20closing=20+=20modal=20meta/download=20(#94b=E2=80=93d,=20#?= =?UTF-8?q?4a/b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- .../src/components/explore/TagGapPanel.vue | 159 ++++++++++++++ .../src/components/modal/ImageMetaBar.vue | 96 +++++++++ frontend/src/components/modal/ImageViewer.vue | 2 + .../src/components/modal/RelatedStrip.vue | 26 ++- frontend/src/router.js | 5 + frontend/src/stores/explore.js | 92 ++++++++ frontend/src/views/ExploreView.vue | 202 ++++++++++++++++++ 7 files changed, 577 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/explore/TagGapPanel.vue create mode 100644 frontend/src/components/modal/ImageMetaBar.vue create mode 100644 frontend/src/stores/explore.js create mode 100644 frontend/src/views/ExploreView.vue diff --git a/frontend/src/components/explore/TagGapPanel.vue b/frontend/src/components/explore/TagGapPanel.vue new file mode 100644 index 0000000..f44546c --- /dev/null +++ b/frontend/src/components/explore/TagGapPanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/components/modal/ImageMetaBar.vue b/frontend/src/components/modal/ImageMetaBar.vue new file mode 100644 index 0000000..b9629ad --- /dev/null +++ b/frontend/src/components/modal/ImageMetaBar.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index b766665..7831206 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -73,6 +73,7 @@