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
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:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user