From 6cd7281af5a1621810ce6cb383e601a10e4ecc9f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 27 Jun 2026 22:56:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(settings):=20tag-eval=20admin=20card=20?= =?UTF-8?q?=E2=80=94=20trigger=20+=20persisted=20report=20(survives=20nav)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for #1130. A maintenance tile in Settings → Tagging: - Editable concept list + "Run eval" → POST /api/tag-eval (one running at a time). - Rehydrates on mount via the persisted run (getRun by latest id) and polls while running — so the report SURVIVES navigation (operator-flagged); the task runs backend-side regardless and the card reconnects to its row. - Renders the saved report: per-concept head-vs-centroid metrics table (AP/F1/ precision/recall) with Δ AP, the learning curve (AP @ N positives), and thumbnail galleries (head-would-suggest / head-doubts-positive) for eyeballing. Backend: _examples now stores thumbnail_urls (not just ids) so the report is a self-contained artifact that renders without per-id lookups on reload. No new top-level surface — slots into the existing maintenance area. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/ml/tag_eval.py | 33 ++- .../components/settings/MaintenancePanel.vue | 2 + .../src/components/settings/TagEvalCard.vue | 211 ++++++++++++++++++ frontend/src/stores/tagEval.js | 27 +++ 4 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/settings/TagEvalCard.vue create mode 100644 frontend/src/stores/tagEval.js diff --git a/backend/app/services/ml/tag_eval.py b/backend/app/services/ml/tag_eval.py index 132d3f1..2a1cbb5 100644 --- a/backend/app/services/ml/tag_eval.py +++ b/backend/app/services/ml/tag_eval.py @@ -201,7 +201,7 @@ def _eval_concept(session: Session, name: str, cfg: dict, np) -> dict[str, Any]: head = _eval_head(Xn, y, cfg["cv_folds"], np) centroid = _eval_centroid(Xn, y, cfg["cv_folds"], np) curve = _learning_curve(Xn, y, cfg["curve_points"], neg_ratio, np) - examples = _examples(Xn, y, ids, np) + examples = _examples(session, Xn, y, ids, np) return { "name": name, "tag_id": tag_id, @@ -296,11 +296,13 @@ def _learning_curve(Xn, y, points, neg_ratio, np) -> list[dict[str, float]]: return out -def _examples(Xn, y, ids, np) -> dict[str, list[int]]: +def _examples(session, Xn, y, ids, np) -> dict[str, list[dict]]: """Train on all data, then surface: top-scoring UNLABELED-ish (highest among the negative pool = what the head would newly suggest) and lowest-scoring POSITIVES (where the head disagrees with the operator's tag — likely the - most informative to review).""" + most informative to review). Resolves thumbnail urls so the stored report + renders without per-id lookups (survives navigation as a self-contained + artifact).""" from sklearn.linear_model import LogisticRegression clf = LogisticRegression(max_iter=1000, class_weight="balanced") @@ -308,9 +310,26 @@ def _examples(Xn, y, ids, np) -> dict[str, list[int]]: s = clf.predict_proba(Xn)[:, 1] neg_idx = np.where(y == 0)[0] pos_idx = np.where(y == 1)[0] - top_neg = neg_idx[np.argsort(s[neg_idx])[::-1][:_EXAMPLES_K]] - low_pos = pos_idx[np.argsort(s[pos_idx])[:_EXAMPLES_K]] + top_neg = [int(ids[i]) for i in neg_idx[np.argsort(s[neg_idx])[::-1][:_EXAMPLES_K]]] + low_pos = [int(ids[i]) for i in pos_idx[np.argsort(s[pos_idx])[:_EXAMPLES_K]]] + thumbs = _resolve_thumbs(session, top_neg + low_pos) return { - "head_would_suggest": [int(ids[i]) for i in top_neg], - "head_doubts_positive": [int(ids[i]) for i in low_pos], + "head_would_suggest": [thumbs[i] for i in top_neg if i in thumbs], + "head_doubts_positive": [thumbs[i] for i in low_pos if i in thumbs], } + + +def _resolve_thumbs(session, ids: list[int]) -> dict[int, dict]: + from ..gallery_service import thumbnail_url + + out: dict[int, dict] = {} + if not ids: + return out + for rid, tp, sha, mime in session.execute( + select( + ImageRecord.id, ImageRecord.thumbnail_path, + ImageRecord.sha256, ImageRecord.mime, + ).where(ImageRecord.id.in_(ids)) + ).all(): + out[rid] = {"id": rid, "thumbnail_url": thumbnail_url(tp, sha, mime)} + return out diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 48cfcf9..68c30d1 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -28,6 +28,7 @@ + @@ -53,6 +54,7 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue' import AllowlistTable from './AllowlistTable.vue' import AliasTable from './AliasTable.vue' +import TagEvalCard from './TagEvalCard.vue' import BackupCard from './BackupCard.vue' import { useSystemActivityStore } from '../../stores/systemActivity.js' diff --git a/frontend/src/components/settings/TagEvalCard.vue b/frontend/src/components/settings/TagEvalCard.vue new file mode 100644 index 0000000..70f3735 --- /dev/null +++ b/frontend/src/components/settings/TagEvalCard.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/stores/tagEval.js b/frontend/src/stores/tagEval.js new file mode 100644 index 0000000..044707b --- /dev/null +++ b/frontend/src/stores/tagEval.js @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia' + +import { useApi } from '../composables/useApi.js' + +// Tag-eval (#1130): trigger + revisit the head-vs-centroid learning-curve eval. +// The run + full report live server-side (tag_eval_run), so the card rehydrates +// from getRun() on mount — the report survives navigation. +export const useTagEvalStore = defineStore('tagEval', () => { + const api = useApi() + + async function start(params) { + return await api.post('/api/tag-eval', { body: { params } }) + } + + async function getRun(id) { + return await api.get(`/api/tag-eval/${id}`) // includes the full report + } + + // The most recent run (light row, no report) — the card calls getRun() with + // its id to pull the persisted report on mount. + async function latest() { + const body = await api.get('/api/tag-eval', { params: { limit: 1 } }) + return (body.runs && body.runs[0]) || null + } + + return { start, getRun, latest } +})