Commit Graph

941 Commits

Author SHA1 Message Date
bvandeusen 3a683d7feb fix(agent): short videos failed as "unprocessable" — fps filter emits 0 frames
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
Root cause of the "no frames sampled from video (unprocessable)" flood
(operator-flagged 2026-07-02, whole 62k-70k image block + others): the
sampler used `-vf fps=1/4`, and ffmpeg's fps filter emits round(duration/4)
frames — which is ZERO for any clip shorter than ~2s. Short animation loops
(0.5s, 1.75s — verified against two originals from different artists) are
complete, valid h264 videos; ffmpeg decoded them fine, emitted no frames,
exited 0, and the agent failed the job as unprocessable. Long videos worked,
so only the short-clip class flooded.

Fix: sample with select ("first frame always, then one per interval of
timestamp") + -fps_mode vfr, and scale=out_range=full so limited-range
yuv420p sources don't trip the mjpeg encoder's full-range strictness
(secondary failure observed on a 4440x2760 clip). Verified locally against
both failing originals (frames extracted, PIL-clean) and a synthetic 15s
video (4 frames at t=0/4/8/12 — long-video behavior unchanged).

Observability (why this hid for weeks): ffmpeg's stderr was discarded, so
every failure logged only "no frames sampled". stderr now goes to a temp
file and its tail is logged on any produced-no-frames/timeout failure — the
log names the actual ffmpeg reason from now on. Also: frames written before
a mid-stream ffmpeg error are now kept (partial > nothing).

VERSION → 2026-07-02.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:00:29 -04:00
bvandeusen 0a618db10c fix(agent): Status froze after one update — conchint.textContent destroyed #capn
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
THE root cause of "the Status section doesn't update" (chased across several
rounds; the backend was always healthy). `#capn` (the max-concurrency number)
was nested inside `#conchint`:

    <div id=conchint>… · max <b id=capn>8</b></div>

and applyStatus() ran, every call: `capn.textContent=CAP` AND
`conchint.textContent = '…max '+CAP`. Setting conchint.textContent replaces
ALL of conchint's children — destroying the <b id=capn> node. So:
  call 1: capn exists → tiles update → conchint.textContent DELETES capn
  call 2+: `capn.textContent` → "capn is not defined" (ReferenceError) →
           applyStatus throws on its FIRST line → aborts before any tile →
           frozen.
This is exactly the observed "ticks a couple times then freezes", and why
/gpu + /logs (which never touch capn) kept updating fine.

The capn write was redundant anyway — conchint.textContent already renders
the max. Remove the nested <b id=capn> element and the capn.textContent line;
the hint still shows "· max N". VERSION → .10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:19:37 -04:00
bvandeusen 713a11e394 fix(agent): server-side rate metrics + killable-on-stop ffmpeg
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m25s
Two follow-ups from live debugging of "work/min never populates" and
"stopped never reached".

1) jobs/min + downloads/min are now computed in the BACKEND on a fixed
   cadence (_rate_loop, EWMA) and reported ready-to-show. The rates were
   derived client-side from poll deltas with a dt<30s guard — but a
   backgrounded/unfocused browser tab throttles its timers to ~1/min, so
   every delta exceeded 30s and the guard blanked the rates forever. A
   server-side rate is independent of how often the tab polls. Frontend just
   displays s.jobs_per_min / s.downloads_per_min. VERSION → .9.

2) ffmpeg video sampling is now killable on Stop. A downloader stuck in a
   slow/reconnecting decode (observed: 47s, 230s for one video) couldn't see
   the stop signal until ffmpeg returned, so Stop detached still-running
   threads and work kept flowing long after — "stopped" that wasn't really
   stopped. sample_frames_from_url now runs ffmpeg via Popen and polls a
   `should_stop` callback every 0.5s, terminating (then killing) the process
   at once on Stop or the per-video timeout. A stop-killed job is handed back
   (transient), not failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:51:26 -04:00
bvandeusen 6282e753a9 fix(agent): real start/stop state machine — kill the stuck "stopping" pill
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m27s
The Status pill hung on "stopping" forever (operator-flagged 2026-07-01).
Root cause: the backend had no lifecycle state — status() only returned
running/stopped — so the UI FABRICATED "stopping" in JS as `!running &&
active>0`. That pill only cleared when the backend's `active` counter hit 0,
but stop() (a) blocked the HTTP handler on lease-release calls to curator and
(b) left `active>0` whenever a consumer wedged mid-submit/release to an
overloaded curator → "stopping" that never resolved.

Give the backend a real, truthful state it drives itself:
  stopped → starting → running → stopping → stopped
- start(): → starting; a downloader flips it to running on its FIRST
  successful lease (so "running" means curator is actually answering, not
  just "Start was clicked"). If curator's down it honestly stays "starting".
- stop(): → stopping; returns immediately (no handler block). A background
  monitor waits for the worker threads to actually exit, releases leases,
  then → stopped — bounded by STOPPING_TIMEOUT (20s) so a wedged submit can
  NEVER hold the UI in "stopping" again. In-flight work is handed back safely.
- Buttons follow the real state (Start only from stopped; both disabled
  through the transition), so you can't fight a transition.
- Log every Start/Stop button press (routes) and every transition (worker),
  so the Logs panel shows exactly what each button did.

Frontend now trusts s.state (drops the active>0 hack); VERSION → .8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:20:12 -04:00
bvandeusen 91ea06be79 feat(agent): Status shows smoothed jobs/min + downloads/min (replace jumpy gauges)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m26s
The buffer / on-GPU / downloader counts flip many times a second, so a 3s
status poll only ever samples noise — the tiles looked frozen (same value
twice) or random (wildly different), reading as "the Status section doesn't
update" when the backend was in fact live (operator-flagged 2026-07-01).

Replace the three instantaneous gauge tiles with two derived RATE tiles:
  - jobs / min      — GPU throughput, from the monotonic `processed` counter
  - downloads / min — fetch throughput, from a new monotonic `downloaded`
                      counter (bumped when a job is decoded into the buffer)
Together they also show pipeline balance (dl/min > j/min ⇒ GPU-bound; the
reverse ⇒ GPU starved). Both are EWMA-smoothed over the poll deltas, clamped
at 0 (agent restart resets the counters), and skip a backgrounded-tab gap.

The still-useful instantaneous state is demoted, not lost: buffer stays as
the occupancy bar; downloaders/consumers/on-GPU move to the sub-line. `waited
out` (transient) gets promoted to a tile.

