Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s

This commit was merged in pull request #137.
This commit is contained in:
2026-06-27 23:49:12 -04:00
2 changed files with 104 additions and 20 deletions
@@ -86,22 +86,41 @@
</div>
<div v-if="c.examples" class="fc-ex">
<div class="fc-ex__row">
<div class="fc-muted text-caption mb-1">Head would suggest (untagged, high score)</div>
<div
v-for="grp in [
{ dir: 'suggest', items: c.examples.head_would_suggest,
label: `Head would suggest — ✓ tag it, ✗ not ${c.name}` },
{ dir: 'doubts', items: c.examples.head_doubts_positive,
label: `Head doubts your tag — ✓ keep, ✗ remove (not ${c.name})` },
]" :key="grp.dir" class="fc-ex__row"
>
<div class="fc-muted text-caption mb-1">{{ grp.label }}</div>
<div class="fc-ex__thumbs">
<img
v-for="it in c.examples.head_would_suggest" :key="`s${it.id}`"
:src="it.thumbnail_url" class="fc-ex__thumb" loading="lazy"
/>
</div>
</div>
<div class="fc-ex__row">
<div class="fc-muted text-caption mb-1">Head doubts these (your positives, low score)</div>
<div class="fc-ex__thumbs">
<img
v-for="it in c.examples.head_doubts_positive" :key="`d${it.id}`"
:src="it.thumbnail_url" class="fc-ex__thumb" loading="lazy"
/>
<div
v-for="it in grp.items" :key="`${grp.dir}${it.id}`"
class="fc-ex__item"
:class="actedLabel(c, grp.dir, it) ? 'fc-ex__item--acted' : ''"
>
<a
class="fc-ex__thumb" :href="`/explore/${it.id}`" target="_blank"
rel="noopener" :title="`#${it.id} — open in Explore`"
>
<img :src="it.thumbnail_url" loading="lazy" />
</a>
<div v-if="actedLabel(c, grp.dir, it)" class="fc-ex__badge">
{{ actedLabel(c, grp.dir, it) }}
</div>
<div v-else class="fc-ex__acts">
<button
class="fc-act fc-act--yes" type="button"
:title="`Yes — it is ${c.name}`" @click="act(c, it, grp.dir, 'yes')"
><v-icon size="15">mdi-check</v-icon></button>
<button
class="fc-act fc-act--no" type="button"
:title="`No — not ${c.name}`" @click="act(c, it, grp.dir, 'no')"
><v-icon size="15">mdi-close</v-icon></button>
</div>
</div>
</div>
</div>
</div>
@@ -183,6 +202,28 @@ function formatTime(iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// Acting on an example writes the SAME tables the head trains on, so a re-run
// reflects the correction. Keyed per (concept, list, image); the report ids are
// frozen at run time, so we just grey out what's been handled in this view.
const acted = ref({})
const actedKey = (c, dir, it) => `${c.tag_id}:${dir}:${it.id}`
const actedLabel = (c, dir, it) => acted.value[actedKey(c, dir, it)] || ''
async function act(c, it, dir, verdict) {
const key = actedKey(c, dir, it)
let call, label
if (dir === 'suggest' && verdict === 'yes') { call = store.applyTag(it.id, c.tag_id); label = 'tagged' }
else if (dir === 'suggest' && verdict === 'no') { call = store.rejectTag(it.id, c.tag_id); label = 'rejected' }
else if (dir === 'doubts' && verdict === 'no') { call = store.removeTag(it.id, c.tag_id); label = 'removed' }
else { acted.value[key] = 'kept'; return } // doubt + yes = keep, no write
try {
await call
acted.value[key] = label
} catch (e) {
toast({ text: `Action failed: ${e.message}`, type: 'error' })
}
}
</script>
<style scoped>
@@ -202,10 +243,29 @@ function formatTime(iso) {
.fc-down { color: rgb(var(--v-theme-error)); }
.fc-curve { margin-bottom: 8px; }
.fc-curve__pt { margin-left: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
.fc-ex__row { margin-top: 6px; }
.fc-ex__thumbs { display: flex; flex-wrap: wrap; gap: 4px; }
.fc-ex__row { margin-top: 8px; }
.fc-ex__thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
.fc-ex__item { position: relative; width: 120px; height: 120px; }
.fc-ex__item--acted { opacity: 0.45; }
.fc-ex__thumb {
width: 56px; height: 56px; object-fit: cover; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
display: block; width: 100%; height: 100%; border-radius: 6px;
overflow: hidden; background: rgb(var(--v-theme-surface-light));
outline: 1px solid transparent; transition: outline-color 0.12s;
}
.fc-ex__thumb:hover { outline-color: rgb(var(--v-theme-accent)); }
.fc-ex__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-ex__acts { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
.fc-act {
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff;
opacity: 0.9; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); transition: transform 0.1s;
}
.fc-act:hover { opacity: 1; transform: scale(1.1); }
.fc-act--yes { background: rgb(var(--v-theme-success)); }
.fc-act--no { background: rgb(var(--v-theme-error)); }
.fc-ex__badge {
position: absolute; bottom: 4px; left: 4px; right: 4px; text-align: center;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.65); color: #fff; border-radius: 3px; padding: 1px 0;
}
</style>
+25 -1
View File
@@ -23,5 +23,29 @@ export const useTagEvalStore = defineStore('tagEval', () => {
return (body.runs && body.runs[0]) || null
}
return { start, getRun, latest }
// --- Acting on the head's example lists (closes the learn-from-tags loop).
// These write the SAME tables the head trains on: image_tag (positives) and
// tag_suggestion_rejection (negatives, via the dismiss endpoint).
// "Yes, it is this" — apply the tag (new positive).
async function applyTag(imageId, tagId) {
return await api.post(`/api/images/${imageId}/tags`,
{ body: { tag_id: tagId, source: 'manual' } })
}
// "No, it's not" on an UNtagged suggestion — record a rejection (hard negative).
async function rejectTag(imageId, tagId) {
return await api.post(`/api/images/${imageId}/suggestions/dismiss`,
{ body: { tag_id: tagId } })
}
// "Not it" on one of YOUR positives the head doubts — remove the tag AND
// record the rejection (kills the bad positive, leaves a hard negative).
async function removeTag(imageId, tagId) {
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
return await api.post(`/api/images/${imageId}/suggestions/dismiss`,
{ body: { tag_id: tagId } })
}
return { start, getRun, latest, applyTag, rejectTag, removeTag }
})