Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
This commit was merged in pull request #134.
This commit is contained in:
@@ -320,27 +320,6 @@ 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()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""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
|
||||
@@ -15,85 +13,6 @@ 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:
|
||||
|
||||
@@ -97,9 +97,19 @@ import { useProvenanceStore } from '../../stores/provenance.js'
|
||||
import { formatPostDate } from '../../utils/date.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
|
||||
// `imageId`/`image` let a non-modal surface (the Explore workspace) render
|
||||
// provenance for its anchor. Default to the modal store's current image so the
|
||||
// image modal is unchanged. Provenance is its own system (loaded by id via the
|
||||
// provenance store), so it only needs the right id + the artist fallback.
|
||||
const props = defineProps({
|
||||
imageId: { type: Number, default: null },
|
||||
image: { type: Object, default: null },
|
||||
})
|
||||
const modal = useModalStore()
|
||||
const prov = useProvenanceStore()
|
||||
const router = useRouter()
|
||||
const effectiveId = computed(() => props.imageId ?? modal.currentImageId)
|
||||
const effectiveImage = computed(() => props.image ?? modal.current)
|
||||
|
||||
// Per-post description collapse state (keyed by provenance_id). Default
|
||||
// collapsed so multiple posts don't each eat ~180px of the panel — the
|
||||
@@ -107,21 +117,21 @@ const router = useRouter()
|
||||
// 2026-05-28. Reset when the viewed image changes.
|
||||
const expanded = reactive({})
|
||||
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
||||
watch(() => modal.currentImageId, () => {
|
||||
watch(() => effectiveId.value, () => {
|
||||
for (const k of Object.keys(expanded)) delete expanded[k]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => modal.currentImageId,
|
||||
() => effectiveId.value,
|
||||
(id) => { if (id != null) prov.loadForImage(id) },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const state = computed(() =>
|
||||
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
|
||||
effectiveId.value == null ? null : prov.imageProv(effectiveId.value)
|
||||
)
|
||||
|
||||
const fallbackArtist = computed(() => modal.current?.artist || null)
|
||||
const fallbackArtist = computed(() => effectiveImage.value?.artist || null)
|
||||
|
||||
const showArtistFallback = computed(() => {
|
||||
const st = state.value
|
||||
|
||||
@@ -30,7 +30,15 @@
|
||||
@accepted="focusTagInput"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="renameDialog" max-width="420">
|
||||
<!-- @after-leave: when either dialog finishes closing (apply OR cancel),
|
||||
hand focus back to the tag input. Fires after Vuetify's own
|
||||
focus-return to the activator, so ours wins; keeps the keyboard flow on
|
||||
the input (operator-asked 2026-06-26). Mobile guard preserved via
|
||||
focusTagInput → TagAutocomplete. -->
|
||||
<v-dialog
|
||||
v-model="renameDialog" max-width="420"
|
||||
@after-leave="focusTagInput"
|
||||
>
|
||||
<TagRenameDialog
|
||||
v-if="renameTarget"
|
||||
:tag="renameTarget"
|
||||
@@ -41,6 +49,7 @@
|
||||
<v-dialog
|
||||
v-model="fandomDialog" max-width="460"
|
||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||
@after-leave="focusTagInput"
|
||||
>
|
||||
<FandomSetDialog
|
||||
ref="fandomSetRef"
|
||||
@@ -92,19 +101,22 @@ async function onNavigate(tag) {
|
||||
function focusTagInput() { tagInputRef.value?.focus?.() }
|
||||
defineExpose({ focusTagInput })
|
||||
|
||||
// Every tag mutation hands focus back to the input so the operator can keep
|
||||
// typing the next tag without re-clicking — matches the accept-suggestion flow
|
||||
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
|
||||
async function onRemove(tagId) {
|
||||
errorMsg.value = null
|
||||
try { await host.removeTag(tagId) }
|
||||
try { await host.removeTag(tagId); focusTagInput() }
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
async function onPickExisting(hit) {
|
||||
errorMsg.value = null
|
||||
try { await host.addExistingTag(hit.id) }
|
||||
try { await host.addExistingTag(hit.id); focusTagInput() }
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
async function onPickNew(payload) {
|
||||
errorMsg.value = null
|
||||
try { await host.createAndAdd(payload) }
|
||||
try { await host.createAndAdd(payload); focusTagInput() }
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||
|
||||
@@ -96,12 +96,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: the modal's tag rail, hosted on the anchor. -->
|
||||
<aside class="fc-ex__rail" aria-label="Tags for the focused image">
|
||||
<TagPanel
|
||||
v-if="store.currentImageId != null"
|
||||
ref="tagPanelRef" :host="store"
|
||||
/>
|
||||
<!-- RIGHT: provenance (post/source — often names the character) above
|
||||
the modal's tag rail, both hosted on the anchor. -->
|
||||
<aside class="fc-ex__rail" aria-label="Provenance and tags for the focused image">
|
||||
<template v-if="store.currentImageId != null">
|
||||
<ProvenancePanel :image-id="store.currentImageId" :image="store.anchor" />
|
||||
<TagPanel ref="tagPanelRef" :host="store" />
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -117,6 +118,7 @@ import { useModalStore } from '../stores/modal.js'
|
||||
import { isTextEntry } from '../utils/textEntry.js'
|
||||
import ImageCanvas from '../components/modal/ImageCanvas.vue'
|
||||
import ImageMetaBar from '../components/modal/ImageMetaBar.vue'
|
||||
import ProvenancePanel from '../components/modal/ProvenancePanel.vue'
|
||||
import TagPanel from '../components/modal/TagPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -130,14 +132,18 @@ const seeding = ref(false)
|
||||
const seedError = ref(null)
|
||||
const tagPanelRef = ref(null)
|
||||
|
||||
// Auto-focus the tag input whenever the focused image changes (seed + every
|
||||
// walk) so tagging needs no extra click — the whole point of the workspace
|
||||
// (operator-asked 2026-06-26). Reuses TagPanel/TagAutocomplete's focus, which
|
||||
// keeps its own mobile guard (no soft-keyboard pop on touch).
|
||||
watch(
|
||||
() => store.currentImageId,
|
||||
(id) => { if (id != null) nextTick(() => tagPanelRef.value?.focusTagInput?.()) },
|
||||
)
|
||||
// Auto-focus the tag input after any action so tagging needs no extra click —
|
||||
// the whole point of the workspace (operator-asked 2026-06-26). nextTick waits
|
||||
// for the post-navigation re-render, then rAF lands the focus AFTER paint so a
|
||||
// neighbour/breadcrumb click that re-renders the grid can't steal it back.
|
||||
// Reuses TagPanel/TagAutocomplete's focus, which keeps its mobile guard (no
|
||||
// soft-keyboard pop on touch).
|
||||
function refocusTag () {
|
||||
nextTick(() => requestAnimationFrame(() => tagPanelRef.value?.focusTagInput?.()))
|
||||
}
|
||||
// Every focused-image change (seed + every walk: neighbour click, breadcrumb,
|
||||
// Random image).
|
||||
watch(() => store.currentImageId, (id) => { if (id != null) refocusTag() })
|
||||
|
||||
// The route is the source of truth. With an anchor, walk it; without one (the
|
||||
// bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
|
||||
|
||||
@@ -87,42 +87,3 @@ 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]]
|
||||
|
||||
@@ -120,59 +120,3 @@ 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) == []
|
||||
|
||||
Reference in New Issue
Block a user