backend: worker.status() gains `downloaded`; `_bump(downloaded=)`.
frontend: retiled Status + rate math in applyStatus; VERSION → .7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:01:29 -04:00
bvandeusen 98b2ac90dd refactor(agent): DRY pass on the GPU agent worker package
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
Consolidate genuine duplication in agent/fc_agent into single-source
helpers (behavior-preserving; DRY Pass process #594):

worker.py
- _fail(jid, image_id, exc, verb) — 4 terminal "fail this job" blocks
  (downloader HTTP-fault + decode, consumer non-transient + generic).
- _release(job_ids) (was _release_owned) — the one lease hand-back path;
  6 inline release([jid])+unhold sites now route through it.
- _stopped(stop_evt) + _abort_if_stopped(jid, stop_evt) — 4 stop-check
  -and-release blocks and every bare stop-check.
- _timed(stage) contextmanager — ~8 monotonic()/_record() timing pairs;
  records only on clean exit, matching the old skip-on-raise behavior.
- _ewma(prev, x, alpha) module fn — 3 EWMA updates in the autoscaler.

client.py
- _submit(path, payload) — submit / submit_embedding (retrying session).
- _post_quiet(path, payload) — heartbeat / fail / release fire-and-forget.

detectors.py
- Proposers._top(detector, image, cap) — merges components() and panels().

config.py
- _bool_env(name, default) — auto_start / auto_scale env parsing.

Left alone (recorded): the xyxy→norm-xywh conversion duplicated across
models.py/detectors.py (2 copies, independent wrapper modules — sharing
would couple them), and the _ensure_embedder/_ensure_proposers pair (same
lock shape, different concepts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:53:58 -04:00
bvandeusen 8a0237eeea fix(agent): stream videos via ffmpeg-from-URL instead of downloading the whole file
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
The failing "poison" jobs were 800MB+ 4K VR videos: the agent pulled the ENTIRE
file into memory (r.content) just to sample a few frames, which buffered ~1GB in
RAM and — on any slow/contended media store — got cut off mid-download
(ChunkedEncodingError), failed, and re-leased forever. Measured the media read at
~4–6 MB/s (raw off the share, curator out of the path), so no serving-layer tweak
helps; the file simply shouldn't be fully downloaded.

Environment-agnostic fix (works for any deployment, completes even when slow):
- media.sample_frames_from_url(): point ffmpeg straight at curator's /images URL.
  It Range-reads only the video index + up to max_frames of content — never the
  whole file — and reconnect flags resume a dropped transfer instead of failing.
  Generous, env-tunable timeout (FFMPEG_TIMEOUT, default 1200s) = completion over
  speed. Removes the bytes-based sample_frames (dead once videos stream).
- worker._download_decode: videos now stream (no fetch_image, no RAM blowup);
  stills still download+decode. On an ffmpeg miss, probe curator liveness
  (client.is_reachable) → fail the job if curator is up (unprocessable file, stops
  the infinite re-lease) vs release if curator is down (transient, survives a
  redeploy). Auth header passed so it works whether or not /images is gated.

Build marker 2026-07-01.6. Refs issue #1225.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 14:15:22 -04:00
bvandeusen aa0605585b fix(agent): log the REAL fetch/submit failure reason, not "curator unreachable"
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
"curator unreachable" was printed for every transient error, hiding whether a
single file's transfer stalled (ReadTimeout — curator is up, that stream is slow)
or curator itself is down (ConnectTimeout/ConnectionError) or errored (HTTP 5xx).
Those need completely different fixes, and we've been diagnosing the download
slowness blind.

Add _transient_reason(exc) → a specific label (HTTP <code>, else the exception
class: ReadTimeout / ConnectTimeout / ConnectionError / …) and use it in both
transient paths:
- downloader: "fetch failed job <id> (image <id>, ReadTimeout) — released, backing off"
- consumer:   "submit failed job <id> (<reason>) — released, re-lease later"

Now the logs say which failure it actually is (and which image), so we can tell a
slow/stalled transfer apart from an unreachable curator. Build marker 2026-07-01.5.
Refs issue #1225.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 12:33:51 -04:00
bvandeusen 0fe1674753 perf(web): stream files in 4 MiB chunks + 4 hypercorn workers (fix 40s downloads)
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m27s
The image library is on a CIFS/SMB share (mounted rsize=4 MiB, actimeo=1), and
Quart's FileBody streams in 8 KiB chunks — so serving one large original was
~19k network round-trips to the storage server, i.e. 30–58s per download
(operator-flagged). That's what starved the GPU agent (constant "curator
unreachable" backoff) AND slowed the browser: every byte is read off CIFS and
streamed through the Python app (no reverse-proxy sendfile), and only 2 hypercorn
workers meant the agent + the browser's thumbnail grid queued behind each other.

In-container fix, no new service:
- Raise FileBody.buffer_size 8 KiB → 4 MiB in create_app, matching the mount's
  read size: one round-trip per read, ~500× fewer. buffer_size is the MAX read so
  small thumbnails still read in one gulp, and Range/mime/ETag/conditional
  handling lives on Response — all preserved. Guarded so a Quart-internal change
  can't break boot.
- HYPERCORN_WORKERS default 2 → 4 so concurrent /images requests stop queuing.

Expected: large-file transfers drop from ~40s toward link speed (a few seconds)
for the agent and the browser. See issue #1223.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 11:51:09 -04:00
bvandeusen c22f37d64d feat(gallery): sort by earliest post date across all posts (new default)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
The gallery's newest/oldest sort keys off image_record.effective_date =
COALESCE(primary post's post_date, created_at). The primary post is often the
repost/download the file came from, so the grid led with download dates rather
than when content was first posted (operator-flagged).

Add a second materialized sort key, earliest_post_date = MIN(post_date) across
ALL of an image's provenance posts (every post it appears in), else created_at —
the original publish date. Mirrors the effective_date pattern so the sort stays a
forward index scan.

- alembic 0071: add earliest_post_date + index (DESC, id DESC); backfill
  created_at baseline then MIN over image_provenance ⋈ post.
- importer: recompute earliest_post_date whenever a dated post is linked (MIN over
  the image's provenance, which now includes the just-added row).
- gallery_service: new sorts posted_new / posted_old key off earliest_post_date;
  cursor + year/month grouping follow the active column transparently.
- api: accept posted_new|posted_old; DEFAULT is now posted_new so the grid leads
  with original publish date. newest/oldest (effective_date) still available.
- frontend: sort dropdown gains "Newest/Oldest post date" (default Newest post
  date); existing effective-date sorts relabelled "Newest/Oldest added".
- tests: service test asserts posted_new/posted_old key off earliest_post_date;
  frontend default-sort omission test updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 10:46:09 -04:00
bvandeusen ccbb5cbc9e fix(agent): stop the downloader pool stampeding a slow curator (congestion collapse)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
Operator hit an outage after the machine slept overnight: the agent showed
"curator unreachable" in a loop while curator's API (lease) was actually fine and
the browser could still load images — just slowly. Root cause is a feedback loop
in the new pipeline: every download streams a full original through curator's
single Python file-serving path, and the autoscaler grows DOWNLOADERS whenever the
buffer is empty. When downloads are merely SLOW/failing, the buffer is empty for
that reason — so the agent piled on more concurrent large-file GETs, saturating
curator's web workers + NFS, which slowed curator (and its browser) further and
produced more failures → more downloaders. Classic congestion collapse.

- Failure-aware autoscaling: if transient download failures rose since the last
  decision, SHRINK the downloader pool toward the floor instead of growing — the
  empty buffer is caused by failures, not the GPU starving. It ramps back up only
  once downloads succeed again.
- DL_MAX 24 → 8: 24 concurrent large-file downloads through one Python serving
  path is too many; 8 keeps a fast GPU fed without stampeding curator.
- fetch_image timeout 180 → (10, 60): the read timeout is between-bytes, so a
  large-but-flowing download still completes, but a stuck/dead connection fails in
  60s instead of hanging a downloader for 3 min and piling up stuck requests.

Build marker 2026-07-01.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 10:33:18 -04:00
bvandeusen ef3318aac1 feat(explore): more variance in the related rail (stronger MMR diversification)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m24s
Operator wants the Explore "related" rail to span more — the #1188 diversifier
was tuned conservatively. Push all three knobs so it reaches further across
clusters instead of clumping near the anchor:

- MMR lam 0.55 → 0.40 — weight the diversity penalty harder (the main dial).
- candidate pool min(200, max(limit*5, 60)) → min(400, max(limit*8, 100)) — a
  wider nearest-cosine pool so MMR has genuinely distinct neighbourhoods to pick
  from, not just the near-dupes.
- pHash dup_threshold 6 → 8 — collapse more near-duplicate reposts/clones,
  freeing rail slots for distinct picks.

Still deterministic (same set per image, just more spread) and relevance-anchored
via the lam*sim-to-anchor term. Backend-only; no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 00:46:56 -04:00
bvandeusen 7cdce0c474 feat(agent): temporal video dedup — drop near-duplicate frames before the GPU
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m24s
Near-static videos are the dominant GPU load: sampled into up to 64 frames, each
re-runs the whole detect→CCIP→SigLIP chain on ~identical content. Add a CPU
perceptual-hash frame dedup upstream of the GPU so the redundant frames are never
processed at all (not just their embeds).

- media.dedupe_frames() + _dhash(): 8×8 difference-hash (64-bit) per frame; greedy
  keep — a frame survives only if its hash differs from every kept frame by
  >= min_distance bits (Hamming). A static run collapses to one frame; genuinely
  distinct scenes all survive. Order + frame_time preserved.
- Called in worker._download_decode right after sample_frames, so it runs in the
  decode stage on the downloader thread (CPU) — the GPU consumers only ever see
  deduped frames, and buffered video items shrink (less RAM too).
- Env-tunable FRAME_DEDUPE_DISTANCE (default 8; higher keeps more frames for brief
  localized changes an 8×8 hash can miss; 0 disables). Logs `video frames N→M`
  when it drops any, so video load reduction is visible.

Complements the spatial per-frame crop dedup (2026-07-01.2); this is the temporal
axis. Build marker 2026-07-01.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 00:35:03 -04:00
bvandeusen eaae896858 feat(agent): dedupe near-duplicate crops before the SigLIP embed
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
Figure boxes are already NMS-merged (iou 0.6) and each YOLO detector self-NMSes,
but the combined per-frame crop pile (figure→concept ∪ anatomy component→concept
∪ panel) was embedded with no cross-proposer dedup — so genuine near-duplicates
slipped through (a figure box ≈ an anatomy component on a solo bust; overlapping
booru head classes on one head), embedding the same region twice and burning a
slot against max_regions.

Add detectors.dedupe_crops(): a greedy, high-IoU (default 0.85), kind-aware pass
over the pending (crop, template) list right before embed_batch — drop boxes that
overlap ≥ iou within the same kind, keep the highest score. The high threshold is
deliberate: it collapses only true near-identical boxes while preserving
intentional nested crops across scopes (a whole figure vs a small head component
sit well below it) and distinct kinds (concept vs panel). Env-tunable DEDUPE_IOU
(≥1.0 disables). Runs on CPU before the GPU work, so it cuts both embed cost and
region count. Temporal (cross-frame) dedup deferred. Build marker 2026-07-01.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 00:20:40 -04:00
bvandeusen afef95a87d feat(agent): download/GPU producer-consumer pipeline + fix detector fuse crash
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m25s
The agent workload is download-bound (download 400–5462ms vs GPU ~300–600ms),
so the old N-slot serial chain (each slot: lease→download→decode→GPU→submit)
left the fast GPU idle during every download. Rearchitect worker.py into a
producer/consumer pipeline:

  downloader pool (autoscaled by BUFFER OCCUPANCY) → bounded queue → 1–2 GPU
  consumers (detect+embed→submit)

- Downloaders are I/O-bound → many overlap; the autoscaler now tunes DOWNLOADER
  count by buffer fill (empty = GPU starving → add; full = outpacing GPU → add a
  2nd consumer if it has util/VRAM headroom and lifts throughput, else trim).
- Bounded buffer (12) = backpressure: a full buffer blocks downloaders, capping
  RAM + lease look-ahead. VRAM pressure sheds a consumer immediately.
- Heartbeat thread keeps every held lease alive (buffered jobs wait on the GPU;
  curator's 180s TTL would otherwise reclaim them mid-buffer).
- Preserves all resilience: lease exp-backoff, submit-path retry (#169),
  release-on-stop, region caps + video early-exit (#171). Stop drains BOTH pools
  and releases every held lease at once (single held-set as source of truth).
- Consumers SHARE one embedder + proposers instance (a 2nd consumer adds
  concurrent inference, not N× VRAM — bounds the VRAM creep seen with N slots).
- UI reworked for the pipeline: tiles show downloaders · buffer · on-GPU ·
  processed · errors, a buffer-occupancy meter, and a consumers/waited-out line;
  the dial now tunes downloaders. Build marker 2026-07-01.1.

Also fix the operator-flagged detector warning: yolo11n + the comic-panel model
threw "'Conv' object has no attribute 'bn'" on every image (ultralytics' load-
time Conv+BN fusion on a version-mismatched graph), silently disabling 2 of 3
crop proposers and spamming the log per image. Disable that fusion (unfused
inference is correct, marginally slower) and permanently self-disable a proposer
on the first inference failure instead of re-throwing forever.

Refs milestone 122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 23:34:12 -04:00
bvandeusen 83f1070a11 fix(agent): bound video GPU work — early-exit the frame loop at max_regions
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m23s
Image 81602 turned out to be a 156 MB mp4, not a huge still: the agent samples
up to 64 frames × ~32 regions/frame → ~2000 regions (the 413) and 64 frames of
detect+CCIP+embed (the 38s). The MAX_REGIONS backstop (#171) only truncated the
SUBMIT — the GPU work was already spent. Break out of the frame loop once
accumulated regions reach max_regions, so a long video costs ~a few frames of
GPU (~2-3s), not all 64 (~38s). The whole-image 'embed' task is unaffected (it
mean-pools all frames and returns before this loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 22:52:32 -04:00
bvandeusen c587ac667c fix(agent): cap figures + global region cap + reset active on stop
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m25s
Three safety/robustness fixes from the operator's run logs:

- Cap figures per frame (MAX_FIGURES, default 8) like components/panels already
  are. Uncapped, a huge/busy image yielded hundreds of figure boxes → hundreds
  of per-figure CCIP calls + crops → a 38s job AND a submit too big to accept
  (image 81602 looped on 413). This is the acute fix.
- Global per-JOB backstop (MAX_REGIONS, default 128): if total regions still
  exceed the cap (long video), keep the highest-scoring and log the drop, so a
  submit body can never blow past curator's limit.
- Stale "active" meter: stop() now resets _active to 0 (no slots remain, so the
  meter must read 0 at once), and _bump clamps at 0 so a slot finishing after the
  reset can't drive it negative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 22:42:50 -04:00
bvandeusen 7e74fa767c fix(agent): load huge images — disable PIL decompression-bomb guard
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Trusted local library, not an upload surface, so a legitimately large image
(90–95M px, operator-flagged) must load. PIL only WARNS at the 89M-px default but
RAISES DecompressionBombError at ~179M px, which would fail those jobs. Set
Image.MAX_IMAGE_PIXELS = None. (The agent works off individual extracted files —
curator's archive_extractor unpacks zip/cbz/rar/7z at import — so this is about
big single images, not archives.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 22:32:51 -04:00
bvandeusen 9f1148b110 chore(agent): modernise base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
Bump the GPU-agent base image from 12.4.1-cudnn-runtime-ubuntu22.04 (Python 3.10,
CUDA 12.4, early-2024) to 12.9.2-cudnn-runtime-ubuntu24.04:

- Ubuntu 24.04 LTS → Python 3.12 — one modern runtime, no more 3.10.
- CUDA 12.9 + cuDNN 9 — current within the CUDA-12 / cuDNN-9 line that the
  default onnxruntime-gpu wheel AND torch cu124 are built against. NOT CUDA 13:
  ONNX Runtime's CUDA-13 support is still nascent (separate wheels + open
  "Unsupported CUDA version: 13" reports), and torch bundles cu124 anyway. The
  GPU (Ampere/Ada, 12 GB) is fine on either — this is a library-alignment call,
  not a hardware limit.
- PIP_BREAK_SYSTEM_PACKAGES=1: 24.04 marks system Python externally-managed
  (PEP 668); a single-purpose container owns its environment, so global installs
  are fine and simplest.
- agent/ruff.toml pinned to py312 (was py310) so CI lints against the real
  runtime; from __future__ import annotations stays (PEP 649 lazy annotations
  are 3.14, so self-refs still evaluate on 3.12).

CI builds the image but has no GPU — validate on the desktop after pull that it
starts and loads CUDAExecutionProvider (not CPU fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 22:25:46 -04:00
bvandeusen f01b59f390 fix(agent): py3.10 startup crash + submit-path retry; pin agent ruff to py310
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
The agent container (CUDA base, Python 3.10) crashed on startup with
`NameError: name 'Config' is not defined` — an earlier `ruff --fix` unquoted the
`from_env(cls) -> Config` self-reference, which is safe on CI's Python 3.14
(PEP 649 lazy annotations) but is evaluated at class-definition time on 3.10.
CI lint/compile run on 3.14, so it slipped through.

- config.py: `from __future__ import annotations` so the self-referential
  annotation is a string, never evaluated — works on 3.10 and every version.
- agent/ruff.toml: pin the agent to `target-version = "py310"` (its real runtime)
  and inherit the root rules. Ruff now flags exactly this class as F821, so CI's
  lint lane catches it instead of shipping a broken image. (CI otherwise lints on
  3.14, masking 3.10 issues.)
- client.py: submit path now retries in-place. A dedicated session with a
  urllib3 Retry (connect/read/status, 0.5s backoff, 500/502/503/504, POST) so a
  momentary blip after the GPU work is done doesn't discard it and force a full
  re-download + recompute elsewhere. A duplicate submit after a lost response is
  a harmless 409 no-op. Lease/fetch keep the plain session + loop-level backoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 22:00:10 -04:00
bvandeusen 79269da802 fix(agent): prompt stop + lazy curator polling + build marker; add agent to CI
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
Addresses operator reports: Stop never finishes, the agent polls curator
constantly, and stale-cached pages get mistaken for a failed deploy.

- Stop is prompt: flip _running BEFORE any lock so /status + worker loops see
  "stopped" immediately, and add a stop/shrink checkpoint in _process (after
  decode, before the expensive detect+embed) that releases the job and bails —
  so a Stop doesn't wait out heavy GPU work.
- Lazy curator polling: the queue snapshot is fetched only while a browser is
  actually watching (a /status hit within UI_IDLE_GRACE) and on a 5s cadence,
  not a constant background loop. The work loop's own lease/submit is curator's
  only visitor otherwise — nothing polls just to poll.
- Build marker: VERSION is embedded in the page and reported on /status; the UI
  shows a "reload" banner when they differ, so a browser-cached page can't be
  mistaken for "the new image didn't deploy" (complements the no-store header).

CI: the lint lane now also `ruff check`s agent/ and compileall-parses it, so the
GPU agent is linted + syntax-checked before its image builds (build.yml only
`docker build`s it). Fixed the agent's pre-existing UP037/B905 so it passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 21:39:00 -04:00
bvandeusen e6a7fe7d03 feat(agent): per-stage timing breakdown (lease/download/decode/gpu/submit)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
Instrument the job pipeline so we can see where wall-clock actually goes and
decide — on data, not theory — whether a download/compute split is worth
building. Each stage is timed per job and a rolling breakdown is logged every
30s to the agent console, e.g.:

  timing/30s — lease 8ms · download 310ms · decode 40ms · gpu 165ms · submit 70ms | wall/job 585ms (214 jobs)

- lease timed around client.lease() in the slot loop (per batch).
- download = fetch_image; decode = image/frame decode; gpu = detect + CCIP +
  batched embed; submit = the results POST. One-time model load is excluded
  from the gpu figure.
- Thread-safe accumulator (stage -> [sum, count]) summarised + reset by a small
  daemon reporter thread; logs only when there was work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 21:28:46 -04:00
bvandeusen 181f1c6a27 perf(gpu-queue): partial indexes + two-phase lease so leasing stays O(batch)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
The throughput bottleneck was curator-side, not the network. lease() claimed the
lowest-id pending/expired jobs with `... ORDER BY id LIMIT n`, but with only a
plain `status` index Postgres walked the primary key from id=1, skipping the
entire prefix of already done/error rows before reaching pending ones. As `done`
grew (69k+), every lease became an O(done) scan — leasing crawled, the DB
saturated, and even /status (the queue GROUP BY count) stalled the agent.

- Migration 0070 adds two partial indexes over just the live slice: pending rows
  indexed by id (hot path), and leased rows by lease_expires_at (crash-recovery
  + orphan sweep). They stay tiny no matter how large the done/error history.
- lease() split into two phases so each uses a partial index: claim pending
  first (id-ordered, O(batch)); reclaim expired leases only when pending can't
  fill the batch. Same semantics (SKIP LOCKED, attempts++, expired reclaim).
- Model __table_args__ declares the indexes so ORM and schema agree.
- Test: a done-prefix at low ids must not stop the lease reaching pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 21:12:12 -04:00
bvandeusen f0f031782d fix(agent): unfreeze status view + smoothed throughput-aware autoscaler + log pane
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m23s
Operator: the status tiles (state/active/processed) and the Start/Stop buttons
freeze while the GPU meters stay live. Root cause: /status made an INLINE
blocking curator call (queue_status) on every poll, and with curator buried
under a 112k-job backlog that call stalled — freezing the whole status refresh
(the GPU bars survived because /gpu is a lock-free local read). Made worse by the
old util-band autoscaler, which grew workers toward the 32 cap forever because
util plateaus ~50% on this IO-bound load and never hit the 70 grow threshold —
piling load onto curator and the agent process.

- /status is now a pure in-memory read: worker.status() is lock-free, and the
  curator queue snapshot is refreshed by a background poller (never inline).
- Autoscaler replaced with a smoothed, throughput-aware climb that SETTLES:
  samples util every 2s and EWMA-smooths it (raw util swings 0↔99), then every
  ~24s grows by one only while each grow keeps lifting smoothed jobs/s; when a
  grow stops helping it backs off one and holds, re-probing occasionally. No
  runaway, no flopping.
- GPU util bar now shows a smoothed value: the agent's own EWMA (util_smooth,
  exposed on /gpu) when running, else smoothed client-side — so it glides
  instead of bouncing 0↔99.
- act() aborts a slow Start/Stop POST after 8s so the buttons can't stick; the
  now-always-fast /status refresh recovers state regardless.
- Log pane: bound the page to the viewport (height:100vh) so the Logs card
  scrolls INTERNALLY instead of overflowing off-screen; cap the ring buffer at
  400 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 20:20:14 -04:00
bvandeusen 82e1a4e127 fix(agent): send Cache-Control: no-store so a new image isn't masked by cache
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m21s
The control page is a static string served with no cache headers, so after
pulling a fresh agent image the browser kept showing the OLD UI until a hard
refresh (operator-flagged). Add a no-store middleware covering the page and the
status/gpu/logs polls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 20:00:26 -04:00
bvandeusen c2e9157822 feat(agent): graceful Start/Stop with starting/stopping states + instant status
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
Operator: the buttons fire but the status view doesn't reflect the change. Cause:
act() ignored the POST's own status response and waited on the separate /status
poll (which lags behind the curator queue call). Now:

- act() applies the POST's returned status immediately for instant feedback, and
  shows an optimistic "starting"/"stopping" state (pulsing, buttons disabled)
  the moment it's clicked.
- A stop that still has in-flight jobs draining shows "stopping" until active
  hits 0, then resolves to "stopped" on its own.
- applyStatus() guards the /status-only fields (connection pill + queue) so the
  lean action response can't blank them — the Start/Stop path deliberately skips
  the slow curator call to stay snappy.

Also de-duplicate GPU reads: read_gpu() now caches (1s TTL) with one probe at a
time, and /status no longer spawns its own nvidia-smi — so the fast /gpu poll +
autoscaler + /status share a single subprocess instead of piling up in the
server thread pool (which was what made clicks feel dead under load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 19:38:37 -04:00
bvandeusen 3b34230fbd fix(agent): stable util-band autoscaler + live GPU meters
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m40s
Two operator-reported issues with the GPU agent:

1. Worker count flopped almost every cycle, spiking the GPU. The hill-climb
   probed +1, judged it over a too-short noisy throughput window, saw no clear
   gain and reverted -1 — every tick. Replace it with a GPU-utilization-band
   controller: HOLD while smoothed util sits in a healthy band, grow only on
   clear spare capacity (util below the low mark + VRAM headroom), shrink under
   saturation or memory pressure. Util is EWMA-smoothed and decisions are spaced
   (DECIDE_EVERY samples), so a noisy nvidia-smi reading can't move the pool.
   Load stays consistent instead of probe/reverting.

2. GPU util/VRAM bars only updated on manual refresh. They rode the /status
   poll, which blocks on the curator queue call (slow when curator is busy), so
   the meters froze between refreshes. Give them a dedicated /gpu endpoint
   (local nvidia-smi only, no curator round-trip) polled every 1.5s, and drop
   the curator queue-status timeout 15s -> 5s so /status itself stays snappy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 19:16:17 -04:00
bvandeusen c259d03618 fix(agent): revert full-width page, grow the Logs section to the bottom
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m24s
Operator meant the LOG section should fill down the viewport (vertical), not the
whole page going full-width horizontally. Restore the centered column (820px),
make .wrap a full-height flex column, and let the Logs card flex to fill the
remaining height to the bottom (drop the fixed 230px log-pane cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 19:09:11 -04:00
bvandeusen 2713c3f773 perf(agent): batch SigLIP crop embeds per image + load truncated images
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
Two issues surfaced by the live logs (GPU pegged at ~0% util, 0.5 jobs/s,
truncated-image failures):

- BATCH the SigLIP embeds: collect all of an image's crops (figure + booru_yolo
  components + panels) and embed them in ONE forward pass instead of one
  forward+lock per crop. The per-crop path serialised every crop through the
  inference lock and starved the GPU (≈0% util, autoscaler stuck oscillating);
  batching gives a real GPU-bound workload + far higher throughput. CCIP still
  runs per figure inline.
- LOAD_TRUNCATED_IMAGES in the agent (matches the server embedder): slightly-
  truncated scraped images now load instead of failing the job 3× then erroring
  ("image file is truncated (N bytes not processed)").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 18:47:33 -04:00
bvandeusen 9eaefac385 feat(agent): full-width control page, Copy-logs button, quiet HTTP log noise
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
- Page fills the viewport horizontally (drop the 780px cap).
- Copy button on the Logs card → copies the console (clipboard API on localhost,
  textarea-execCommand fallback), with a brief "Copied" confirmation.
- Silence httpx/httpcore/huggingface_hub/urllib3/filelock/uvicorn.access/
  ultralytics to WARNING so the console shows agent activity (detector loads,
  job errors, autoscale moves) instead of per-request HF-download spam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 18:41:49 -04:00
bvandeusen c1b099e5a3 feat(agent): in-UI log console + a real styling pass on the control page
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m29s
- logbuf.py: bounded in-memory log ring buffer + a logging.Handler on the root
  logger; GET /logs serves it; the control page polls it into a console pane —
  so runs are monitorable without `docker logs`. worker now logs autoscale moves
  (one line per change, with jobs/s + util + VRAM) and job failures (job + image
  + reason); detectors already log load/disable.
- Restyled the whole control page: a proper dark layout with a header + live
  connection pill, cards (Control / Status / Logs), a styled Auto switch +
  worker stepper, status tiles, separate GPU-util and VRAM meters, and the log
  console. No longer feels like an afterthought; all the existing control hooks
  are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 18:34:22 -04:00
bvandeusen 6d7b17b0b5 feat(agent): autoscale the worker count (throughput hill-climb), Auto default-on
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m23s
The new per-job workload (3 detectors + several SigLIP embeds) is far more
GPU-bound than the old I/O-bound CCIP pass, so the right worker count shifted and
is hard to guess. Add an Auto mode (default ON) that finds it:

- _control_loop samples jobs/sec + GPU util/VRAM every ~6s and hill-climbs the
  target: grow while throughput keeps improving and VRAM stays under budget,
  revert a step that doesn't help, back off under memory pressure (VRAM >= 90%),
  then settle and periodically re-probe (the GPU/IO balance shifts over a run).
- A manual concurrency set is an override → leaves Auto; an "Auto" toggle in the
  control UI re-enables it. status() reports `auto`; the dial reflects the
  auto-chosen count (read-only) while Auto is on.
- AUTO_SCALE env (default on) + compose doc. Agent py-compiled (outside CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 18:19:15 -04:00
bvandeusen 359bc5a283 feat(ml): default to SigLIP 2 (new installs) + model dropdown, no free-text (#1203)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
- Migration 0069: new installs default to SigLIP 2 (so400m, 512px, 1152-d drop-in)
  — UPDATE applies ONLY where no image is embedded yet (fresh install), so an
  existing library is NOT silently invalidated; it switches deliberately via the
  dropdown → Re-embed → Retrain. Column server_defaults moved to SigLIP 2.
- GET /api/ml/embedder-models: server-authoritative supported list (SigLIP 2 512
  recommended / 384 faster / SigLIP 1 384 original) so the UI never free-types.
- GpuAgentCard: the two name/version text fields → a single model dropdown;
  Save sets name+version from the picked option (the current model is always
  selectable even if off-list).
- embedder.py DEFAULT_MODEL_NAME unchanged (stays the baked local-dir SigLIP 1)
  to avoid a local-dir/weights mismatch; SigLIP 2 loads by HF name, cached on the
  ml-worker's persistent HF_HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 16:29:27 -04:00
bvandeusen 80f8eb4756 feat(gpu): re-process trigger to apply new crop detectors to the existing library (#1202)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
The siglip/ccip backfills skip images that already have current-version regions,
so adding crop detectors only affected NEW images — the back-catalogue would
never be re-cropped. Add a reprocess trigger that resets every done/error job of
a task back to pending, so the agent re-runs the FULL pipeline (figure detection
+ CCIP + concept/panel crops) over the whole library under the current detectors.

- reprocess_gpu_jobs(task='ccip') task + POST /api/gpu/reprocess.
- gpu store reprocess() + GpuAgentCard "Re-process library (re-detect + re-crop)"
  button with a confirm (it's heavy).
- Test: a done job resets to pending (attempts cleared).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 16:09:37 -04:00
bvandeusen ce5db5caaf chore(agent): default the anatomy + panel proposer weights to working values
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
booru_yolo yolov11m_aa22.pt (40MB) + mosesb/best-comic-panel-detection::best.pt.
Each self-disables if its download fails, so defaulting them on is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 15:28:50 -04:00
bvandeusen d5f29f7056 feat(agent): crop proposers — booru_yolo anatomy + COCO person + comic panels (#1202)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
Better region PROPOSERS feeding the existing crop→SigLIP→max-over-bag heads (no
change to the learned-tagging approach; no per-tag cost — propose once, embed
each region, all heads in one matmul).

- detectors.py: lazy ultralytics YOLO wrapper, each proposer independently
  optional + guarded (a bad weight spec / inference error self-disables that one,
  logged, never breaks the worker). Weights resolve from an ultralytics name |
  http(s) URL | "hf_repo::file", cached under HF_HOME. NMS merge so a figure two
  detectors both find collapses to one crop.
- worker: figure boxes = imgutils detect_person ∪ general COCO person (merged)
  → CCIP + concept (anime + Western/realistic coverage); booru_yolo anatomy
  components (head/cat-head/anatomy/…) → concept crops; comic panels → kind=
  'panel' concept crops. Capped per frame (MAX_COMPONENTS/MAX_PANELS).
- config + compose: PERSON_WEIGHTS (default yolo11n.pt, works OOB),
  ANATOMY_WEIGHTS + PANEL_WEIGHTS (operator sets booru_yolo URL + mosesb panel
  hf::file; empty = off). ultralytics added to requirements.
- backend: image_region 'kind' doc notes 'panel'; no migration (free String,
  and the bag scorer keys on a non-null siglip_embedding, not the kind, so any
  SigLIP region joins the bag automatically).

Agent is outside CI — py-compiled here; operator tests on the GPU and checks
Western-vs-anime crop quality via /api/ccip observability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 15:27:26 -04:00
bvandeusen 3d7f60a6e3 fix(lint): use dict() not a dict-comprehension in tag_stats (C416)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 13:52:16 -04:00
bvandeusen 9a3cda007a feat(api): agent-friendly tag analysis endpoints — /tags/top + /tags/<id>/stats (#1136)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m23s
Fast, read-only, indexed aggregates shaped for ANALYSIS (not the paged UI
directory, which is alphabetical + builds previews and timed out at 10 min on a
full count sweep).

- GET /api/tags/top — top tags by image count, desc. ?kind, ?limit (cap 500),
  ?min_count, ?source=all|human|manual|accepted|auto (human=manual+ml_accepted,
  auto=head_auto+ccip_auto+ml_auto). One GROUP BY over image_tag (indexed on
  tag_id).
- GET /api/tags/<id>/stats — per-tag dataset health: total + per-source counts
  (manual/accepted/head_auto/ccip_auto), human vs auto rollups, rejection count,
  and whether a trained head exists. Backs concept-readiness + source-split
  analysis.

Plain-HTTP homelab posture, no auth change. Tests cover ranking, source filter,
min_count, the source breakdown, and 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 13:47:55 -04:00
bvandeusen bc6d43d3f2 refactor(ml): drop dead tagger/suggestion settings + columns (#1199)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m31s
Hygiene follow-up to the Camie retirement (#1189) — these were left inert to
bound that change; nothing reads them now. Migration 0068 drops:
- ml_settings: tagger_store_floor, tagger_model_version, suggestion_threshold_
  character/general (already dead pre-retirement — scoring uses per-head
  thresholds), video_min_tag_frames (only the deleted video-prediction
  aggregator used it).
- image_record: tagger_model_version (no writer), centroid_scores (dead JSON
  cache, no reader).

Also: ml_admin _EDITABLE/GET/_validate pruned (dropped the store-floor invariant
+ video_min_tag_frames check); MLThresholdSliders trimmed to a video-embedding
card (interval + max frames only); importer no longer resets the dropped cols;
download_models drops the Camie fetch; stale CASCADE comments in cleanup_service
no longer name the removed tables. Tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 13:41:25 -04:00
bvandeusen 3d97667f5b fix(lint): drop unused select import in tags.py after allowlist removal
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 13:08:46 -04:00
bvandeusen 485387ff0b refactor(ml): retire the Camie tagger + allowlist bulk-apply (#1189)
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m27s
Heads + CCIP are the tag source and head auto-apply is the earned propagation.
The Camie tagger ran only to feed the allowlist bulk-apply (its ImagePrediction
rows had no other consumer), and the allowlist was a SECOND, un-earned auto-apply
path firing in parallel with heads on every accept — exactly the un-earned spray
the v2 pivot replaced. Retire both.

Behavior change: accepting a suggestion now applies the tag to THAT image only
(source='ml_accepted', a head-training positive) — it no longer allowlists +
fans the tag across the library via Camie. Propagation is heads' earned
auto-apply. (Loses instant cold-start propagation for booru-vocab tags; that was
un-earned and bypassed the precision gate.)

- tag_and_embed is now EMBED-ONLY (no Camie load/infer, no ImagePrediction
  writes); backfill enqueues it for images with no embedding.
- Removed: services/ml/tagger.py, apply_allowlist_tags + helpers + daily beat +
  every enqueue caller (accept/alias/merge/per-image), api/allowlist.py +
  blueprint, ImagePrediction + TagAllowlist models/tables (migration 0067),
  AllowlistTable.vue + allowlist store, the accept coverage-projection payload.
- AllowlistService gutted to accept/dismiss/undismiss/reject (the rejection store
  the rail still needs); accept returns nothing, API returns {accepted, tag_id}.
- tag merge no longer repoints/triggers the allowlist; _keep_as_alias now keys on
  ML-applied image_tag sources (incl. head_auto) instead of the allowlist.
- UI: MLBackfillCard relabelled to embedding-only; accept toast simplified;
  MaintenancePanel drops the allowlist tile.

Left for a follow-up hygiene pass (now-inert, harmless): the dead settings
columns (tagger_store_floor, tagger_model_version, suggestion_threshold_*,
video_min_tag_frames), image_record.tagger_model_version, MLThresholdSliders
trim, and the Camie model download in download_models.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 13:04:31 -04:00
bvandeusen 3d77a38a25 refactor(ml): remove the dead per-tag centroid subsystem (#1189)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m32s
The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
Centroids were still recomputed (on every tag merge + a daily beat) but NOTHING
read them — suggestions come from heads+CCIP and apply_allowlist_tags applies
via Camie predictions, not centroids. Pure dead wiring; remove it.

Removed: CentroidService, recompute_centroid/recompute_centroids tasks, the
daily beat, POST /api/ml/recompute-centroids, the recompute-on-merge trigger,
the tag_reference_embedding table + model, the centroid_similarity_threshold +
min_reference_images settings (migration 0066), the CentroidRecomputeCard +
its store action + MaintenancePanel tile, and the centroid slider in
MLThresholdSliders. _keep_as_alias drops its vestigial has-centroid branch (the
allowlist branch already covers "could re-emit"); tag merge no longer clears a
table that no longer exists.

NOT touched (still live, parallel to heads): the Camie tagger, ImagePrediction,
and the allowlist bulk-apply — accepting a suggestion still allowlists + applies
it across the library. The tag-eval "centroid" baseline metric is unrelated
(in-memory) and stays. (image_record.centroid_scores JSON column also remains —
separate legacy field, its own micro-cleanup.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 11:48:09 -04:00
bvandeusen 4daa3f2790 feat(ml): operator model swap — GPU re-embed + embedder as a setting (#1190)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m33s
Make the SigLIP embedder an operator choice (drop-in to SigLIP 2:
google/siglip2-so400m-patch16-512 is a verified 1152-d model at 512px → no
schema change, better small-cue fidelity). A swap = set model + re-embed +
retrain, all operator-driven; the GPU agent does the re-embed so it's fast.

- settings: embedder_model_name is now a setting (migration 0065) alongside the
  existing embedder_model_version; both editable + validated (non-empty) in the
  ml admin API. The server embedder loads by HF name (AutoImageProcessor/Model,
  model-agnostic), preferring the pre-downloaded local dir for the default so
  existing deploys don't re-download; rebuilds on a name change.
- agent: new 'embed' job = whole-image SigLIP embedding (mean-pool video frames)
  under the lease-announced model → POST /jobs/submit_embedding writes
  image_record.siglip_embedding + siglip_model_version. The lease now announces
  the model FROM THE SETTING (not a constant).
- re-embed routing: enqueue_gpu_backfill('embed') selects unembedded + stale-
  version images; 'siglip' now re-embeds concept crops whose version != current
  (so a swap re-triggers crops, not just the never-embedded back-catalogue). The
  CPU ml-worker backfill no longer re-embeds on a version mismatch (it can't
  churn the library at 512px) — the GPU agent owns version re-embeds. Daily
  'embed' + 'siglip' beats self-heal.
- scoring: score_image only bags embeddings in the CURRENT model's space (whole-
  image gated by siglip_model_version, concept regions by embedding_version) so a
  mid-swap stale vector isn't scored by new-space heads; legacy NULL = current.
- UI: GpuAgentCard "Embedding model (advanced)" — edit name/version, Save, and
  "Re-embed library (GPU)" (queues embed + siglip); points at SigLIP 2.

Tests: lease announces model + submit_embedding round-trip; enqueue 'embed'
selects stale/unembedded; stale-version excluded from scoring; embedder model
settable + empty rejected; siglip gate updated to current-version concept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 10:24:30 -04:00
bvandeusen 0f472b2f9e fix(explore): diversify "more like this" so it stops getting stuck (#1188)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m32s
Pure nearest-cosine piled near-identical images into the neighbour grid — a
reposted banner filled all 24 slots, and once you wandered into a B&W /
comic-panel cluster every neighbour was more of the same with no way back to
colour without the Random button (operator-reported, with screenshot).

similar() now over-fetches a wide candidate pool (5x the requested limit, cap
200), then diversifies down to `limit`:
- pHash near-duplicate collapse: drop candidates within 6 Hamming bits of the
  anchor or an already-kept candidate, so a repost (and the anchor's own clones)
  appears at most once.
- MMR re-rank: greedily pick for closeness-to-anchor minus similarity-to-already
  -picked (lambda 0.55), so the result SPANS clusters instead of returning 40
  variations of one image. Falls back to nearest-order on any failure / small
  pool, so existing nearest-first behaviour is unchanged when there's nothing to
  diversify.

Frontend forwardTarget drops the now-redundant skip-nearest-third hack (the list
is already diversified server-side) — plain random-over-unvisited gives the
variance now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 09:01:01 -04:00
bvandeusen 715e276c03 Merge pull request 'feat(tagging): SigLIP concept crops + max-over-bag scoring (#114)' (#153) from feat/siglip-concept-crops into dev
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-30 08:40:24 -04:00
bvandeusen 55fa4656ff feat(agent): survive + auto-recover when curator is unreachable
For redeploying curator while away with nobody to restart the agent:

- _process now distinguishes a TRANSPORT error (curator down/redeploying, 5xx,
  401/403/408/409/429, or our lease reclaimed mid-flight) from a genuine job
  fault. On a transport error it hands the job back (best effort) and signals
  the loop to back off — instead of calling fail(), which would burn the job's
  server-side attempt budget (MAX_ATTEMPTS=3) and permanently error good jobs
  across a redeploy. Job-specific 4xx (404 image gone) still fail so they don't
  re-lease forever.
- lease loop retries with capped exponential backoff (poll_idle → 60s) and
  resets on the first successful lease, so a long outage is gentle and recovery
  is automatic within ≤60s of curator returning. Sleeps are interruptible so
  Stop / pool-shrink stays responsive.
- AUTO_START env (default on in compose) resumes the worker on container start,
  so a host reboot / crash-restart (restart: unless-stopped) self-heals with
  nobody at the desktop.
- control UI shows a "waited out" counter + an "curator unreachable, holding
  work" banner so the recovering state reads as recovery, not failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 08:33:33 -04:00
bvandeusen c6f38b0dac feat(tagging): SigLIP concept crops + max-over-bag scoring (#114)
Lift recall on small/local concepts (glasses, cum, stomach-bulge, xray,
lactation) that the whole-image SigLIP vector washes out: the GPU agent now
embeds figure crops with SigLIP too, stored as kind='concept' regions, and the
suggestion rail scores each image as a BAG (whole-image + every concept crop),
taking each head's MAX over the bag. The whole-image vector is always in the
bag, so this can never score lower than before.

Model-agnostic by construction: the server ANNOUNCES the embedding model
(HF name + version) in the lease, so the agent loads whatever the heads were
trained in and stays in lock-step — a model swap is a server setting + a
re-embed migration, never an agent change.

- agent: model-agnostic CropEmbedder (torch/transformers get_image_features,
  fp16 on CUDA, inference-locked); worker branches on job.task — 'ccip' emits
  figure(CCIP)+concept(SigLIP) in one pass, 'siglip' emits concept-only so the
  back-catalogue backfill never churns figure/CCIP regions; torch cu124 +
  transformers in the image.
- server: lease announces embed_model_name/embed_version; score_image is
  max-over-bag (version-filtered region embeddings); enqueue_gpu_backfill
  'siglip' gates on a missing concept region (drains the back-catalogue,
  retries failures, no double-enqueue); daily siglip-backfill beat; UI button;
  /api/ccip/overview reports images_with_concept_siglip.
- v1 scope: suggestion rail only — auto-apply stays whole-image (conservative;
  heads' thresholds were calibrated on whole-image). Bulk-apply bag = follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 08:17:47 -04:00
bvandeusen b91a230f12 feat(ccip): automation + reference quality — keep identity flowing hands-free (#114)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m32s
Works through the optional CCIP ideas + the "keep moving even if I forget" ask:

AUTOMATION (no button needed):
- Hourly beat auto-enqueues CCIP backfill — new images get embedded (and errored
  ones retried) on their own; the queue never goes idle waiting for a click.
- CCIP auto-apply: a daily sweep tags confident matches (source='ccip_auto') so
  identity tags keep flowing. ON by default (opt-out, like head auto-apply);
  ml_settings.ccip_auto_apply_enabled + _threshold (0.92, above the suggest cut),
  migration 0064. Vectorized (one matmul + reduceat per image), reversible, skips
  already-applied/rejected. Switch + threshold in the GPU agent card; GET/PATCH
  /api/ml/settings; auto_applied count in /api/ccip/overview.

REFERENCE QUALITY (the over-fire root cause):
- character_references now draws ONLY from single-character images — on a
  multi-character image the tag is image-level, so every figure would otherwise
  pollute each character's prototypes (a 2-char image tagged 'Velma' made
  Daphne's figure a Velma reference). This is the contamination behind residual
  over-firing.
- Cached on a cheap signature (char-tag count + ccip-region count/max-id) so the
  reference load isn't redone on every modal open.

Tests: multi-character image not used as a reference; auto-apply tags a confident
match as ccip_auto.

NEXT (not done, confirmed): comic-panel cropping + SigLIP concept crops ("spot
interesting content").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-29 22:25:40 -04:00
bvandeusen 74b7ceaf47 fix(tags): return focus to the tag input after reject/un-reject too
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
Accept already re-focused the tag input (so you keep typing without re-clicking);
reject (✗) and un-reject (↶) went straight to the store and skipped it. Route
them through onDismiss/onUndismiss which emit 'dismissed', and wire that to
focusTagInput in TagPanel — same return-to-input behaviour as accept. TagPanel is
shared, so this covers both the image modal and the Explore workspace. The
field's mobile-focus guard is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-29 21:06:34 -04:00
bvandeusen 301f2de989 fix(explore): variance + no loop-back on → navigation (#94)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
Two reports: → sometimes "loops back", and the walk gets stuck on near-identical
images. Cause: forwardTarget picked a uniformly-random neighbour from the 24
NEAREST, so it (a) often landed on an image already in the trail — which snaps
the cursor back into history and makes → bounce between visited nodes — and (b)
only ever offered near-duplicates.

forwardTarget now: excludes already-visited neighbours (→ opens something new,
no snap-back), and skips the closest third of the (similarity-sorted) pool so the
jump favours the more-varied remainder instead of lookalikes. Neighbour pool
widened 24→40 for more variety to browse + jump into. The post-← browser-forward
walk through visited crumbs is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-29 20:44:16 -04:00