7b10f4caabaadab71b96019392938636518fae7c
885 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7b10f4caab |
fix(agent): cuDNN base image so onnxruntime-gpu loads (#114)
onnxruntime-gpu needs cuDNN 9; the plain cuda:12.4.1-runtime image lacks it (libcudnn.so.9 missing → CUDAExecutionProvider falls back to CPU). Switch to the -cudnn-runtime variant which bundles cuDNN 9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
b6b151a500 |
docs(agent): docker-compose for the GPU agent
compose file (pull the published image, GPU reservation, model-cache volume, .env for the token) so the agent runs with `docker compose up -d` instead of a long docker run. A copy + .env template also placed in ~/Documents/fc-gpu-agent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
9449241fc2 |
ci(agent): publish the GPU agent image (build-agent job)
Build + push fabledcurator-agent alongside web/ml (own CUDA + onnxruntime-gpu image, context=agent/, same tag cadence: main → :main/:latest/:c-<sha>, tag → :<version>). So the operator PULLS + runs it on the GPU machine instead of building locally. README switched to docker pull. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
8419ebd761 |
feat(agent): desktop GPU agent container — CCIP + figure crops over HTTP (#114)
The last piece: a Dockerised desktop-GPU worker that talks to FC ONLY over HTTP (lease → fetch pixels → detect figures + CCIP-embed → submit), so Redis/Postgres stay private. New top-level agent/ (outside CI scope — verified by running it): - fc_agent/worker.py: the lease/compute/submit loop, concurrency 1, start/pause/ stop (stop frees the card; unprocessed leases expire + re-queue). - fc_agent/models.py: imgutils wrappers — detect_person (figures) + CCIP embed. The two API seams to verify against the installed dghs-imgutils (flagged). - fc_agent/media.py: stills + video frame sampling (ffmpeg) at FC's cadence → per-frame instances (the bag). - fc_agent/crops.py: vendored crop primitive. client.py: the FC HTTP client. - fc_agent/app.py: FastAPI localhost control UI (start/pause/stop + progress + queue depth). Dockerfile (CUDA + onnxruntime-gpu + ffmpeg) + requirements + README (token → build → run --gpus all → Start; CPU-fallback path). This completes the CCIP pipeline end to end: agent produces region CCIP vectors → RegionService stores → matcher suggests characters → rail. Verified by running on the desktop (not CI). README calls out the imgutils API + model-string checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
60f26247e9 |
style: alphabetize ccip_bp import (ruff I001)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
de33bab41c |
feat(ccip): read-only observability API for the crop/CCIP work (#114)
So the work can be checked through an API as the agent fills in vectors (same pattern as /api/heads/metrics): - GET /api/ccip/overview: regions by kind, images with figure CCIP vectors, the per-character reference counts (which characters have enough examples to match on), and the embedding versions present. - GET /api/ccip/images/<id>: that image's stored regions (bbox, frame_time, has_ccip/has_siglip, versions) + the CCIP character matches it would get — for spot-checking detector + matcher output. Read-only, no GPU. (Queue depth is already at /api/gpu/status.) Tests: overview coverage counts + per-character refs; per-image regions + matches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
5faf34a3b5 |
feat(suggestions): overlay CCIP character matches onto the rail (#114)
SuggestionService.for_image now merges CCIP character matches with the SigLIP head suggestions — they're complementary, not exclusive: CCIP is the identity- specialized signal but needs a detected figure; the heads work whole-image but conflate identity with style. Merged by tag: 'both' when they corroborate (higher score wins), 'ccip' / 'head' otherwise. Cheap when no CCIP vectors exist yet (match_image returns early without a figure vector), so it's a no-op until the agent runs. Suggestion.source is now 'head' | 'ccip' | 'both'. Test: a character with a CCIP reference figure surfaces (source='ccip') on a new image whose figure matches. NEXT: the agent container (real CCIP/detector models, hands-on) that produces the vectors this consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
d57ca847e7 |
feat(ccip): few-shot character matcher (#114 slice 5)
The server-side brain that turns stored CCIP vectors into character suggestions
— no GPU. character_references() gathers each character tag's prototype vectors
(figure/face-region CCIP embeddings on images carrying that tag); match_image()
cosine-matches an image's figure vectors against every character (multi-
prototype: best over a character's examples), surfacing those above a tunable
threshold as {tag_id, name, category:'character', score, source:'ccip'},
excluding already-applied characters. v1 = cosine on raw CCIP vectors; the exact
CCIP metric/threshold gets validated against the model in the hands-on eval.
Tests (synthetic vectors): same-character match across images, no-match for an
orthogonal figure, already-applied exclusion, no-figure-vectors empty.
NEXT: merge CCIP character suggestions into the rail; the agent container that
actually produces the vectors (hands-on, GPU — not CI-verifiable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
|
||
|
|
d91eef7a4b |
feat(gpu): GPU agent admin card — token, queue, backfill (#114)
The FC-side control surface the operator asked for: Settings → Tagging → "GPU agent". Generate/reveal/copy/rotate the agent bearer token (with the FC URL to point the agent at), see the live job-queue depth (pending/in-flight/done/ errored, polled), and a "Queue character embedding (CCIP)" button that triggers the library backfill. Plain-HTTP-safe copy (copyText resolves on success, throws on fail). Closes the "how do I get the token in the UI" gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
558d965a1c |
fix(gpu): count backfill enqueues via RETURNING, not rowcount
result.rowcount is unreliable for INSERT…SELECT (returned -1), failing the idempotency assert. Use .returning(GpuJob.id) and count the rows. (run 1652) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
f247f9247c |
style(gpu): ruff — split as-import, dict(rows) over comprehension
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
6cabef07a4 |
feat(gpu): HTTP job API + token auth + backfill — the agent's server side (#114 slice 3b)
The thin HTTP surface over the queue so the desktop agent stays HTTP-only: - Agent endpoints (Authorization: Bearer <token>): POST /api/gpu/jobs/lease (returns jobs + image_url + mime + video frame cadence), /submit (stores regions via RegionService + closes the job; 409 on a stale lease), /heartbeat, /fail. Token validated against AppSetting (mirrors the extension-key pattern, constant-time compare). - Admin (browser): GET/POST /api/gpu/token[/rotate] (generate + show the agent token), GET /api/gpu/status (queue counts), POST /api/gpu/backfill → dispatches enqueue_gpu_backfill. - enqueue_gpu_backfill(task): one INSERT…SELECT enqueues a job per image lacking one for the task (scales to the full library; idempotent). Agent flow: lease over HTTP → fetch pixels via the normal FC image URL → compute on the GPU → submit. Redis/Postgres never exposed. Tests: bearer required (+ wrong-token 401), lease→submit round-trip (region+CCIP vector stored, job done via /status), stale-lease 409, backfill enqueue + idempotency. NEXT: the agent container + control UI, then the CCIP detector/embedder + matcher. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
b735432d02 |
feat(gpu): video-ready regions + the HTTP GPU-job queue engine (#114 slice 3)
Answers "how are videos/all media handled by the GPU worker": a job is per ITEM, but the agent fans a VIDEO into per-frame instances (ffmpeg in the agent, the existing cadence), each stored with a timestamp — so a video becomes a BAG of frame embeddings (fixes the mean-embedding muddle) instead of one washed-out vector. Stills → frame_time NULL; animated GIF/WebP treated like short video. - image_region.frame_time (migration 0061, not yet deployed so folded in): the source frame's seconds for video/animated media; NULL for stills. RegionService passes it through. A whole frame is just kind='frame'. - gpu_job + GpuJobService (migration 0062): the durable work list that keeps the desktop agent HTTP-only — enqueue (dedupes (image,task)) / lease (FOR UPDATE SKIP LOCKED, re-claims expired leases so the queue self-heals) / heartbeat / complete / fail (re-queues until MAX_ATTEMPTS then 'error'). The server enqueues; the agent leases+submits over the web API; Redis/Postgres stay private. Tests: enqueue dedupe, lease-then-skip-when-held, expired-lease reclaim, scoped heartbeat, complete, fail-requeue-then-error. region test now covers frame_time. NEXT: the thin HTTP API (lease/submit/heartbeat) + bearer-token auth, then the agent container + control UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
0ea7ecdea5 |
feat(regions): image_region storage + service for the crop pipeline (#114 slice 2)
The storage backbone both crop jobs write to and read from. image_region =
normalized bbox (rx/ry/rw/rh) + kind ('face'/'figure' → CCIP character id;
'concept' → SigLIP head bag) + the crop's embedding (nullable Vector(768) CCIP /
Vector(1152) SigLIP, one per kind) + version stamps for compute-once gating. The
bbox doubles as grounded-tag provenance. Migration 0061.
RegionService.replace_regions (scoped BY KIND so the figure + concept pipelines
don't clobber each other) + get_regions — the GPU agent's results endpoint will
call the writer; the character matcher + bag scorer read. Server-side, no GPU.
Tests: replace/get round-trip, kind-scoped replacement, CCIP vector round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
|
||
|
|
e8d3400d22 |
feat(crops): shared crop primitive for the region/crop pipeline (#114)
The trunk of both crop jobs — CCIP figure-crops and SigLIP concept-crops call the SAME crop_region(): normalized-bbox crop with optional context padding, edge-clamping, and the lower-bound size floor (max of a fraction-of-short-side and an absolute pixel floor) below which a region is too small to embed and returns None. Only the proposer (where) and embedder (what) differ; the crop is shared. Pure Pillow — importable + testable anywhere (the GPU agent imports it for the crop step). Unit-lane tests (no DB): region pixels, floor rejection, edge clamp, pad expansion, out-size resize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
f6e10ccc4f |
fix(explore): render videos with VideoCanvas, not ImageCanvas
The Explore center pane hardcoded ImageCanvas, so a video anchor (e.g. a 169 MB
MP4) tried to load the MP4 into an <img> and showed only the alt text — the
thumbnail worked but the "main image" never rendered. Branch on
mime.startsWith('video/') to VideoCanvas (with mime), exactly like the image
modal. The anchor payload already carries mime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
|
||
|
|
ad2921b4a0 |
fix(tags): allow creating a same-named character in a different fandom
The autocomplete suppressed the Create row whenever any existing tag matched name+kind — but characters are unique by (name, kind, fandom), so a same-named character in a different fandom (e.g. another "Raven") is a valid distinct tag. allowCreate now always offers Create for the character kind; the fandom picker disambiguates and find_or_create is idempotent if the same fandom is re-picked. The Create row reads "Create another \"Raven\" character (different fandom)" when a same-name character already exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
1463794778 |
feat(heads): auto-apply UI on the Concept-heads card (#114 auto-apply C)
Surfaces earned auto-apply + its observability in Settings → Tagging → Concept heads: - Auto-apply section: an on/off switch (writes head_auto_apply_enabled), the precision-target + min-examples-to-fire tuning inputs, a Preview (dry-run → "would apply N", per-concept chips) and Apply-now button, with live run state. - "How auto-apply is landing": per-concept table from /api/heads/metrics — applied volume, misfires, realized misfire rate (green/amber/red), and missed (under-fires) — the signal to tune the precision target from. store: autoApply(dryRun) / autoApplyStatus() / metrics(). Card polls the sweep to completion, then refreshes counts + metrics. Completes the auto-apply task. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
a5a95320df |
fix(test): disable switch explicitly now that auto-apply defaults ON
test_auto_apply_disabled_blocks_real_run assumed head_auto_apply_enabled defaulted False; it now defaults True (opt-out), so a real sweep is accepted (202). Set the switch off in the test to exercise the disabled→400 path. (run 1629) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
9326a82b29 |
fix(heads): .all() before dict() in snapshot_head_metrics
dict(session.execute(...)) on a bare Result invokes the mapping protocol (a Result has .keys() = column names) and subscripts it → "CursorResult is not subscriptable". Materialize with .all() so dict() consumes rows as key-value pairs. The API path already did this; the snapshot task missed it. Caught by test_snapshot_records_timeseries_point (run 1628). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
48c8811d69 |
feat(heads): auto-apply observability + on by default (#114 auto-apply B)
Auto-apply is now ON by default (operator-asked: opt-OUT, not opt-in) — migration 0059 + model default flipped. The support (>=30) + measured-precision gates keep it safe and every auto-tag is reversible. Observability so the operator can tune from real data: - MISFIRE = an auto-applied (source='head_auto') tag the operator later removes. UNDER-FIRE = a tag with a head the operator adds by hand (the head missed it). Both captured at correction time in TagService.add_to_image/remove_from_image (source is lost on delete) into durable per-tag counters (head_metric), keyed by tag so they survive head retrain/prune. - Daily snapshot_head_metrics writes a per-concept time-series point (head_metrics_snapshot): auto-applied volume + cumulative misfires/under-fires + head quality; 180-day retention; daily beat. - GET /api/heads/metrics: per-concept current counts + realized misfire rate + head quality, plus the snapshot time-series — the report to tune the precision target + support floor. Migration 0060. Tests: misfire/under-fire counting (and the negatives — manual removal isn't a misfire, headless manual add isn't an under-fire), snapshot time-series, metrics API. What's the autofire threshold? There's no single number — each graduated head derives its OWN probability cutoff from its PR curve: the operating point that holds precision >= head_auto_apply_precision (0.97) at max recall. The global knobs are that target + the >=30 support floor. NEXT (slice 3): UI — enable toggle, dry-run preview, per-concept trends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
01933c5b26 |
style(test): drop unused img in ungraduated-head sweep test (ruff F841)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
74fef908d2 |
feat(heads): earned auto-apply — sweep mechanism, off by default (#114 auto-apply A)
Graduated heads can now apply their tag without a human — gated so it's safe:
- FIRING GATE: a head fires only when the master switch (head_auto_apply_enabled,
default OFF) is on AND it has >= head_auto_apply_min_positives (default 30)
clean labels. A precise-looking but under-supported low-N head can't spray tags.
- auto_apply_sweep (heads.py): streams every embedded image in chunks, scores
against the eligible heads (numpy, no sklearn), applies each head's tag where
score >= its auto_apply_threshold and the tag isn't already applied/rejected,
with source='head_auto' (distinguishable + reversible). dry_run counts only.
- HeadAutoApplyRun (migration 0059) tracks each sweep / preview; apply_head_tags
task (ml queue) + scheduled_apply_head_tags daily beat (no-op unless enabled)
+ recovery sweep + retention(20).
- API: POST /api/heads/auto-apply {dry_run} (202 / 409 running / 400 disabled),
GET /api/heads/auto-apply (recent runs + per-concept report). Settings
head_auto_apply_enabled + min_positives via /api/ml/settings.
Tests: sweep applies above threshold, dry-run writes nothing, skips under-
supported + ungraduated heads; API disabled/dry-run/conflict guards.
NEXT (slice 2): the observability the operator asked for — per-concept misfire
(auto-applied-then-removed) + under-fire tracking, time-series snapshots, and a
reporting API to tune. Slice 3: the UI (enable, preview, trends).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
|
||
|
|
77baee49fd |
feat(heads): nightly auto-retrain + inline Retrain button in Explore
Two cadences for keeping heads in sync with your tagging: - PASSIVE: a nightly `scheduled_train_heads` beat (skips if a run is already in flight; creates+commits the run row before dispatching train_heads so the ml worker always finds it). Folds the day's accepts/rejects + newly-eligible concepts into the heads without anyone clicking. - ACTIVE: a "Retrain heads" button in the Explore trail bar — bank the +/- feedback you just gave while walking content, without a trip to Settings. Shared logic in a new useHeadTraining composable (trigger + poll + start/finish toasts), used by the Explore button; reflects an already-running run (incl. the nightly one) on mount. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
353b5d8087 |
feat(explore): ← / → keyboard navigation through the walk
Arrow keys walk the Explore breadcrumb trail: ← steps back, → goes forward to an already-visited item or — with no forward history — jumps to a RANDOM neighbour to keep the rabbit-hole going (operator-asked). The trail gains a cursor (browser back/forward semantics): stepping back no longer trims the forward branch, so → can return to it; a genuinely new walk off a back-step truncates the stale branch then appends. The crumb-bar "current" highlight follows the cursor, not the tip. Arrows are ignored while typing a tag, but still navigate when the tag input is focused-but-empty (it auto-focuses after every walk, so otherwise arrow-nav would dead-end after one step). Modifier-key combos pass through untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
ca1c17446c |
feat(suggestions): heads are the suggestion source — Camie + centroid removed (#114 C)
The rail's Suggestions now come from the trained per-concept heads. SuggestionService.for_image scores the image's frozen SigLIP embedding against every head (heads.score_image) and surfaces concepts above each head's own suggest threshold; the typed-dropdown's min=0 "show everything" mode maps to a flat floor so any head-scored concept can still be picked. Already-applied tags drop; rejected tags stay flagged + reversible (unchanged). REMOVED from the suggestion path (rule 22, no fallback): the Camie ImagePrediction candidate/alias/merge pipeline and the per-tag centroid augmentation, plus the now-dead SuggestionService internals (_load_predictions, _threshold_for, _settings, self.aliases, self.centroids). Head suggestions are always canonical tags, so raw_name/via_alias are null/false and the rail's alias kebab is inert by data (its removal + the Camie ingest-tagger rip are the flagged follow-up). for_selection (bulk consensus) now aggregates head suggestions unchanged. Tests rewritten to the head path: test_ml_suggestions (surfaces/applied/ rejected-reversible/override/no-embedding/no-heads), test_suggestions_bulk (consensus), test_api_suggestions (get + dropped the Camie-alias roundtrip), and test_ml_artist_retired (artist not head-eligible via _HEAD_KINDS). DEPLOY NOTE: after this lands, the rail is empty until you run Train heads (Settings → Tagging → Concept heads) — deploy, train, then the rail populates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
06d5e83da4 |
feat(heads): admin card to train + inspect concept heads (#114 B)
The UI for the heads subsystem: Settings → Tagging → "Concept heads". Shows head count, auto-apply-ready count, and last-trained; a Train/Retrain button (one run at a time, polls while running, surfaces a failed run's error); an empty state guiding the operator to tag first; and a per-concept table (name, category, +tags, AP, P, R, auto-apply ⚡) sorted strongest-first so weak/under- tagged concepts are obvious. Rehydrates status from GET /api/heads on mount so it survives navigation. Pulls head_min_positives from ML settings for copy. Slice C (swap the rail's suggestions to heads, remove Camie + centroid) is next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
1ed0895e8d |
style(heads): fix import ordering (ruff I001)
Alphabetize HeadTrainingRun in models/__init__ + maintenance imports (H before I), and drop the inline comment that split heads.py's import block. Pure import ordering — no behavior change. (run 1601 lint) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
291b90803d |
fix(test): match rejected suggestion by id, not display casing
test_rejected_tag_surfaced_flagged_then_reversible asserted "Rejectme" but an
existing tag keeps its stored name ("rejectme"), so the suggestion's
display_name is lowercase. Match by canonical_tag_id instead (casing-robust).
The feature was correct — only the assertion was wrong (run 1595 integration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
|
||
|
|
22c3b54746 |
feat(heads): production per-concept heads — train + score backend (#114 A)
The eval (#1130) proved the frozen-embedding + trained-head spine; this lands its production form (the first of three slices that make heads the suggestion source, replacing Camie + centroid). - tag_head: one logistic-regression head per general/character concept with enough labelled positives. Weights (pgvector), honest CV-derived suggest threshold + earned-auto-apply point, and per-concept quality metrics. - head_training_run: persisted batch lifecycle (mirrors tag_eval_run) so the admin card shows live + historical status across navigation. - services/ml/heads.py: TRAIN (sync, ml worker, reuses tag_eval's proven data loaders + metric math so production heads match measured eval numbers) and SCORE (async, API worker — numpy via pgvector, no scikit-learn): score one image's embedding against all heads → the rail's suggestions, cached on (count, max trained_at) so a retrain invalidates without per-request loads. - tasks.ml.train_heads (ml queue, commits per head so a kill leaves progress) + recover_stalled_head_training_runs sweep + retention(20) + 5-min beat (rule 89). - api/heads.py: POST /api/heads/train (one run at a time, 409 guard) + GET /api/heads (count, graduated, last-trained, running, per-concept table, recent runs). - ml_settings: head_min_positives + head_auto_apply_precision, tunable via /api/ml/settings. Scoring isn't wired into the rail yet (slice C) and the admin UI is slice B — this slice makes training + scoring exist and CI-verifiable. 'precision' column stored as precision_cv (SQL reserved word). Migration 0058. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
179c1a9dcc |
feat(suggestions): visible, reversible rejection in the modal rail
A red-✗ dismissal no longer makes the suggestion vanish. The rejected tag stays in the rail — dimmed, struck-through, with a "rejected" pill and a one-click undo (↶) in place of the ✗ — so a misclick is recoverable and the operator can see what they've said no to (operator-asked 2026-06-27). Backend: SuggestionService.for_image now KEEPS rejected tags, flagged rejected=True, sorted to the bottom of their category, instead of dropping them. New AllowlistService.undismiss + POST /suggestions/undismiss clears the TagSuggestionRejection. Rejected items are still excluded from bulk consensus (for_selection) and the type-to-add dropdown, whose jobs are unchanged. Frontend: store.dismiss flags in place (canonical tags) rather than dropping; new store.undismiss reverts. SuggestionItem renders the rejected state and swaps ✗→↶; ✓ still accepts (which clears the rejection server-side). Tests: rejected-surfaced-flagged-then-reversible (service) + undismiss endpoint idempotency (API). Completes #1134's reversible-rejection half. Heads-as-suggestion-source is the remaining piece. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
1d39afa3b6 |
feat(modal): green ✓ / red ✗ verdict pair on suggestion rows
Replace the single "Accept" pill in the modal Suggestions rail with the eval card's green ✓ / red ✗ language: ✓ accepts the tag (positive), ✗ dismisses it for this image — which already persists a TagSuggestionRejection (hard negative the heads train on). The pair occupies ~the footprint of the old pill, so per-image rejection becomes a one-click peer of accepting instead of being buried in the kebab. Dismiss moves off the 3-dot menu, so the kebab now only carries alias actions and is hidden when none apply (centroid hits with no alias option). Toward #1134 (native per-image negatives in the rail). The bigger piece — heads as a suggestion source feeding this panel — is still ahead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa |
||
|
|
b69c70ab2b |
feat(tag-eval): "keep" records a confirmation so doubts stop resurfacing
"Keep" on a doubted positive was a no-op, so the same confirmed-correct images came back in "head doubts" every run (operator-flagged: reinforcement keeps surfacing the same images). Add tag_positive_confirmation (mirror of tag_suggestion_rejection): keep → POST /images/<id>/tags/<tag_id>/confirm, and the eval excludes confirmed positives from the doubts list — exactly as rejected items already drop out of the suggest list. The tag stays a positive either way (confirmation is a "reviewed" marker, not a training change). - model TagPositiveConfirmation + migration 0057; confirm endpoint (idempotent). - tag_eval: _confirmed_ids + exclude from head_doubts_positive examples. - store.confirmTag + card "keep" calls it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4fd8790c85 |
fix(tag-eval): don't re-suggest already-rejected items every run
"head would suggest" drew from the whole negative pool, which INCLUDES the images the operator rejected. A rejected near-miss (e.g. an orc under "goblin") is a hard negative that still scores high, so it kept resurfacing as a fresh suggestion every run (operator-flagged: "same items keep appearing"). Exclude already-rejected ids from the suggest list — once you've said no, it's gone. (head doubts = lowest-scoring positives is unchanged; genuinely-hard true positives legitimately recur there.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5143f4c34f |
feat(tag-eval): auto-apply operating point + server-side top-N concept discovery
Two additions driven by "what's the commit threshold?" + "find more tags":
1. High-precision operating point (Bar 4). Per concept, report the threshold that
maximizes recall while holding precision >= a target (default 0.97, configurable
via `precision_target`) — i.e. "could this fire without a human, and how much
would it catch?" `head.auto_apply` = {target, threshold, precision, recall} or
null if the target is unreachable. Surfaced on the card.
2. Server-side concept auto-discovery. `auto_top_n` param unions the explicit
concept list with the N most-tagged general tags (one fast DB query) so the
eval can broaden itself without hand-listing — replaces the slow HTTP directory
paging. Card gains "+ auto-add top-N" and precision-target inputs.
No migration; numpy/sklearn stay lazy. Existing _normalize_params test still
holds (new keys additive; None still falls back to DEFAULT_CONCEPTS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
fc64f130b8 |
fix(tag-eval): thumbnail click opens the view modal, not Explore
Clicking an example in the maintenance card navigated to /explore/<id> — heavier than wanted (operator: just want a bigger look). Open the existing app-wide ImageViewer modal via modal.open(id) instead: bigger image + tags in place, no navigation away from Settings. The ✓/✗ actions are unaffected (separate overlay buttons). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
13d297b881 |
feat(tag-eval): inline confirm/reject actions on example thumbnails
Closes the learn-from-tags loop directly on the eval lists (operator-flagged: no surface to confirm/refine the head's suggestions). Each thumbnail gets a green ✓ / red ✗ that writes the SAME tables the head trains on: - suggest + ✓ → apply tag (new positive, POST /images/<id>/tags) - suggest + ✗ → record rejection (hard negative, suggestions/dismiss) - doubt + ✗ → remove tag + record rejection (kill bad positive, add negative) - doubt + ✓ → keep (stays a positive, no write) Acted thumbs grey out with a badge; re-run to see the head sharpen. Thumb still links to /explore/<id>. All endpoints already existed — no backend change. Inline is the starting point; longer-term the modal Suggestions rail gets the red "No" (negative) so per-image rejection is native there too (next slice). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4974b7cf77 |
feat(tag-eval): bigger, clickable example thumbnails (label-review queue)
The 56px example thumbs were too small to judge a label (operator-flagged). Bump to 120px and wrap each in a link to /explore/<id> (new tab) so the "head doubts / would suggest" galleries double as a review-and-fix queue — click a doubted positive, land on it in Explore, correct the tag, re-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6cd7281af5 |
feat(settings): tag-eval admin card — trigger + persisted report (survives nav)
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) <noreply@anthropic.com> |
||
|
|
6e3c5f697f |
feat(ml): tag-eval backend — head-vs-centroid learning-curve eval (persisted)
Slice 1 of milestone #114 (tagging v2). Proves the frozen-embedding + trained- head spine on the operator's own data, reusing the SigLIP embeddings already stored on image_record — no re-embedding, no GPU. Per concept: train a logistic-regression HEAD (positives + negatives = explicit rejections + sampled unlabeled) vs the old single-CENTROID baseline; report cross-validated precision/recall/AP for both, a LEARNING CURVE (AP/F1 as tagged positives grow 10→30→100→300), and example image ids (head-would-suggest / head-doubts-positive) to eyeball. Persisted so the report SURVIVES navigation (operator-flagged): the run + full report live in a new tag_eval_run row (mirrors library_audit_run); the admin card will rehydrate from GET on mount, not transient state. - models.TagEvalRun + migration 0056; runs on the ml queue (only worker with numpy/sklearn) — numpy/sklearn lazy-imported so the API can still enqueue. - services/ml/tag_eval (compute + start helper, one-running guard), tasks.ml .tag_eval_run, api/tag-eval (POST create, GET history light / detail w/ report). - recover_stalled_tag_eval_runs sweep + retention (keep last 20) + 5-min beat (rule 89). scikit-learn added to requirements-ml. - tests: param normalization + the rehydrate read-path + create/conflict. Frontend admin card (trigger + render persisted report) follows next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
958378312c |
fix(settings): sticky headers on the virtual data tables
Allowlist / Alias / ImportTask tables scroll their bodies (height=360/480) but the column headers scrolled away with the rows, so you lost the column labels (operator-flagged 2026-06-27). Add Vuetify `fixed-header` so the header row stays pinned while the body scrolls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d63dfa511a |
fix(explore): bound the 3-pane grid row so a tall rail can't scroll the page
The panes grid had no explicit row, so the implicit `auto` row sized to its tallest pane's content. With Provenance + Tags + a long Suggestions list, the rail outgrew the fixed-height workspace, spilled over and made the WHOLE page scrollable — showing as a weird empty gap at the top (operator-flagged 2026-06-26). grid-template-rows: minmax(0, 1fr) bounds the row to the container so each pane's own overflow-y:auto scrolls internally instead. Reset to `none` in the stacked (<=1100px) layout where the page is meant to scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e34f79fc56 |
feat(explore): show Provenance in the tag rail (post often names the character)
The post title/description frequently names the character, so surface it while tagging in Explore (operator-asked 2026-06-26). ProvenancePanel gains optional imageId/image props (default = modal store, so the modal is unchanged) since provenance is its own system loaded by id; ExploreView renders it above TagPanel in the right rail, hosted on the anchor. Self-collapses when the image has no provenance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c8a8e23050 |
feat(explore/tags): return focus to the tag input after every action
Explore is a rapid walk-and-tag surface, so focus must keep returning to the tag input with no extra click (operator-asked 2026-06-26). Two gaps closed: - Navigation hardening: refocus on every focused-image change (neighbour click, breadcrumb, Random image, seed) now runs nextTick → requestAnimationFrame, so it lands AFTER the post-navigation re-render/paint instead of being stolen back by the neighbour-grid re-render. - All tag actions refocus, in both Explore and the modal: tag add (existing/new) and remove now hand focus back like accept-suggestion already did; and the rename + fandom-assignment dialogs refocus on @after-leave (fires after Vuetify's own focus-return to the activator, so ours wins). TagAutocomplete's mobile guard is preserved throughout (no soft-keyboard pop on touch). Modal behaviour gains the same stickier focus — consistent, low-risk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e3855a5ae0 |
chore(tags): remove orphaned cluster tag-gaps route + service method
The cluster tag-gap feature's only UI (Explore's TagGapPanel) was removed in the 3-pane rework, leaving the backend that fed it with no caller. Surgical removal: - drop the POST /api/images/cluster/tag-gaps route (cluster_tag_gaps) - drop BulkTagService.tag_gaps (+ the now-unused `import math`) - drop the tag_gaps tests (test_bulk_tag_service, test_api_bulk_tags) BulkTagService's common_tags / bulk_add / bulk_remove stay — they still back the gallery bulk editor. Pure deletion, no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5be6b9cada |
feat(explore): auto-focus the tag input on every image change
The workspace is built for rapid walk-and-tag, but the tag field was only focused once (TagAutocomplete's on-mount autofocus) — walking to a neighbour left focus behind, so the operator had to click the field each time (operator-asked 2026-06-26). TagPanel now exposes focusTagInput; ExploreView watches the focused image id and re-focuses the field on seed + every walk via nextTick. Reuses the existing focus path, so TagAutocomplete's mobile guard (no soft-keyboard pop on touch) is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4a1f255164 |
fix(modal): place meta + save block under Provenance, above Tags
Operator-clarified 2026-06-26: the dimensions/size/type + save (floppy) block should sit DIRECTLY above the Tags section — i.e. just under Provenance — not at the very top of the rail. Reorder the rail's main scroll area to Provenance → ImageMetaBar → TagPanel (Related stays pinned at the bottom). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1728b43167 |
fix(modal): pin Related to rail bottom, floppy-disk download, drop suggestions cap
Right-rail layout fixes (operator-flagged 2026-06-26 — the prior change wasn't
the intended improvement):
- Pin the Related strip to the BOTTOM of the rail: the side becomes a flex
column with a scrolling main area (meta + provenance + tags + suggestions)
and a pinned Related footer (capped at 45% of the rail, scrolls past that).
Related now stays reachable no matter how long Tags/Suggestions run, and
self-collapses (no footer space) when there's nothing to show.
- Remove the 320px suggestions scroll cap (
|
||
|
|
2d1cddd9b7 |
feat(explore): 3-pane tagging workspace — gallery | viewer | tag rail
Reworks Explore from "anchor + neighbour grid + cluster tag-gap rail" into a persistent 3-pane workspace that unfolds the image modal so you can tag while rabbit-holing (operator concept 2026-06-26): - LEFT neighbour grid (larger thumbs), click = walk; breadcrumb retained. - CENTER light viewer — reuses ImageCanvas + ImageMetaBar(:image) for the focused image; "Open full viewer" still launches the overlay modal. - RIGHT the modal's TagPanel, hosted on the anchor for modal-parity tagging (chips, autocomplete, suggestions + Accept, fandom-on-chip, T/"/" focus). Reuse without destabilising the audited modal store: TagPanel and SuggestionsPanel gain an optional `host` prop (default = modal store, so the image modal is unchanged); the explore store implements the same small tag-CRUD surface (current/currentImageId + reloadTags/addExistingTag/ removeTag/createAndAdd) over the anchor. ImageMetaBar gains an optional `image` prop for the same reason. Drops the mass/cluster tagger (TagGapPanel deleted; clusterIds/thumbById removed) — per-image tagging feeds the per-tag reference-embedding centroid better than bulk ops. Nav: keep the Explore tab but bare /explore now SEEDS a random image (GET /api/showcase?limit=1 → /explore/:id) so the tab kick-starts a rabbit hole; explicit meta.navOrder pins nav order (Explore after Gallery) since router.getRoutes() doesn't preserve declaration order. Note: the backend cluster tag-gaps route/service (#94a) is now frontend-orphaned — left in place; flag for a separate cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1aadf3267b |
fix(tags): correct directory image_count — fandom leg must correlate the outer tag
The directory card count regressed to a globally-inflated number (~every card showed the same ~469): the fandom leg used a doubly-nested correlated subquery — image_tag.tag_id IN (SELECT member.id WHERE member.fandom_id == Tag.id) — whose inner predicate did not correlate the outer Tag, so it matched EVERY character that has any fandom and counted all their images for every tag. The gallery scope and cleanup count were unaffected (they pass a literal tag id, a single-level subquery), which is why only the card diverged from the gallery. Rewrite the count as a single-level correlated scalar subquery: join `member` (the tag applied to the image) and match image_tag.tag_id == Tag.id (direct) OR member.fandom_id == Tag.id (a character of this fandom). Strengthen the directory test with a second unrelated fandom/character so a non-correlating fandom leg fails (count would read 4 instead of 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |