Compare commits

..

205 Commits

Author SHA1 Message Date
bvandeusen 2923257529 Merge pull request 'fix(ui): tag-chip left-edge clipping + kind-coloured border' (#206) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-08 10:37:09 -04:00
bvandeusen 934731e9ef Merge pull request 'feat(translation): per-field language detection for mixed-language posts' (#205) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
2026-07-08 09:25:23 -04:00
bvandeusen 4c67c116b2 Merge pull request 'Bolder auto-tag accept/reject + larger tag chips' (#204) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-07 23:21:26 -04:00
bvandeusen f8b667604f Merge pull request 'Graceful shutdown + resilience & Explore polish (m147)' (#203) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m48s
2026-07-07 22:53:34 -04:00
bvandeusen 11572f469a Merge pull request 'Re-translate on Interpreter model change — artist-scoped + global (m146)' (#202) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m41s
2026-07-07 20:22:40 -04:00
bvandeusen ba3fd2a118 Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
2026-07-07 13:05:18 -04:00
bvandeusen 9bf095179a Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s
2026-07-07 01:13:02 -04:00
bvandeusen cefc064e0f Merge pull request 'Surface auto-hide misfires proactively (review strip no longer gated on Show-hidden)' (#199) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s
2026-07-07 00:43:45 -04:00
bvandeusen 06757daf80 Merge pull request 'Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes' (#198) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m46s
2026-07-07 00:24:51 -04:00
bvandeusen a723ef436b Merge pull request 'Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist)' (#197) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m46s
2026-07-06 21:03:30 -04:00
bvandeusen d68f39ca50 Merge pull request 'Suggestions perf + UX polish: incremental CCIP + head training, hover label, scroll/toast fixes, dead-preview removal' (#196) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-07-06 16:41:08 -04:00
bvandeusen a66a695977 Merge pull request 'Tag-hover → crop-region grounding overlay (#1206, milestone #133)' (#195) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m51s
2026-07-06 13:29:31 -04:00
bvandeusen 5289fa3879 Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-05 20:10:02 -04:00
bvandeusen ebac34e17b Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-04 22:43:12 -04:00
bvandeusen 4c3ba20198 Merge pull request 'Recapture image→post linking (#1288) + ugoira timings + system suggestion category' (#192) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m36s
2026-07-04 20:36:58 -04:00
bvandeusen d2acec61ae Merge pull request 'Pixiv native ingester (#129) + SubscribeStar .art fix, disable-clears-failure, system-tag suggestion floor' (#191) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m35s
2026-07-03 16:36:56 -04:00
bvandeusen 15ae5ef4fa Merge pull request 'SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128)' (#190) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m34s
2026-07-03 09:16:16 -04:00
bvandeusen e3ceefc820 Merge pull request 'Surgical re-fetch for deep posts (ExternalLink reset) — recovery no longer needs deep scans' (#189) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 14s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m10s
CI / integration (push) Successful in 3m40s
2026-07-02 21:14:45 -04:00
bvandeusen e52090a4cf Merge pull request 'Agent sleep mode: empty queue sheds to one downloader, lease poll backs off to 15 min' (#188) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m50s
2026-07-02 20:37:48 -04:00
bvandeusen bfba8045e4 Merge pull request 'Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset' (#187) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m29s
2026-07-02 18:01:56 -04:00
bvandeusen a708357436 Merge pull request 'Settings defaults, GPU error tombstones + failure triage/recovery, agent bandwidth cap, approved retirements' (#186) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m33s
2026-07-02 15:48:48 -04:00
bvandeusen d9354ac1e1 Merge pull request 'Agent: short-video sampling fix + "Retry errored jobs" recovery action' (#185) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-07-01 21:13:48 -04:00
bvandeusen 1d48770793 Merge pull request 'Agent: fix Status freeze — conchint.textContent destroyed #capn (root cause)' (#184) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 20:46:28 -04:00
bvandeusen 489e6aaaee Merge pull request 'Agent: server-side throughput rates + killable-on-stop ffmpeg' (#183) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 20:01:00 -04:00
bvandeusen ed20df905b Merge pull request 'Agent: DRY pass + Status rate-metrics + real start/stop state machine' (#182) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 16:30:32 -04:00
bvandeusen 5ba9871ef0 Merge pull request 'fix(agent): stream videos via ffmpeg-from-URL (no full download) — env-agnostic' (#181) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-07-01 14:20:53 -04:00
bvandeusen 2a820d0848 Merge pull request 'fix(agent): log the real fetch/submit failure reason (diagnose #1225)' (#180) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m28s
2026-07-01 12:37:45 -04:00
bvandeusen 2f9aa3d86c Merge pull request 'perf(web): 4 MiB file streaming + 4 hypercorn workers (fix 40s downloads)' (#179) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / integration (push) Successful in 3m26s
2026-07-01 11:55:35 -04:00
bvandeusen 560a5000a2 Merge pull request 'gallery: sort by earliest post date across all posts (new default)' (#178) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 10:54:41 -04:00
bvandeusen 7ddad231f8 Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m22s
2026-07-01 10:45:48 -04:00
bvandeusen a78f7eaace Merge pull request 'explore: more variance in the related rail (stronger MMR diversification)' (#176) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-07-01 00:50:59 -04:00
bvandeusen 71337b0ba4 Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 00:38:53 -04:00
bvandeusen 3996205f3b Merge pull request 'agent: dedupe near-duplicate crops before the SigLIP embed' (#174) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
2026-07-01 00:24:39 -04:00
bvandeusen 9f9db01456 Merge pull request 'agent: download/GPU producer-consumer pipeline + detector fuse fix' (#173) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 23:39:25 -04:00
bvandeusen 216b7fc743 Merge pull request 'Agent: bound video GPU work (early-exit frame loop at max_regions)' (#172) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 22:57:33 -04:00
bvandeusen fbb76e6f36 Merge pull request 'Agent: huge-image load + figure/region caps + stale-active fix' (#171) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 22:47:48 -04:00
bvandeusen 5ef1478ade Merge pull request 'Modernise agent base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9' (#170) from dev into main
Build images / build-ml (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 8m44s
2026-06-30 22:30:18 -04:00
bvandeusen 88ff4147e1 Merge pull request 'Fix: agent py3.10 startup crash + submit-path retry + pin agent ruff to py310' (#169) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 22:03:57 -04:00
bvandeusen 1ea02ad44c Merge pull request 'Release: prompt agent stop + lazy curator polling + build marker + agent in CI' (#168) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m23s
2026-06-30 21:42:55 -04:00
bvandeusen 937cfb65b4 Merge pull request 'Release: agent per-stage timing breakdown' (#167) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 21:28:57 -04:00
bvandeusen baac851220 Merge pull request 'perf: GPU-job leasing stays O(batch) — partial indexes + two-phase lease' (#166) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m27s
2026-06-30 21:18:23 -04:00
bvandeusen a28c33281a Merge pull request 'Release: unfreeze agent status view + smoothed throughput autoscaler + log pane' (#165) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:20:34 -04:00
bvandeusen 40be0a9323 Merge pull request 'Release: agent page no-store cache header' (#164) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Failing after 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:00:36 -04:00
bvandeusen 2c6bf26bfc Merge pull request 'Release: graceful Start/Stop (starting/stopping states) + cached GPU reads' (#163) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m24s
2026-06-30 19:38:50 -04:00
bvandeusen 3c1e76bd44 Merge pull request 'Release: stable util-band autoscaler + live GPU meters' (#162) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
2026-06-30 19:16:29 -04:00
bvandeusen 831c5b1c10 Merge pull request 'Release: agent page — centered column + logs fill to bottom' (#161) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 19:10:50 -04:00
bvandeusen 76b6af4903 Merge pull request 'Release: agent perf (batched crop embeds) + UI (full-width, copy logs, quiet noise) + truncated-image fix' (#160) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m32s
2026-06-30 18:47:55 -04:00
bvandeusen 92494ec4ed Merge pull request 'Release: agent control-page logs console + styling pass' (#159) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 18:37:35 -04:00
bvandeusen bdfc17477c Merge pull request 'Release: GPU agent autoscaler (throughput hill-climb, Auto default-on)' (#158) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m24s
2026-06-30 18:23:15 -04:00
bvandeusen 6cd3153bf4 Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s
2026-06-30 18:01:01 -04:00
bvandeusen 5f2853168a Merge pull request 'Release: GPU re-process trigger (apply new crop detectors to the existing library)' (#156) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 3m35s
2026-06-30 16:13:36 -04:00
bvandeusen 9a979ee808 Merge pull request 'Release: Explore diversification, model-swap, Camie/centroid retirement, tag-API, crop proposers' (#155) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m26s
Build images / build-agent (push) Successful in 10m56s
2026-06-30 16:06:13 -04:00
bvandeusen 3138f912fd Merge pull request 'Release: SigLIP concept crops + max-over-bag + agent redeploy-survival' (#154) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m35s
Build images / build-agent (push) Successful in 7m50s
2026-06-30 08:46:20 -04:00
bvandeusen 9df874e396 Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
2026-06-29 22:29:30 -04:00
bvandeusen 6915a7590a Merge pull request 'fix(tags): refocus tag input after reject/un-reject too' (#151) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-29 21:07:21 -04:00
bvandeusen 5e8c28236a Merge pull request 'CCIP match threshold (tunable, 0.85) + Explore → variance' (#150) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-29 20:48:14 -04:00
bvandeusen 7e3c0f0b74 Merge pull request 'agent: raise worker cap to 32 + size HTTP pool' (#149) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 1s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m47s
Build images / build-agent (push) Successful in 3m11s
CI / integration (push) Successful in 3m31s
2026-06-29 19:42:11 -04:00
bvandeusen 5d0c7ba706 Merge pull request 'fix(agent): flatten transparency onto white before RGB' (#148) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-06-29 19:18:48 -04:00
bvandeusen 18300e1f8a Merge pull request 'Agent: GPU load + live worker tuning + fast orphan recovery' (#147) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m27s
2026-06-29 19:11:22 -04:00
bvandeusen d52ac0a0e2 Merge pull request 'fix(agent): cuDNN base image so onnxruntime-gpu loads' (#146) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m50s
Build images / build-agent (push) Successful in 3m29s
CI / integration (push) Successful in 3m28s
2026-06-29 18:48:24 -04:00
bvandeusen 401fe8213e Merge pull request 'ci: publish the GPU agent image' (#145) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-agent (push) Successful in 3m15s
CI / integration (push) Successful in 3m27s
2026-06-29 14:26:51 -04:00
bvandeusen e8774d7953 Merge pull request 'CCIP characters + crop/region pipeline + desktop GPU agent (#114)' (#144) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m26s
2026-06-29 14:18:57 -04:00
bvandeusen bc0f00c51b Merge pull request 'Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav' (#143) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m24s
2026-06-29 07:30:41 -04:00
bvandeusen 1bef68aa29 Merge pull request 'Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject' (#142) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m25s
2026-06-28 11:55:49 -04:00
bvandeusen 1a4bc2f981 Merge pull request 'tag-eval: "keep" records a confirmation so doubts stop resurfacing' (#141) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m20s
2026-06-28 01:36:26 -04:00
bvandeusen 862ace69d6 Merge pull request 'fix(tag-eval): stop re-suggesting already-rejected items' (#140) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m19s
2026-06-28 01:10:26 -04:00
bvandeusen abf88b1a15 Merge pull request 'tag-eval: auto-apply operating point + server-side top-N concept discovery' (#139) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m19s
2026-06-28 00:54:14 -04:00
bvandeusen c05dcafbea Merge pull request 'fix(tag-eval): example thumbnail opens the view modal instead of Explore' (#138) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m20s
2026-06-28 00:11:20 -04:00
bvandeusen 55e8632dab Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s
2026-06-27 23:49:12 -04:00
bvandeusen 825e6b90bf Merge pull request 'Tag-eval (heads vs centroid) + focus/provenance/layout/sticky-header fixes + tag-gaps cleanup' (#136) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m31s
CI / integration (push) Successful in 3m21s
2026-06-27 23:03:04 -04:00
bvandeusen fb012c557c Merge pull request 'fix(explore): bound 3-pane grid row so a tall rail can't scroll the page' (#135) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m20s
2026-06-26 23:37:43 -04:00
bvandeusen 66593ab895 Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
2026-06-26 21:40:56 -04:00
bvandeusen b266a54ad3 Merge pull request 'feat(explore): auto-focus tag input on every image change' (#133) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
2026-06-26 11:28:32 -04:00
bvandeusen ad803b646f Merge pull request 'fix(modal): place meta + save block under Provenance, above Tags' (#132) from dev into main
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 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
2026-06-26 10:14:15 -04:00
bvandeusen 1f5da3d283 Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s
2026-06-26 08:24:26 -04:00
bvandeusen 93034f580d Merge pull request 'fix(tags): fandom views aggregate images via their characters' (#130) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m34s
CI / integration (push) Successful in 3m19s
2026-06-26 00:28:21 -04:00
bvandeusen 9b9b12f410 Merge pull request 'Modal suggestions scroll-cap + Celery broker resilience' (#129) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Failing after 2m34s
CI / integration (push) Successful in 3m30s
2026-06-24 08:34:17 -04:00
bvandeusen 376d310693 Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s
2026-06-23 08:57:34 -04:00
bvandeusen bc69495a16 Merge pull request 'Provenance archive linkage, post reconciliation close-out, and the cleanup/admin DRY pass' (#127) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m22s
2026-06-22 20:22:56 -04:00
bvandeusen 478f898e72 Merge pull request 'feat(settings): Maintenance tab compact tiles + centered Settings (pass 2)' (#126) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 3m3s
CI / integration (push) Successful in 3m26s
2026-06-18 00:11:54 -04:00
bvandeusen 38a5e7f332 Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s
2026-06-17 23:49:21 -04:00
bvandeusen 57fe15c267 Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
2026-06-17 22:01:12 -04:00
bvandeusen eb3231ef10 Merge pull request 'fix(subscribestar): port gallery-dl date extraction (wrapped dates) + parse canary' (#123) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
2026-06-17 21:15:43 -04:00
bvandeusen e9af459c0d Merge pull request 'feat(subscribestar): port gallery-dl doc + audio attachment extraction' (#122) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m15s
2026-06-17 16:20:12 -04:00
bvandeusen 6f02806aec Merge pull request 'fix(subscribestar): port gallery-dl content + preview-skip extraction (body bug)' (#121) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m25s
2026-06-17 16:10:56 -04:00
bvandeusen a1d19bd96a Merge pull request 'fix(subscribestar): mirror gallery-dl's full request profile (verify_subscriber gate)' (#120) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
2026-06-17 15:32:28 -04:00
bvandeusen 26827ff38f Merge pull request 'fix(subscribestar): match gallery-dl's generic post delimiter (live-feed drift)' (#119) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m15s
2026-06-17 15:07:59 -04:00
bvandeusen 26dcfaf6c2 Merge pull request 'fix(subscribestar): initial feed GET is a navigation, not XHR (first-run drift)' (#118) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m14s
2026-06-17 14:30:02 -04:00
bvandeusen 9b1b0369cc Merge pull request 'SubscribeStar → native core ingester + native-ingest DRY pass (milestone #71)' (#117) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-17 12:48:29 -04:00
bvandeusen 18123fb9cb Merge pull request 'fix(external): recovery-sweep threshold + queue recording + split fetch timeouts (#883)' (#116) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m14s
2026-06-16 21:24:31 -04:00
bvandeusen 2e806f202f Merge pull request 'test(artist-dir): fix flaky uq_image_record_sha256 collision' (#115) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s
2026-06-16 20:34:21 -04:00
bvandeusen 18d5c05639 Merge pull request 'fix(ml): per-task async engine for recompute_centroid (#881)' (#114) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Failing after 3m19s
2026-06-16 20:24:37 -04:00
bvandeusen 11ddfc3876 Merge pull request 'fix(maint): resurface dedup/gated-purge results after navigate-away (#877)' (#113) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
Build images / build-web (push) Successful in 15s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-16 16:48:52 -04:00
bvandeusen 2b8ce86622 Merge pull request 'Gated Patreon posts: skip on ingest + cleanup tool (#874)' (#112) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m27s
2026-06-16 15:25:34 -04:00
bvandeusen 49bee77cdc Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
2026-06-16 14:08:40 -04:00
bvandeusen c209e3b37e Merge pull request 'Tier-1 video dedup: import-time + retroactive cleanup (#871)' (#110) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 26s
CI / integration (push) Successful in 3m16s
2026-06-16 08:55:38 -04:00
bvandeusen cffdd93418 Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s
2026-06-15 21:37:39 -04:00
bvandeusen fd84be40dd Merge pull request 'External-attach orphan fix (#859) + image-less post display' (#108) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m19s
2026-06-15 01:55:36 -04:00
bvandeusen 79f510d7f8 Merge pull request 'Merge dev → main: read post body from content_json_string (the empty-body fix) (#842)' (#107) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m24s
2026-06-15 00:19:45 -04:00
bvandeusen 59181069da Merge pull request 'Merge dev → main: per-post stdout diagnostics + post-field write DRY (#842/#753)' (#106) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
Build images / build-web (push) Successful in 2m15s
Build images / build-ml (push) Successful in 2m55s
CI / integration (push) Successful in 3m11s
2026-06-14 23:45:39 -04:00
bvandeusen 428ecd8642 Merge pull request 'Merge dev → main: per-post body-capture diagnostics in the event UI (#842)' (#105) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m11s
2026-06-14 23:14:14 -04:00
bvandeusen ed1e04b831 Merge pull request 'Merge dev → main: recapture body-fetch fix + diagnostics (#842)' (#104) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m15s
2026-06-14 22:31:41 -04:00
bvandeusen f5156bd847 Merge pull request 'Merge dev → main: Recapture mode (#842) — re-grab post bodies/links + localize on-disk inline images' (#103) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m30s
Build images / build-ml (push) Successful in 3m27s
CI / integration (push) Successful in 3m29s
2026-06-14 21:21:49 -04:00
bvandeusen dfc3922d24 Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s
2026-06-14 19:16:09 -04:00
bvandeusen 3eb08e926b Merge pull request 'fix(aliases): modal raw-key bug + alias visibility/management' (#101) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-12 14:02:00 -04:00
bvandeusen 9e81ced359 Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
2026-06-12 00:44:56 -04:00
bvandeusen 11e9f5af60 Merge pull request 'fix(browse): tabs and search on one row' (#99) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m13s
2026-06-12 00:28:38 -04:00
bvandeusen 909fa37b15 Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
2026-06-12 00:14:31 -04:00
bvandeusen dfab8f65ff Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s
2026-06-11 22:07:57 -04:00
bvandeusen 618f7cdc36 Merge pull request 'feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)' (#96) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m29s
Build images / build-ml (push) Successful in 3m0s
CI / integration (push) Successful in 3m13s
2026-06-11 19:32:05 -04:00
bvandeusen 028ea33a7c Merge pull request 'fix(migration): make 0045 DDL-only; backfill image_prediction via batched task (#768)' (#95) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m50s
Build images / build-ml (push) Successful in 2m58s
CI / integration (push) Successful in 3m15s
2026-06-11 09:22:22 -04:00
bvandeusen 444c1fb075 Merge pull request 'perf(migration): 0045 streams json_each (no materialize / no temp blowup)' (#94) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m14s
Build images / build-ml (push) Successful in 2m36s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m8s
2026-06-10 22:07:16 -04:00
bvandeusen 26c68b0a75 Merge pull request 'fix(migration): 0045 guards json_each against scalar tagger_predictions' (#93) from dev into main
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 3m13s
Build images / sign-extension (push) Successful in 2s
Build images / build-web (push) Successful in 6s
Build images / build-ml (push) Successful in 2m40s
2026-06-10 20:33:27 -04:00
bvandeusen e75427b19a Merge pull request '#768 steps 1+2: normalized image_prediction table (read cutover)' (#92) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m8s
2026-06-10 20:15:26 -04:00
bvandeusen 5447fab987 Merge pull request 'Activity search + RecoverySweep fix + tagger_predictions shrink (#762, #764) + backup polish' (#91) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 2m51s
CI / integration (push) Successful in 3m10s
2026-06-10 14:33:35 -04:00
bvandeusen bad37e07b2 Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s
2026-06-10 00:24:01 -04:00
bvandeusen 2bfc9936a1 Merge pull request 'UI batch: tagging flow, series browse, fandom chips, nav' (#89) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Successful in 3m24s
2026-06-09 20:48:19 -04:00
bvandeusen 4c6406ee18 Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
2026-06-08 19:42:31 -04:00
bvandeusen bb47e80b3e Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-08 18:17:49 -04:00
bvandeusen dc1083b5e0 Merge pull request 'Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter' (#86) from dev into main
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 3m16s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 2m21s
2026-06-08 17:27:55 -04:00
bvandeusen e46893fefd Merge pull request 'Migration lock safety + remove the merge's full-table scan (the real 0040-hang fix)' (#85) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m7s
2026-06-08 01:03:06 -04:00
bvandeusen 0666e15211 Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
2026-06-07 21:17:57 -04:00
bvandeusen 747390631d Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s
2026-06-07 19:31:04 -04:00
bvandeusen e0d2a20588 Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
2026-06-07 12:17:43 -04:00
bvandeusen 1d84f67418 Merge pull request 'Modal: large centered spinner + kebab z-index fix' (#81) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m7s
2026-06-07 10:57:00 -04:00
bvandeusen 91265df3d6 Merge pull request 'Maintenance-queue health + modal/tagging keyboard pass' (#80) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m26s
CI / integration (push) Successful in 3m1s
2026-06-07 10:31:01 -04:00
bvandeusen 11acdb0322 Merge pull request 'Patreon: a missing media file_name is a fallback, not API drift' (#79) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 23:14:25 -04:00
bvandeusen 2eb9fd5dd0 Merge pull request 'Patreon: enforce the backfill time-box mid-post (stop soft-limit overruns)' (#78) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
2026-06-06 23:00:29 -04:00
bvandeusen 01e5ce1410 Merge pull request 'Patreon: resolve creator campaign from a single-post URL' (#77) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
2026-06-06 21:38:33 -04:00
bvandeusen 3bb94674cf Merge pull request 'Patreon download concurrency cap + immediate backfill kickoff on create' (#76) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 21:27:22 -04:00
bvandeusen a75c602175 Merge pull request 'Subscriptions UX overhaul + Patreon /cw/ vanity fix' (#75) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
2026-06-06 21:12:02 -04:00
bvandeusen ef8f4f7193 Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 19:59:32 -04:00
bvandeusen ec3d27b219 Merge pull request 'Tag-maintenance sweep + bug-fix batch: #699 #700 #701 #709 #711 #712 #713 #714' (#73) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 16:49:00 -04:00
bvandeusen 03bd3b2eda Merge pull request 'Native Patreon ingester + download-engine ownership (plans #697, #703–#708)' (#72) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m1s
2026-06-06 12:57:31 -04:00
bvandeusen 7395e77d75 Merge pull request 'Smarter backfill: time-boxed chunks, run-until-done (plan #693)' (#71) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
2026-06-05 16:33:06 -04:00
bvandeusen 575d817919 Merge pull request '#70 dev→main: cursor-paged backfill + mobile modal fixes' from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m59s
2026-06-05 11:10:02 -04:00
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-web (push) Successful in 2m9s
Build images / build-ml (push) Successful in 2m53s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
Build images / build-ml (push) Successful in 3m28s
CI / intimp (push) Successful in 4m26s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
2026-06-03 13:18:44 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen 93e37681b7 Merge pull request 'audit-g1: six one-liner drift fixes from 2026-06-02 audit' (#49) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 10s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m28s
CI / intcore (push) Successful in 8m3s
2026-06-02 13:29:17 -04:00
bvandeusen 64ca858574 Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 08:26:58 -04:00
bvandeusen 9d0c0b7da8 Merge pull request 'fix(thumbnails): surface backfill counts + tighten validity check' (#47) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 18s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m4s
2026-06-01 22:34:38 -04:00
bvandeusen 8e4d252ae4 Merge pull request 'fix(downloads): enqueue thumbnail + ML per attached image' (#46) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m46s
2026-06-01 21:52:42 -04:00
bvandeusen fdd3e01f56 Merge pull request 'ux(failing-sources): visible row separators + clearer hover' (#45) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 7m48s
CI / intcore (push) Successful in 8m16s
2026-06-01 21:09:28 -04:00
bvandeusen c82fb308b6 Merge pull request 'Post.source_id refactor + tick/backfill modes + PARTIAL classifier + Mux fix + UX' (#44) from dev into main
Build images / build-ml (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 12s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 31s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m16s
2026-06-01 20:44:23 -04:00
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
110 changed files with 1288 additions and 3652 deletions
+8 -13
View File
@@ -9,15 +9,10 @@ name: CI
on: on:
push: push:
branches: [dev, main] branches: [dev, main]
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches # pull_request trigger intentionally absent — with branches: [dev, main]
# never push to dev/main, so the push trigger above gives them NO pre-merge # above, every PR commit already fires CI via the push event on dev. Adding
# CI — a bump could only be validated after it was already merged. This # pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
# pull_request trigger (base `dev` only) validates Renovate PRs before merge. # (single-operator Forgejo repo) so push coverage is complete.
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
pull_request:
branches: [dev]
jobs: jobs:
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so # Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
@@ -97,10 +92,10 @@ jobs:
# If we want strict lockfile-based reproducibility later, commit a # If we want strict lockfile-based reproducibility later, commit a
# package-lock.json and flip this back to `npm ci`. # package-lock.json and flip this back to `npm ci`.
- run: npm install --no-audit --no-fund - run: npm install --no-audit --no-fund
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc), # `npm run check` (vue-tsc --noEmit) skipped: the frontend is pure JS
# so a type-checker has nothing to do. The vue-tsc devDep + its `check` # with no .ts files and no JSDoc annotations, so vue-tsc has nothing
# script were dropped 2026-07-11 rather than bumped to v3. If we add # to type-check. Re-enable once we add a tsconfig.json and either
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step. # convert to TS or add JSDoc.
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
lint: lint:
runs-on: python-ci runs-on: python-ci
container: container:
image: node:24-bookworm-slim image: node:22-bookworm-slim
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install web-ext - name: Install web-ext
+2 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.25 # syntax=docker/dockerfile:1.7
FROM node:24-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /build WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
# No package-lock.json is tracked yet (we don't run npm locally per # No package-lock.json is tracked yet (we don't run npm locally per
+1 -1
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.25 # syntax=docker/dockerfile:1.7
FROM python:3.14-slim FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
@@ -1,51 +0,0 @@
"""translation strictness setting + per-post translation override (milestone 155)
ImportSettings gains ``translation_min_confidence`` (the latin-script acceptance
floor, now operator-tunable in the UI; default 0.9 — stricter than the old
hardcoded 0.8, since Interpreter confidently mis-detects short ASCII English at
~0.86). Post gains ``translation_override`` — a sticky per-post choice of
auto / force / original so the operator can force a skipped translation on, or
knock a wrongly-translated one back to the original, and have it survive a
Re-translate-all.
Revision ID: 0084
Revises: 0083
Create Date: 2026-07-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0084"
down_revision: Union[str, None] = "0083"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"translation_min_confidence", sa.Float(), nullable=False,
server_default=sa.text("0.9"),
),
)
op.add_column(
"post",
sa.Column(
"translation_override", sa.String(16), nullable=False,
server_default="auto",
),
)
op.create_check_constraint(
"ck_post_translation_override",
"post",
"translation_override IN ('auto', 'force', 'original')",
)
def downgrade() -> None:
op.drop_constraint("ck_post_translation_override", "post", type_="check")
op.drop_column("post", "translation_override")
op.drop_column("import_settings", "translation_min_confidence")
@@ -1,35 +0,0 @@
"""title-based WIP auto-tagging (task #1458) — ImportSettings toggle
ImportSettings gains wip_title_tagging_enabled (ON by default): when a freshly
imported post's title explicitly declares work-in-progress ("WIP" / "work in
progress"), the importer applies the `wip` system tag to its images. No new
table — the tag itself is the seeded `wip` system tag (migration 0075) and the
application reuses image_tag with source='wip_title'.
Revision ID: 0085
Revises: 0084
Create Date: 2026-07-12
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0085"
down_revision: Union[str, None] = "0084"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"wip_title_tagging_enabled", sa.Boolean(), nullable=False,
server_default=sa.text("true"),
),
)
def downgrade() -> None:
op.drop_column("import_settings", "wip_title_tagging_enabled")
@@ -1,61 +0,0 @@
"""process auto-apply settings + review mode (#1464) — system-tag refactor
The system-tag behavior refactor gives `wip` / `editor screenshot` (the PROCESS
group) their own provisional auto-apply, parallel to the presentation (chrome)
sweep. MLSettings gains three knobs: enabled (OFF by default — a new whole-library
auto-tagger is opt-in), the flat apply threshold, and the ring-loud conflict
threshold. presentation_review gains a `mode` column so one review surface serves
both chrome and process flags (existing rows backfill 'chrome'). server_defaults
so the existing rows fill cleanly.
Revision ID: 0086
Revises: 0085
Create Date: 2026-07-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0086"
down_revision: Union[str, None] = "0085"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ml_settings",
sa.Column(
"process_auto_apply_enabled", sa.Boolean(), nullable=False,
server_default=sa.text("false"),
),
)
op.add_column(
"ml_settings",
sa.Column(
"process_auto_apply_threshold", sa.Float(), nullable=False,
server_default="0.90",
),
)
op.add_column(
"ml_settings",
sa.Column(
"process_conflict_threshold", sa.Float(), nullable=False,
server_default="0.50",
),
)
op.add_column(
"presentation_review",
sa.Column(
"mode", sa.String(16), nullable=False,
server_default="chrome",
),
)
def downgrade() -> None:
op.drop_column("presentation_review", "mode")
op.drop_column("ml_settings", "process_conflict_threshold")
op.drop_column("ml_settings", "process_auto_apply_threshold")
op.drop_column("ml_settings", "process_auto_apply_enabled")
@@ -1,33 +0,0 @@
"""soft WIP title tier toggle (#1474) — ImportSettings.wip_soft_title_tagging_enabled
The soft tier also tags sketch/doodle/scribble titles, but with a provisional source
that never trains the head. OFF by default (a lower-precision tier is opt-in).
server_default so the existing singleton row (id=1) fills cleanly.
Revision ID: 0087
Revises: 0086
Create Date: 2026-07-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0087"
down_revision: Union[str, None] = "0086"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"wip_soft_title_tagging_enabled", sa.Boolean(), nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
+3 -19
View File
@@ -148,17 +148,6 @@ async def similar():
# Explore passes exclude_wip=1 to also drop work-in-progress from the # Explore passes exclude_wip=1 to also drop work-in-progress from the
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274). # rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True") exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
# distance bands so the walk can escape a dense cluster. exclude_ids = the
# breadcrumb, so already-walked images aren't re-served as neighbours.
try:
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
except ValueError:
reach = 0.0
exclude_ids = [
int(x) for x in request.args.get("exclude_ids", "").split(",")
if x.strip().isdigit()
] or None
# post_id is the exclusive post-detail view — not a similarity scope. # post_id is the exclusive post-detail view — not a similarity scope.
# include_hidden is a gallery-browse flag; similar() has its OWN presentation # include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141). # exclusion (a similarity-quality concern, #1274), so drop it here (#141).
@@ -169,8 +158,7 @@ async def similar():
svc = GalleryService(session) svc = GalleryService(session)
try: try:
images = await svc.similar( images = await svc.similar(
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
reach=reach, exclude_ids=exclude_ids, **scope)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if images is None: if images is None:
@@ -248,10 +236,8 @@ async def jump():
# content", surfaced in the gallery's Show-hidden review strip. ----------- # content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"]) @gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review(): async def hidden_review():
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464), """Unresolved presentation auto-hide flags, most-concerning first (highest
most-concerning first (highest content score) — for the review strip. `mode` content score) — for the gallery's Hidden-view review strip."""
tells the client whether the flagged tag hid the image ('chrome') or left it
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
ptag = aliased(Tag) ptag = aliased(Tag)
ctag = aliased(Tag) ctag = aliased(Tag)
async with get_session() as session: async with get_session() as session:
@@ -261,7 +247,6 @@ async def hidden_review():
PresentationReview.tag_id, PresentationReview.tag_id,
PresentationReview.conflict_tag_id, PresentationReview.conflict_tag_id,
PresentationReview.conflict_score, PresentationReview.conflict_score,
PresentationReview.mode,
ImageRecord.path, ImageRecord.thumbnail_path, ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime, ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"), ptag.name.label("tag_name"),
@@ -281,7 +266,6 @@ async def hidden_review():
"conflict_tag_id": r.conflict_tag_id, "conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name, "conflict_name": r.conflict_name,
"conflict_score": r.conflict_score, "conflict_score": r.conflict_score,
"mode": r.mode,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime), "thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path), "image_url": image_url(r.path),
} }
+3 -1
View File
@@ -256,7 +256,9 @@ async def lease():
if not await _agent_authed(session): if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401 return jsonify({"error": "unauthorized"}), 401
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch) jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
ml = await MLSettings.load(session) ml = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
# image rows for url/mime in one shot # image rows for url/mime in one shot
ids = [j.image_record_id for j in jobs] ids = [j.image_record_id for j in jobs]
imgs = { imgs = {
+38 -24
View File
@@ -4,7 +4,6 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session from ..extensions import get_session
from ..models import MLSettings from ..models import MLSettings
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml") ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
@@ -43,9 +42,6 @@ _EDITABLE = (
"presentation_auto_apply_enabled", "presentation_auto_apply_enabled",
"presentation_auto_apply_threshold", "presentation_auto_apply_threshold",
"presentation_conflict_threshold", "presentation_conflict_threshold",
"process_auto_apply_enabled",
"process_auto_apply_threshold",
"process_conflict_threshold",
"embedder_model_name", "embedder_model_name",
"embedder_model_version", "embedder_model_version",
*_DETECTOR_FIELDS, *_DETECTOR_FIELDS,
@@ -84,21 +80,45 @@ async def embedder_models():
@ml_admin_bp.route("/settings", methods=["GET"]) @ml_admin_bp.route("/settings", methods=["GET"])
async def get_settings(): async def get_settings():
from sqlalchemy import select
async with get_session() as session: async with get_session() as session:
s = await MLSettings.load(session) s = (
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field await session.execute(select(MLSettings).where(MLSettings.id == 1))
# can never be silently absent from GET — the split that historically dropped ).scalar_one()
# fields. _EDITABLE already includes *_DETECTOR_FIELDS. return jsonify(
return jsonify({f: getattr(s, f) for f in _EDITABLE}) {
"cpu_embed_enabled": s.cpu_embed_enabled,
"video_frame_interval_seconds": s.video_frame_interval_seconds,
"video_max_frames": s.video_max_frames,
"embedder_model_version": s.embedder_model_version,
"head_min_positives": s.head_min_positives,
"head_auto_apply_precision": s.head_auto_apply_precision,
"head_auto_apply_enabled": s.head_auto_apply_enabled,
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
"ccip_match_threshold": s.ccip_match_threshold,
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
"presentation_conflict_threshold": s.presentation_conflict_threshold,
"embedder_model_name": s.embedder_model_name,
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
}
)
@ml_admin_bp.route("/settings", methods=["PATCH"]) @ml_admin_bp.route("/settings", methods=["PATCH"])
async def patch_settings(): async def patch_settings():
from sqlalchemy import select
body = await request.get_json() body = await request.get_json()
if not isinstance(body, dict): if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400 return jsonify({"error": "body must be an object"}), 400
async with get_session() as session: async with get_session() as session:
s = await MLSettings.load(session) s = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
# Merge the patch over current values, then validate the result as a # Merge the patch over current values, then validate the result as a
# whole — the store-floor invariant couples three fields, so they # whole — the store-floor invariant couples three fields, so they
@@ -128,26 +148,20 @@ def _validate(p: dict) -> str | None:
# Head training (#114). # Head training (#114).
if int(p["head_min_positives"]) < 1: if int(p["head_min_positives"]) < 1:
return "head_min_positives must be >= 1" return "head_min_positives must be >= 1"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX): if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}" return "head_auto_apply_precision must be between 0.5 and 0.999"
if int(p["head_auto_apply_min_positives"]) < 1: if int(p["head_auto_apply_min_positives"]) < 1:
return "head_auto_apply_min_positives must be >= 1" return "head_auto_apply_min_positives must be >= 1"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX): if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}" return "ccip_match_threshold must be between 0.5 and 0.999"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX): if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}" return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is # Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
# consequential); the conflict cut is a plain probability [0,1]. # consequential); the conflict cut is a plain probability [0,1].
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX): if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}" return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0): if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
return "presentation_conflict_threshold must be between 0 and 1" return "presentation_conflict_threshold must be between 0 and 1"
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
# low-harm (excludes-from-training + a review flag), but keep the same bar.
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Embedder model swap (#1190): both must be non-empty. Changing them means a # Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after. # different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"): for key in ("embedder_model_name", "embedder_model_version"):
-83
View File
@@ -3,28 +3,12 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from ..extensions import get_session from ..extensions import get_session
from ..models import ImportSettings, Post
from ..services import interpreter_client as ic
from ..services.post_feed_service import PostFeedService from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS from ..services.source_service import KNOWN_PLATFORMS
from ..utils.text import html_to_plain
from ._responses import error_response as _bad from ._responses import error_response as _bad
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts") posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
def _queue_for_sweep(post: Post) -> None:
"""Mark a post untranslated (all translation columns NULL) so the periodic
sweep re-runs it under its new override — used when Interpreter is down and we
can't translate inline."""
post.post_title_translated = None
post.description_translated = None
post.translated_source_lang = None
post.translation_engine_version = None
post.translated_at = None
@posts_bp.route("", methods=["GET"]) @posts_bp.route("", methods=["GET"])
async def list_posts(): async def list_posts():
@@ -98,70 +82,3 @@ async def get_post(post_id: int):
if item is None: if item is None:
return _bad("not_found", status=404, detail=f"post id={post_id}") return _bad("not_found", status=404, detail=f"post id={post_id}")
return jsonify(item) return jsonify(item)
@posts_bp.route("/<int:post_id>/translation-override", methods=["POST"])
async def set_translation_override(post_id: int):
"""Sticky per-post translation override (milestone 155). Body:
``{"override": "auto" | "force" | "original"}``.
'original' keeps the original (clears any stored translation now — no
Interpreter needed). 'force'/'auto' translate the post immediately if the
service is up (force bypasses the acceptance floor; auto re-runs the gate);
if it's down we save the flag and mark the post untranslated so the next sweep
applies it. The override persists, so the sweep + Re-translate-all keep
honoring it. Returns the updated translation fields + an ``applied`` status."""
body = await request.get_json(silent=True) or {}
override = body.get("override")
if override not in _TRANSLATION_OVERRIDES:
return _bad(
"invalid_override",
detail=f"override must be one of {list(_TRANSLATION_OVERRIDES)}",
)
# Lazy import (mirrors settings.py) so the API module doesn't pull the celery
# task graph at import time.
from ..tasks.translation import _store_translation, _translate_field
async with get_session() as session:
post = await session.get(Post, post_id)
if post is None:
return _bad("not_found", status=404, detail=f"post id={post_id}")
post.translation_override = override
cfg = await ImportSettings.load(session)
target = (cfg.translation_target_lang or "en").strip() or "en"
if override == "original":
_store_translation(post, (None, None, None), (None, None, None), target)
applied = "cleared"
else:
base_url = (cfg.interpreter_base_url or "").strip()
if cfg.translation_enabled and base_url and ic.health(base_url):
force = override == "force"
title = (post.post_title or "").strip()
desc = (html_to_plain(post.description) if post.description else "") or ""
desc = desc.strip()
mc = cfg.translation_min_confidence
try:
title_res = _translate_field(title, base_url, target, mc, force=force)
desc_res = _translate_field(desc, base_url, target, mc, force=force)
except ic.InterpreterUnavailable:
_queue_for_sweep(post)
applied = "queued"
else:
_store_translation(post, title_res, desc_res, target)
applied = "translated"
else:
# Disabled / no URL / unhealthy → let the sweep apply it later.
_queue_for_sweep(post)
applied = "queued"
await session.commit()
return jsonify({
"id": post.id,
"translation_override": post.translation_override,
"post_title_translated": post.post_title_translated,
"description_translated": post.description_translated,
"translated_source_lang": post.translated_source_lang,
"applied": applied,
})
+27 -80
View File
@@ -47,9 +47,6 @@ _EDITABLE_FIELDS = (
"translation_enabled", "translation_enabled",
"interpreter_base_url", "interpreter_base_url",
"translation_target_lang", "translation_target_lang",
"translation_min_confidence",
"wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
) )
# Per-host external-download toggles — all plain booleans, validated uniformly. # Per-host external-download toggles — all plain booleans, validated uniformly.
@@ -66,9 +63,31 @@ _EXTDL_TOGGLE_FIELDS = (
async def get_import_settings(): async def get_import_settings():
async with get_session() as session: async with get_session() as session:
row = await ImportSettings.load(session) row = await ImportSettings.load(session)
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field return jsonify({
# can't be silently absent from GET. "min_width": row.min_width,
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS}) "min_height": row.min_height,
"skip_transparent": row.skip_transparent,
"transparency_threshold": row.transparency_threshold,
"skip_single_color": row.skip_single_color,
"single_color_threshold": row.single_color_threshold,
"single_color_tolerance": row.single_color_tolerance,
"phash_threshold": row.phash_threshold,
"download_rate_limit_seconds": row.download_rate_limit_seconds,
"download_validate_files": row.download_validate_files,
"download_schedule_default_seconds": row.download_schedule_default_seconds,
"download_event_retention_days": row.download_event_retention_days,
"download_failure_warning_threshold": row.download_failure_warning_threshold,
"series_suggest_enabled": row.series_suggest_enabled,
"series_suggest_threshold": row.series_suggest_threshold,
"extdl_mega_enabled": row.extdl_mega_enabled,
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
"translation_enabled": row.translation_enabled,
"interpreter_base_url": row.interpreter_base_url,
"translation_target_lang": row.translation_target_lang,
})
@settings_bp.route("/settings/import", methods=["PATCH"]) @settings_bp.route("/settings/import", methods=["PATCH"])
@@ -136,32 +155,12 @@ async def update_import_settings():
for key in ("interpreter_base_url", "translation_target_lang"): for key in ("interpreter_base_url", "translation_target_lang"):
if key in body and not isinstance(body[key], str): if key in body and not isinstance(body[key], str):
return jsonify({"error": f"{key} must be a string"}), 400 return jsonify({"error": f"{key} must be a string"}), 400
# Acceptance floor (milestone 155): latin-script translations below this
# Interpreter confidence are kept as the original.
if "translation_min_confidence" in body:
v = body["translation_min_confidence"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "translation_min_confidence must be a number in [0, 1]"}
), 400
if "series_suggest_threshold" in body: if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"] v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify( return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"} {"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400 ), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"}
), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 400
async with get_session() as session: async with get_session() as session:
row = await ImportSettings.load(session) row = await ImportSettings.load(session)
@@ -173,18 +172,6 @@ async def update_import_settings():
return await get_import_settings() return await get_import_settings()
@settings_bp.route("/settings/wip-title/scan", methods=["POST"])
async def wip_title_scan():
"""Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
apply the `wip` system tag to EXISTING posts whose title declares
work-in-progress. New imports are tagged live by the importer; this catches
the existing library. Returns the Celery task id (202)."""
from ..tasks.maintenance import backfill_wip_title_tags
r = backfill_wip_title_tags.delay()
return jsonify({"celery_task_id": r.id}), 202
@settings_bp.route("/system/stats", methods=["GET"]) @settings_bp.route("/system/stats", methods=["GET"])
async def system_stats(): async def system_stats():
async with get_session() as session: async with get_session() as session:
@@ -377,49 +364,9 @@ async def translation_test():
return jsonify({"healthy": healthy}) return jsonify({"healthy": healthy})
@settings_bp.route("/settings/translation/probe", methods=["POST"])
async def translation_probe():
"""Diagnostic for the Settings 'Test translation' box: translate a pasted
snippet WITHOUT saving anything, returning what Interpreter *detected*
(language + confidence) alongside the result. Lets the operator see why a
given string was (mis-)detected — e.g. a short English title flagged as
another language — so a detection guard can be tuned from real numbers.
Read-only: no post is touched. Uses the currently-saved base URL + target."""
body = await request.get_json(silent=True)
body = body if isinstance(body, dict) else {}
text = (body.get("text") or "").strip()
if not text:
return jsonify({"error": "provide text to translate"}), 400
async with get_session() as session:
cfg = await ImportSettings.load(session)
base_url = (cfg.interpreter_base_url or "").strip()
if not base_url:
return jsonify({"error": "no Interpreter base URL is set"}), 400
target = (cfg.translation_target_lang or "en").strip() or "en"
try:
res = await asyncio.to_thread(
ic.translate, [text], base_url=base_url, target=target,
)
except ic.InterpreterUnavailable as e:
return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
except ic.InterpreterBadRequest as e:
return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
translations = res.get("translations") or []
return jsonify({
"target": target,
"detected_lang": res.get("detected_lang"),
"detected_confidence": res.get("detected_confidence"),
"engine": res.get("engine"),
"engine_version": res.get("engine_version"),
"translated": translations[0] if translations else None,
})
@settings_bp.route("/settings/translation/run", methods=["POST"]) @settings_bp.route("/settings/translation/run", methods=["POST"])
async def translation_run(): async def translation_run():
"""Enqueue the translate sweep now (the Settings 'Translate now' button). """Enqueue the translate sweep now (the Settings 'Translate now' button)."""
Runs in drain mode — run-until-done — so one press chases the whole
untranslated backlog to zero rather than a single 300-post chunk."""
async with get_session() as session: async with get_session() as session:
cfg = await ImportSettings.load(session) cfg = await ImportSettings.load(session)
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip(): if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
@@ -428,7 +375,7 @@ async def translation_run():
), 400 ), 400
from ..tasks.translation import translate_posts from ..tasks.translation import translate_posts
r = translate_posts.delay(drain=True) r = translate_posts.delay()
return jsonify({"celery_task_id": r.id}), 202 return jsonify({"celery_task_id": r.id}), 202
+30 -10
View File
@@ -11,11 +11,10 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"]) @suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int): async def get_suggestions(image_id: int):
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The # ?min=<float> overrides the configured per-category thresholds so the typed
# rail sends min=0 in its single per-image fetch to get EVERY head (each row # tag-input dropdown can surface EVERY stored prediction (min=0), including
# still carries above_threshold vs its natural cut), then derives the panel # low-confidence actions/features, in canonical formatting. Omitted → the
# (above_threshold) and the typed dropdown (all, filtered by text) client-side # curated above-threshold list the Suggestions panel uses.
# — no second request. Omitted → only above-threshold rows.
override = None override = None
raw_min = request.args.get("min") raw_min = request.args.get("min")
if raw_min is not None: if raw_min is not None:
@@ -37,11 +36,12 @@ async def get_suggestions(image_id: int):
"category": s.category, "category": s.category,
"score": round(s.score, 4), "score": round(s.score, 4),
"source": s.source, "source": s.source,
# whether the score cleared the head's own suggest cut. "creates_new_tag": s.creates_new_tag,
# The single min=0 fetch returns every head; the panel # raw model key (alias is stored under this) + whether an
# shows above_threshold, the typed dropdown shows all and # operator alias produced this suggestion — drive the
# annotates each match with its score. # modal's "Treat as alias"/"Remove alias" affordances.
"above_threshold": s.above_threshold, "raw_name": s.raw_name,
"via_alias": s.via_alias,
# operator dismissed this tag for this image — surfaced # operator dismissed this tag for this image — surfaced
# (not dropped) so the rail can show it rejected + offer # (not dropped) so the rail can show it rejected + offer
# one-click un-reject. # one-click un-reject.
@@ -73,6 +73,26 @@ async def accept_suggestion(image_id: int):
return jsonify({"accepted": True, "tag_id": tag_id}) return jsonify({"accepted": True, "tag_id": tag_id})
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
)
async def alias_suggestion(image_id: int):
body = await request.get_json()
required = {"alias_string", "alias_category", "canonical_tag_id"}
if not body or not required.issubset(body):
return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session:
await AllowlistService(session).add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
canonical_tag_id,
)
await session.commit()
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
@suggestions_bp.route( @suggestions_bp.route(
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"] "/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
) )
+3 -17
View File
@@ -171,30 +171,16 @@ def make_celery() -> Celery:
}, },
"presentation-auto-apply-daily": { "presentation-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply", "task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
"schedule": 86400.0, # auto-hide banner chrome (#141); "schedule": 86400.0, # auto-hide banner/editor chrome (#141);
# no-op unless presentation_auto_apply_enabled # no-op unless presentation_auto_apply_enabled
}, },
"process-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
# no-op unless process_auto_apply_enabled (opt-in)
},
"soft-wip-conflict-audit-daily": {
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
# for review (#1474); no-op with no content heads
},
"prune-presentation-reviews-daily": { "prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews", "task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d "schedule": 86400.0, # retention: drop resolved review flags >30d
}, },
"translate-posts-8h": { "translate-posts-daily": {
"task": "backend.app.tasks.translation.translate_posts", "task": "backend.app.tasks.translation.translate_posts",
"schedule": 28800.0, # every 8h: steady-state cadence for the "schedule": 86400.0, # no-op unless translation configured + healthy
# trickle of newly-imported posts (no-op unless translation
# configured + healthy). One bounded 300-chunk per fire — the
# one-time backlog drains via the "Translate now" button
# (drain=True, run-until-done), not this sweep.
}, },
"snapshot-head-metrics-daily": { "snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics", "task": "backend.app.tasks.maintenance.snapshot_head_metrics",
-27
View File
@@ -106,33 +106,6 @@ class ImportSettings(Base):
translation_target_lang: Mapped[str] = mapped_column( translation_target_lang: Mapped[str] = mapped_column(
Text, nullable=False, default="en", server_default="en", Text, nullable=False, default="en", server_default="en",
) )
# The latin-script acceptance floor for the translation gate: a translation
# whose Interpreter-reported confidence is below this is kept as the original
# (operator-tunable, milestone 155). Default 0.9 — stricter than the old
# hardcoded 0.8, because Interpreter confidently mis-detects short ASCII
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
# trusted regardless (script-detected). Per-post overrides handle the misses.
translation_min_confidence: Mapped[float] = mapped_column(
Float, nullable=False, default=0.9, server_default="0.9",
)
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
# TITLE explicitly declares work-in-progress ("WIP" / "work in progress"),
# the importer applies the `wip` system tag to its images — the artist's own
# label, used to keep unfinished pieces out of the Explore/gallery browse. ON
# by default (rule 26 — the feature works out of the box). Gates only the
# LIVE import hook; the existing catalogue is caught by the operator-triggered
# "Scan existing posts" backfill (which runs regardless of this flag).
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
# precision tier is opt-in (the ring-loud audit surfaces false positives).
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
@classmethod @classmethod
async def load(cls, session) -> ImportSettings: async def load(cls, session) -> ImportSettings:
+6 -40
View File
@@ -10,7 +10,6 @@ from sqlalchemy import (
Integer, Integer,
String, String,
func, func,
select,
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -86,14 +85,12 @@ class MLSettings(Base):
Float, nullable=False, default=0.95 Float, nullable=False, default=0.95
) )
# -- Presentation chrome auto-hide (#141) ------------------------------- # -- Presentation chrome auto-hide (#141) -------------------------------
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep # banner / editor screenshot auto-apply on the sweep with their OWN flat
# with its OWN flat threshold (decoupled from content-head graduation) and is # threshold (decoupled from content-head graduation). Hiding is consequential
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an # so it runs HIGH. `wip` is never auto-applied. When an image would be
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold # auto-hidden but ALSO scores >= presentation_conflict_threshold on a content
# on a content head, it's still hidden but flagged for review # head, it's still hidden but flagged for review (PresentationReview) instead
# (PresentationReview, mode='chrome') instead of buried silently. ON by default # of buried silently. ON by default (opt-out); every auto-tag is reversible.
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
# screenshot` are no longer chrome — they went to the PROCESS path below.
presentation_auto_apply_enabled: Mapped[bool] = mapped_column( presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True Boolean, nullable=False, default=True
) )
@@ -103,26 +100,6 @@ class MLSettings(Base):
presentation_conflict_threshold: Mapped[float] = mapped_column( presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.50
) )
# -- Process auto-apply (#1464) ----------------------------------------
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
# it learns only from title (`wip_title`) + manual labels, which breaks the
# runaway loop. When a process tag would be applied but the image ALSO scores
# >= process_conflict_threshold on a content head, it's flagged for review
# (PresentationReview, mode='process') rather than silently marked. OFF by
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
process_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
process_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
)
process_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds. # existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column( embedder_model_version: Mapped[str] = mapped_column(
@@ -213,14 +190,3 @@ class MLSettings(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@classmethod
async def load(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via an async session. Mirrors
ImportSettings.load — the shared singleton-loader pattern."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
+1 -25
View File
@@ -8,17 +8,7 @@ artist-filter queries don't depend on the Source detour).
from datetime import datetime from datetime import datetime
from sqlalchemy import ( from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
JSON,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -33,10 +23,6 @@ class Post(Base):
# (created in alembic 0030) covers that case via # (created in alembic 0030) covers that case via
# (artist_id, external_post_id). # (artist_id, external_post_id).
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"), UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
CheckConstraint(
"translation_override IN ('auto', 'force', 'original')",
name="ck_post_translation_override",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -78,16 +64,6 @@ class Post(Base):
translated_at: Mapped[datetime | None] = mapped_column( translated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
# Sticky per-post override of the translation decision (milestone 155):
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
# translation even below the confidence floor (rescue a skipped legit-foreign
# title); 'original' = never translate, keep the original (kill a confidently
# mis-flagged one the floor can't catch). The sweep reads this on every run,
# and re-translate leaves 'original' posts alone, so the choice survives a
# Re-translate-all.
translation_override: Mapped[str] = mapped_column(
String(16), nullable=False, default="auto", server_default="auto",
)
downloaded_at: Mapped[datetime] = mapped_column( downloaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
+7 -15
View File
@@ -1,17 +1,15 @@
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked """PresentationReview — an auto-hidden presentation tag that ALSO looked like
like real content, flagged for operator review (milestone 141 + #1464). real content, flagged for operator review (milestone 141).
When a sweep applies a system tag but the image ALSO scores highly on a content When the auto-apply sweep hides an image as chrome (banner / editor screenshot)
head, it still applies the tag but records this row so a review strip can surface but the image ALSO scores highly on a content head, it still hides it but records
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner — this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>")
image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor for a keep-hidden / un-hide decision. Resolved rows are pruned by retention.
screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows
are pruned by retention.
""" """
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, func from sqlalchemy import DateTime, Float, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -33,12 +31,6 @@ class PresentationReview(Base):
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
) )
conflict_score: Mapped[float] = mapped_column(Float, nullable=False) conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
# Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process'
# (wip / editor screenshot, shown). Drives which review strip surfaces it and
# what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'.
mode: Mapped[str] = mapped_column(
String(16), nullable=False, default="chrome", server_default="chrome"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+7 -12
View File
@@ -43,19 +43,14 @@ class TagKind(StrEnum):
# to keep historic tag rows queryable. # to keep historic tag rows queryable.
# The seeded system tags (migration 0075). Two behavior groups (#1464): # The seeded system tags (migration 0075). PRESENTATION tags additionally
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default # hide from whole-image similarity results — they cluster on UI chrome, not
# gallery + from similarity; auto-applied via the sweep's chrome mode. # content. `wip` is real art: only the training pipelines exclude it.
# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots
# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore
# rabbit-hole; auto-applied via the sweep's process mode (provisional source,
# ring-loud review guard).
# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene,
# keyed on is_system); a system tag's OWN head trains on them — that's what makes
# auto-flagging work.
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot") SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
CHROME_SYSTEM_TAGS = ("banner",) PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot") # `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
WIP_SYSTEM_TAG = "wip" WIP_SYSTEM_TAG = "wip"
image_tag = Table( image_tag = Table(
+2 -7
View File
@@ -35,14 +35,9 @@ class InvalidUrlError(Exception):
# reviewers catch drift. # reviewers catch drift.
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("patreon", re.compile( ("patreon", re.compile(
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
# (the "creator workspace" URL served once subscribed, see
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
# creator's inner page still derives the slug. Nav pages stay excluded.
r"^https?://(?:www\.)?patreon\.com/" r"^https?://(?:www\.)?patreon\.com/"
r"(?:cw/|c/)?" r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))" r"(?P<slug>[^/?#]+)/?$",
r"(?P<slug>[^/?#]+)",
re.IGNORECASE, re.IGNORECASE,
)), )),
("subscribestar", re.compile( ("subscribestar", re.compile(
+14 -50
View File
@@ -31,7 +31,7 @@ from ..models import (
Tag, Tag,
TagPositiveConfirmation, TagPositiveConfirmation,
) )
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
from .pagination import decode_cursor, encode_cursor from .pagination import decode_cursor, encode_cursor
from .tag_query import ( from .tag_query import (
fandom_join_alias, fandom_join_alias,
@@ -396,25 +396,6 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
return [kept[i] for i in order] return [kept[i] for i in order]
def _reach_sample(rows, limit, reach):
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
MMR — the Explore "reach" dial (#1476).
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
pool), evenly strided from rank 0 so the nearest are still represented. In a
dense signature the nearest ranks are near-identical, so reaching farther is the
only way to hand MMR genuinely different content — MMR alone can't escape a pool
that's already all-near. reach<=0 or a small pool passes through unchanged."""
n = len(rows)
want = max(limit * 8, 100)
if reach <= 0 or n <= want:
return rows
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
return [rows[i] for i in idx]
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical """Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -438,17 +419,16 @@ class GalleryService:
async def _hidden_tag_ids( async def _hidden_tag_ids(
self, include_hidden, tag_ids, tag_or_groups, self, include_hidden, tag_ids, tag_or_groups,
) -> list[int] | None: ) -> list[int] | None:
"""Chrome (banner) tag ids to implicitly exclude from a gallery query, or """Presentation-chrome tag ids to implicitly exclude from a gallery query,
None. None when the caller asked to include hidden, when the operator is or None. None when the caller asked to include hidden, when the operator
explicitly filtering FOR a chrome tag (they clearly want to see it), or when is explicitly filtering FOR a presentation tag (they clearly want to see
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS it), or when no presentation tags exist. (milestone 141)"""
— shown — so only `banner` hides here.)"""
if include_hidden: if include_hidden:
return None return None
rows = await self.session.execute( rows = await self.session.execute(
select(Tag.id).where( select(Tag.id).where(
Tag.is_system.is_(True), Tag.is_system.is_(True),
Tag.name.in_(CHROME_SYSTEM_TAGS), Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
) )
) )
pres = [r[0] for r in rows] pres = [r[0] for r in rows]
@@ -736,7 +716,6 @@ class GalleryService:
untagged: bool = False, no_artist: bool = False, untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None, date_from: datetime | None = None, date_to: datetime | None = None,
exclude_wip: bool = False, exclude_wip: bool = False,
reach: float = 0.0, exclude_ids: list[int] | None = None,
) -> list[GalleryImage] | None: ) -> list[GalleryImage] | None:
"""Visual "more like this": images near `image_id`'s SigLIP embedding """Visual "more like this": images near `image_id`'s SigLIP embedding
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result (pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
@@ -765,27 +744,20 @@ class GalleryService:
# wide pool there's nothing but the near-dupes to choose from. Widened # wide pool there's nothing but the near-dupes to choose from. Widened
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct # (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
# neighbourhoods to reach into for more variance (operator, 2026-07-01). # neighbourhoods to reach into for more variance (operator, 2026-07-01).
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
# nearest few hundred are all near-identical, so far-enough candidates only
# exist deeper in the ranked pool. _reach_sample then strides across them.
if reach > 0:
pool_n = min(1000, max(limit * 25, 100))
else:
pool_n = min(400, max(limit * 8, 100)) pool_n = min(400, max(limit * 8, 100))
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding) distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col() eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt) stmt = _outer_join_primary_post(stmt)
# Chrome (banner, #128) clusters on UI rather than content, so near any one # Presentation images (banner / editor-screenshot system tags, #128)
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor # cluster on UI chrome rather than content, so near any one of them
# itself may be a banner). PROCESS art (wip / editor screenshot) stays # they'd fill the grid. Excluded from CANDIDATES only — the anchor
# surfaced here by default (real content; only the training pipelines exclude # itself may be a banner. `wip` stays surfaced here by default (real art;
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole # only the training pipelines exclude it), but the Explore rabbit-hole
# process group so a browse doesn't keep surfacing work-in-progress # passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
# (operator, 2026-07-08; #1464 — editor now rides with wip here). excluded_system_tags = PRESENTATION_SYSTEM_TAGS
excluded_system_tags = CHROME_SYSTEM_TAGS
if exclude_wip: if exclude_wip:
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS) excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
presentation = ( presentation = (
select(image_tag.c.image_record_id) select(image_tag.c.image_record_id)
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
@@ -799,10 +771,6 @@ class GalleryService:
ImageRecord.id != image_id, ImageRecord.id != image_id,
ImageRecord.id.not_in(presentation), ImageRecord.id.not_in(presentation),
) )
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
# walked images aren't re-served as neighbours — → can't loop you back in.
if exclude_ids:
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None, stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
@@ -812,10 +780,6 @@ class GalleryService:
) )
stmt = stmt.order_by(distance.asc()).limit(pool_n) stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
# Explore reach: stride across an outward-growing distance span so the pool
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
if reach > 0:
rows = _reach_sample(rows, limit, reach)
rows = _diversify_similar(src, rows, limit) rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows]) artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists) return _gallery_images(rows, artists)
-65
View File
@@ -47,21 +47,9 @@ from .attachment_store import AttachmentStore
from .audits import single_color from .audits import single_color
from .link_extract import extract_external_links from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer from .thumbnailer import Thumbnailer
from .wip_title import (
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE,
apply_wip_image_tags,
matches_soft_wip_title,
matches_wip_title,
resolve_wip_tag_id,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Sentinel for the lazily-resolved wip tag id (distinguishes "not resolved yet"
# from a genuine None = tag absent, so absence is cached and not re-queried).
_UNSET = object()
class SkipReason(StrEnum): class SkipReason(StrEnum):
too_small = "too_small" too_small = "too_small"
@@ -195,10 +183,6 @@ class Importer:
# invalidated mid-Importer (Importer instances are per-task / # invalidated mid-Importer (Importer instances are per-task /
# per-archive-import so cross-instance staleness is harmless). # per-archive-import so cross-instance staleness is harmless).
self._phash_candidates: list[tuple] | None = None self._phash_candidates: list[tuple] | None = None
# Lazily-resolved `wip` system tag id for title-based WIP auto-tagging
# (task #1458). Sentinel _UNSET so a genuine None (tag absent) is cached
# and not re-queried per media. Importer is per-task, so this can't stale.
self._wip_tag_id: int | None = _UNSET
def _phash_candidates_cache(self) -> list[tuple]: def _phash_candidates_cache(self) -> list[tuple]:
"""Cached `(phash, width, height, id)` rows from image_record. """Cached `(phash, width, height, id)` rows from image_record.
@@ -949,10 +933,6 @@ class Importer:
# Thumbnail is queued separately by the calling task; the importer # Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving. # does not generate thumbnails inline so the import queue stays moving.
# Title-based WIP auto-tag (task #1458): fresh import only, after the
# sidecar has linked the post so record.primary_post_id / its title exist.
self._maybe_apply_wip_title(record)
self.session.commit() self.session.commit()
return ImportResult(status="imported", image_id=record.id) return ImportResult(status="imported", image_id=record.id)
@@ -996,47 +976,6 @@ class Importer:
self.session.commit() self.session.commit()
return ImportResult(status="refreshed", image_id=existing.id) return ImportResult(status="refreshed", image_id=existing.id)
def _maybe_apply_wip_title(self, record: ImageRecord) -> None:
"""Auto-apply the `wip` system tag to a FRESHLY-imported image when its
primary post's TITLE explicitly declares work-in-progress (task #1458 —
the artist's own "WIP" / "work in progress" label).
Called ONLY from the two new-record paths (never deep-scan / supersede),
so a manually-removed WIP tag is never re-applied by a routine re-scan —
removal sticks. The existing catalogue is covered separately by the
operator-triggered backfill sweep. Gated by the settings toggle, and
best-effort: any failure is logged, never allowed to fail the import."""
hard_on = self.settings.wip_title_tagging_enabled
soft_on = self.settings.wip_soft_title_tagging_enabled
if not (hard_on or soft_on):
return
if record.primary_post_id is None:
return
try:
title = self.session.execute(
select(Post.post_title).where(Post.id == record.primary_post_id)
).scalar_one_or_none()
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
# that never trains (source wip_title_soft).
if hard_on and matches_wip_title(title):
source = WIP_TITLE_SOURCE
elif soft_on and matches_soft_wip_title(title):
source = WIP_TITLE_SOFT_SOURCE
else:
return
if self._wip_tag_id is _UNSET:
self._wip_tag_id = resolve_wip_tag_id(self.session)
if self._wip_tag_id is None:
return
apply_wip_image_tags(
self.session, [record.id], self._wip_tag_id, source=source
)
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
log.warning(
"wip-title auto-tag failed for image %s: %s", record.id, exc
)
def _apply_post_fields(self, post: Post, sd) -> None: def _apply_post_fields(self, post: Post, sd) -> None:
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE """Write a parsed sidecar's post-level fields onto a Post — the SINGLE
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar) predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
@@ -1314,10 +1253,6 @@ class Importer:
# per-post Source row. # per-post Source row.
self._apply_sidecar(record, path, artist, explicit_source=source) self._apply_sidecar(record, path, artist, explicit_source=source)
# Title-based WIP auto-tag (task #1458): fresh import only, see the
# matching call in _import_media.
self._maybe_apply_wip_title(record)
self.session.commit() self.session.commit()
return ImportResult(status="imported", image_id=record.id) return ImportResult(status="imported", image_id=record.id)
+1 -5
View File
@@ -98,7 +98,6 @@ def translate(
{"translations": [str, ...], # SAME order & length as `texts` {"translations": [str, ...], # SAME order & length as `texts`
"detected_lang": str | None, # aggregate: describes the FIRST item "detected_lang": str | None, # aggregate: describes the FIRST item
"detected_confidence": float | None, # detector's confidence, if given
"engine": str | None, "engine": str | None,
"engine_version": str | None} "engine_version": str | None}
@@ -111,7 +110,6 @@ def translate(
""" """
if not texts: if not texts:
return {"translations": [], "detected_lang": None, return {"translations": [], "detected_lang": None,
"detected_confidence": None,
"engine": None, "engine_version": None} "engine": None, "engine_version": None}
try: try:
r = session.post( r = session.post(
@@ -142,11 +140,9 @@ def translate(
if not isinstance(out, list): if not isinstance(out, list):
out = [out] out = [out]
interp = body.get("interpreter") or {} interp = body.get("interpreter") or {}
detected = body.get("detectedLanguage") or {}
return { return {
"translations": out, "translations": out,
"detected_lang": detected.get("language"), "detected_lang": (body.get("detectedLanguage") or {}).get("language"),
"detected_confidence": detected.get("confidence"),
"engine": interp.get("engine"), "engine": interp.get("engine"),
"engine_version": interp.get("engine_version"), "engine_version": interp.get("engine_version"),
} }
+14
View File
@@ -12,11 +12,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...models import TagSuggestionRejection from ...models import TagSuggestionRejection
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService
class AllowlistService: class AllowlistService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
self.aliases = AliasService(session)
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str): async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
stmt = insert(image_tag).values( stmt = insert(image_tag).values(
@@ -39,6 +41,18 @@ class AllowlistService:
await self._apply_image_tag(image_id, tag_id, source="ml_accepted") await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
await self._clear_rejection(image_id, tag_id) await self._clear_rejection(image_id, tag_id)
async def add_alias_and_accept(
self,
image_id: int,
alias_string: str,
alias_category: str,
canonical_tag_id: int,
) -> None:
await self.aliases.create(
alias_string, alias_category, canonical_tag_id
)
await self.accept(image_id, canonical_tag_id)
async def dismiss(self, image_id: int, tag_id: int) -> None: async def dismiss(self, image_id: int, tag_id: int) -> None:
stmt = insert(TagSuggestionRejection).values( stmt = insert(TagSuggestionRejection).values(
image_record_id=image_id, tag_id=tag_id image_record_id=image_id, tag_id=tag_id
@@ -150,7 +150,9 @@ def refresh_character_prototypes(
"""Incrementally refresh the prototype store. `full=True` rebuilds every """Incrementally refresh the prototype store. `full=True` rebuilds every
character regardless of the gate/fingerprints (nightly reconcile). Returns character regardless of the gate/fingerprints (nightly reconcile). Returns
{skipped, rebuilt, removed}; commits.""" {skipped, rebuilt, removed}; commits."""
settings = MLSettings.load_sync(session) settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
sig = _global_signature(session) sig = _global_signature(session)
if not full and settings.ccip_ref_signature == sig: if not full and settings.ccip_ref_signature == sig:
return {"skipped": True, "rebuilt": 0, "removed": 0} return {"skipped": True, "rebuilt": 0, "removed": 0}
@@ -202,7 +204,9 @@ def retract_auto_applied_ccip(session: Session) -> int:
n_retracted.""" n_retracted."""
import numpy as np import numpy as np
settings = MLSettings.load_sync(session) settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
if not settings.ccip_auto_apply_enabled: if not settings.ccip_auto_apply_enabled:
return 0 return 0
thr = float(settings.ccip_auto_apply_threshold) thr = float(settings.ccip_auto_apply_threshold)
+82 -196
View File
@@ -23,7 +23,6 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import delete, exists, func, select from sqlalchemy import delete, exists, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -40,10 +39,9 @@ from ...models import (
TagPositiveConfirmation, TagPositiveConfirmation,
TagSuggestionRejection, TagSuggestionRejection,
) )
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
from .training_data import ( from .training_data import (
_AUTO_SOURCES, _AUTO_SOURCES,
_applied_or_rejected,
_auto_apply_point, _auto_apply_point,
_hygiene_excluded_ids, _hygiene_excluded_ids,
_ids_with_tag, _ids_with_tag,
@@ -63,14 +61,6 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise
_UNLABELED_POOL = 4000 _UNLABELED_POOL = 4000
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head _EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
# Auto-apply / match confidence operating range. Every graduated auto-apply or
# CCIP-match threshold the operator can set lives in this band, and the head
# precision target is clamped to it: below 0.5 "auto-apply" is meaningless, and
# 1.0 is unachievable so 0.999 is the ceiling. One source shared by the service
# clamp (_normalize_params) and the API validator (ml_admin._validate).
AUTO_APPLY_THRESHOLD_MIN = 0.5
AUTO_APPLY_THRESHOLD_MAX = 0.999
# Only these tag kinds get heads (the surfaced suggestion categories). # Only these tag kinds get heads (the surfaced suggestion categories).
_HEAD_KINDS = (TagKind.general, TagKind.character) _HEAD_KINDS = (TagKind.general, TagKind.character)
# tag.kind -> the suggestion category the rail groups under. # tag.kind -> the suggestion category the rail groups under.
@@ -88,38 +78,6 @@ _CATEGORY = {TagKind.general: "general", TagKind.character: "character"}
_SYSTEM_TAG_SUGGEST_FLOOR = 0.65 _SYSTEM_TAG_SUGGEST_FLOOR = 0.65
def _sigmoid(z, np):
"""Logistic sigmoid 1/(1+e^-z): the head score→probability transform. One home
for what was inlined at every scoring site (suggest, both sweeps, retract)."""
return 1.0 / (1.0 + np.exp(-z))
def _conflict_scores(Xn, Wc, bc, np):
"""The presentation conflict signal (#141): per row, the MAX content-head
probability and WHICH head produced it. Shared by the system-tag sweep's guard-2
and the soft-wip audit — both ask "does this ALSO look like real content?"."""
cprobs = _sigmoid(Xn @ Wc.T + bc, np)
return cprobs.max(axis=1), cprobs.argmax(axis=1)
def _insert_presentation_review(
session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode,
):
"""Single-source the ring-loud PresentationReview row shape so the two writers
(system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` —
they share the (image_record_id, tag_id) composite PK, so a divergent `mode`
would be a silent first-writer-wins bug."""
session.execute(
pg_insert(PresentationReview)
.values(
image_record_id=image_record_id, tag_id=tag_id,
conflict_tag_id=conflict_tag_id, conflict_score=conflict_score,
mode=mode,
)
.on_conflict_do_nothing()
)
class HeadTrainingAlreadyRunning(Exception): class HeadTrainingAlreadyRunning(Exception):
"""Raised by start_head_training_run when a run is already in flight.""" """Raised by start_head_training_run when a run is already in flight."""
@@ -145,7 +103,9 @@ def start_head_training_run(session: Session, params: dict[str, Any]) -> int:
def _settings(session: Session) -> MLSettings: def _settings(session: Session) -> MLSettings:
return MLSettings.load_sync(session) return session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]: def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
@@ -164,7 +124,7 @@ def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[s
except (TypeError, ValueError): except (TypeError, ValueError):
cv_folds = DEFAULT_CV_FOLDS cv_folds = DEFAULT_CV_FOLDS
try: try:
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), AUTO_APPLY_THRESHOLD_MIN), AUTO_APPLY_THRESHOLD_MAX) precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999)
except (TypeError, ValueError): except (TypeError, ValueError):
precision_target = s.head_auto_apply_precision precision_target = s.head_auto_apply_precision
return { return {
@@ -540,19 +500,13 @@ async def score_image(
session: AsyncSession, image_id: int, threshold_override: float | None = None, session: AsyncSession, image_id: int, threshold_override: float | None = None,
) -> list[dict]: ) -> list[dict]:
"""Suggestions for one image from the trained heads: [{tag_id, name, """Suggestions for one image from the trained heads: [{tag_id, name,
category, score, above_threshold, grounding}], ranked. A concept is INCLUDED category, score}], ranked. A concept surfaces when its score clears the
when its score clears the head's own suggest_threshold — or, when head's own suggest_threshold — or, when threshold_override is given (the
threshold_override is given (the typed-dropdown "show everything" mode), that typed-dropdown "show everything" mode), that flat floor instead (0 → every
flat floor instead (0 → every head). System-tag heads (wip/banner/editor) head). System-tag heads (wip/banner/editor) instead use a flat
instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection
for rejection (still overridden by threshold_override). Empty if the image has (still overridden by threshold_override). Empty if the image has no
no embedding or no heads exist yet. embedding or no heads exist yet.
``above_threshold`` is reported SEPARATELY from inclusion: it's always whether
the score cleared the head's NATURAL cut (suggest_threshold, or the system
floor), regardless of any override. So the single min=0 fetch returns every
head, and the caller can split panel (above_threshold) from dropdown (all)
without a second request.
MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image
vector PLUS every concept-region crop the agent embedded (same model vector PLUS every concept-region crop the agent embedded (same model
@@ -576,32 +530,28 @@ async def score_image(
norms[norms == 0] = 1.0 norms[norms == 0] = 1.0
Xn = X / norms Xn = X / norms
Z = Xn @ heads["W"].T + heads["b"] # (B, H) Z = Xn @ heads["W"].T + heads["b"] # (B, H)
probs_bag = _sigmoid(Z, np) # (B, H) probs_bag = 1.0 / (1.0 + np.exp(-Z)) # (B, H)
probs = probs_bag.max(axis=0) # (H,) best over the bag probs = probs_bag.max(axis=0) # (H,) best over the bag
# ARGMAX beside the max: WHICH bag row won each head → the region that grounds # ARGMAX beside the max: WHICH bag row won each head → the region that grounds
# the tag (bag_meta[win]); None when the whole-image vector won (#1206). # the tag (bag_meta[win]); None when the whole-image vector won (#1206).
winners = probs_bag.argmax(axis=0) # (H,) winners = probs_bag.argmax(axis=0) # (H,)
out = [] out = []
for i, p in enumerate(probs): for i, p in enumerate(probs):
m = heads["meta"][i] if threshold_override is not None:
# The head's NATURAL suggest cut — system tags use the flat floor (see cut = threshold_override
# _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the elif heads["meta"][i]["is_system"]:
# operator to reject; content heads use their own precision-tuned # System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR)
# threshold. This is what "above threshold" means (drives the panel). # so their false positives show up for the operator to reject.
natural = ( cut = _SYSTEM_TAG_SUGGEST_FLOOR
_SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i]) else:
) cut = heads["thr"][i]
# INCLUSION is looser under threshold_override (dropdown show-all,
# override=0): every head comes back so a low-confidence concept can still
# be typed + picked, each carrying its own above_threshold flag.
cut = threshold_override if threshold_override is not None else natural
if p >= cut: if p >= cut:
m = heads["meta"][i]
out.append({ out.append({
"tag_id": m["tag_id"], "tag_id": m["tag_id"],
"name": m["name"], "name": m["name"],
"category": m["category"], "category": m["category"],
"score": float(p), "score": float(p),
"above_threshold": bool(p >= natural),
"grounding": bag_meta[int(winners[i])], "grounding": bag_meta[int(winners[i])],
}) })
out.sort(key=lambda d: d["score"], reverse=True) out.sort(key=lambda d: d["score"], reverse=True)
@@ -654,7 +604,9 @@ async def ground_applied_tag(
async def _settings_async(session: AsyncSession) -> MLSettings: async def _settings_async(session: AsyncSession) -> MLSettings:
return await MLSettings.load(session) return (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
# --- Earned auto-apply (sync, ml worker) --------------------------------- # --- Earned auto-apply (sync, ml worker) ---------------------------------
@@ -725,6 +677,7 @@ def auto_apply_sweep(
embeddings in chunks; commits per chunk on a real run. Returns embeddings in chunks; commits per chunk on a real run. Returns
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}.""" {n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
import numpy as np import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert
settings = _settings(session) settings = _settings(session)
rows = _auto_apply_heads( rows = _auto_apply_heads(
@@ -741,7 +694,18 @@ def auto_apply_sweep(
names = [r.name for r in rows] names = [r.name for r in rows]
# Skip images that already carry, or have rejected, each tag. # Skip images that already carry, or have rejected, each tag.
skip = _applied_or_rejected(session, tag_ids) skip = {tid: set() for tid in tag_ids}
for tid in tag_ids:
for (iid,) in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
):
skip[tid].add(iid)
for (iid,) in session.execute(
select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == tid
)
):
skip[tid].add(iid)
applied = [0] * len(rows) applied = [0] * len(rows)
scanned = 0 scanned = 0
@@ -755,7 +719,7 @@ def auto_apply_sweep(
if not cids: if not cids:
continue continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
probs = _sigmoid(Xn @ W.T + b, np) # (N, H) probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H)
scanned += len(cids) scanned += len(cids)
for h in range(len(rows)): for h in range(len(rows)):
tid = tag_ids[h] tid = tag_ids[h]
@@ -785,42 +749,18 @@ def auto_apply_sweep(
_PRESENTATION_SOURCE = "presentation_auto" _PRESENTATION_SOURCE = "presentation_auto"
_PROCESS_SOURCE = "process_auto"
# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply
# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review
# guard — and differ ONLY in which tags, which settings knobs, and which
# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process'
# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of
# the tag's group membership, not of this sweep).
_SWEEP_MODES = {
"chrome": {
"names": CHROME_SYSTEM_TAGS,
"enabled": "presentation_auto_apply_enabled",
"threshold": "presentation_auto_apply_threshold",
"conflict": "presentation_conflict_threshold",
"source": _PRESENTATION_SOURCE,
},
"process": {
"names": PROCESS_SYSTEM_TAGS,
"enabled": "process_auto_apply_enabled",
"threshold": "process_auto_apply_threshold",
"conflict": "process_conflict_threshold",
"source": _PROCESS_SOURCE,
},
}
def _system_tag_heads(session: Session, embedding_version: str, names): def _presentation_heads(session: Session, embedding_version: str):
"""Trained heads for a system-tag group (chrome banner / process wip+editor). """Trained heads for the presentation chrome tags (banner / editor screenshot).
They fire at the group's FLAT threshold regardless of graduation — a head They fire at the FLAT presentation threshold regardless of graduation — a head
exists once the operator has labelled enough (head_min_positives).""" exists once the operator has labelled enough chrome (head_min_positives)."""
return session.execute( return session.execute(
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias) select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
.join(Tag, Tag.id == TagHead.tag_id) .join(Tag, Tag.id == TagHead.tag_id)
.where(TagHead.embedding_version == embedding_version) .where(TagHead.embedding_version == embedding_version)
.where(Tag.is_system.is_(True)) .where(Tag.is_system.is_(True))
.where(Tag.name.in_(names)) .where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS))
).all() ).all()
@@ -852,32 +792,27 @@ def _valued_image_ids(session: Session) -> set[int]:
return {r[0] for r in rows} return {r[0] for r in rows}
def system_tag_auto_apply_sweep( def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict:
session: Session, *, mode: str, dry_run: bool = False """Auto-hide presentation chrome (banner / editor screenshot) at the FLAT
) -> dict: presentation threshold (#141) — NOT the per-head graduated threshold. Two
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner, guards keep it safe: (1) never hide an image carrying a human/confirmed content
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold
VISIBLE — the ONLY difference is the tag group's gallery membership, not this on a content head, still hide it but flag it (PresentationReview) so the Hidden
sweep. Two guards keep it safe: (1) never touch an image carrying a view surfaces "also looks like <X>" for review. No-op unless
human/confirmed content tag; (2) if the image ALSO scores >= the conflict presentation_auto_apply_enabled. numpy-only (no sklearn). Returns
threshold on a content head, still apply but flag it (PresentationReview, {n_applied, n_flagged, concepts}."""
mode=<mode>) so the review strip surfaces "also looks like <X>". The source is
PROVISIONAL so the head never trains on its own output. No-op unless the mode's
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
concepts}."""
import numpy as np import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert
cfg = _SWEEP_MODES[mode]
settings = _settings(session) settings = _settings(session)
if not dry_run and not getattr(settings, cfg["enabled"]): if not dry_run and not settings.presentation_auto_apply_enabled:
return {"n_applied": 0, "n_flagged": 0, "concepts": []} return {"n_applied": 0, "n_flagged": 0, "concepts": []}
ver = settings.embedder_model_version ver = settings.embedder_model_version
pres = _system_tag_heads(session, ver, cfg["names"]) pres = _presentation_heads(session, ver)
if not pres: if not pres:
return {"n_applied": 0, "n_flagged": 0, "concepts": []} return {"n_applied": 0, "n_flagged": 0, "concepts": []}
thr = float(getattr(settings, cfg["threshold"])) thr = float(settings.presentation_auto_apply_threshold)
conflict_thr = float(getattr(settings, cfg["conflict"])) conflict_thr = float(settings.presentation_conflict_threshold)
source = cfg["source"]
Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres]) Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres])
bp = np.asarray([r.bias for r in pres], dtype=np.float32) bp = np.asarray([r.bias for r in pres], dtype=np.float32)
@@ -894,7 +829,18 @@ def system_tag_auto_apply_sweep(
valued = _valued_image_ids(session) valued = _valued_image_ids(session)
# Skip images that already carry, or have rejected, each presentation tag. # Skip images that already carry, or have rejected, each presentation tag.
skip = _applied_or_rejected(session, pres_tag_ids) skip = {tid: set() for tid in pres_tag_ids}
for tid in pres_tag_ids:
for (iid,) in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
):
skip[tid].add(iid)
for (iid,) in session.execute(
select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == tid
)
):
skip[tid].add(iid)
applied = [0] * len(pres) applied = [0] * len(pres)
n_flagged = 0 n_flagged = 0
@@ -909,9 +855,11 @@ def system_tag_auto_apply_sweep(
if not cids: if not cids:
continue continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
probs = _sigmoid(Xn @ Wp.T + bp, np) # (N, P) probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P)
if Wc is not None: if Wc is not None:
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,) cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
max_c = cprobs.max(axis=1)
arg_c = cprobs.argmax(axis=1)
scanned += len(cids) scanned += len(cids)
for p in range(len(pres)): for p in range(len(pres)):
tid = pres_tag_ids[p] tid = pres_tag_ids[p]
@@ -926,22 +874,22 @@ def system_tag_auto_apply_sweep(
pg_insert(image_tag) pg_insert(image_tag)
.values( .values(
image_record_id=iid, tag_id=tid, image_record_id=iid, tag_id=tid,
source=source, source=_PRESENTATION_SOURCE,
) )
.on_conflict_do_nothing() .on_conflict_do_nothing()
) )
# Guard 2: also looks like real content → still apply, but flag it # Guard 2: also looks like content → hide but flag for review.
# for the review strip instead of silently marking (chrome hides,
# process stays visible — either way the operator gets a heads-up).
if Wc is not None and float(max_c[idx]) >= conflict_thr: if Wc is not None and float(max_c[idx]) >= conflict_thr:
n_flagged += 1 n_flagged += 1
if not dry_run: if not dry_run:
_insert_presentation_review( session.execute(
session, pg_insert(PresentationReview)
.values(
image_record_id=iid, tag_id=tid, image_record_id=iid, tag_id=tid,
conflict_tag_id=conf_tag_ids[int(arg_c[idx])], conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
conflict_score=float(max_c[idx]), conflict_score=float(max_c[idx]),
mode=mode, )
.on_conflict_do_nothing()
) )
if not dry_run: if not dry_run:
session.commit() session.commit()
@@ -956,68 +904,6 @@ def system_tag_auto_apply_sweep(
} }
def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
"""Ring-loud audit for the SOFT WIP-title cohort (#1474). Images auto-tagged
`wip` from a low-precision sketch/doodle title (source='wip_title_soft') that ALSO
score >= the process conflict threshold on a content head are probably FINISHED
art mis-tagged as process — flag them (PresentationReview, mode='process') so the
review strip surfaces them ("also looks like <X>", Keep tag / Remove tag). Does
NOT remove the tag; the operator decides. No-op when there are no content heads.
numpy-only. Returns {n_scanned, n_flagged}."""
import numpy as np
from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id
settings = _settings(session)
ver = settings.embedder_model_version
conflict_thr = float(settings.process_conflict_threshold)
conf = _conflict_heads(session, ver)
wip_id = resolve_wip_tag_id(session)
if not conf or wip_id is None:
return {"n_scanned": 0, "n_flagged": 0}
Wc = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in conf])
bc = np.asarray([r.bias for r in conf], dtype=np.float32)
conf_tag_ids = [r.tag_id for r in conf]
soft_ids = [iid for (iid,) in session.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == wip_id)
.where(image_tag.c.source == WIP_TITLE_SOFT_SOURCE)
)]
# Skip images already flagged for this tag (idempotent re-runs).
flagged = {iid for (iid,) in session.execute(
select(PresentationReview.image_record_id)
.where(PresentationReview.tag_id == wip_id)
)}
soft_ids = [i for i in soft_ids if i not in flagged]
n_flagged = 0
scanned = 0
for start in range(0, len(soft_ids), _AUTO_APPLY_CHUNK):
chunk = soft_ids[start:start + _AUTO_APPLY_CHUNK]
emb = _load_embeddings(session, chunk)
cids = [i for i in chunk if i in emb]
if not cids:
continue
scanned += len(cids)
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np)
for k in range(len(cids)):
if float(max_c[k]) >= conflict_thr:
n_flagged += 1
if not dry_run:
_insert_presentation_review(
session,
image_record_id=cids[k], tag_id=wip_id,
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
conflict_score=float(max_c[k]),
mode="process",
)
if not dry_run:
session.commit()
return {"n_scanned": scanned, "n_flagged": n_flagged}
def retract_auto_applied_heads(session: Session) -> int: def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto' """Soft auto-apply (milestone 139): re-score every standing source='head_auto'
tag against its CURRENT head and REMOVE the ones now BELOW the head's tag against its CURRENT head and REMOVE the ones now BELOW the head's
@@ -1065,7 +951,7 @@ def retract_auto_applied_heads(session: Session) -> int:
continue continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
w = np.asarray(weights, dtype=np.float32) w = np.asarray(weights, dtype=np.float32)
probs = _sigmoid(Xn @ w + float(bias), np) probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias))))
below = [cids[k] for k in np.where(probs < float(thr))[0]] below = [cids[k] for k in np.where(probs < float(thr))[0]]
for iid in below: for iid in below:
session.execute( session.execute(
+17 -17
View File
@@ -22,20 +22,22 @@ from .heads import score_image
@dataclass(frozen=True) @dataclass(frozen=True)
class Suggestion: class Suggestion:
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept # canonical_tag_id is None when this is a raw Camie tag with no alias and
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap # no existing Tag row — accepting it will create the tag.
# cases are gone — a suggestion always maps to a real tag id. canonical_tag_id: int | None
canonical_tag_id: int
display_name: str display_name: str
category: str category: str
score: float score: float
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2) source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
# above_threshold = the score cleared the head's own suggest cut (or the creates_new_tag: bool
# system floor). The Suggestions PANEL shows only these; the typed-tag # raw_name = the booru model vocab key behind this suggestion. It's the key
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each # an alias MUST be stored under (resolution looks up the raw key), so the
# matching row with its score, so a low-confidence concept can still be typed # modal needs it to author an alias correctly. None for centroid-only hits
# and picked. CCIP character matches are always above their match threshold. # (no underlying prediction → nothing to alias).
above_threshold: bool raw_name: str | None = None
# via_alias = this suggestion was surfaced because an operator alias remapped
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
via_alias: bool = False
# rejected = the operator dismissed this tag for this image (a stored # rejected = the operator dismissed this tag for this image (a stored
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so # TagSuggestionRejection). It stays in the list — flagged, not dropped — so
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery, # the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
@@ -106,7 +108,6 @@ class SuggestionService:
for h in hits: for h in hits:
merged[(h["category"], h["tag_id"])] = { merged[(h["category"], h["tag_id"])] = {
"name": h["name"], "score": h["score"], "source": "head", "name": h["name"], "score": h["score"], "source": "head",
"above_threshold": h["above_threshold"],
"grounding": h.get("grounding"), "grounding": h.get("grounding"),
} }
for c in ccip_hits: for c in ccip_hits:
@@ -115,16 +116,12 @@ class SuggestionService:
if ex is not None: if ex is not None:
ex["source"] = "both" ex["source"] = "both"
ex["score"] = max(ex["score"], c["score"]) ex["score"] = max(ex["score"], c["score"])
# CCIP only returns matches above its own threshold, so a CCIP
# corroboration always makes the merged suggestion above-threshold.
ex["above_threshold"] = True
# Keep the head's localized crop if it had one; else fall back to # Keep the head's localized crop if it had one; else fall back to
# the CCIP figure so a corroborated character still grounds (#1206). # the CCIP figure so a corroborated character still grounds (#1206).
ex["grounding"] = ex.get("grounding") or c.get("grounding") ex["grounding"] = ex.get("grounding") or c.get("grounding")
else: else:
merged[key] = { merged[key] = {
"name": c["name"], "score": c["score"], "source": "ccip", "name": c["name"], "score": c["score"], "source": "ccip",
"above_threshold": True,
"grounding": c.get("grounding"), "grounding": c.get("grounding"),
} }
@@ -139,7 +136,7 @@ class SuggestionService:
category=cat, category=cat,
score=m["score"], score=m["score"],
source=m["source"], source=m["source"],
above_threshold=m["above_threshold"], creates_new_tag=False,
rejected=tag_id in rejected, rejected=tag_id in rejected,
grounding=m.get("grounding"), grounding=m.get("grounding"),
) )
@@ -160,7 +157,8 @@ class SuggestionService:
was suggested for (or already applied to) >= threshold fraction of was suggested for (or already applied to) >= threshold fraction of
the selection AND was acceptable on >= 1 image. Confidence is the the selection AND was acceptable on >= 1 image. Confidence is the
mean over images where it was suggested. Aggregated by mean over images where it was suggested. Aggregated by
canonical_tag_id (every suggestion is a canonical tag now).""" canonical_tag_id; creates-new (no canonical id) suggestions are
skipped (bulk Accept applies by tag id)."""
if not image_ids: if not image_ids:
return {} return {}
threshold = min(1.0, max(0.0, threshold)) threshold = min(1.0, max(0.0, threshold))
@@ -171,6 +169,8 @@ class SuggestionService:
sl = await self.for_image(image_id) sl = await self.for_image(image_id)
for category, items in sl.by_category.items(): for category, items in sl.by_category.items():
for s in items: for s in items:
if s.canonical_tag_id is None or s.creates_new_tag:
continue
# for_image keeps rejected tags (flagged) for the rail; # for_image keeps rejected tags (flagged) for the rail;
# bulk consensus must still ignore them — a tag dismissed on # bulk consensus must still ignore them — a tag dismissed on
# an image isn't a suggestion for that image. # an image isn't a suggestion for that image.
+1 -27
View File
@@ -29,15 +29,7 @@ from ...models.tag import image_tag
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping # a CCIP reference) unless the operator confirms them (milestone 139). Keeping
# auto-applied predictions out of training is what makes them "soft" — a misfire # auto-applied predictions out of training is what makes them "soft" — a misfire
# can't reinforce itself, so the retraction sweep can actually drop it. # can't reinforce itself, so the retraction sweep can actually drop it.
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are _AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
# ALSO provisional — the head must learn only from title (`wip_title`) + manual
# labels, never its own auto-applied output, or it would runaway (operator 2026-07-12).
# `wip_title_soft` (#1474): the soft title tier (sketch/doodle) is LOW-precision, so
# it's provisional too — a finished piece titled "sketch" must not train the wip head.
_AUTO_SOURCES = (
"head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto",
"wip_title_soft",
)
def _hygiene_excluded_ids(session: Session) -> set[int]: def _hygiene_excluded_ids(session: Session) -> set[int]:
@@ -94,24 +86,6 @@ def _rejected_ids(session: Session, tag_id: int) -> list[int]:
] ]
def _applied_or_rejected(session: Session, tag_ids) -> dict[int, set[int]]:
"""Per-tag skip set for the auto-apply sweeps: every image that ALREADY carries
the tag (ANY source — not just training positives) OR has rejected it. A sweep
never re-applies to these. Shared by auto_apply_sweep + system_tag_auto_apply_sweep
(heads.py) and scheduled_ccip_auto_apply (tasks/ml.py). Callers mutate the returned
sets in-place to also dedupe within a single run."""
skip: dict[int, set[int]] = {}
for tid in tag_ids:
ids = {
r[0] for r in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
).all()
}
ids.update(_rejected_ids(session, tid))
skip[tid] = ids
return skip
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]: def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are """Random image ids (with an embedding) NOT carrying the tag. Concepts are
sparse, so an untagged image is almost always a true negative.""" sparse, so an untagged image is almost always a true negative."""
+36 -20
View File
@@ -91,46 +91,48 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
) )
def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None: def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
"""The first `data` object from Patreon's campaigns API filtered by vanity
(`?filter[vanity]=<vanity>&fields[campaign]=name`), or None on any failure
(network / non-200 / non-JSON / empty). The single request shape shared by
_lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the
display name)."""
jar = _load_cookie_jar(cookies_path) jar = _load_cookie_jar(cookies_path)
headers = {
"User-Agent": _USER_AGENT,
"Accept": "application/vnd.api+json",
}
params = {
"filter[vanity]": vanity,
"fields[campaign]": "name",
}
try: try:
resp = requests.get( resp = requests.get(
_CAMPAIGNS_URL, _CAMPAIGNS_URL,
params={"filter[vanity]": vanity, "fields[campaign]": "name"}, params=params,
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}, headers=headers,
cookies=jar, cookies=jar,
timeout=_TIMEOUT_SECONDS, timeout=_TIMEOUT_SECONDS,
) )
except requests.RequestException as exc: except requests.RequestException as exc:
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc) log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
return None return None
if resp.status_code != 200: if resp.status_code != 200:
log.warning( log.warning(
"Patreon campaigns API returned HTTP %d for vanity=%s", "Patreon campaigns API returned HTTP %d for vanity=%s",
resp.status_code, vanity, resp.status_code, vanity,
) )
return None return None
try: try:
payload = resp.json() payload = resp.json()
except ValueError as exc: except ValueError as exc:
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc) log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
return None return None
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
return data[0]
if not isinstance(payload, dict):
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
first = _campaigns_api_first(vanity, cookies_path)
if first is None:
return None return None
campaign_id = first.get("id") data = payload.get("data")
if not isinstance(data, list) or not data:
return None
first = data[0] if isinstance(data[0], dict) else None
campaign_id = first.get("id") if first else None
if not isinstance(campaign_id, str) or not campaign_id: if not isinstance(campaign_id, str) or not campaign_id:
return None return None
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id) log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
@@ -142,10 +144,24 @@ def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None (`fields[campaign]=name`), used to name the Artist at add-time (#130). None
on any failure — the caller falls back to the vanity handle. Sync: call from on any failure — the caller falls back to the vanity handle. Sync: call from
an executor.""" an executor."""
first = _campaigns_api_first(vanity, cookies_path) jar = _load_cookie_jar(cookies_path)
if first is None: try:
resp = requests.get(
_CAMPAIGNS_URL,
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
cookies=jar,
timeout=_TIMEOUT_SECONDS,
)
if resp.status_code != 200:
return None return None
name = (first.get("attributes") or {}).get("name") data = resp.json().get("data")
except (requests.RequestException, ValueError) as exc:
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
return None
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
name = (data[0].get("attributes") or {}).get("name")
return name.strip() if isinstance(name, str) and name.strip() else None return name.strip() if isinstance(name, str) and name.strip() else None
@@ -397,8 +397,6 @@ class PostFeedService:
"post_title_translated": post.post_title_translated, "post_title_translated": post.post_title_translated,
"description_translated": desc_trans_short, "description_translated": desc_trans_short,
"translated_source_lang": post.translated_source_lang, "translated_source_lang": post.translated_source_lang,
# Sticky per-post translation choice (auto/force/original, #155).
"translation_override": post.translation_override,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"source": ( "source": (
{"id": source.id, "platform": source.platform} {"id": source.id, "platform": source.platform}
@@ -35,7 +35,6 @@ def _post_dict(p: Post) -> dict:
"title_translated": p.post_title_translated, "title_translated": p.post_title_translated,
"description_translated": p.description_translated, "description_translated": p.description_translated,
"translated_source_lang": p.translated_source_lang, "translated_source_lang": p.translated_source_lang,
"translation_override": p.translation_override,
} }
-125
View File
@@ -1,125 +0,0 @@
"""Title-based WIP auto-tagging (task #1458).
Deterministic heuristic: when a post's TITLE explicitly declares work-in-progress
(the artist's own "WIP" / "work in progress" label), the ``wip`` system tag is
applied to that post's images — a cheap, high-precision complement to the
image-based ML ``wip`` head. WIP images are excluded from the Explore/gallery
browse (see gallery_service ``excluded_system_tags``), so honouring the artist's
own label keeps unfinished pieces out of the main browse right at import.
Precision over recall — a false WIP tag HIDES a finished post — so matching is
token-anchored: ``swipe`` / ``wiped`` / ``wiping`` never trip it (a letter on the
boundary blocks the match).
Sync-only: both consumers (the importer and the backfill Celery task) run on a
sync Session. Application is idempotent-additive (ON CONFLICT DO NOTHING) and
stamps a distinct ``image_tag.source`` so a later pass can tell where a wip tag
came from — the "manual" / "head_auto" / "ccip_auto" / "ml_accepted" provenance
family gains one member.
"""
import re
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other
# apply sources so provenance stays legible and a future undo can target only these.
# HARD tier ("WIP"/"work in progress") is high-precision → trains the wip head.
WIP_TITLE_SOURCE = "wip_title"
# SOFT tier (sketch/doodle/scribble, #1474) is LOWER-precision — a finished "sketch"
# is often not WIP. This source is PROVISIONAL (in training_data._AUTO_SOURCES) so it
# NEVER trains the wip head; a soft-tagged image that also looks like real content is
# surfaced by the ring-loud audit for review.
WIP_TITLE_SOFT_SOURCE = "wip_title_soft"
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
# make this precision-first: `s|wip|e`, `|wip|ed`, `|wip|ing` all have a letter
# abutting the token, so they're rejected. A trailing digit is allowed so
# "WIP2" (= WIP part 2) still matches.
_WIP_RE = re.compile(
r"(?<![A-Za-z])(?:w\.?i\.?p\.?|work[\s_-]+in[\s_-]+progress)(?![A-Za-z])",
re.IGNORECASE,
)
# Soft tier: sketch / doodle / scribble (+ plurals), letter-boundary anchored so
# "sketchbook" / "kadoodle" don't trip it. Deliberately conservative — recall is
# secondary because the soft source doesn't train the head and the ring-loud audit
# catches false positives.
_SOFT_WIP_RE = re.compile(
r"(?<![A-Za-z])(?:sketch|sketches|doodle|doodles|scribble|scribbles)(?![A-Za-z])",
re.IGNORECASE,
)
# Coarse SQL prefilters for the backfill sweep — narrow the post scan to rows that
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
_INSERT_CHUNK = 5000
def matches_wip_title(title: str | None) -> bool:
"""True when a post title explicitly marks it work-in-progress (HARD tier)."""
if not title:
return False
return _WIP_RE.search(title) is not None
def matches_soft_wip_title(title: str | None) -> bool:
"""True when a title carries a SOFT WIP cue (sketch/doodle/scribble, #1474)."""
if not title:
return False
return _SOFT_WIP_RE.search(title) is not None
def resolve_wip_tag_id(session: Session) -> int | None:
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
return session.execute(
select(Tag.id).where(Tag.name == WIP_SYSTEM_TAG, Tag.is_system.is_(True))
).scalar_one_or_none()
def apply_wip_image_tags(
session: Session, image_ids, tag_id: int, *, source: str = WIP_TITLE_SOURCE
) -> int:
"""Attach ``tag_id`` (stamped with ``source``) to each image id, idempotently —
never disturbs an existing tag or its source. Returns the number of image_tag
rows newly inserted. Does NOT commit.
The insert count is computed from a pre-SELECT of already-tagged ids rather
than the statement's ``rowcount``: psycopg reports -1 for a multi-row
ON CONFLICT DO NOTHING insert (it runs via an executemany path), so rowcount
is unusable here. The SELECT is accurate within this single transaction (no
concurrent writer touches these (image, wip) rows); ON CONFLICT DO NOTHING
stays as a race-safety belt so a rare concurrent insert can't error."""
ids = list({int(i) for i in image_ids})
if not ids:
return 0
inserted = 0
for start in range(0, len(ids), _INSERT_CHUNK):
chunk = ids[start:start + _INSERT_CHUNK]
already = set(session.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == tag_id)
.where(image_tag.c.image_record_id.in_(chunk))
).scalars())
to_insert = [iid for iid in chunk if iid not in already]
if not to_insert:
continue
session.execute(
pg_insert(image_tag)
.values([
{"image_record_id": iid, "tag_id": tag_id, "source": source}
for iid in to_insert
])
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
)
inserted += len(to_insert)
return inserted
+70 -135
View File
@@ -76,8 +76,6 @@ DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7 OLD_TASK_DAYS = 7
PHASH_PAGE = 500 PHASH_PAGE = 500
VERIFY_PAGE = 200 VERIFY_PAGE = 200
# Title-based WIP backfill (task #1458): posts scanned per keyset page.
WIP_BACKFILL_PAGE = 500
FFPROBE_TIMEOUT_SECONDS = 10 FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
@@ -776,62 +774,89 @@ def recover_stalled_library_audit_runs() -> int:
return recovered return recovered
def _recover_stalled_runs(model, *, stall_minutes: int, keep_runs: int, label: str) -> int:
"""Shared recovery + retention sweep for the head run-tracking tables
(HeadTrainingRun / HeadAutoApplyRun, which share the
status/last_progress_at/started_at/finished_at/error/id columns): flip 'running'
rows with no progress past `stall_minutes` to 'error', then prune to the last
`keep_runs` (rule 89). Returns the number recovered. NOTE the two other recover
tasks are deliberately NOT folded in — library-audit has no prune tail and
backup uses a single started_at cutoff."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=stall_minutes)
with SessionLocal() as session:
result = session.execute(
update(model)
.where(model.status == "running")
.where(func.coalesce(model.last_progress_at, model.started_at) < cutoff)
.values(
status="error", finished_at=now,
error=f"stranded by recovery sweep (no progress for {stall_minutes} min)",
)
)
keep = session.execute(
select(model.id).order_by(model.id.desc()).limit(keep_runs)
).scalars().all()
if keep:
session.execute(delete(model).where(model.id.not_in(keep)))
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info("%s: recovered %d rows", label, recovered)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs") @celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
def recover_stalled_head_training_runs() -> int: def recover_stalled_head_training_runs() -> int:
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to """Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention, 'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention,
rule 89). Runs every 5 min on the maintenance lane; no-op when idle.""" rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
return _recover_stalled_runs( SessionLocal = _sync_session_factory()
HeadTrainingRun, now = datetime.now(UTC)
stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES, cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES)
keep_runs=HEAD_TRAINING_KEEP_RUNS, with SessionLocal() as session:
label="recover_stalled_head_training_runs", result = session.execute(
update(HeadTrainingRun)
.where(HeadTrainingRun.status == "running")
.where(
func.coalesce(
HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at
) )
< cutoff
)
.values(
status="error", finished_at=now,
error=(
f"stranded by recovery sweep (no progress for "
f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)"
),
)
)
keep = session.execute(
select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc())
.limit(HEAD_TRAINING_KEEP_RUNS)
).scalars().all()
if keep:
session.execute(
delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep))
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_head_training_runs: recovered %d rows", recovered
)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs") @celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
def recover_stalled_head_auto_apply_runs() -> int: def recover_stalled_head_auto_apply_runs() -> int:
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the """Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane.""" last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
return _recover_stalled_runs( SessionLocal = _sync_session_factory()
HeadAutoApplyRun, now = datetime.now(UTC)
stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES, cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES)
keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS, with SessionLocal() as session:
label="recover_stalled_head_auto_apply_runs", result = session.execute(
update(HeadAutoApplyRun)
.where(HeadAutoApplyRun.status == "running")
.where(
func.coalesce(
HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at
) )
< cutoff
)
.values(
status="error", finished_at=now,
error=(
f"stranded by recovery sweep (no progress for "
f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)"
),
)
)
keep = session.execute(
select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc())
.limit(HEAD_AUTO_APPLY_KEEP_RUNS)
).scalars().all()
if keep:
session.execute(
delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep))
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_head_auto_apply_runs: recovered %d rows", recovered
)
return recovered
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends). # Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
@@ -1020,96 +1045,6 @@ def cleanup_old_download_events() -> int:
return result.rowcount or 0 return result.rowcount or 0
def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
"""One keyset-paginated pass over posts whose title matches a WIP tier, applying
`tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers
(#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
`matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row
count newly applied."""
from ..models import Post
from ..models.image_provenance import ImageProvenance
from ..services.wip_title import apply_wip_image_tags
applied = 0
last_id = 0
while True:
rows = session.execute(
select(Post.id, Post.post_title)
.where(Post.id > last_id)
.where(Post.post_title.is_not(None))
.where(or_(*[Post.post_title.ilike(p) for p in prefilter]))
.order_by(Post.id.asc())
.limit(WIP_BACKFILL_PAGE)
).all()
if not rows:
break
last_id = rows[-1][0]
match_ids = [pid for pid, title in rows if matcher(title)]
if match_ids:
image_ids = session.execute(
select(ImageProvenance.image_record_id)
.where(ImageProvenance.post_id.in_(match_ids))
).scalars().all()
applied += apply_wip_image_tags(session, image_ids, tag_id, source=source)
session.commit()
return applied
@celery.task(
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
# Coarse-prefiltered scan over posts; the candidate set is small on a typical
# library, but bound it like the other full-library sweeps.
soft_time_limit=1800, time_limit=2100,
)
def backfill_wip_title_tags() -> int:
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
#1474 soft tier). New imports are tagged live by the importer; this covers the
existing library.
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
wip_soft_title_tagging_enabled, AFTER hard so a title matching both keeps the
trained hard tag (ON CONFLICT DO NOTHING). Keyset-paginated, restart-safe.
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
posts and silently undo a manual WIP removal, so it stays an explicit operator
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
"""
from ..models import ImportSettings
from ..services.wip_title import (
SOFT_WIP_TITLE_SQL_PREFILTER,
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE,
WIP_TITLE_SQL_PREFILTER,
matches_soft_wip_title,
matches_wip_title,
resolve_wip_tag_id,
)
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
tag_id = resolve_wip_tag_id(session)
if tag_id is None:
log.warning(
"backfill_wip_title_tags: no `wip` system tag present; nothing to do"
)
return 0
settings = ImportSettings.load_sync(session)
applied = _backfill_wip_tier(
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
WIP_TITLE_SOURCE,
)
if settings.wip_soft_title_tagging_enabled:
applied += _backfill_wip_tier(
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
WIP_TITLE_SOFT_SOURCE,
)
if applied:
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
return applied
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze") @celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
def vacuum_analyze() -> dict: def vacuum_analyze() -> dict:
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to """Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
+36 -52
View File
@@ -105,7 +105,9 @@ def embed_image(self, image_id: int) -> dict:
record = session.get(ImageRecord, image_id) record = session.get(ImageRecord, image_id)
if record is None: if record is None:
return {"status": "missing", "image_id": image_id} return {"status": "missing", "image_id": image_id}
settings = MLSettings.load_sync(session) settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
src = Path(record.path) src = Path(record.path)
is_vid = _is_video(src) is_vid = _is_video(src)
@@ -486,10 +488,15 @@ def scheduled_ccip_auto_apply() -> str:
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ImageRegion, MLSettings, Tag, TagKind from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection
from ..models.tag import image_tag from ..models.tag import image_tag
from ..services.ml.ccip import _FIGURE_KINDS
from ..services.ml.training_data import _applied_or_rejected, _l2norm fig = ("face", "figure")
def _l2(m):
n = np.linalg.norm(m, axis=1, keepdims=True)
n[n == 0] = 1.0
return m / n
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
@@ -514,7 +521,7 @@ def scheduled_ccip_auto_apply() -> str:
) )
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character) .where(Tag.kind == TagKind.character)
.where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.kind.in_(fig))
.where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(single)) .where(ImageRegion.image_record_id.in_(single))
).all() ).all()
@@ -525,16 +532,29 @@ def scheduled_ccip_auto_apply() -> str:
for tid, vec in ref_rows: for tid, vec in ref_rows:
by_char.setdefault(tid, []).append(vec) by_char.setdefault(tid, []).append(vec)
ref_tags = list(by_char) ref_tags = list(by_char)
mats = [_l2norm(np.asarray(by_char[t], dtype=np.float32), np) for t in ref_tags] mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags]
allref = np.vstack(mats) # (total, 768) allref = np.vstack(mats) # (total, 768)
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
# Per character: images that already carry OR rejected the tag — skip. # Per character: images that already carry OR rejected the tag — skip.
skip = _applied_or_rejected(session, ref_tags) skip = {t: set() for t in ref_tags}
for t in ref_tags:
for (iid,) in session.execute(
sa_select(image_tag.c.image_record_id).where(
image_tag.c.tag_id == t
)
):
skip[t].add(iid)
for (iid,) in session.execute(
sa_select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == t
)
):
skip[t].add(iid)
img_ids = list(session.execute( img_ids = list(session.execute(
sa_select(ImageRegion.image_record_id) sa_select(ImageRegion.image_record_id)
.where(ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None))
.distinct() .distinct()
).scalars()) ).scalars())
@@ -546,7 +566,7 @@ def scheduled_ccip_auto_apply() -> str:
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding) sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
.where( .where(
ImageRegion.image_record_id.in_(chunk), ImageRegion.image_record_id.in_(chunk),
ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.kind.in_(fig),
ImageRegion.ccip_embedding.is_not(None), ImageRegion.ccip_embedding.is_not(None),
) )
).all() ).all()
@@ -554,7 +574,7 @@ def scheduled_ccip_auto_apply() -> str:
for iid, vec in rows: for iid, vec in rows:
by_img.setdefault(iid, []).append(vec) by_img.setdefault(iid, []).append(vec)
for iid, vecs in by_img.items(): for iid, vecs in by_img.items():
q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768) q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768)
colmax = (q @ allref.T).max(axis=0) # (total,) colmax = (q @ allref.T).max(axis=0) # (total,)
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,) charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
for ci in np.where(charmax >= thr)[0]: for ci in np.where(charmax >= thr)[0]:
@@ -579,54 +599,18 @@ def scheduled_ccip_auto_apply() -> str:
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
) )
def scheduled_presentation_auto_apply() -> str: def scheduled_presentation_auto_apply() -> str:
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141). """Auto-hide presentation chrome (banner / editor screenshot) on a daily
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
are skipped — so an interrupted run simply re-runs next cycle (that IS the — already-hidden images are skipped — so an interrupted run simply re-runs next
recovery). Wall-clock bounded by the task time limits.""" cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import system_tag_auto_apply_sweep from ..services.ml.heads import presentation_auto_apply_sweep
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
result = system_tag_auto_apply_sweep(session, mode="chrome") result = presentation_auto_apply_sweep(session)
return f"applied={result['n_applied']} flagged={result['n_flagged']}" return f"applied={result['n_applied']} flagged={result['n_flagged']}"
@celery.task(
name="backend.app.tasks.ml.scheduled_process_auto_apply",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_process_auto_apply() -> str:
"""Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily
passive sweep (#1464) — provisional source, ring-loud review guard, image stays
VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent —
already-tagged/rejected images are skipped — so an interrupted run just re-runs
next cycle (the recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import system_tag_auto_apply_sweep
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = system_tag_auto_apply_sweep(session, mode="process")
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
@celery.task(
name="backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_soft_wip_conflict_audit() -> str:
"""Ring-loud audit over the SOFT WIP-title cohort (#1474) — flag sketch/doodle
auto-tags that ALSO look like real content for review. No-op when there are no
content heads; idempotent (already-flagged images skipped). Runs regardless of
the process-sweep toggle, since soft-title tags come from the importer, not that
sweep. Wall-clock bounded by the task time limits."""
from ..services.ml.heads import soft_wip_conflict_audit
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = soft_wip_conflict_audit(session)
return f"scanned={result['n_scanned']} flagged={result['n_flagged']}"
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews") @celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
def prune_presentation_reviews() -> str: def prune_presentation_reviews() -> str:
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30 """Retention (rule 89): drop RESOLVED presentation-review flags older than 30
+36 -146
View File
@@ -6,16 +6,9 @@ viewing is instant and re-runs are cache-fast. Sync (Celery workers are sync),
same pattern as the other backfill sweeps. No-op unless translation is enabled, same pattern as the other backfill sweeps. No-op unless translation is enabled,
a base URL is set, and the service is healthy. a base URL is set, and the service is healthy.
Curator does NO language detection of its own: each field's translation is
accepted or rejected purely on Interpreter's reported engine + detected language
+ confidence (see ``_accept``) — a short English title Interpreter mis-labels as
a European language scores below the latin-script floor and is kept as-is.
Two entry points: Two entry points:
- ``translate_posts`` — the periodic untranslated sweep (every 8h; - ``translate_posts`` — the daily untranslated sweep (translated_source_lang IS
translated_source_lang IS NULL only), one bounded chunk per fire. The Settings NULL only); also the Settings "Translate now" button.
"Translate now" button calls it with ``drain=True`` to chase the tail
run-until-done and clear the whole backlog in a single press.
- ``retranslate_posts`` — after a model change, resets the stored translation - ``retranslate_posts`` — after a model change, resets the stored translation
columns for a scoped set of posts (all, or a given set of artists) so the columns for a scoped set of posts (all, or a given set of artists) so the
untranslated selection re-runs them, then chases the tail until drained untranslated selection re-runs them, then chases the tail until drained
@@ -50,28 +43,6 @@ _RETRANSLATE_COUNTDOWN = 5
_INTERRUPT_BACKOFF = 60 _INTERRUPT_BACKOFF = 60
_INTERRUPT_BACKOFF_MAX = 900 _INTERRUPT_BACKOFF_MAX = 900
# --- Translation acceptance gate (Scribe rule 133; contract note #1347) -------
# Curator does NO language detection of its own — it accepts or rejects a
# translation purely on Interpreter's reported detection. CJK is script-detected
# (kana/hangul/kanji) and reliably high-confidence, so ja/ko/zh are trusted
# outright (pure-kanji Japanese can even come back labelled zh ~0.75, still
# correctly translated). Latin-script detection is a real statistical
# probability, so a short English title Interpreter mis-labels as a European
# language scores low; require it to clear this floor, else keep the original.
# Calibrated 2026-07-09 against fresh (uncached) probes once Interpreter returned
# real langdetect confidence: genuine German detected at 1.0, while a correctly-
# detected but ambiguous latin string dipped to 0.86 — so a single constant can't win.
# The floor is now operator-tunable (ImportSettings.translation_min_confidence,
# milestone 155) with a stricter 0.90 default; per-post overrides
# (Post.translation_override) rescue the false negatives it skips. Genuine German
# and mis-flagged short English both land ~0.86, and single-word mis-flags sit at
# a confident 1.0 no floor catches. Real langdetect fixed some cases at the
# source (some short English titles now detect as English → passthrough), so this
# floor is a safety net, not the primary fix. Re-tune via the "Test translation"
# box (send fresh text — cache hits report 1.0).
_CJK_LANGS = frozenset({"ja", "ko", "zh"})
_DEFAULT_MIN_LATIN_CONFIDENCE = 0.90
def _interrupt_backoff(retry_after) -> int: def _interrupt_backoff(retry_after) -> int:
"""Seconds to wait before resuming after an Interpreter interruption: the """Seconds to wait before resuming after an Interpreter interruption: the
@@ -85,51 +56,31 @@ def _interrupt_backoff(retry_after) -> int:
name="backend.app.tasks.translation.translate_posts", name="backend.app.tasks.translation.translate_posts",
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
) )
def translate_posts(drain: bool = False) -> str: def translate_posts() -> str:
"""Translate untranslated non-English post title/description (default target """Translate untranslated non-English post title/description (default target
en) via Interpreter. Per-post [title, description] batch → per-post detected en) via Interpreter. Per-post [title, description] batch → per-post detected
language. Passthrough / already-target posts are marked handled (source == language. Passthrough / already-target posts are marked handled (source ==
target) with no stored translation. 503 or a connection error interrupts the target) with no stored translation. 503 or a connection error interrupts the
run (retry next cycle); 400 stops it (fix config); posts done so far stay run (retry next cycle); 400 stops it (fix config); posts done so far stay
committed. No-op unless configured + healthy. Returns a summary string. committed. No-op unless configured + healthy. Returns a summary string."""
``drain`` (the Settings "Translate now" button) chases the tail
run-until-done: on a clean chunk with work still remaining it re-enqueues
itself until the untranslated backlog is zero, like ``retranslate_posts``. The
periodic beat leaves it False → one bounded chunk per fire (the 8h cadence
drives the rest, enough for the trickle of newly-imported posts)."""
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
ready = _translation_config(session) ready = _translation_config(session)
if isinstance(ready, str): if isinstance(ready, str):
return ready return ready
base_url, target, min_confidence = ready base_url, target = ready
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN) posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
status, translated, retry_after = _translate_batch( status, translated, retry_after = _translate_batch(
session, posts, base_url, target, min_confidence, session, posts, base_url, target,
) )
# Drain mode (manual "Translate now"): chase the tail until the backlog is # Steady-state daily sweep: one chunk per run on success (the beat drives
# zero, so one press clears the whole pile instead of one 300-chunk. # the rest — no tail-chase). But if Interpreter drained mid-chunk, don't
# Termination is guaranteed — every handled post leaves the untranslated # make a manual "Translate now" wait a whole day — re-enqueue after its
# set, so the remaining count strictly decreases each chunk. # Retry-After hint (or the default backoff). Self-terminating: once the
if status == "ok" and drain: # service is down the health gate returns "interpreter unavailable" early.
remaining = _count_untranslated(session, None) if status == "interrupted" and _count_untranslated(session, None):
if remaining:
translate_posts.apply_async(
(), {"drain": True}, countdown=_RETRANSLATE_COUNTDOWN,
)
log.info(
"translate_posts: draining — %d remaining → re-enqueued",
remaining,
)
# Interpreter drained mid-chunk (graceful restart): don't make a manual
# "Translate now" wait a whole beat cycle — re-enqueue after its
# Retry-After hint (or the default backoff), preserving `drain` so a bulk
# drain resumes cleanly. Self-terminating: once the service is down the
# health gate returns "interpreter unavailable" early.
elif status == "interrupted" and _count_untranslated(session, None):
delay = _interrupt_backoff(retry_after) delay = _interrupt_backoff(retry_after)
translate_posts.apply_async((), {"drain": drain}, countdown=delay) translate_posts.apply_async((), {}, countdown=delay)
log.info( log.info(
"translate_posts: interrupted (service draining) → retry in %ss", "translate_posts: interrupted (service draining) → retry in %ss",
delay, delay,
@@ -155,7 +106,7 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
ready = _translation_config(session) ready = _translation_config(session)
if isinstance(ready, str): if isinstance(ready, str):
return ready return ready
base_url, target, min_confidence = ready base_url, target = ready
if not _reset_done: if not _reset_done:
n = _reset_translations(session, artist_ids) n = _reset_translations(session, artist_ids)
@@ -166,7 +117,7 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
posts = _select_untranslated(session, artist_ids, _MAX_POSTS_PER_RUN) posts = _select_untranslated(session, artist_ids, _MAX_POSTS_PER_RUN)
status, translated, retry_after = _translate_batch( status, translated, retry_after = _translate_batch(
session, posts, base_url, target, min_confidence, session, posts, base_url, target,
) )
# run-until-done: chase the tail when the chunk finished cleanly. # run-until-done: chase the tail when the chunk finished cleanly.
@@ -204,19 +155,17 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
def _translation_config(session): def _translation_config(session):
"""Resolve (base_url, target, min_confidence) if translation is enabled + """Resolve (base_url, target) if translation is enabled + healthy, else a
healthy, else a short status string ("disabled" / "interpreter unavailable") short status string ("disabled" / "interpreter unavailable") for the caller
for the caller to return. Centralises the guard so translate/retranslate agree to return. Centralises the guard so translate/retranslate agree exactly."""
exactly, including the operator-tunable acceptance floor."""
cfg = ImportSettings.load_sync(session) cfg = ImportSettings.load_sync(session)
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip(): if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
return "disabled" return "disabled"
base_url = cfg.interpreter_base_url.strip() base_url = cfg.interpreter_base_url.strip()
target = (cfg.translation_target_lang or "en").strip() or "en" target = (cfg.translation_target_lang or "en").strip() or "en"
min_confidence = cfg.translation_min_confidence
if not ic.health(base_url): if not ic.health(base_url):
return "interpreter unavailable" return "interpreter unavailable"
return base_url, target, min_confidence return base_url, target
def _untranslated_filter(stmt, artist_ids): def _untranslated_filter(stmt, artist_ids):
@@ -249,13 +198,10 @@ def _reset_translations(session, artist_ids) -> int:
"""Clear the stored translation columns for scoped, already-handled posts so """Clear the stored translation columns for scoped, already-handled posts so
the untranslated sweep re-runs them. Only touches rows that were translated the untranslated sweep re-runs them. Only touches rows that were translated
(translated_source_lang IS NOT NULL) — untranslated rows are already NULL. (translated_source_lang IS NOT NULL) — untranslated rows are already NULL.
Skips 'keep original' posts (translation_override = 'original') so that choice Returns the row count reset (commits it)."""
survives a Re-translate-all (milestone 155). Returns the row count reset
(commits it)."""
stmt = ( stmt = (
update(Post) update(Post)
.where(Post.translated_source_lang.is_not(None)) .where(Post.translated_source_lang.is_not(None))
.where(Post.translation_override != "original")
.values( .values(
post_title_translated=None, post_title_translated=None,
description_translated=None, description_translated=None,
@@ -271,7 +217,7 @@ def _reset_translations(session, artist_ids) -> int:
return n return n
def _translate_batch(session, posts, base_url: str, target: str, min_confidence: float): def _translate_batch(session, posts, base_url: str, target: str):
"""Translate each post in the chunk with a per-post commit (an interrupted """Translate each post in the chunk with a per-post commit (an interrupted
run keeps progress). Returns (status, translated, retry_after) where status is run keeps progress). Returns (status, translated, retry_after) where status is
one of "ok" / "interrupted" (unavailable/drain) / "stopped" (400) / "timeout"; one of "ok" / "interrupted" (unavailable/drain) / "stopped" (400) / "timeout";
@@ -280,7 +226,7 @@ def _translate_batch(session, posts, base_url: str, target: str, min_confidence:
translated = 0 translated = 0
for post in posts: for post in posts:
try: try:
translated += _translate_one(session, post, base_url, target, min_confidence) translated += _translate_one(session, post, base_url, target)
session.commit() session.commit()
except ic.InterpreterUnavailable as e: except ic.InterpreterUnavailable as e:
session.rollback() session.rollback()
@@ -304,69 +250,33 @@ def _summary(status: str, translated: int, scanned: int) -> str:
return f"translated={translated} scanned={scanned}" return f"translated={translated} scanned={scanned}"
def _accept( def _translate_field(text: str, base_url: str, target: str):
language: str, confidence, min_confidence: float = _DEFAULT_MIN_LATIN_CONFIDENCE,
) -> bool:
"""Should curator store Interpreter's translation, or keep the original?
Consumes ONLY Interpreter's own detection (curator does none, Scribe rule
133): CJK languages are trusted regardless of the reported number; a
latin-script detection must clear ``min_confidence`` (the operator-tunable
ImportSettings.translation_min_confidence). A missing confidence fails OPEN
(accept), so a service that omits the field never silently drops a
translation. Per-post ``force`` overrides bypass this entirely (see
``_translate_one``)."""
if (language or "").split("-", 1)[0].lower() in _CJK_LANGS:
return True
if confidence is None:
return True
return confidence >= min_confidence
def _translate_field(
text: str, base_url: str, target: str, min_confidence: float, force: bool = False,
):
"""Translate ONE field independently. Returns (translated, source_lang, """Translate ONE field independently. Returns (translated, source_lang,
engine_version), or (None, None, None) when there's nothing to store — empty engine_version), or (None, None, None) when there's nothing to store — empty
text, already the target language, a passthrough (engine "none"), or a text, already the target language, or a passthrough (engine "none"). Per-field
latin-script detection the acceptance gate rejects as too low-confidence (kept (not aggregate first-item) detection so a non-English description still gets
as the original). ``force`` (a per-post 'force' override) bypasses the translated when the title is already English (mixed-language posts)."""
confidence gate so a legitimately-foreign title Interpreter is only ~0.86 sure
about is still stored; a genuine passthrough stores nothing regardless (there
is no translation to force). Per-field (not aggregate first-item) detection so
a non-English description still translates when the title is already English."""
if not text: if not text:
return None, None, None return None, None, None
res = ic.translate([text], base_url=base_url, target=target) res = ic.translate([text], base_url=base_url, target=target)
detected = res["detected_lang"] or target detected = res["detected_lang"] or target
if detected == target or res["engine"] == "none": if detected == target or res["engine"] == "none":
return None, None, None return None, None, None
# Trust only Interpreter's own detection (Scribe rule 133): keep the original
# when a latin-script detection doesn't clear the confidence floor, so a short
# English title mis-labelled as e.g. German isn't rewritten into the archive.
# A 'force' override skips the gate — the operator is overriding a rejection.
if not force and not _accept(
detected, res.get("detected_confidence"), min_confidence,
):
return None, None, None
return res["translations"][0], detected, res["engine_version"] return res["translations"][0], detected, res["engine_version"]
def _store_translation(post, title_res, desc_res, target: str) -> int: def _translate_one(session, post, base_url: str, target: str) -> int:
"""Apply per-field (translated, source_lang, engine_version) results to a post """Translate a post's title/description in place, each field independently.
in place. Returns 1 if a translation was stored, 0 when the post is all Returns 1 if it stored at least one translation, 0 when the post is all
passthrough/empty — in which case the translated columns are CLEARED and the passthrough/empty (still marks it handled so the sweep won't revisit it)."""
post is marked handled (translated_source_lang = target). Clearing (not just title = (post.post_title or "").strip()
leaving) matters on re-apply: a 'keep original' override, or a re-translate desc = (html_to_plain(post.description) if post.description else "") or ""
that now rejects a previously-accepted mis-flag, must remove the stale desc = desc.strip()
translation, not keep it.""" title_tr, title_lang, title_ev = _translate_field(title, base_url, target)
title_tr, title_lang, title_ev = title_res desc_tr, desc_lang, desc_ev = _translate_field(desc, base_url, target)
desc_tr, desc_lang, desc_ev = desc_res
if title_tr is None and desc_tr is None: if title_tr is None and desc_tr is None:
post.post_title_translated = None # Nothing non-target (or nothing to translate) → handled, store nothing.
post.description_translated = None
post.translated_source_lang = target post.translated_source_lang = target
post.translation_engine_version = None
post.translated_at = None
return 0 return 0
post.post_title_translated = title_tr post.post_title_translated = title_tr
post.description_translated = desc_tr post.description_translated = desc_tr
@@ -376,23 +286,3 @@ def _store_translation(post, title_res, desc_res, target: str) -> int:
post.translation_engine_version = title_ev or desc_ev post.translation_engine_version = title_ev or desc_ev
post.translated_at = datetime.now(UTC) post.translated_at = datetime.now(UTC)
return 1 return 1
def _translate_one(
session, post, base_url: str, target: str, min_confidence: float,
) -> int:
"""Translate a post's title/description in place, each field independently,
honoring the per-post override (milestone 155): 'original' keeps the original
(clears any stored translation, no Interpreter call); 'force' translates even
below the confidence floor; 'auto' (default) runs the acceptance gate. Returns
1 if it stored a translation, else 0 (still marks the post handled so the sweep
won't revisit it)."""
if post.translation_override == "original":
return _store_translation(post, (None, None, None), (None, None, None), target)
force = post.translation_override == "force"
title = (post.post_title or "").strip()
desc = (html_to_plain(post.description) if post.description else "") or ""
desc = desc.strip()
title_res = _translate_field(title, base_url, target, min_confidence, force=force)
desc_res = _translate_field(desc, base_url, target, min_confidence, force=force)
return _store_translation(post, title_res, desc_res, target)
+30 -92
View File
@@ -31,68 +31,6 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
browser.runtime.onStartup.addListener(() => ensureInitialized()); browser.runtime.onStartup.addListener(() => ensureInitialized());
ensureInitialized().catch(e => console.error('init failed:', e)); ensureInitialized().catch(e => console.error('init failed:', e));
// ---- Extension self-update check (#1489) ----
// Installed per-instance from the operator's FC host, so Firefox's static
// update_url can't apply (each instance has a different host). Instead ask the
// configured backend for the latest published version and nudge the operator to
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
function versionIsNewer(candidate, current) {
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
}
return false;
}
async function checkForUpdateInfo() {
await ensureInitialized();
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
let info;
try {
info = await api.getExtensionManifest();
} catch (e) {
return { updateAvailable: false, error: e.message };
}
const currentVersion = browser.runtime.getManifest().version;
const latestVersion = info && info.version ? info.version : null;
// latest_url is served from the web root, not the JSON API.
const base = api.webRoot();
return {
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
currentVersion,
latestVersion,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
};
}
async function refreshUpdateBadge() {
let r;
try { r = await checkForUpdateInfo(); } catch { return; }
try {
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
if (r.updateAvailable) {
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
} else {
await browser.action.setTitle({ title: 'FabledCurator' });
}
} catch { /* action API unavailable — non-fatal */ }
}
// Daily proactive check (needs the "alarms" permission). create() is idempotent
// by name, so re-running it on each event-page load is safe.
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
});
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
// ---- Discord token capture via webRequest ---- // ---- Discord token capture via webRequest ----
browser.webRequest.onBeforeSendHeaders.addListener( browser.webRequest.onBeforeSendHeaders.addListener(
@@ -210,21 +148,6 @@ browser.webRequest.onBeforeRedirect.addListener(
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] }, { urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
); );
// Extract → verify → upload one cookie-auth platform. Returns a structured
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
// their own response + skip semantics. Verifies the captured cookies are
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
// through to upload.
async function exportPlatformCookies(key) {
const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { status: 'empty' };
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
}
// ---- Message router ---- // ---- Message router ----
browser.runtime.onMessage.addListener(async (msg) => { browser.runtime.onMessage.addListener(async (msg) => {
@@ -269,14 +192,22 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (!platform) return { error: `Unknown platform: ${key}` }; if (!platform) return { error: `Unknown platform: ${key}` };
try { try {
if (platform.authType === 'cookies') { if (platform.authType === 'cookies') {
const r = await exportPlatformCookies(key); const cookies = await extractCookiesForPlatform(key);
if (r.status === 'empty') return { error: 'No cookies found — log in first.' }; if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
if (r.status === 'stale') { // Verify the captured cookies are actually live BEFORE
// uploading. Skips upload on confirmed-stale sessions so we
// don't overwrite FC-side credentials with garbage. Platforms
// without a verify config (verify.ok === null) fall through
// to upload as before.
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
return { return {
error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`, error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
}; };
} }
return { success: true, cookieCount: r.cookieCount, verified: r.verified }; const data = toNetscapeFormat(cookies);
await api.uploadCredentials(key, 'cookies', data);
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
} }
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
@@ -304,10 +235,18 @@ browser.runtime.onMessage.addListener(async (msg) => {
continue; continue;
} }
try { try {
const r = await exportPlatformCookies(key); const cookies = await extractCookiesForPlatform(key);
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' }; if (cookies.length === 0) {
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` }; results[key] = { skipped: true, reason: 'no cookies' };
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified }; continue;
}
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
results[key] = { error: `verify failed: ${v.reason}` };
continue;
}
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
} catch (e) { } catch (e) {
results[key] = { error: e.message }; results[key] = { error: e.message };
} }
@@ -344,9 +283,11 @@ browser.runtime.onMessage.addListener(async (msg) => {
} }
case 'OPEN_ARTIST_PAGE': { case 'OPEN_ARTIST_PAGE': {
// The SPA artist route (/artist/:slug) is served from the web root, not // apiUrl is configured with the /api suffix (see
// the JSON API — see api.webRoot(). // options/options.html placeholder); the SPA artist route is
const base = api.webRoot(); // /artist/:slug, served from the same origin. Strip /api so the
// browser-level URL hits the Vue router, not the JSON API.
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
const slug = encodeURIComponent(msg.slug || ''); const slug = encodeURIComponent(msg.slug || '');
if (!base || !slug) return { error: 'apiUrl or slug missing' }; if (!base || !slug) return { error: 'apiUrl or slug missing' };
try { try {
@@ -357,9 +298,6 @@ browser.runtime.onMessage.addListener(async (msg) => {
} }
} }
case 'CHECK_UPDATE':
return await checkForUpdateInfo();
default: default:
return { error: `Unknown message type: ${msg.type}` }; return { error: `Unknown message type: ${msg.type}` };
} }
-13
View File
@@ -89,19 +89,6 @@ class FabledCuratorAPI {
const qs = new URLSearchParams({ url }).toString(); const qs = new URLSearchParams({ url }).toString();
return this.request('GET', `/extension/probe?${qs}`); return this.request('GET', `/extension/probe?${qs}`);
} }
// Latest published extension version on this instance — drives the in-app
// update prompt. Public endpoint (no key needed, but request() sends it
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
getExtensionManifest() {
return this.request('GET', '/extension/manifest');
}
// The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped.
// Where the Vue router (artist pages) and the served XPI live, NOT the JSON
// API. Used by OPEN_ARTIST_PAGE + the self-update check.
webRoot() {
return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
}
// Connection test = the cheapest read with auth. // Connection test = the cheapest read with auth.
testConnection() { testConnection() {
+1 -10
View File
@@ -86,16 +86,7 @@ const PLATFORMS = {
* script to decide whether to show the floating "Add as source" button. * script to decide whether to show the floating "Add as source" button.
*/ */
const PLATFORM_ARTIST_PATTERNS = { const PLATFORM_ARTIST_PATTERNS = {
// Patreon serves the same creator under three URL shapes (see backend patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
// `cw/` "creator workspace" — the last is the URL you land on once you're
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
// drop the single-segment end-anchor so a creator's inner page
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
// vanished once subscribed because the old pattern only matched the bare root).
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i, deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
+2 -3
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.9", "version": "1.0.7",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
@@ -22,8 +22,7 @@
"tabs", "tabs",
"activeTab", "activeTab",
"webRequest", "webRequest",
"webRequestBlocking", "webRequestBlocking"
"alarms"
], ],
"host_permissions": [ "host_permissions": [
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.9", "version": "1.0.7",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "scripts": {
@@ -10,6 +10,6 @@
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET" "sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
}, },
"devDependencies": { "devDependencies": {
"web-ext": "^10.0.0" "web-ext": "^8.0.0"
} }
} }
-11
View File
@@ -72,17 +72,6 @@ body {
.btn.block { display: block; width: 100%; margin-top: 8px; } .btn.block { display: block; width: 100%; margin-top: 8px; }
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; } .btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
.btn.link:hover { color: var(--accent); } .btn.link:hover { color: var(--accent); }
.btn.small { padding: 6px 12px; font-size: 13px; }
/* In-app update prompt (accent-tinted so it reads as an actionable notice). */
.update-banner {
display: flex; align-items: center; gap: 10px;
margin: 10px 10px 0; padding: 10px 12px;
background: rgba(244, 186, 122, 0.12);
border: 1px solid rgba(244, 186, 122, 0.4);
border-radius: 6px;
}
#update-text { flex: 1; font-size: 13px; }
.source-row .play { .source-row .play {
background: none; border: none; color: var(--on-surface-variant); background: none; border: none; color: var(--on-surface-variant);
-5
View File
@@ -20,11 +20,6 @@
</section> </section>
<section id="main-content" class="main hidden"> <section id="main-content" class="main hidden">
<div id="update-banner" class="update-banner hidden">
<span id="update-text"></span>
<button id="update-btn" class="btn primary small">Update</button>
</div>
<nav class="tabs"> <nav class="tabs">
<button class="tab active" data-tab="platforms">Platforms</button> <button class="tab active" data-tab="platforms">Platforms</button>
<button class="tab" data-tab="sources">Sources</button> <button class="tab" data-tab="sources">Sources</button>
+12 -33
View File
@@ -2,15 +2,6 @@ document.addEventListener('DOMContentLoaded', init);
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000; const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
// A centered muted note div — the loading / empty state shared by the platform
// and sources lists.
function mutedNote(text) {
const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = text;
return d;
}
async function init() { async function init() {
try { try {
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' }); const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
@@ -23,7 +14,6 @@ async function init() {
setupEventListeners(); setupEventListeners();
showPlatformsLoading(); showPlatformsLoading();
testConnectionIfNeeded(); testConnectionIfNeeded();
checkForUpdate();
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`)); loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
} catch (e) { } catch (e) {
showSetupRequired(); showSetupRequired();
@@ -47,7 +37,10 @@ function showSetupRequired() {
function showPlatformsLoading() { function showPlatformsLoading() {
const c = document.getElementById('platforms-list'); const c = document.getElementById('platforms-list');
c.textContent = ''; c.textContent = '';
c.appendChild(mutedNote('Loading platforms…')); const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = 'Loading platforms…';
c.appendChild(d);
} }
async function testConnectionIfNeeded() { async function testConnectionIfNeeded() {
@@ -70,26 +63,6 @@ function updateConnectionDot(connected) {
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected'; d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
} }
// Nudge to reinstall when the configured instance publishes a newer signed XPI
// (the extension is self-hosted, so there's no Firefox auto-update). Never
// blocks the popup — a failed check just leaves the banner hidden.
async function checkForUpdate() {
try {
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
} catch { /* non-fatal */ }
}
function showUpdateBanner(r) {
document.getElementById('update-text').textContent =
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
// Opening the signed XPI triggers Firefox's native install prompt.
document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl });
});
document.getElementById('update-banner').classList.remove('hidden');
}
async function loadPlatformStatus() { async function loadPlatformStatus() {
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' }); const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
const c = document.getElementById('platforms-list'); const c = document.getElementById('platforms-list');
@@ -189,7 +162,10 @@ async function exportAllCookies() {
async function loadSources() { async function loadSources() {
const c = document.getElementById('sources-list'); const c = document.getElementById('sources-list');
c.textContent = ''; c.textContent = '';
c.appendChild(mutedNote('Loading sources…')); const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = 'Loading sources…';
c.appendChild(d);
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' }); const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
c.textContent = ''; c.textContent = '';
if (r.error) { if (r.error) {
@@ -200,7 +176,10 @@ async function loadSources() {
return; return;
} }
if (!r.sources || r.sources.length === 0) { if (!r.sources || r.sources.length === 0) {
c.appendChild(mutedNote('No sources yet.')); const empty = document.createElement('div');
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
empty.textContent = 'No sources yet.';
c.appendChild(empty);
return; return;
} }
for (const src of r.sources) c.appendChild(createSourceRow(src)); for (const src of r.sources) c.appendChild(createSourceRow(src));
+13 -11
View File
@@ -4,28 +4,30 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=22"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest run" "test:unit": "vitest run",
"check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.0", "vue": "^3.4.0",
"vue-router": "^5.0.0", "vue-router": "^4.3.0",
"pinia": "^3.0.0", "pinia": "^2.1.0",
"vuetify": "^4.0.0", "vuetify": "^3.5.0",
"@mdi/font": "^7.4.0" "@mdi/font": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^5.0.0",
"vite": "^8.0.0", "vite": "^5.2.0",
"vite-plugin-vuetify": "^2.1.0", "vue-tsc": "^2.0.0",
"vite-plugin-vuetify": "^2.0.0",
"sass": "^1.71.0", "sass": "^1.71.0",
"vitest": "^4.0.0", "vitest": "^2.1.0",
"@vue/test-utils": "^2.4.0", "@vue/test-utils": "^2.4.0",
"happy-dom": "^20.0.0" "happy-dom": "^15.0.0"
} }
} }
+6 -6
View File
@@ -18,11 +18,11 @@ const route = useRoute()
<style scoped> <style scoped>
.fc-content { .fc-content {
min-height: 100vh; min-height: 100vh;
/* NO padding-top: the TopNav is position:sticky, so it already reserves its /* Push initial viewport content below the sticky TopNav. Without
own space in the v-app flex column — content flows directly below it. The this, some views' first rows / form fields / table headers can
old 64px padding-top was a leftover from a FIXED navbar and double-counted end up obscured by the navbar (depending on parent overflow
that space, leaving a large empty band at the top of EVERY view (and pushing context interacting with position: sticky). Scrolled-down content
the full-height calc(100vh - 64px) views down so they overflowed). Removed still slides under the nav — the gradient-fade design is intact. */
2026-07-13. Scrolled content still slides under the sticky nav as before. */ padding-top: 64px;
} }
</style> </style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<v-snackbar <v-snackbar
v-model="show" :color="color" location="bottom right" timeout="4000" v-model="show" :color="color" location="bottom right" timeout="4000"
min-height="68" elevation="4" multi-line elevation="4"
> >
{{ message }} {{ message }}
<template #actions> <template #actions>
+7 -52
View File
@@ -1,5 +1,5 @@
<template> <template>
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }"> <header class="fc-topnav">
<div class="fc-nav-left"> <div class="fc-nav-left">
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home"> <RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" /> <img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
@@ -64,39 +64,13 @@
</template> </template>
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import router, { FRONT_DOOR } from '../router.js' import router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js' import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue' import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore() const system = useSystemStore()
onMounted(() => system.refreshHealth())
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
const navEl = ref(null)
let navRO = null
onMounted(() => {
system.refreshHealth()
if (navEl.value && 'ResizeObserver' in window) {
navRO = new ResizeObserver(() => {
const h = navEl.value?.offsetHeight
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
})
navRO.observe(navEl.value)
}
})
onBeforeUnmount(() => { navRO?.disconnect() })
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
// its bottom — it hands off at the shared seam alpha so the sub-header can
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
const route = useRoute()
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
// Every route with a meta.title is a nav entry. Order by meta.navOrder — // Every route with a meta.title is a nav entry. Order by meta.navOrder —
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers // router.getRoutes() does NOT guarantee declaration order, so explicit numbers
@@ -145,35 +119,16 @@ const health = computed(() => {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 → /* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
0.84) through the top half, then eases to transparent over the bottom
quarter so it tails off softly instead of a straight line to a hard edge
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
sub-header continuation. */
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(var(--fc-chrome-rgb), 0.92) 0%, rgba(20, 23, 26, 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.84) 50%, rgba(20, 23, 26, 0.65) 60%,
rgba(var(--fc-chrome-rgb), 0.55) 75%, rgba(20, 23, 26, 0) 100%
rgba(var(--fc-chrome-rgb), 0) 100%
); );
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
} }
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
stops fading at the shared seam alpha instead of going fully transparent — the
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
global :root in app.css (custom props inherit into scoped styles). */
.fc-topnav.fc-topnav--chrome {
background: linear-gradient(
to bottom,
rgba(var(--fc-chrome-rgb), 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.84) 60%,
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
);
}
.fc-brand { .fc-brand {
display: flex; display: flex;
@@ -10,7 +10,7 @@
filter, applied retroactively to the existing library. filter, applied retroactively to the existing library.
</p> </p>
<v-row density="compact"> <v-row dense>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="minW" label="Min width (px)" type="number" v-model.number="minW" label="Min width (px)" type="number"
@@ -13,7 +13,7 @@
cadence as the transparency audit. cadence as the transparency audit.
</p> </p>
<v-row density="compact"> <v-row dense>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="threshold" label="Threshold (01)" v-model.number="threshold" label="Threshold (01)"
@@ -1,56 +0,0 @@
<!--
Canonical settings number field (DRY pass #161): a compact numeric v-text-field
with a built-in clamp to [min,max] on commit. Hand-rolled identically across the
ML settings cards (HeadsCard x6, CropProposersCard, VideoEmbeddingCard).
The clamp is the point: the cards previously sent Number(raw) straight to the
API, so an out-of-range value bounced off the API's 400 validator (only
TranslationCard clamped). This is now the single home for that clamp.
Binds `modelValue` (v-model) and emits `change` on blur/enter AFTER clamping, so
the parent's save reads the already-clamped value same as the prior
`v-model.number` + `@change=save` pattern.
-->
<template>
<v-text-field
:model-value="modelValue"
:label="label"
type="number"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:density="density" hide-details
:style="{ maxWidth }"
@update:model-value="v => emit('update:modelValue', v)"
@change="onCommit"
/>
</template>
<script setup>
const props = defineProps({
modelValue: { type: [Number, String], default: null },
label: { type: String, default: '' },
min: { type: [Number, String], default: null },
max: { type: [Number, String], default: null },
step: { type: [Number, String], default: 1 },
maxWidth: { type: String, default: '200px' },
density: { type: String, default: 'compact' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onCommit() {
// On blur/enter: coerce to a number and clamp to [min,max] so an out-of-range
// value never reaches the API. props.modelValue reflects the latest keystroke
// (kept in sync by the passthrough above); re-emit the clamped number, then let
// the parent persist.
let n = Number(props.modelValue)
if (!Number.isNaN(n)) {
if (props.min !== null && props.min !== '') n = Math.max(Number(props.min), n)
if (props.max !== null && props.max !== '') n = Math.min(Number(props.max), n)
if (n !== Number(props.modelValue)) emit('update:modelValue', n)
}
emit('change')
}
</script>
@@ -1,42 +0,0 @@
<!--
Canonical settings toggle row (DRY pass #161): an accent icon + an uppercase
.fc-section-h label + a right-aligned switch. Hand-rolled identically in the
ML settings cards (HeadsCard x3, CropProposersCard, MLBackfillCard).
Two-way binds `modelValue` (so the parent switch state stays optimistic) AND
emits `change` with the new boolean, so the parent can persist + revert on
failure matching the prior `v-model` + `@update:model-value=handler` pattern.
-->
<template>
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon v-if="icon" size="18" :color="iconColor">{{ icon }}</v-icon>
<span class="fc-section-h">{{ label }}</span>
<v-switch
:model-value="modelValue"
:loading="loading"
:disabled="disabled"
hide-details density="compact" color="success" class="ml-auto"
@update:model-value="onSwitch"
/>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
icon: { type: String, default: '' },
// Icon tint. Default accent; pass null for the theme default (e.g. when a row
// is off). null (not undefined) so the default doesn't override it.
iconColor: { type: String, default: 'accent' },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onSwitch(v) {
const b = !!v
emit('update:modelValue', b)
emit('change', b)
}
</script>
+3 -3
View File
@@ -14,10 +14,10 @@
@update:search="onSearch" @update:search="onSearch"
@update:model-value="onPick" @update:model-value="onPick"
> >
<template #item="{ props: itemProps, internalItem }"> <template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="internalItem.raw.name"> <v-list-item v-bind="itemProps" :title="item.raw.name">
<template #subtitle> <template #subtitle>
{{ internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind }} {{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
</template> </template>
</v-list-item> </v-list-item>
</template> </template>
@@ -1,5 +1,5 @@
<template> <template>
<div class="fc-filterbar-wrap fc-chrome-continues"> <div class="fc-filterbar-wrap">
<div class="fc-filterbar"> <div class="fc-filterbar">
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
@@ -13,14 +13,14 @@
@update:search="onSearch" @update:search="onSearch"
@update:model-value="onPick" @update:model-value="onPick"
> >
<template #item="{ props: itemProps, internalItem }"> <template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="internalItem.raw.name"> <v-list-item v-bind="itemProps" :title="item.raw.name">
<template #prepend> <template #prepend>
<v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon> <v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
</template> </template>
<template #subtitle> <template #subtitle>
{{ internalItem.raw.kind === 'artist' ? 'artist' {{ item.raw.kind === 'artist' ? 'artist'
: (internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind) }} : (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
</template> </template>
</v-list-item> </v-list-item>
</template> </template>
@@ -306,17 +306,27 @@ function pushFilter(mutate) {
frosted block pinned directly under the 64px TopNav and continuous with it. */ frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar-wrap { .fc-filterbar-wrap {
position: sticky; position: sticky;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */ top: 64px;
z-index: 5; z-index: 5;
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px) /* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
so the bar sits flush at 64px even at scroll 0 — without this it detaches so the bar sits flush at 64px even at scroll 0 — without this it detaches
and a gap shows through when scrolled to the top. */ and a gap shows through when scrolled to the top. */
margin-top: -8px; margin-top: -8px;
margin-bottom: 12px; margin-bottom: 12px;
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues /* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent the two read as one continuous piece of chrome — images scroll visibly
rather than re-darkening, so the nav + bar read as one gradient (operator under both. The nav's gradient fades to transparent at ITS bottom; this
2026-07-13). This block only owns the sticky positioning now. */ bar re-darkens at its top, so a faint seam (the page/image showing through
the nav's transparent edge) separates them when scrolled to the very top,
while under-scroll they frost as one. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
} }
.fc-filterbar { .fc-filterbar {
display: flex; display: flex;
@@ -331,20 +341,6 @@ function pushFilter(mutate) {
.fc-filterbar-wrap :deep(.v-btn-group) { .fc-filterbar-wrap :deep(.v-btn-group) {
background-color: rgba(20, 23, 26, 0.72); background-color: rgba(20, 23, 26, 0.72);
} }
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
SEGMENT individually, so the rounded ends collided at the joins — the shapes
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
segments (over the pill utility's !important) and clip the group to a single
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
only — no height change, so the bar height and nav offset are untouched. */
.fc-filterbar-wrap :deep(.v-btn-toggle) {
border-radius: 8px;
overflow: hidden;
}
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
border-radius: 0 !important;
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; } .fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */ /* The tag chips' bodies toggle include/exclude — signal they're clickable. */
@@ -1,15 +1,14 @@
<template> <template>
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked <!-- Auto-hidden chrome that ALSO looked like real content surfaced PROACTIVELY
like real content surfaced PROACTIVELY atop the gallery whenever there's atop the gallery whenever there's something to review (NOT gated on the
something to review (NOT gated on the Show-hidden toggle, so misfires can't Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
go unnoticed), most-concerning first, with keep / remove (#141, #1464). with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
Renders nothing when there's nothing to review. --> <section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
<div class="fc-review__head"> <div class="fc-review__head">
<v-icon size="18" color="warning">mdi-alert-outline</v-icon> <v-icon size="18" color="warning">mdi-alert-outline</v-icon>
<span class="fc-review__title"> <span class="fc-review__title">
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }} {{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review may be real content — review before they stay hidden
</span> </span>
</div> </div>
<div class="fc-review__cards"> <div class="fc-review__cards">
@@ -27,16 +26,16 @@
> >
also looks like <strong>{{ it.conflict_name || 'content' }}</strong> also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
</div> </div>
<div class="fc-review-card__tag">{{ tagLine(it) }}</div> <div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
<div class="fc-review-card__acts"> <div class="fc-review-card__acts">
<button <button
type="button" class="fc-review-btn fc-review-btn--keep" type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>{{ keepLabel(it) }}</button> >Keep hidden</button>
<button <button
type="button" class="fc-review-btn fc-review-btn--unhide" type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>{{ removeLabel(it) }}</button> >Un-hide</button>
</div> </div>
</div> </div>
</div> </div>
@@ -55,11 +54,6 @@ const items = ref([])
const busy = ref([]) const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` } function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
async function load() { async function load() {
// Fetched unconditionally on mount — the strip prompts for pending misfires // Fetched unconditionally on mount — the strip prompts for pending misfires
@@ -77,8 +71,7 @@ async function resolve(it, action) {
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`) await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
items.value = items.value.filter((x) => keyOf(x) !== k) items.value = items.value.filter((x) => keyOf(x) !== k)
if (action === 'unhide') { if (action === 'unhide') {
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden' toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
} }
} catch (e) { } catch (e) {
toast({ toast({
@@ -0,0 +1,72 @@
<template>
<v-card>
<v-card-title>Treat as alias for</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Pick the existing tag the model's prediction should map to. All
future predictions of this name will resolve to your tag.
</p>
<v-autocomplete
v-model="selectedId"
v-model:menu="menuOpen"
:items="results"
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
:item-value="(t) => t.id"
:loading="loading"
label="Canonical tag"
no-filter clearable density="compact"
@update:search="onSearch"
@keydown.enter.capture="onEnter"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn
color="primary" rounded="pill" :disabled="!selectedId"
@click="$emit('confirm', selectedId)"
>Use this tag</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
const props = defineProps({ category: { type: String, required: true } })
const emit = defineEmits(['confirm', 'cancel'])
const api = useApi()
const results = ref([])
const loading = ref(false)
const selectedId = ref(null)
let debounce = null
// Enter on the closed dropdown confirms the selection instead of re-opening it.
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
if (selectedId.value != null) emit('confirm', selectedId.value)
})
function onSearch(q) {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(async () => {
const query = (q || '').trim()
if (!query) { results.value = []; return }
loading.value = true
try {
// Scope the autocomplete to the prediction's category where it
// maps to a tag kind. Only 'character' surfaces as both a
// suggestion category and a tag kind now ('artist' + 'copyright'
// retired); other categories search unscoped.
const kind = props.category === 'character' ? 'character' : null
const params = { q: query, limit: 20 }
if (kind) params.kind = kind
results.value = await api.get('/api/tags/autocomplete', { params })
} finally {
loading.value = false
}
}, 200)
}
</script>
@@ -11,6 +11,10 @@
{{ suggestion.display_name }} {{ suggestion.display_name }}
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag" <span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
title="You rejected this for this image — un-reject to recover">rejected</span> title="You rejected this for this image — un-reject to recover">rejected</span>
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
title="No matching tag yet — accepting creates it">+ new</span>
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
</span> </span>
<span class="fc-suggestion__score">{{ scorePct }}</span> <span class="fc-suggestion__score">{{ scorePct }}</span>
<!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval <!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval
@@ -42,14 +46,40 @@
@click="$emit('dismiss', suggestion)" @click="$emit('dismiss', suggestion)"
><v-icon size="16">mdi-close</v-icon></button> ><v-icon size="16">mdi-close</v-icon></button>
</div> </div>
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
teleported image modal — #711). Only rendered when an alias action
applies — dismiss now lives on the red ✗, so a centroid hit with no
alias option has no menu. -->
<KebabMenu
v-if="hasMenu"
class="fc-suggestion__menu" size="small" variant="outlined"
:label="`More actions for ${suggestion.display_name}`"
>
<!-- Alias is a tagger-prediction remap, so only offer it for tagger
suggestions with a raw model key that aren't already aliased.
Centroid hits (raw_name null) have nothing to alias. -->
<v-list-item
v-if="suggestion.raw_name && !suggestion.via_alias"
@click="$emit('alias', suggestion)"
>
<v-list-item-title>Treat as alias for</v-list-item-title>
</v-list-item>
<v-list-item
v-if="suggestion.via_alias"
@click="$emit('remove-alias', suggestion)"
>
<v-list-item-title>Remove alias</v-list-item-title>
</v-list-item>
</KebabMenu>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } }) const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'dismiss', 'undismiss']) defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
// #1206: on hover, tell the image viewer which crop produced this suggestion so // #1206: on hover, tell the image viewer which crop produced this suggestion so
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere. // it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
@@ -62,6 +92,12 @@ function onLeave () {
} }
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
// Kebab now only carries alias actions: show it when this suggestion can be
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
const hasMenu = computed(() =>
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
)
</script> </script>
<style scoped> <style scoped>
@@ -83,6 +119,26 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.fc-suggestion__new {
display: inline-block;
font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-accent));
background: rgba(var(--v-theme-accent), 0.12);
border: 1px solid rgb(var(--v-theme-accent), 0.4);
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.fc-suggestion__alias {
display: inline-block;
font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.fc-suggestion__score { .fc-suggestion__score {
flex: 0 0 auto; min-width: 38px; text-align: right; flex: 0 0 auto; min-width: 38px; text-align: right;
font-size: 11px; font-size: 11px;
@@ -110,6 +166,10 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
background: transparent; color: rgb(var(--v-theme-on-surface-variant)); background: transparent; color: rgb(var(--v-theme-on-surface-variant));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5); border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
} }
.fc-suggestion__menu {
flex: 0 0 auto;
}
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it /* Rejected state: the row stays put (recovery), dimmed + red-edged so it
reads as "handled, negative" without shouting over live suggestions. */ reads as "handled, negative" without shouting over live suggestions. */
.fc-suggestion--rejected { .fc-suggestion--rejected {
@@ -26,6 +26,8 @@
v-for="(s, i) in items" :key="`${s.display_name}-${i}`" v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
:suggestion="s" :suggestion="s"
@accept="$emit('accept', $event)" @accept="$emit('accept', $event)"
@alias="$emit('alias', $event)"
@remove-alias="$emit('remove-alias', $event)"
@dismiss="$emit('dismiss', $event)" @dismiss="$emit('dismiss', $event)"
@undismiss="$emit('undismiss', $event)" @undismiss="$emit('undismiss', $event)"
/> />
@@ -43,7 +45,7 @@ const props = defineProps({
collapsible: { type: Boolean, default: false }, collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true } defaultOpen: { type: Boolean, default: true }
}) })
defineEmits(['accept', 'dismiss', 'undismiss', 'reject-all']) defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears. // Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length) const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
@@ -20,39 +20,48 @@
first, so false positives are easy to spot and reject (reject-to-train first, so false positives are easy to spot and reject (reject-to-train
for these heads). Small set collapsible, open by default. --> for these heads). Small set collapsible, open by default. -->
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.aboveByCategory.system && store.aboveByCategory.system.length" v-if="store.byCategory.system && store.byCategory.system.length"
label="System" :items="store.aboveByCategory.system" label="System" :items="store.byCategory.system"
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('system')" @reject-all="onRejectAll('system')"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat" v-for="cat in peopleCats" :key="cat"
v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length" v-show="store.byCategory[cat] && store.byCategory[cat].length"
:label="labelFor(cat)" :items="store.aboveByCategory[cat] || []" :label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll(cat)" @reject-all="onRejectAll(cat)"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.aboveByCategory.general && store.aboveByCategory.general.length" v-if="store.byCategory.general && store.byCategory.general.length"
label="General" :items="store.aboveByCategory.general" label="General" :items="store.byCategory.general"
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('general')" @reject-all="onRejectAll('general')"
/> />
</div> </div>
<v-dialog v-model="aliasDialog" max-width="480">
<AliasPickerDialog
v-if="aliasTarget"
:category="aliasTarget.category"
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
/>
</v-dialog>
</section> </section>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { computed, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js' import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({ const props = defineProps({
imageId: { type: Number, required: true }, imageId: { type: Number, required: true },
@@ -91,14 +100,13 @@ const peopleCats = ['character']
function labelFor(c) { return CATEGORY_LABELS[c] || c } function labelFor(c) { return CATEGORY_LABELS[c] || c }
const isEmpty = computed(() => const isEmpty = computed(() =>
Object.values(store.aboveByCategory).every(list => !list || list.length === 0) Object.values(store.byCategory).every(list => !list || list.length === 0)
) )
watch(() => props.imageId, (id) => { watch(() => props.imageId, (id) => {
if (id == null) return if (id == null) return
// One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the store.load(id) // panel: curated, ≥ threshold
// typed tag-input dropdown reads the full set. No second request. store.loadAll(id) // dropdown: full prediction set (low-confidence included)
store.load(id)
}, { immediate: true }) }, { immediate: true })
// After a successful accept/alias-accept, refresh the modal's current // After a successful accept/alias-accept, refresh the modal's current
@@ -117,6 +125,31 @@ async function onAccept(s) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' }) toast({ text: `Accept failed: ${e.message}`, type: 'error' })
} }
} }
const aliasDialog = ref(false)
const aliasTarget = ref(null)
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
async function onAliasConfirm(canonicalTagId) {
try {
await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false
await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
}
}
// Undo the model-key→tag mapping behind an aliased suggestion. The store
// reloads suggestions so the prediction reverts to its raw form; the applied
// canonical tag (if any) stays, so no tag-rail reload is needed.
async function onRemoveAlias(s) {
try {
await store.removeAlias(s)
} catch (e) {
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
}
}
</script> </script>
<style scoped> <style scoped>
@@ -17,14 +17,7 @@
density="compact" class="fc-tag-autocomplete__list" density="compact" class="fc-tag-autocomplete__list"
> >
<template v-for="(row, idx) in rows" :key="row.key"> <template v-for="(row, idx) in rows" :key="row.key">
<!-- One unified row per DB tag (server autocomplete). Every suggestion is <!-- Existing tag match (server autocomplete). -->
a canonical tag now, so when the model ALSO scored this tag for the
image the row just carries its confidence (🔧 %) in place of the
redundant kind label the coloured leading icon already shows kind.
That means a searched tag that's also a suggestion shows its % on ONE
row, with no dedup and no flicker. Picking it emits pick-existing;
TagPanel.findPending routes a matching suggestion through accept() so
the acceptance is still recorded + it drops from the panel. -->
<v-list-item <v-list-item
v-if="row.type === 'hit'" v-if="row.type === 'hit'"
:active="idx === highlight" @click="onPickRow(row)" :active="idx === highlight" @click="onPickRow(row)"
@@ -39,18 +32,32 @@
<span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span> <span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span>
</v-list-item-title> </v-list-item-title>
<template #append> <template #append>
<span <span class="text-caption">{{ row.hit.kind }}</span>
v-if="row.sugg" </template>
class="fc-tag-autocomplete__sugg-tag" </v-list-item>
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
:title="row.sugg.above_threshold <!-- ML suggestion for THIS image that matches the typed query. Picking
? 'The model suggests this tag for this image' it routes through the same accept path as the Suggestions panel, so
: 'The model scored this tag below its suggest threshold'" it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
<v-list-item
v-else-if="row.type === 'suggestion'"
:active="idx === highlight" @click="onPickRow(row)"
class="fc-tag-autocomplete__sugg"
> >
<template #prepend>
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
{{ iconFor(row.sugg.category) }}
</v-icon>
</template>
<v-list-item-title>
{{ row.sugg.display_name }}
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
</v-list-item-title>
<template #append>
<span class="fc-tag-autocomplete__sugg-tag">
<v-icon size="x-small">mdi-auto-fix</v-icon> <v-icon size="x-small">mdi-auto-fix</v-icon>
{{ scorePct(row.sugg) }} {{ scorePct(row.sugg) }}
</span> </span>
<span v-else class="text-caption">{{ row.hit.kind }}</span>
</template> </template>
</v-list-item> </v-list-item>
@@ -93,10 +100,11 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
import { useInflightToken } from '../../composables/useInflightToken.js' import { useInflightToken } from '../../composables/useInflightToken.js'
import FandomPicker from './FandomPicker.vue' import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel']) const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
// The host surface's applied tags (host.current?.tags). The dropdown filters // The host surface's applied tags (host.current?.tags). Both dropdown sections
// against this so a just-added tag drops out of the search the moment the chip // filter against this so a just-added tag drops out of the search the moment
// rail updates, not after the next modal refresh (operator-asked 2026-07-03). // the chip rail updates, not after the next modal refresh (operator-asked
// 2026-07-03).
const props = defineProps({ const props = defineProps({
appliedTags: { type: Array, default: () => [] }, appliedTags: { type: Array, default: () => [] },
}) })
@@ -230,6 +238,9 @@ const createLabel = computed(() =>
function scorePct (s) { return `${Math.round(s.score * 100)}%` } function scorePct (s) { return `${Math.round(s.score * 100)}%` }
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id))) const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
const appliedNames = computed(() =>
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
)
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists // Server matches minus the image's applied tags. allowCreate/sameNameCharExists
// keep reading the RAW hits — they reason about the tag universe (does this tag // keep reading the RAW hits — they reason about the tag universe (does this tag
@@ -238,29 +249,49 @@ const visibleHits = computed(() =>
hits.value.filter(h => !appliedIds.value.has(h.id)), hits.value.filter(h => !appliedIds.value.has(h.id)),
) )
// This image's suggestions keyed by canonical tag id, so a matching DB-tag row // This image's suggestions that match the typed query, minus any the server
// can show the model's confidence inline. Every suggestion is a canonical tag // autocomplete already returned (same name+kind) so a tag never shows twice.
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup, // Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't // the threshold-filtered panel list — so a low-confidence action/feature the
// advertise a score in the type-to-add dropdown (un-reject lives in the panel). // model saw can be typed and accepted in canonical formatting instead of being
const suggByTagId = computed(() => { // hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
const m = new Map() // the only filter; the threshold no longer hides anything here.
for (const list of Object.values(suggestions.byCategory)) { const suggestionHits = computed(() => {
const q = parsedName.value.toLowerCase()
if (!q) return []
const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
const out = []
for (const list of Object.values(suggestions.allByCategory)) {
for (const s of list || []) { for (const s of list || []) {
// Rejected suggestions now stay in allByCategory (flagged) so the panel
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
// whose job is finding a tag to ADD (un-reject lives in the panel).
if (s.rejected) continue if (s.rejected) continue
if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s) // Already on the image (matched by canonical id or name+kind): nothing
// to add. Covers the window between an add and the next suggestions
// fetch, where the stale row would otherwise still be pickable.
if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (appliedNames.value.has(key)) continue
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
seen.add(key)
out.push(s)
} }
} }
return m // Best matches first; cap generously so a specific typed query surfaces its
// matches even when many predictions exist, while the list stays scrollable.
out.sort((a, b) => b.score - a.score)
return out.slice(0, 20)
}) })
// One ordered list backing both the rendered dropdown and keyboard nav, so the // One ordered list backing both the rendered dropdown and keyboard nav, so the
// highlight index maps 1:1 to a row. Each hit is annotated with its suggestion // highlight index maps 1:1 to a row regardless of which section it's in.
// (score) when the model scored that tag for this image.
const rows = computed(() => { const rows = computed(() => {
const r = visibleHits.value.map(h => ({ const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null, for (const s of suggestionHits.value) {
})) r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
}
if (allowCreate.value) r.push({ type: 'create', key: 'create' }) if (allowCreate.value) r.push({ type: 'create', key: 'create' })
return r return r
}) })
@@ -282,9 +313,16 @@ function moveHighlight (delta) {
// Dispatch a chosen dropdown row by its type. // Dispatch a chosen dropdown row by its type.
function onPickRow (row) { function onPickRow (row) {
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() } if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
else { onCreate() } else { onCreate() }
} }
// Picking an ML suggestion hands it to the parent, which runs the same accept
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
// drops it from the panel). reset() clears the query; the panel-drop makes it
// fall out of suggestionHits reactively.
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
function onCreate () { function onCreate () {
const name = parsedName.value const name = parsedName.value
const kind = parsedKind.value const kind = parsedKind.value
@@ -351,16 +389,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
border: 1px solid rgb(var(--v-theme-surface-light)); border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; border-radius: 6px;
} }
/* The model-confidence badge on a row whose tag the model also scored. Accent /* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
when above the head's suggest threshold; muted when below (a low-confidence .fc-tag-autocomplete__sugg {
match you can still type + pick). */ border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
}
.fc-tag-autocomplete__sugg-tag { .fc-tag-autocomplete__sugg-tag {
display: inline-flex; align-items: center; gap: 2px; display: inline-flex; align-items: center; gap: 2px;
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-accent));
} }
.fc-tag-autocomplete__sugg-tag--below {
color: rgb(var(--v-theme-on-surface-variant));
}
</style> </style>
@@ -17,6 +17,7 @@
ref="tagInputRef" ref="tagInputRef"
:applied-tags="host.current?.tags || []" :applied-tags="host.current?.tags || []"
@pick-existing="onPickExisting" @pick-new="onPickNew" @pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/> />
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable> <v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
@@ -133,6 +134,7 @@ async function onRemove(tagId) {
// until the next modal open (operator-asked 2026-07-03). // until the next modal open (operator-asked 2026-07-03).
if (host.currentImageId != null) { if (host.currentImageId != null) {
suggestions.load(host.currentImageId) suggestions.load(host.currentImageId)
suggestions.loadAll(host.currentImageId)
} }
focusTagInput() focusTagInput()
} }
@@ -176,6 +178,18 @@ async function onPickNew(payload) {
} }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
// acceptance, and drops it from the panel; then we refresh the chip rail.
async function onAcceptSuggestion(s) {
errorMsg.value = null
try {
await suggestions.accept(s)
await host.reloadTags()
focusTagInput()
} catch (e) { errorMsg.value = e.message }
}
const renameDialog = ref(false) const renameDialog = ref(false)
const renameTarget = ref(null) const renameTarget = ref(null)
@@ -70,14 +70,6 @@
@click.stop="showOriginal = !showOriginal" @click.stop="showOriginal = !showOriginal"
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button> >{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
<!-- Per-post translation override (#155): force a skipped translation on,
or keep the original for a confidently mis-flagged title. Only where
there's text to translate. -->
<PostTranslationControl
v-if="post.post_title || post.description_plain"
:post="post"
/>
<!-- Faithful (semantic) body render once expanded: backend-sanitized <!-- Faithful (semantic) body render once expanded: backend-sanitized
HTML (headings, lists, links, inline images). Collapsed and the HTML (headings, lists, links, inline images). Collapsed and the
no-detail fallback stay plain text. --> no-detail fallback stay plain text. -->
@@ -144,7 +136,6 @@ import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js' import { usePostsStore } from '../../stores/posts.js'
import { toPlainText } from '../../utils/htmlSanitize.js' import { toPlainText } from '../../utils/htmlSanitize.js'
import PostSeriesMenu from './PostSeriesMenu.vue' import PostSeriesMenu from './PostSeriesMenu.vue'
import PostTranslationControl from './PostTranslationControl.vue'
const props = defineProps({ const props = defineProps({
post: { type: Object, required: true }, post: { type: Object, required: true },
@@ -1,102 +0,0 @@
<template>
<!-- Sticky per-post translation override (#155). Quiet inline control under the
post title: force a skipped legit-foreign title on, or knock a wrongly
mis-translated one back to the original. The choice sticks through a
Re-translate-all. -->
<div class="fc-post-tx">
<span class="fc-post-tx__label">Translation:</span>
<v-menu :disabled="busy">
<template #activator="{ props: menuProps }">
<button
type="button" class="fc-post-tx__btn" v-bind="menuProps" :disabled="busy"
:aria-label="`Translation handling: ${currentLabel}`"
>
{{ currentLabel }}
<v-icon size="14">mdi-menu-down</v-icon>
</button>
</template>
<v-list density="compact" min-width="240" class="fc-post-tx__list">
<v-list-item
v-for="opt in OPTIONS" :key="opt.value"
:active="opt.value === current" @click="choose(opt.value)"
>
<template #prepend>
<v-icon size="small">{{ opt.icon }}</v-icon>
</template>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
<v-list-item-subtitle>{{ opt.help }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
<v-progress-circular v-if="busy" indeterminate size="13" width="2" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { usePostsStore } from '../../stores/posts.js'
import { toast } from '../../utils/toast.js'
const props = defineProps({ post: { type: Object, required: true } })
const posts = usePostsStore()
const busy = ref(false)
const OPTIONS = [
{ value: 'auto', label: 'Auto', icon: 'mdi-cog-outline',
help: 'Translate only when confident enough' },
{ value: 'force', label: 'Force translate', icon: 'mdi-translate',
help: 'Always translate, even at low confidence' },
{ value: 'original', label: 'Keep original', icon: 'mdi-translate-off',
help: 'Never translate — keep the original text' },
]
const current = computed(() => props.post.translation_override || 'auto')
const currentLabel = computed(
() => (OPTIONS.find((o) => o.value === current.value) || OPTIONS[0]).label,
)
async function choose (value) {
if (value === current.value || busy.value) return
busy.value = true
try {
const res = await posts.applyTranslationOverride(props.post.id, value)
if (res.applied === 'queued') {
toast({
text: 'Saved — this post will translate on the next sweep (service offline).',
type: 'info',
})
}
} catch (e) {
toast({ text: `Couldn't update translation: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
</script>
<style scoped>
.fc-post-tx {
display: inline-flex; align-items: center; gap: 6px;
margin: 2px 0 6px;
}
.fc-post-tx__label {
font-size: 0.72rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-tx__btn {
display: inline-flex; align-items: center; gap: 1px;
padding: 0; background: none; border: 0; cursor: pointer;
font-size: 0.72rem; font-weight: 700;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-tx__btn:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-tx__btn:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
border-radius: 2px;
}
.fc-post-tx__btn:disabled { cursor: default; opacity: 0.6; }
.fc-post-tx__list :deep(.v-list-item-subtitle) {
font-size: 0.7rem;
}
</style>
@@ -15,23 +15,28 @@
</p> </p>
<div v-for="p in proposers" :key="p.key" class="fc-proposer"> <div v-for="p in proposers" :key="p.key" class="fc-proposer">
<SettingToggleRow <div class="d-flex align-center mb-1" style="gap: 10px;">
v-model="p.on" :loading="busy" :icon="p.icon" <v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
:icon-color="p.on ? 'accent' : null" :label="p.label" <span class="fc-section-h">{{ p.label }}</span>
@change="v => saveToggle(p, v)" <v-switch
v-model="p.on" :loading="busy" hide-details density="compact"
color="success" class="ml-auto"
@update:model-value="v => saveToggle(p, v)"
/> />
</div>
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p> <p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
<div class="d-flex flex-wrap mb-4" style="gap: 12px;"> <div class="d-flex flex-wrap mb-4" style="gap: 12px;">
<v-text-field <v-text-field
v-model="p.weights" label="Weights" density="compact" hide-details v-model="p.weights" label="Weights" density="compact" hide-details
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on" style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
placeholder="name | URL | hf_repo::file" placeholder="name | URL | hf_repo::file"
@change="saveField({ [`detector_${p.key}_weights`]: p.weights })" @change="save({ [`detector_${p.key}_weights`]: p.weights })"
/> />
<SettingNumberField <v-text-field
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05" v-model.number="p.conf" label="Confidence" type="number"
max-width="140px" :disabled="busy || !p.on" min="0" max="1" step="0.05" density="compact" hide-details
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })" style="max-width: 140px;" :disabled="busy || !p.on"
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
/> />
</div> </div>
</div> </div>
@@ -43,12 +48,12 @@
storage. Dedupe IoU drops near-duplicate crops before embedding. storage. Dedupe IoU drops near-duplicate crops before embedding.
</p> </p>
<div class="d-flex flex-wrap" style="gap: 12px;"> <div class="d-flex flex-wrap" style="gap: 12px;">
<SettingNumberField <v-text-field
v-for="c in caps" :key="c.key" v-for="c in caps" :key="c.key"
v-model="c.val" :label="c.label" v-model.number="c.val" :label="c.label" type="number"
:min="c.min" :max="c.max" :step="c.step || 1" :min="c.min" :max="c.max" :step="c.step || 1" density="compact"
max-width="165px" :disabled="busy" hide-details style="max-width: 165px;" :disabled="busy"
@change="saveField({ [c.key]: Number(c.val) })" @change="save({ [c.key]: Number(c.val) })"
/> />
</div> </div>
</div> </div>
@@ -56,16 +61,14 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
const mlSettings = useMLStore() const mlSettings = useMLStore()
const { busy, save } = useSettingSave(mlSettings.patchSettings) const busy = ref(false)
const proposers = ref([]) const proposers = ref([])
const caps = ref([]) const caps = ref([])
@@ -108,20 +111,31 @@ onMounted(async () => {
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 })) caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
}) })
// Field @change → persist with a "Saved" confirmation. SettingNumberField has async function save(patch, revert) {
// already clamped numeric values to their [min,max] before this fires. busy.value = true
function saveField(patch) { try {
save(patch, { successMessage: 'Saved' }) await mlSettings.patchSettings(patch)
toast({ text: 'Saved', type: 'success' })
} catch (e) {
if (revert) revert()
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
} }
async function saveToggle(p, v) { function saveToggle (p, v) {
// Revert the switch on failure so it never lies about the persisted state. // Revert the switch on failure so it never lies about the persisted state.
const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' }) save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
if (!ok) p.on = !v
} }
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-proposer { .fc-proposer {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px; border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
} }
@@ -112,6 +112,7 @@ async function onCommit() {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-code { .fc-code {
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
border-radius: 4px; padding: 2px 8px; border-radius: 4px; padding: 2px 8px;
@@ -42,7 +42,7 @@
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
<p v-else class="text-caption mt-3 fc-muted"> <p v-else class="text-caption mt-3" style="opacity: 0.6;">
No table statistics yet. No table statistics yet.
</p> </p>
</MaintenanceTile> </MaintenanceTile>
@@ -72,5 +72,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-bad { color: rgb(var(--v-theme-error)); } .fc-bad { color: rgb(var(--v-theme-error)); }
</style> </style>
@@ -95,6 +95,7 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-cells { display: flex; gap: 28px; } .fc-cells { display: flex; gap: 28px; }
.fc-cell__n { .fc-cell__n {
font-size: 20px; font-weight: 700; line-height: 1.1; font-size: 20px; font-weight: 700; line-height: 1.1;
@@ -104,5 +105,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
} }
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-bad { color: rgb(var(--v-theme-error)); } .fc-bad { color: rgb(var(--v-theme-error)); }
</style> </style>
@@ -367,6 +367,11 @@ async function onReprocess() {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-token { .fc-token {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
@@ -385,4 +390,6 @@ async function onReprocess() {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
} }
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style> </style>
@@ -155,6 +155,11 @@ async function onRecover(it) {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-queue { display: flex; gap: 24px; } .fc-queue { display: flex; gap: 24px; }
.fc-q__n { .fc-q__n {
font-size: 20px; font-weight: 700; line-height: 1.1; font-size: 20px; font-weight: 700; line-height: 1.1;
@@ -164,6 +169,8 @@ async function onRecover(it) {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
} }
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
.fc-defect { .fc-defect {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
background: rgb(var(--v-theme-surface-light)); border-radius: 8px; background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
+84 -88
View File
@@ -95,10 +95,14 @@
<!-- Earned auto-apply --> <!-- Earned auto-apply -->
<div class="fc-auto mt-6"> <div class="fc-auto mt-6">
<SettingToggleRow <div class="d-flex align-center mb-1" style="gap: 10px;">
v-model="autoEnabled" :loading="settingBusy" <v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto" <span class="fc-section-h">Auto-apply</span>
<v-switch
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
color="success" class="ml-auto" @update:model-value="onToggleAuto"
/> />
</div>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}% on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
@@ -107,14 +111,17 @@
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<SettingNumberField <v-text-field
v-model="autoPrecisionInput" label="Precision target" v-model.number="autoPrecisionInput" label="Precision target"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy" type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings" @change="onSaveSettings"
/> />
<SettingNumberField <v-text-field
v-model="autoMinPosInput" label="Min examples to fire" v-model.number="autoMinPosInput" label="Min examples to fire"
:min="1" :disabled="settingBusy" @change="onSaveSettings" type="number" min="1" density="compact" hide-details
style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings"
/> />
</div> </div>
@@ -154,65 +161,40 @@
<!-- Presentation chrome auto-hide (#141) --> <!-- Presentation chrome auto-hide (#141) -->
<div class="fc-auto mt-6"> <div class="fc-auto mt-6">
<SettingToggleRow <div class="d-flex align-center mb-1" style="gap: 10px;">
v-model="presentationEnabled" :loading="settingBusy" <v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
icon="mdi-image-off-outline" label="Hide presentation chrome" <span class="fc-section-h">Hide presentation chrome</span>
@change="onTogglePresentation" <v-switch
v-model="presentationEnabled" :loading="settingBusy" hide-details
density="compact" color="success" class="ml-auto"
@update:model-value="onTogglePresentation"
/> />
</div>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Auto-hide <code>banner</code> chrome from the gallery once a head has Auto-hide banners and editor screenshots from the gallery once a head has
learned it ( {{ minPositives }} examples) and clears learned them ( {{ minPositives }} examples) and clears
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence. {{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
(<code>wip</code> and <code>editor screenshot</code> are handled by the <code>wip</code> is never auto-hidden. If a hidden image also looks like
process auto-tagger below.) If a hidden image also looks like real content real content ( {{ Math.round((presentationConflictInput || 0) * 100) }}%
( {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content on a content tag), it's flagged in the Hidden view instead of buried.
tag), it's flagged for review instead of buried. Every auto-hide is reversible. Every auto-hide is reversible.
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<SettingNumberField <v-text-field
v-model="presentationThresholdInput" label="Hide confidence" v-model.number="presentationThresholdInput" label="Hide confidence"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy" type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation" @change="onSavePresentation"
/> />
<SettingNumberField <v-text-field
v-model="presentationConflictInput" label="Flag if content ≥" v-model.number="presentationConflictInput" label="Flag if content ≥"
:min="0" :max="1" :step="0.05" :disabled="settingBusy" type="number" min="0" max="1" step="0.05" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation" @change="onSavePresentation"
/> />
</div> </div>
</div> </div>
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
<div class="fc-auto mt-6">
<SettingToggleRow
v-model="processEnabled" :loading="settingBusy"
icon="mdi-progress-wrench" label="Auto-tag work-in-progress"
@change="onToggleProcess"
/>
<p class="fc-muted text-body-2 mb-3">
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
once a head has learned them (≥ {{ minPositives }} examples) and clears
{{ Math.round((processThresholdInput || 0) * 100) }}% confidence. These stay
<strong>visible</strong> in the gallery — the tag just keeps them out of
training and the Explore rabbit-hole. Off by default. If a tagged image also
looks like real content (≥ {{ Math.round((processConflictInput || 0) * 100) }}%
on a content tag), it's flagged for review. Learns only from your titles +
manual tags, never its own guesses so it can't run away. Every tag reversible.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<SettingNumberField
v-model="processThresholdInput" label="Tag confidence"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
@change="onSaveProcess"
/>
<SettingNumberField
v-model="processConflictInput" label="Flag if content ≥"
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
@change="onSaveProcess"
/>
</div>
</div>
<!-- Performance / tuning --> <!-- Performance / tuning -->
<div v-if="metricsConcepts.length" class="mt-5"> <div v-if="metricsConcepts.length" class="mt-5">
<div class="fc-section-h mb-1">How auto-apply is landing</div> <div class="fc-section-h mb-1">How auto-apply is landing</div>
@@ -237,7 +219,7 @@
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td> <td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
<td class="fc-r fc-mono">{{ c.n_misfires }}</td> <td class="fc-r fc-mono">{{ c.n_misfires }}</td>
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)"> <td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
{{ pct(c.misfire_rate) }} {{ ratePct(c.misfire_rate) }}
</td> </td>
<td class="fc-r fc-mono">{{ c.n_underfires }}</td> <td class="fc-r fc-mono">{{ c.n_underfires }}</td>
</tr> </tr>
@@ -253,9 +235,6 @@ import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useHeadsStore } from '../../stores/heads.js' import { useHeadsStore } from '../../stores/heads.js'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
@@ -269,9 +248,7 @@ let pollTimer = null
const autoEnabled = ref(false) const autoEnabled = ref(false)
const autoPrecisionInput = ref(0.97) const autoPrecisionInput = ref(0.97)
const autoMinPosInput = ref(30) const autoMinPosInput = ref(30)
// Shared settings-save flow (busy + toast + revert); `settingBusy` gates the const settingBusy = ref(false)
// toggles/fields, `save` returns ok/false for the optimistic-switch revert.
const { busy: settingBusy, save } = useSettingSave(mlSettings.patchSettings)
const autoBusy = ref(false) const autoBusy = ref(false)
const autoStatus = ref(null) const autoStatus = ref(null)
const metricsData = ref(null) const metricsData = ref(null)
@@ -281,9 +258,6 @@ let autoTimer = null
const presentationEnabled = ref(true) const presentationEnabled = ref(true)
const presentationThresholdInput = ref(0.90) const presentationThresholdInput = ref(0.90)
const presentationConflictInput = ref(0.50) const presentationConflictInput = ref(0.50)
const processEnabled = ref(false)
const processThresholdInput = ref(0.90)
const processConflictInput = ref(0.50)
const autoRunning = computed(() => autoStatus.value?.running_id != null) const autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() => const lastSweep = computed(() =>
@@ -318,9 +292,6 @@ onMounted(async () => {
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90 presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50 presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
processEnabled.value = s.process_auto_apply_enabled ?? false
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
processConflictInput.value = s.process_conflict_threshold ?? 0.50
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
await refresh() await refresh()
if (running.value) startPoll() if (running.value) startPoll()
@@ -381,39 +352,55 @@ function startAutoPoll() {
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } } function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
async function onToggleAuto(val) { async function onToggleAuto(val) {
const ok = await save({ head_auto_apply_enabled: !!val }, settingBusy.value = true
{ successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' }) try {
if (!ok) autoEnabled.value = !val // revert the switch await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
} catch (e) {
autoEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onSaveSettings() { async function onSaveSettings() {
await save({ settingBusy.value = true
try {
await mlSettings.patchSettings({
head_auto_apply_precision: Number(autoPrecisionInput.value), head_auto_apply_precision: Number(autoPrecisionInput.value),
head_auto_apply_min_positives: Number(autoMinPosInput.value), head_auto_apply_min_positives: Number(autoMinPosInput.value),
}) })
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onTogglePresentation(val) { async function onTogglePresentation(val) {
const ok = await save({ presentation_auto_apply_enabled: !!val }, settingBusy.value = true
{ successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' }) try {
if (!ok) presentationEnabled.value = !val // revert the switch await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
} catch (e) {
presentationEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onSavePresentation() { async function onSavePresentation() {
await save({ settingBusy.value = true
try {
await mlSettings.patchSettings({
presentation_auto_apply_threshold: Number(presentationThresholdInput.value), presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
presentation_conflict_threshold: Number(presentationConflictInput.value), presentation_conflict_threshold: Number(presentationConflictInput.value),
}) })
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
} }
async function onToggleProcess(val) {
const ok = await save({ process_auto_apply_enabled: !!val },
{ successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
if (!ok) processEnabled.value = !val // revert the switch
}
async function onSaveProcess() {
await save({
process_auto_apply_threshold: Number(processThresholdInput.value),
process_conflict_threshold: Number(processConflictInput.value),
})
} }
function onPreview() { startSweep(true) } function onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) } function onApplyNow() { startSweep(false) }
@@ -439,6 +426,7 @@ function sweepConcepts(run) {
.sort((a, b) => b.applied - a.applied) .sort((a, b) => b.applied - a.applied)
} }
function sweepTotal(run) { return run?.n_applied ?? 0 } function sweepTotal(run) { return run?.n_applied ?? 0 }
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
function rateClass(x) { function rateClass(x) {
if (x == null) return '' if (x == null) return ''
if (x <= 0.03) return 'fc-good' if (x <= 0.03) return 'fc-good'
@@ -469,6 +457,12 @@ function relTime(iso) {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-auto { .fc-auto {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px; border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
} }
@@ -525,5 +519,7 @@ function relTime(iso) {
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 999px; padding: 1px 6px; border-radius: 999px;
} }
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-ok { color: rgb(var(--v-theme-on-surface)); } .fc-ok { color: rgb(var(--v-theme-on-surface)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style> </style>
@@ -15,7 +15,7 @@
being dropped as duplicates;</strong> raise it to collapse more being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports. look-alikes. Applies to new imports.
</div> </div>
<v-row no-gutters class="align-center"> <v-row align="center" no-gutters>
<v-col cols="12" sm="9"> <v-col cols="12" sm="9">
<v-slider <v-slider
v-model="local.phash_threshold" v-model="local.phash_threshold"
@@ -79,46 +79,6 @@
</v-col> </v-col>
</v-row> </v-row>
<v-divider class="my-5" />
<!-- Title-based WIP auto-tagging (task #1458). The switch gates the LIVE
import hook; the button runs the one-off back-catalogue scan (an
explicit action it is deliberately not a scheduled sweep so it can't
silently re-apply a WIP tag you removed by hand). -->
<div class="fc-wip">
<div class="fc-wip__title">WIP auto-tagging</div>
<v-switch
v-model="local.wip_title_tagging_enabled"
label="Tag work-in-progress from post titles"
density="compact" hide-details color="primary" @change="save"
/>
<div class="fc-help mb-3">
When a post's title says <strong>WIP</strong> or
<strong>work in progress</strong>, new imports get the
<code>wip</code> tag automatically keeping unfinished pieces out of
the Explore browse. Applies to new imports; run the scan below to catch
posts already in your library.
</div>
<v-switch
v-model="local.wip_soft_title_tagging_enabled"
label="Also tag “sketch” / “doodle” titles (lower precision)"
density="compact" hide-details color="primary" @change="save"
/>
<div class="fc-help mb-3">
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
<code>scribble</code>). These stay <strong>visible</strong> and never train
the tagging model a daily audit flags any that actually look like finished
art for review. Off by default.
</div>
<v-btn
variant="tonal" color="primary" size="small"
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
@click="store.scanWipTitles()"
>
Scan existing posts for WIP titles
</v-btn>
</div>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable> <v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
{{ store.settingsError }} {{ store.settingsError }}
</v-alert> </v-alert>
@@ -149,8 +109,6 @@ const local = reactive({
skip_transparent: false, transparency_threshold: 0.9, skip_transparent: false, transparency_threshold: 0.9,
skip_single_color: false, single_color_threshold: 0.95, skip_single_color: false, single_color_threshold: 0.95,
phash_threshold: 10, phash_threshold: 10,
wip_title_tagging_enabled: true,
wip_soft_title_tagging_enabled: false,
}) })
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
@@ -166,18 +124,11 @@ async function save() {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px; margin-top: 2px;
} }
.fc-phash__title, .fc-phash__title {
.fc-wip__title {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
} }
.fc-wip code {
font-size: 0.85em;
padding: 1px 4px;
border-radius: 3px;
background: rgb(var(--v-theme-surface-variant));
}
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */ /* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
.fc-phash__slider { margin-bottom: 18px; } .fc-phash__slider { margin-bottom: 18px; }
</style> </style>
@@ -39,14 +39,13 @@
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import { useSettingSave } from '../../composables/useSettingSave.js'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore() const store = useMLStore()
const { busy: saving, save } = useSettingSave(store.patchSettings)
const busy = ref(false) const busy = ref(false)
const done = ref(false) const done = ref(false)
const enabled = ref(true) const enabled = ref(true)
const saving = ref(false)
onMounted(async () => { onMounted(async () => {
try { try {
await store.loadSettings() await store.loadSettings()
@@ -56,12 +55,21 @@ onMounted(async () => {
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
}) })
async function onToggle() { async function onToggle() {
const ok = await save({ cpu_embed_enabled: enabled.value }, { saving.value = true
successMessage: enabled.value try {
await store.patchSettings({ cpu_embed_enabled: enabled.value })
toast({
text: enabled.value
? 'CPU embedding on — imports queue embeds for the ml-worker' ? 'CPU embedding on — imports queue embeds for the ml-worker'
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds', : 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
type: 'success',
}) })
if (!ok) enabled.value = !enabled.value } catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
enabled.value = !enabled.value
} finally {
saving.value = false
}
} }
async function run() { async function run() {
busy.value = true busy.value = true
@@ -72,4 +80,5 @@ async function run() {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -12,17 +12,17 @@
</div> </div>
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<SettingNumberField <v-text-field
v-model="local.video_frame_interval_seconds" v-model.number="local.video_frame_interval_seconds"
label="Frame interval (s)" :min="0.5" :step="0.5" label="Frame interval (s)" type="number" min="0.5" step="0.5"
density="comfortable" max-width="none" @change="onSave" density="comfortable" hide-details @change="save"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<SettingNumberField <v-text-field
v-model="local.video_max_frames" v-model.number="local.video_max_frames"
label="Max frames" :min="1" :step="1" label="Max frames" type="number" min="1" step="1"
density="comfortable" max-width="none" @change="onSave" density="comfortable" hide-details @change="save"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -32,23 +32,21 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
const store = useMLStore() const store = useMLStore()
const { save } = useSettingSave(store.patchSettings)
const local = reactive({}) const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
// SettingNumberField clamps interval to 0.5 and max-frames to 1 before this async function save() {
// fires, so an out-of-range value never reaches the API. const patch = {
function onSave() { video_frame_interval_seconds: local.video_frame_interval_seconds,
save({ video_max_frames: local.video_max_frames
video_frame_interval_seconds: Number(local.video_frame_interval_seconds), }
video_max_frames: Number(local.video_max_frames), try { await store.patchSettings(patch) }
}) catch (e) { toast({ text: e.message, type: 'error' }) }
} }
</script> </script>
@@ -24,7 +24,6 @@
the CPU fallback. the CPU fallback.
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<VideoEmbeddingCard />
<GpuAgentCard /> <GpuAgentCard />
<GpuTriageCard /> <GpuTriageCard />
<MLBackfillCard /> <MLBackfillCard />
@@ -37,6 +36,7 @@
Suggestion thresholds, trained heads and tag aliases. Suggestion thresholds, trained heads and tag aliases.
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<MLThresholdSliders />
<CropProposersCard /> <CropProposersCard />
<HeadsCard /> <HeadsCard />
<AliasTable /> <AliasTable />
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue' import MissingFileRepairCard from './MissingFileRepairCard.vue'
import GpuTriageCard from './GpuTriageCard.vue' import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue'
import VideoEmbeddingCard from './VideoEmbeddingCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import CropProposersCard from './CropProposersCard.vue' import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue' import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue' import GpuAgentCard from './GpuAgentCard.vue'
@@ -1,5 +1,5 @@
<template> <template>
<v-row density="compact"> <v-row dense>
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2"> <v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2">
<v-card class="fc-stat"> <v-card class="fc-stat">
<v-card-text> <v-card-text>
@@ -28,21 +28,6 @@
@change="onSave" @change="onSave"
/> />
<!-- Acceptance floor (#155): latin-script translations below this Interpreter
confidence are kept as the original. Per-post overrides handle exceptions. -->
<v-text-field
v-model.number="minConfidence" label="Acceptance confidence"
type="number" min="0" max="1" step="0.01" density="compact"
hide-details style="max-width: 200px;" class="mb-1" :disabled="busy"
@change="onSaveConfidence"
/>
<div class="fc-muted text-caption mb-3">
Latin-script titles Interpreter is less than this sure about (01) are kept
as the original; CJK is always translated. Default 0.90 — raise it to reject
more mis-detections, lower it to translate more. Per-post controls in the
posts feed override this either way.
</div>
<div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;"> <div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;">
<v-icon size="12" :color="statusColor">mdi-circle</v-icon> <v-icon size="12" :color="statusColor">mdi-circle</v-icon>
<span class="fc-muted text-body-2">{{ statusText }}</span> <span class="fc-muted text-body-2">{{ statusText }}</span>
@@ -93,61 +78,6 @@
artist, use the Re-translate button on that artist's page. artist, use the Re-translate button on that artist's page.
</p> </p>
<v-divider class="my-3" />
<!-- Diagnostic probe (task #1376): paste text → what Interpreter DETECTS
(language + confidence) and returns, without saving. Used to see why a
short/abbreviation-heavy English title gets mis-detected, and to pick a
detection-confidence / min-length guard from real numbers. -->
<div class="mb-1">
<span class="fc-section-h">Test translation</span>
<p class="fc-muted text-caption mt-1 mb-2">
Paste a title or snippet to see what Interpreter detects and returns —
handy for checking why a short string is mis-detected. Nothing is saved.
</p>
</div>
<v-textarea
v-model="probeText" label="Text to test" rows="2" auto-grow
density="compact" hide-details class="mb-2" :disabled="!baseUrl"
/>
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
<v-btn
size="small" variant="tonal" rounded="pill" prepend-icon="mdi-magnify"
:loading="probing" :disabled="!baseUrl || !probeText.trim()"
@click="onProbe"
>Test translation</v-btn>
</div>
<v-sheet
v-if="probeResult" rounded class="pa-3 mb-1 text-body-2"
color="rgba(var(--v-theme-accent), 0.06)"
>
<div class="d-flex flex-wrap" style="gap: 4px 16px;">
<span>
<span class="fc-muted">Detected:</span>
<strong>{{ probeResult.detected_lang || '' }}</strong>
</span>
<span v-if="probeResult.detected_confidence != null">
<span class="fc-muted">Confidence:</span>
<strong>{{ fmtConf(probeResult.detected_confidence) }}</strong>
</span>
<span>
<span class="fc-muted">Engine:</span>
{{ probeResult.engine || '' }}<template
v-if="probeResult.engine_version"
> ({{ probeResult.engine_version }})</template>
</span>
<span><span class="fc-muted">Target:</span> {{ probeResult.target }}</span>
</div>
<v-divider class="my-2" />
<div class="fc-muted mb-1">
Result<template v-if="probeResult.detected_lang === probeResult.target">
— already {{ probeResult.target }}, so the sweep leaves it as-is</template>:
</div>
<p class="mb-0" style="white-space: pre-wrap;">{{
probeResult.translated || '(no change)'
}}</p>
</v-sheet>
<v-alert <v-alert
v-if="err" type="error" variant="tonal" density="compact" v-if="err" type="error" variant="tonal" density="compact"
class="mt-3" closable @click:close="err = null" class="mt-3" closable @click:close="err = null"
@@ -190,7 +120,6 @@ const store = useImportStore()
const enabled = ref(false) const enabled = ref(false)
const baseUrl = ref('') const baseUrl = ref('')
const targetLang = ref('en') const targetLang = ref('en')
const minConfidence = ref(0.9)
const busy = ref(false) const busy = ref(false)
const running = ref(false) const running = ref(false)
const retranslating = ref(false) const retranslating = ref(false)
@@ -200,17 +129,6 @@ const testResult = ref(null) // null = not tested this session; true/false = l
const status = ref(null) const status = ref(null)
const err = ref(null) const err = ref(null)
// #1376 "Test translation" probe: paste text → detected lang/confidence + result.
const probeText = ref('')
const probing = ref(false)
const probeResult = ref(null)
// Confidence scale is Interpreter-defined (0100 or 01) — show it as-is, just
// trimmed to 2 decimals, so the operator reads the true number.
function fmtConf (c) {
return c == null ? '' : Math.round(c * 100) / 100
}
const statusColor = computed(() => { const statusColor = computed(() => {
if (!enabled.value || !baseUrl.value) return 'grey' if (!enabled.value || !baseUrl.value) return 'grey'
return status.value?.healthy ? 'success' : 'error' return status.value?.healthy ? 'success' : 'error'
@@ -252,21 +170,6 @@ async function onTest() {
} }
} }
async function onProbe () {
probing.value = true
err.value = null
probeResult.value = null
try {
probeResult.value = await api.post('/api/settings/translation/probe', {
body: { text: probeText.value.trim() },
})
} catch (e) {
err.value = e.message
} finally {
probing.value = false
}
}
onMounted(async () => { onMounted(async () => {
try { try {
await store.loadSettings() await store.loadSettings()
@@ -274,7 +177,6 @@ onMounted(async () => {
enabled.value = !!s.translation_enabled enabled.value = !!s.translation_enabled
baseUrl.value = s.interpreter_base_url || '' baseUrl.value = s.interpreter_base_url || ''
targetLang.value = s.translation_target_lang || 'en' targetLang.value = s.translation_target_lang || 'en'
minConfidence.value = s.translation_min_confidence ?? 0.9
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
await loadStatus() await loadStatus()
}) })
@@ -299,14 +201,6 @@ async function onSave() {
}) })
await loadStatus() await loadStatus()
} }
async function onSaveConfidence() {
// Clamp to [0, 1] so a stray value never bounces off the API 400.
let v = Number(minConfidence.value)
if (!Number.isFinite(v)) v = 0.9
v = Math.min(1, Math.max(0, v))
minConfidence.value = v
await save({ translation_min_confidence: v })
}
async function onRun() { async function onRun() {
running.value = true running.value = true
err.value = null err.value = null
@@ -1,33 +0,0 @@
import { ref } from 'vue'
import { toast } from '../utils/toast.js'
// The shared "persist a settings patch" flow for the ML settings cards. Flips a
// busy flag, calls the store's patch (which rethrows on failure), toasts
// success/error, and returns true/false so a toggle handler can revert its
// optimistic switch on failure. Centralises the try/catch/toast the cards each
// hand-rolled (HeadsCard x6, CropProposersCard, MLBackfillCard) — and where the
// threshold-clamp drifted; the clamp now lives in <SettingNumberField>.
//
// Pass the store's patch fn, e.g. useSettingSave(ml.patchSettings).
export function useSettingSave(patchFn) {
const busy = ref(false)
// opts.successMessage — toast on success (toggles announce their new state;
// silent field-saves omit it). opts.errorPrefix — the failure toast prefix
// ("Could not save" default; toggles used "Could not update").
async function save(patch, { successMessage = '', errorPrefix = 'Could not save' } = {}) {
busy.value = true
try {
await patchFn(patch)
if (successMessage) toast({ text: successMessage, type: 'success' })
return true
} catch (e) {
toast({ text: `${errorPrefix}: ${e.message}`, type: 'error' })
return false
} finally {
busy.value = false
}
}
return { busy, save }
}
+5 -5
View File
@@ -20,7 +20,7 @@ const routes = [
// FC-2: image backbone // FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } }, { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } }, { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours // Explore: a 3-pane tagging workspace — walk an image's visual neighbours
// (left) while tagging the focused image (center viewer + modal-parity tag // (left) while tagging the focused image (center viewer + modal-parity tag
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random // rail). Optional anchor param — the bare /explore nav entry SEEDS a random
@@ -29,11 +29,11 @@ const routes = [
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs — // Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old // the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below). // standalone paths redirect into the matching tab (below).
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } }, { path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
// Artist detail — no meta.title (reached by clicking an artist, not nav). // Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView }, { path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). // Series browse — a nav entry (meta.title).
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } }, { path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
// Series management — no meta.title (reached from a series card/tag). // Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView }, { path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title). // Series reader — immersive (no top nav, no meta.title).
@@ -41,10 +41,10 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads), // FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub. // distinct from the Browse hub.
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } }, { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
// Settings — config, pinned to the right of the nav (TopNav special-cases it). // Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } }, { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// The old standalone paths now redirect into the Browse hub, preserving any // The old standalone paths now redirect into the Browse hub, preserving any
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The // deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
+3 -21
View File
@@ -25,10 +25,6 @@ export const useExploreStore = defineStore('explore', () => {
const cursor = ref(-1) const cursor = ref(-1)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
// mid-far escape routes so the walk diversifies without hitting "Random image".
const reach = ref(0.4)
const inflight = useInflightToken() const inflight = useInflightToken()
@@ -50,13 +46,7 @@ export const useExploreStore = defineStore('explore', () => {
const body = await api.get('/api/gallery/similar', { const body = await api.get('/api/gallery/similar', {
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole // exclude_wip: keep work-in-progress out of the Explore rabbit-hole
// (the gallery's own "similar" button still shows it) — operator 2026-07-08. // (the gallery's own "similar" button still shows it) — operator 2026-07-08.
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
// an already-walked image, so the walk keeps moving instead of getting stuck.
params: {
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
reach: reach.value,
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
},
}) })
if (!t.isCurrent()) return if (!t.isCurrent()) return
neighbors.value = body.images || [] neighbors.value = body.images || []
@@ -123,14 +113,6 @@ export const useExploreStore = defineStore('explore', () => {
loading.value = false loading.value = false
} }
// Change how far the walk reaches and re-fetch the current anchor's neighbours
// with the new setting (the anchor + trail are unchanged — only the grid varies).
function setReach (v) {
reach.value = Math.max(0, Math.min(1, Number(v)))
const id = anchor.value?.id
if (id != null) anchorOn(id)
}
// --- TagPanel "host" surface --------------------------------------------- // --- TagPanel "host" surface ---------------------------------------------
// The anchor IS the current image (same /api/gallery/image/<id> payload the // The anchor IS the current image (same /api/gallery/image/<id> payload the
// modal uses), so these mirror the modal store's tag-CRUD, targeting the // modal uses), so these mirror the modal store's tag-CRUD, targeting the
@@ -207,8 +189,8 @@ export const useExploreStore = defineStore('explore', () => {
function close () {} function close () {}
return { return {
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach, anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
anchorOn, reset, backTarget, forwardTarget, setReach, anchorOn, reset, backTarget, forwardTarget,
// host surface // host surface
current, currentImageId, current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close, reloadTags, addExistingTag, removeTag, createAndAdd, close,
+2 -20
View File
@@ -16,7 +16,6 @@ export const useImportStore = defineStore('import', () => {
const settings = ref(null) const settings = ref(null)
const settingsLoading = ref(false) const settingsLoading = ref(false)
const settingsError = ref(null) const settingsError = ref(null)
const wipScanBusy = ref(false)
async function loadSettings() { async function loadSettings() {
settingsLoading.value = true settingsLoading.value = true
@@ -41,25 +40,8 @@ export const useImportStore = defineStore('import', () => {
} }
} }
// Enqueue the back-catalogue WIP-title scan (task #1458). New imports are
// tagged live; this catches posts already in the library. Fire-and-forget —
// the sweep runs on the maintenance worker and its run shows in Activity.
async function scanWipTitles() {
wipScanBusy.value = true
try {
const r = await api.post('/api/settings/wip-title/scan')
toast({ text: 'Scanning existing posts for WIP titles…', type: 'success' })
return r
} catch (e) {
toast({ text: `WIP scan failed: ${e.message}`, type: 'error' })
throw e
} finally {
wipScanBusy.value = false
}
}
return { return {
settings, settingsLoading, settingsError, wipScanBusy, settings, settingsLoading, settingsError,
loadSettings, patchSettings, scanWipTitles, loadSettings, patchSettings,
} }
}) })
+1 -19
View File
@@ -71,24 +71,6 @@ export const usePostsStore = defineStore('posts', () => {
return await api.get(`/api/posts/${id}`) return await api.get(`/api/posts/${id}`)
} }
// Set a post's sticky translation override (auto/force/original, #155) and
// patch the loaded item in place with the endpoint's result (it translates /
// clears immediately when the service is up), so the card reflects the change
// without a feed reload.
async function applyTranslationOverride(postId, override) {
const res = await api.post(`/api/posts/${postId}/translation-override`, {
body: { override },
})
const item = items.value.find((p) => p.id === postId)
if (item) {
item.translation_override = res.translation_override
item.post_title_translated = res.post_title_translated
item.description_translated = res.description_translated
item.translated_source_lang = res.translated_source_lang
}
return res
}
// Filter overlay for the around/older/newer (in-context anchored) // Filter overlay for the around/older/newer (in-context anchored)
// path. Keep this distinct from `filters.value` (the down-only feed) // path. Keep this distinct from `filters.value` (the down-only feed)
// so a normal-feed filter change doesn't leak into an active anchored // so a normal-feed filter change doesn't leak into an active anchored
@@ -186,7 +168,7 @@ export const usePostsStore = defineStore('posts', () => {
return { return {
items, cursor, loading, done, error, filters, items, cursor, loading, done, error, filters,
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId, cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
loadInitial, loadMore, getPostFull, applyTranslationOverride, loadInitial, loadMore, getPostFull,
loadAround, loadOlder, loadNewer, loadAround, loadOlder, loadNewer,
} }
}) })
+149 -70
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js' import { toast } from '../utils/toast.js'
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js' import { useInflightToken } from '../composables/useInflightToken.js'
@@ -18,112 +18,182 @@ export const CATEGORY_LABELS = {
export const useSuggestionsStore = defineStore('suggestions', () => { export const useSuggestionsStore = defineStore('suggestions', () => {
const api = useApi() const api = useApi()
// ONE source of truth per image: every trained head scored for this image const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
// (min=0), each row carrying above_threshold. The Suggestions PANEL renders // The typed tag-input dropdown searches the model's FULL prediction set for
// aboveByCategory; the typed tag-input dropdown reads the full set and // the image (min=0, down to the store floor) so low-confidence actions/
// annotates matching DB-tag rows with their score. A single fetch backs both — // features can be picked in canonical formatting (operator-asked 2026-06-09).
// every suggestion is a canonical tag now (tagging-v2, #114), so there is no const allByCategory = ref({})
// raw-prediction / create-new / alias-remap path to reconcile.
const byCategory = ref({})
const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null let currentImageId = null
// Audit 2026-06-02: without an inflight guard a late /suggestions response from const inflightAll = useInflightToken()
// a prior image could overwrite byCategory while currentImageId points at a new // Audit 2026-06-02: this store had no inflight guard — a late
// one, and accept() could apply image A's tag to image B. Both guarded below by // /suggestions response from a prior image could overwrite
// capturing imageId at call-time and gating writes on the token. // byCategory while currentImageId pointed at a new one, and
// accept() dereferenced currentImageId AFTER an awaited POST so
// the subsequent /suggestions/accept could apply A's chosen tag
// to image B (and push it to the allowlist). Both fixed below
// by capturing imageId at call-time and gating writes on the token.
const inflight = useInflightToken() const inflight = useInflightToken()
// The curated above-threshold subset the Suggestions panel shows. A rejected
// row that still scores above its cut stays (flagged) so the rejection is
// visible + reversible; below-threshold rows live only in the dropdown.
const aboveByCategory = computed(() => {
const out = {}
for (const [cat, list] of Object.entries(byCategory.value)) {
const kept = (list || []).filter(s => s.above_threshold)
if (kept.length) out[cat] = kept
}
return out
})
async function load(imageId) { async function load(imageId) {
// Cancel any in-flight load from the previous image so its late response // Cancel any in-flight load from the previous image so its late
// can't overwrite this image's list. // response can't overwrite this image's byCategory.
inflight.cancel() inflight.cancel()
currentImageId = imageId currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim() const t = inflight.claim()
await run(async () => { await run(async () => {
// min=0 → every head, each flagged above_threshold; the panel + the typed const body = await api.get(`/api/images/${imageId}/suggestions`)
// dropdown are both derived from this one payload.
const body = await api.get(`/api/images/${imageId}/suggestions`, {
params: { min: 0 },
})
if (!t.isCurrent()) return if (!t.isCurrent()) return
byCategory.value = body.by_category || {} byCategory.value = body.by_category || {}
}) })
} }
// A stable identity for a suggestion across the panel + dropdown surfaces: // Load the full prediction set for the dropdown (separate inflight guard so a
// its canonical tag id (every suggestion has one now). // late response from a prior image can't overwrite the current one's list).
function _keyOf(s) { async function loadAll(imageId) {
return `id:${s.canonical_tag_id}` inflightAll.cancel()
allByCategory.value = {}
const t = inflightAll.claim()
try {
const body = await api.get(`/api/images/${imageId}/suggestions`, {
params: { min: 0 },
})
if (!t.isCurrent()) return
allByCategory.value = body.by_category || {}
} catch {
// Dropdown is best-effort — the panel surfaces load errors.
}
} }
// Drop an accepted suggestion from the list so it can't reappear. // A stable identity for a suggestion across the two lists (panel vs dropdown
// are separate fetches, so object identity differs): tag id when known, else
// the raw display key.
function _keyOf(s) {
return s.canonical_tag_id != null
? `id:${s.canonical_tag_id}`
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
}
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
// dropdown's full list so it can't reappear in either surface.
function _dropEverywhere(suggestion) { function _dropEverywhere(suggestion) {
const key = _keyOf(suggestion) const key = _keyOf(suggestion)
const list = byCategory.value[suggestion.category] const cat = suggestion.category
if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key) for (const map of [byCategory.value, allByCategory.value]) {
const list = map[cat]
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
}
} }
// Flip the `rejected` flag on the matching suggestion in place, so a // Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
// reject/un-reject shows immediately without dropping the row (visible, // so a reject/un-reject shows immediately without dropping the row (visible,
// reversible rejection — misclick recovery, operator-asked 2026-06-27). // reversible rejection — misclick recovery, operator-asked 2026-06-27).
function _setRejectedEverywhere(suggestion, value) { function _setRejectedEverywhere(suggestion, value) {
const key = _keyOf(suggestion) const key = _keyOf(suggestion)
for (const s of byCategory.value[suggestion.category] || []) { const cat = suggestion.category
for (const map of [byCategory.value, allByCategory.value]) {
for (const s of map[cat] || []) {
if (_keyOf(s) === key) s.rejected = value if (_keyOf(s) === key) s.rejected = value
} }
} }
}
// opts.tagId: the tag's known id when the caller already resolved it (TagPanel's // opts.tagId: the tag's known id, when the caller already created/resolved
// manual-add-matches-suggestion path). Passed separately — NOT spread onto the // it (TagPanel's manual-add-matches-suggestion path). Passed separately —
// suggestion — because _dropEverywhere keys off the suggestion object. // NOT spread onto the suggestion — because _dropEverywhere keys off the
// suggestion object, and mutating canonical_tag_id on a raw suggestion
// would stop it matching its own row in the lists.
async function accept(suggestion, { tagId: knownTagId = null } = {}) { async function accept(suggestion, { tagId: knownTagId = null } = {}) {
// Capture imageId so a mid-flight prev/next can't reroute the accept POST to // Capture imageId so a mid-flight prev/next can't reroute the
// a different image. // accept POST to a different image AND push the tag to that
// image's allowlist.
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return if (imageId == null) return
const tagId = knownTagId ?? suggestion.canonical_tag_id // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
// accept endpoint needs a tag_id, so for raw tags we create the tag
// first via the existing /api/tags endpoint, then accept by id.
let tagId = knownTagId ?? suggestion.canonical_tag_id
if (tagId == null) {
const created = await api.post('/api/tags', {
body: { name: suggestion.display_name, kind: suggestion.category }
})
tagId = created.id
}
await api.post(`/api/images/${imageId}/suggestions/accept`, { await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
// Only drop from THIS image's list — if the user navigated, the new image // Only drop from THIS image's category list — if the user navigated,
// has its own suggestions and this drop would corrupt them. // the new image has its own suggestions and this drop would corrupt them.
if (currentImageId === imageId) { if (currentImageId === imageId) {
_dropEverywhere(suggestion) _dropEverywhere(suggestion)
} }
_acceptToast('Tagged', suggestion.display_name) _acceptToast('Tagged', suggestion.display_name)
} }
// One non-blocking toast for accept. The accepted tag is applied to this image // One non-blocking toast for accept/alias. The accepted tag is applied to this
// and feeds head training; head auto-apply handles propagation (earned). // image and feeds head training; head auto-apply handles propagation (earned),
// so there's no instant fan-out to project.
function _acceptToast(verb, displayName) { function _acceptToast(verb, displayName) {
toast({ text: `${verb}: ${displayName}`, type: 'success' }) toast({ text: `${verb}: ${displayName}`, type: 'success' })
} }
// Find a live (non-rejected) suggestion matching a manually-picked tag — by async function aliasAccept(suggestion, canonicalTagId) {
// canonical id when known, else by (kind, name). Manually adding a tag the const imageId = currentImageId
// model also suggested must be indistinguishable from accepting the suggestion if (imageId == null) return
// (recorded + dropped from the panel), so TagPanel routes matches through // The alias MUST be stored under the raw model key — resolution looks up the
// accept() (operator-asked 2026-07-03). // raw prediction key, not the normalized display name. Sending display_name
// (the old bug) stored an alias that never resolved, so the prediction kept
// reappearing unaliased. raw_name is null only for centroid hits, which
// can't be aliased (the UI hides the action for them).
const aliasString = suggestion.raw_name ?? suggestion.display_name
await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: {
alias_string: aliasString,
alias_category: suggestion.category,
canonical_tag_id: canonicalTagId
}
})
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
}
_acceptToast('Aliased & tagged', suggestion.display_name)
}
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
// its unaliased form on reload). The canonical tag stays applied if it was
// accepted — this only undoes the model-key→tag mapping.
async function removeAlias(suggestion) {
const imageId = currentImageId
if (imageId == null || suggestion.raw_name == null) return
await api.delete(
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
)
if (currentImageId === imageId) {
await load(imageId)
await loadAll(imageId)
}
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
}
// Find a live (non-rejected) pending suggestion matching a manually-picked
// tag — by canonical id when known, else by (kind, name). Manually adding a
// tag the model also suggested must be indistinguishable from accepting the
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
// set first, then the panel list (loadAll is best-effort and can be empty).
function findPending(kind, name, tagId = null) { function findPending(kind, name, tagId = null) {
const lname = (name || '').toLowerCase() const lname = (name || '').toLowerCase()
for (const list of Object.values(byCategory.value)) { for (const map of [allByCategory.value, byCategory.value]) {
for (const list of Object.values(map)) {
for (const s of list || []) { for (const s of list || []) {
if (s.rejected) continue if (s.rejected) continue
if (tagId != null && s.canonical_tag_id === tagId) return s if (tagId != null && s.canonical_tag_id === tagId) return s
if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s if (
s.category === kind &&
(s.display_name || '').toLowerCase() === lname
) return s
}
} }
} }
return null return null
@@ -132,8 +202,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
async function dismiss(suggestion) { async function dismiss(suggestion) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return if (imageId == null) return
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
// nothing to persist a rejection against — drop them client-side as before.
// Canonical tags persist a rejection and STAY in the list flagged rejected, // Canonical tags persist a rejection and STAY in the list flagged rejected,
// so the operator can see it and one-click un-reject (misclick recovery). // so the operator can see it and one-click un-reject (misclick recovery).
if (suggestion.canonical_tag_id == null) {
if (currentImageId === imageId) _dropEverywhere(suggestion)
return
}
await api.post(`/api/images/${imageId}/suggestions/dismiss`, { await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id } body: { tag_id: suggestion.canonical_tag_id }
}) })
@@ -142,31 +218,33 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
} }
// Reject every still-unhandled VISIBLE (above-threshold) suggestion in a // Reject every still-unhandled suggestion in a category in one go ("confirm
// category in one go ("confirm the good ones, reject the rest" — operator-asked // the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
// 2026-07-06). Only touches what the panel shows, not the low-confidence tail // persist a rejection and STAY flagged rejected (reversible, one-click
// the dropdown carries. Dispatched in parallel so a big section clears fast. // un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
// so a big section clears fast.
async function dismissRemaining(category) { async function dismissRemaining(category) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return if (imageId == null) return
const targets = (byCategory.value[category] || []).filter( const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
(s) => s.above_threshold && !s.rejected
)
if (!targets.length) return if (!targets.length) return
await Promise.all(targets.map((s) => const canon = targets.filter((s) => s.canonical_tag_id != null)
const raw = targets.filter((s) => s.canonical_tag_id == null)
await Promise.all(canon.map((s) =>
api.post(`/api/images/${imageId}/suggestions/dismiss`, { api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: s.canonical_tag_id }, body: { tag_id: s.canonical_tag_id },
}) })
)) ))
if (currentImageId === imageId) { if (currentImageId === imageId) {
targets.forEach((s) => _setRejectedEverywhere(s, true)) canon.forEach((s) => _setRejectedEverywhere(s, true))
raw.forEach((s) => _dropEverywhere(s))
} }
} }
// Undo a per-image dismissal — the suggestion reverts to a live row. // Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) { async function undismiss(suggestion) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return if (imageId == null || suggestion.canonical_tag_id == null) return
await api.post(`/api/images/${imageId}/suggestions/undismiss`, { await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
body: { tag_id: suggestion.canonical_tag_id } body: { tag_id: suggestion.canonical_tag_id }
}) })
@@ -176,7 +254,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
return { return {
byCategory, aboveByCategory, loading, error, byCategory, allByCategory, loading, error,
load, accept, dismiss, dismissRemaining, undismiss, findPending load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
undismiss, findPending
} }
}) })
-88
View File
@@ -39,91 +39,3 @@
deliberately not used here. `.fc-muted` is a custom class Vuetify never deliberately not used here. `.fc-muted` is a custom class Vuetify never
emits, so no specificity/reorder fight — no !important needed. */ emits, so no specificity/reorder fight — no !important needed. */
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } .fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
/* Section sub-heading in settings cards (DRY pass #161): was redefined
identically in 4 cards, and TranslationCard used the class with NO local def
so its section headers rendered unstyled. Now one global utility. */
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error,
consolidated from the GPU / heads cards. fc-ok is intentionally NOT global —
it means on-surface in HeadsCard but success in QueuesTable. */
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
/* Vuetify 4 dropped its global CSS reset (normalisation moved into each
component). FC's layouts assumed the reset zeroed margins on text elements, so
restore just that — the "minimal reset" from the v4 upgrade guide — inside
Vuetify's own reset layer, which is low precedence so component + app styles
still win over it. Batch-4 Vuetify 3→4 (#1449). */
@layer vuetify-core.reset {
ul, ol, figure, details, summary { padding: 0; margin: 0; }
h1, h2, h3, h4, h5, h6, p { margin: 0; }
}
/* Active-tab indicator (operator-flagged 2026-07-13 in the Vuetify-4 review): v4's
MD3 v-tab "slider" underline renders wider than the tab and floats below it. The
active tab's TEXT is already accent-coloured (color="accent"), so drop the slider
and mark the active tab with a subtle accent fill + rounded top — a clean,
unambiguous highlight app-wide (Subscriptions / Browse / Settings / Series). */
.v-tab__slider { display: none !important; }
.v-tab[aria-selected="true"],
.v-tab.v-tab--selected {
background: rgb(var(--v-theme-accent) / 0.12);
border-radius: 8px 8px 0 0;
}
/* --- Continuous chrome fade (operator-asked 2026-07-13: "group the sub-nav as
part of the nav and use a single gradient in them"). ------------------------
The TopNav and any sticky sub-header pinned directly beneath it (Gallery's
filter bar, the Browse/Series/Settings/Subscriptions tabs bars) used to each
paint their OWN dark-to-transparent gradient (or a solid band), so the fade
read as happening TWICE — dark, fade out, then dark again. Instead the two
share ONE obsidian fade: the nav paints the TOP half (opaque → the seam
alpha) and the sub-header paints the CONTINUATION (seam alpha → transparent)
over its own height. Both reference --fc-chrome-seam, so the alphas meet
exactly at the 64px boundary — no re-darkening, no doubling, one gradient.
--fc-chrome-seam is the single knob: raise it for a heavier sub-header (more
legible tabs/controls over scrolling content), lower it for a lighter fade. */
:root {
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
/* Alpha where the nav hands off to the sub-header — also the "hold" level of
the fade. The chrome stays fairly opaque (0.92 → this) through the bulk of
its height, then drops to transparent in a small eased section at the very
bottom (see the multi-stop gradients), so it reads as a slow falloff that
tails off softly rather than a straight line to a hard edge (operator
2026-07-13). Raise for heavier/more-legible chrome, lower for a lighter fade. */
--fc-chrome-seam: 0.68;
/* Actual TopNav height, measured live (ResizeObserver in TopNav.vue) and used
by full-height workspaces (Explore/Subscriptions: calc(100vh - var)) and by
every sticky sub-header pinned beneath the nav (top: var). This was a
hardcoded 64px in ~6 places; Vuetify 4's MD3 sizing made the real nav a
different height, so the Explore workspace overflowed and its breadcrumb
tucked under the nav (#1481). This fallback is only used pre-measure. */
--fc-nav-h: 64px;
}
/* Applied to a sticky sub-header so it continues the nav's fade instead of
restarting it. Percentage stops so the fade always spans the element's height
(survives the filter bar's expanding refine panel). The blur keeps tabs and
controls legible as the fill thins toward transparent — the solid-surface
bars it replaces had none, so it must live here. */
.fc-chrome-continues {
/* Continues the nav's fade: HOLDS near the seam alpha through the first ~55%
(subtle), then eases down to transparent over the last ~45% with an
intermediate stop so the tail is soft — no hard line at the bottom edge
(operator 2026-07-13). Percentage stops keep the shape spanning the
element's height (survives the filter bar's expanding refine panel). */
background: linear-gradient(
to bottom,
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
rgba(var(--fc-chrome-rgb), 0.60) 55%,
rgba(var(--fc-chrome-rgb), 0.28) 82%,
rgba(var(--fc-chrome-rgb), 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
+3 -4
View File
@@ -7,7 +7,7 @@
switcher and the search stay reachable no matter how far you scroll. switcher and the search stay reachable no matter how far you scroll.
Background uses the theme surface token so content scrolls cleanly Background uses the theme surface token so content scrolls cleanly
under it (matches SettingsView's sticky tabs). --> under it (matches SettingsView's sticky tabs). -->
<div class="fc-browse__head fc-chrome-continues"> <div class="fc-browse__head">
<v-container fluid class="py-0"> <v-container fluid class="py-0">
<!-- Tabs and search share one row: the axis switcher on the left, the <!-- Tabs and search share one row: the axis switcher on the left, the
search field + active-scope chips on the right (operator-asked search field + active-scope chips on the right (operator-asked
@@ -154,10 +154,9 @@ function clearFilter(key) {
<style scoped> <style scoped>
.fc-browse__head { .fc-browse__head {
position: sticky; position: sticky;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */ top: 64px; /* directly under AppShell's 64px sticky TopNav */
z-index: 4; z-index: 4;
/* Background is the shared .fc-chrome-continues fade — it continues the nav's background: rgb(var(--v-theme-surface));
gradient instead of a solid surface band (operator 2026-07-13). */
} }
.fc-browse__bar { .fc-browse__bar {
display: flex; display: flex;
+3 -33
View File
@@ -31,20 +31,6 @@
<img :src="c.thumbnail_url" alt="" loading="lazy" /> <img :src="c.thumbnail_url" alt="" loading="lazy" />
</button> </button>
<div class="fc-ex__trail-actions"> <div class="fc-ex__trail-actions">
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
Raise it to break out of a dense signature without hitting Random. -->
<div
class="fc-ex__reach"
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
>
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
<v-slider
:model-value="store.reach" @end="store.setReach"
:min="0" :max="1" :step="0.2" hide-details density="compact"
color="accent" class="fc-ex__reach-slider"
/>
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
</div>
<!-- Active retrain right where you tag: fold the +/- you just gave <!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the into the heads without a trip to Settings (the nightly beat is the
passive cadence). --> passive cadence). -->
@@ -167,12 +153,6 @@ const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null) const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/')) const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
const reachLabel = computed(() => {
const r = store.reach
if (r <= 0.15) return 'Near'
if (r <= 0.55) return 'Varied'
return 'Far'
})
// #1206: hovering a suggestion in the rail highlights the crop it came from on // #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer). // the anchor image (same provide/inject as the modal viewer).
@@ -284,14 +264,12 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL /* Full-height workspace under the sticky top nav. */
measured height (set by TopNav) — a hardcoded 64px here overflowed the
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
.fc-ex { .fc-ex {
display: flex; flex-direction: column; display: flex; flex-direction: column;
height: calc(100vh - var(--fc-nav-h, 64px)); height: calc(100vh - 64px);
min-height: 0; min-height: 0;
} }
@@ -318,14 +296,6 @@ onUnmounted(() => {
.fc-ex__trail-actions { .fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto; margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
} }
.fc-ex__reach {
display: flex; align-items: center; gap: 6px;
margin-right: 8px;
}
.fc-ex__reach-slider { width: 96px; }
.fc-ex__reach-label {
font-size: 12px; min-width: 44px; text-align: left;
}
/* The three panes fill the remaining height; each scrolls on its own. /* The three panes fill the remaining height; each scrolls on its own.
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
+4 -5
View File
@@ -4,7 +4,7 @@
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
search/sort stay reachable on a long grid. The controls can't sit search/sort stay reachable on a long grid. The controls can't sit
inside v-window (it clips sticky children), so they're hoisted here. --> inside v-window (it clips sticky children), so they're hoisted here. -->
<div class="fc-series__head fc-chrome-continues"> <div class="fc-series__head">
<v-tabs v-model="tab" density="compact"> <v-tabs v-model="tab" density="compact">
<v-tab value="browse">Browse</v-tab> <v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions"> <v-tab value="suggestions">
@@ -294,13 +294,12 @@ onMounted(() => {
<style scoped> <style scoped>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so /* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
content scrolls cleanly beneath it. Background is the shared content scrolls cleanly beneath it. Surface bg matches SettingsView. */
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
band (operator 2026-07-13). */
.fc-series__head { .fc-series__head {
position: sticky; position: sticky;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */ top: 64px;
z-index: 4; z-index: 4;
background: rgb(var(--v-theme-surface));
padding-bottom: 12px; padding-bottom: 12px;
} }
.fc-series-browse__controls { .fc-series-browse__controls {
+6 -4
View File
@@ -7,11 +7,13 @@
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance <!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll- panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the to-top just to change tab. AppShell's TopNav is 64px sticky, so the
tab strip lives directly under it. The .fc-chrome-continues fade tab strip lives directly under it. Background uses the theme surface
continues the nav's gradient across the strip (operator 2026-07-13). --> token so it visually merges with the page rather than the
translucent v-tabs default. -->
<v-tabs <v-tabs
v-model="tab" color="accent" class="mb-4 fc-chrome-continues" v-model="tab" color="accent" class="mb-4"
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;" style="position: sticky; top: 64px; z-index: 4;
background: rgb(var(--v-theme-surface));"
> >
<v-tab value="overview">Overview</v-tab> <v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab> <v-tab value="activity">Activity</v-tab>
+4 -7
View File
@@ -5,7 +5,7 @@
align-tabs="start" align-tabs="start"
color="accent" color="accent"
density="compact" density="compact"
class="fc-subs-tabs fc-chrome-continues" class="fc-subs-tabs"
> >
<v-tab value="subscriptions"> <v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon> <v-icon start>mdi-account-multiple-check</v-icon>
@@ -55,18 +55,15 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay /* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
put while ONLY the tab content scrolls — previously the whole view put while ONLY the tab content scrolls — previously the whole view
scrolled instead of just the subscription list (operator-flagged scrolled instead of just the subscription list (operator-flagged
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */ 2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
height: calc(100vh - var(--fc-nav-h, 64px)); height: calc(100vh - 64px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.fc-subs-tabs { .fc-subs-tabs {
flex: 0 0 auto; flex: 0 0 auto;
/* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
the .fc-chrome-continues fade read as one gradient with it (operator
2026-07-13). The fade replaces the old border-bottom separator. */
margin-top: -8px;
} }
.fc-subs-window { .fc-subs-window {
flex: 1 1 auto; flex: 1 1 auto;
+1
View File
@@ -349,6 +349,7 @@ async function onDeleteTagConfirm() {
.fc-tags__sentinel { .fc-tags__sentinel {
display: flex; justify-content: center; padding: 32px 0; min-height: 60px; display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
} }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-merge-preview { .fc-merge-preview {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12); border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
+27 -36
View File
@@ -16,44 +16,25 @@ function stubFetch(handler) {
}) })
} }
// Every suggestion is a canonical DB tag now (tagging-v2): a real id, flagged
// above/below its head's suggest threshold. No raw / creates-new / alias cases.
const sugg = (over = {}) => ({ const sugg = (over = {}) => ({
canonical_tag_id: 7, canonical_tag_id: 7,
display_name: 'Ichigo', display_name: 'Ichigo',
category: 'character', category: 'character',
score: 0.9, score: 0.9,
source: 'head', source: 'head',
above_threshold: true, creates_new_tag: false,
rejected: false, rejected: false,
...over, ...over,
}) })
// Seed the store through its single load() so currentImageId is set the way the // Seed the store through its own load/loadAll so currentImageId is set the
// panel sets it (accept() derives the image from it). // way the panel sets it (accept() derives the image from it).
async function seed(store, byCategory) { async function seed(store, byCategory) {
stubFetch(() => ({ status: 200, body: { by_category: byCategory } })) stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
await store.load(1) await store.load(1)
await store.loadAll(1)
} }
describe('suggestions store: load derives aboveByCategory', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => {
const s = useSuggestionsStore()
await seed(s, {
general: [
sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }),
sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }),
],
})
expect(s.byCategory.general).toHaveLength(2) // dropdown sees all
expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only
expect(s.aboveByCategory.general[0].display_name).toBe('sword')
})
})
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => { describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
beforeEach(() => setActivePinia(createPinia())) beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
@@ -64,12 +45,15 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo') expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
}) })
it('matches by kind + name, case-insensitively', async () => { it('matches raw suggestions by kind + name, case-insensitively', async () => {
const s = useSuggestionsStore() const s = useSuggestionsStore()
await seed(s, { await seed(s, {
general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })], general: [sugg({
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})],
}) })
expect(s.findPending('general', 'holding sword')?.canonical_tag_id).toBe(12) expect(s.findPending('general', 'holding sword')).toBeTruthy()
// Kind must match: same name under another kind is a different tag. // Kind must match: same name under another kind is a different tag.
expect(s.findPending('character', 'holding sword')).toBeNull() expect(s.findPending('character', 'holding sword')).toBeNull()
}) })
@@ -82,31 +66,37 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
}) })
}) })
describe('suggestions store: accept', () => { describe('suggestions store: accept with a known tag id', () => {
beforeEach(() => setActivePinia(createPinia())) beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
it('POSTs only the accept endpoint (canonical id) and drops the row', async () => { it('POSTs only the accept endpoint (no tag create) and drops the row', async () => {
const s = useSuggestionsStore() const s = useSuggestionsStore()
await seed(s, { character: [sugg()] }) await seed(s, { character: [sugg()] })
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
calls.push({ url, method: init?.method, body: init?.body }) calls.push({ url, method: init?.method })
return { status: 200, body: { accepted: true, tag_id: 7 } } return { status: 200, body: { accepted: true, tag_id: 7 } }
}) })
await s.accept(s.byCategory.character[0]) await s.accept(s.byCategory.character[0], { tagId: 7 })
expect(calls).toHaveLength(1) expect(calls).toHaveLength(1)
expect(calls[0].url).toContain('/api/images/1/suggestions/accept') expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 })
expect(s.byCategory.character).toHaveLength(0) expect(s.byCategory.character).toHaveLength(0)
expect(s.allByCategory.character).toHaveLength(0)
}) })
it('honors a known tagId and still drops the row by the suggestion identity', async () => { it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => {
// TagPanel's manual-add-matches-suggestion path passes the resolved tag id; // TagPanel's manual-create path: the tag was just created by
// accept sends exactly it and drops the original suggestion row (which keys // host.createAndAdd, so accept must not create again — and the drop must
// off the suggestion object, not the passed id). // key off the original raw suggestion object, not the resolved id
// (regression: spreading canonical_tag_id onto the suggestion changed its
// identity key and left the panel row behind).
const s = useSuggestionsStore() const s = useSuggestionsStore()
await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] }) const raw = sugg({
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})
await seed(s, { general: [raw] })
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
calls.push({ url, body: init?.body }) calls.push({ url, body: init?.body })
@@ -117,5 +107,6 @@ describe('suggestions store: accept', () => {
expect(calls[0].url).toContain('/api/images/1/suggestions/accept') expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 }) expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
expect(s.byCategory.general).toHaveLength(0) expect(s.byCategory.general).toHaveLength(0)
expect(s.allByCategory.general).toHaveLength(0)
}) })
}) })
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"description": "Bundle the interlocking frontend build/test toolchain into ONE PR. vite, vitest, @vitejs/plugin-vue, happy-dom and @vue/test-utils are version-coupled — a lone major bump red-fails the build/tests until its peers land, so they must move together.",
"matchPackageNames": [
"vite",
"vitest",
"@vitejs/plugin-vue",
"happy-dom",
"@vue/test-utils"
],
"groupName": "frontend build/test toolchain"
}
]
}
+5 -5
View File
@@ -7,14 +7,14 @@ sqlalchemy[asyncio]>=2.0,<2.1
asyncpg>=0.31,<0.32 asyncpg>=0.31,<0.32
psycopg[binary]>=3.3,<3.4 psycopg[binary]>=3.3,<3.4
alembic>=1.18,<1.19 alembic>=1.18,<1.19
pgvector>=0.5,<0.6 pgvector>=0.4,<0.5
# Task queue # Task queue
celery>=5.6,<5.7 celery>=5.6,<5.7
redis>=7.4,<8.0 redis>=7.4,<8.0
# Crypto for credential storage (lands in FC-3, but pinned now for stability) # Crypto for credential storage (lands in FC-3, but pinned now for stability)
cryptography>=49,<50 cryptography>=48,<49
# Image handling (lands in FC-2) # Image handling (lands in FC-2)
pillow>=12,<13 pillow>=12,<13
@@ -30,10 +30,10 @@ yt-dlp>=2025.1
# Utilities # Utilities
python-dotenv>=1.2,<2.0 python-dotenv>=1.2,<2.0
structlog>=26.1,<26.2 structlog>=25.5,<26.0
# HTML sanitization for scraped post descriptions (FC-2d provenance) # HTML sanitization for scraped post descriptions (FC-2d provenance)
nh3>=0.3,<0.4 nh3>=0.2,<0.3
# Archive extraction (FC-2d-iii filesystem-import aid) # Archive extraction (FC-2d-iii filesystem-import aid)
rarfile>=4.2,<5 rarfile>=4.2,<5
@@ -42,4 +42,4 @@ py7zr>=1,<2
# Google Drive fetcher for off-platform file-host links (external downloads, # Google Drive fetcher for off-platform file-host links (external downloads,
# #830). Handles Drive's confirm-token + virus-scan interstitial. mega.nz uses # #830). Handles Drive's confirm-token + virus-scan interstitial. mega.nz uses
# the `megatools` binary instead (Debian apt pkg in the runtime image, not pip). # the `megatools` binary instead (Debian apt pkg in the runtime image, not pip).
gdown>=6,<7 gdown>=5,<6
-62
View File
@@ -184,68 +184,6 @@ async def test_rejects_bad_direction(client):
assert (await resp.get_json())["error"] == "invalid_direction" assert (await resp.get_json())["error"] == "invalid_direction"
@pytest.mark.asyncio
async def test_translation_override_rejects_bad_value(client, seeded_post):
_, _, post = seeded_post
resp = await client.post(
f"/api/posts/{post.id}/translation-override", json={"override": "nope"}
)
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "invalid_override"
@pytest.mark.asyncio
async def test_translation_override_404_for_unknown(client):
resp = await client.post(
"/api/posts/999999/translation-override", json={"override": "original"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_translation_override_original_clears_and_keeps(client, db, seeded_post):
# 'keep original' clears a stored translation immediately (no Interpreter) and
# marks the post handled (== target). Asserts on the response, which reflects
# the endpoint's own committed session.
_, _, post = seeded_post
post.post_title_translated = "STALE"
post.description_translated = "STALE"
post.translated_source_lang = "de"
await db.commit()
resp = await client.post(
f"/api/posts/{post.id}/translation-override", json={"override": "original"}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["translation_override"] == "original"
assert body["applied"] == "cleared"
assert body["post_title_translated"] is None
assert body["translated_source_lang"] == "en"
@pytest.mark.asyncio
async def test_translation_override_force_queues_when_disabled(client, seeded_post):
# Translation disabled (default) → can't translate inline; the override is
# saved and the post is queued (columns NULLed) for the next sweep.
_, _, post = seeded_post
resp = await client.post(
f"/api/posts/{post.id}/translation-override", json={"override": "force"}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["translation_override"] == "force"
assert body["applied"] == "queued"
assert body["translated_source_lang"] is None
@pytest.mark.asyncio
async def test_list_exposes_translation_override(client, seeded_post):
resp = await client.get("/api/posts")
assert resp.status_code == 200
body = await resp.get_json()
assert body["items"][0]["translation_override"] == "auto" # default
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_detail_returns_uncapped_thumbnails(client, db): async def test_detail_returns_uncapped_thumbnails(client, db):
"""Feed query caps thumbnails at 6 for previews; detail endpoint """Feed query caps thumbnails at 6 for previews; detail endpoint
+1 -62
View File
@@ -53,26 +53,6 @@ async def test_translation_settings_defaults_and_patch(client):
"/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400 "/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400
@pytest.mark.asyncio
async def test_translation_min_confidence_default_and_patch(client):
# #155: the latin-script acceptance floor is an operator-tunable setting,
# default 0.9 (stricter than the old hardcoded 0.8).
body = await (await client.get("/api/settings/import")).get_json()
assert body["translation_min_confidence"] == 0.9
ok = await client.patch(
"/api/settings/import", json={"translation_min_confidence": 0.95}
)
assert ok.status_code == 200
assert (await ok.get_json())["translation_min_confidence"] == 0.95
# Out-of-range + wrong-type are rejected.
assert (await client.patch(
"/api/settings/import", json={"translation_min_confidence": 1.5})).status_code == 400
assert (await client.patch(
"/api/settings/import", json={"translation_min_confidence": "high"})).status_code == 400
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_translation_status_defaults(client): async def test_translation_status_defaults(client):
# Off + no URL → no network health call, healthy False (#143). # Off + no URL → no network health call, healthy False (#143).
@@ -133,10 +113,9 @@ async def test_translation_test_connection(client, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_translation_run_enqueues_when_configured(client, monkeypatch): async def test_translation_run_enqueues_when_configured(client, monkeypatch):
sink = {}
monkeypatch.setattr( monkeypatch.setattr(
"backend.app.tasks.translation.translate_posts.delay", "backend.app.tasks.translation.translate_posts.delay",
lambda *a, **k: sink.update(k) or type("R", (), {"id": "t1"})(), lambda *a, **k: type("R", (), {"id": "t1"})(),
) )
await client.patch("/api/settings/import", json={ await client.patch("/api/settings/import", json={
"translation_enabled": True, "interpreter_base_url": "http://i.lan", "translation_enabled": True, "interpreter_base_url": "http://i.lan",
@@ -144,46 +123,6 @@ async def test_translation_run_enqueues_when_configured(client, monkeypatch):
resp = await client.post("/api/settings/translation/run") resp = await client.post("/api/settings/translation/run")
assert resp.status_code == 202 assert resp.status_code == 202
assert (await resp.get_json())["celery_task_id"] == "t1" assert (await resp.get_json())["celery_task_id"] == "t1"
assert sink["drain"] is True # "Translate now" chases the whole backlog
@pytest.mark.asyncio
async def test_translation_probe_requires_text(client):
resp = await client.post(
"/api/settings/translation/probe", json={"text": " "})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_translation_probe_requires_base_url(client):
# Text given but no Interpreter URL saved → nothing to probe against.
resp = await client.post(
"/api/settings/translation/probe", json={"text": "hello"})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_translation_probe_returns_detection(client, monkeypatch):
# The diagnostic surfaces detected language + confidence + result, unsaved.
monkeypatch.setattr(
"backend.app.api.settings.ic.translate",
lambda texts, **k: {
"translations": ["Work in Progress"],
"detected_lang": "de", "detected_confidence": 71.5,
"engine": "llm", "engine_version": "v1",
},
)
await client.patch("/api/settings/import", json={
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
})
resp = await client.post(
"/api/settings/translation/probe", json={"text": "WIP"})
assert resp.status_code == 200
body = await resp.get_json()
assert body["detected_lang"] == "de"
assert body["detected_confidence"] == 71.5
assert body["translated"] == "Work in Progress"
assert body["target"] == "en"
def _fake_retranslate(monkeypatch, sink): def _fake_retranslate(monkeypatch, sink):
+9 -1
View File
@@ -52,7 +52,6 @@ async def test_get_suggestions(client, db):
general = body["by_category"].get("general", []) general = body["by_category"].get("general", [])
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id) s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
assert s2["source"] == "head" assert s2["source"] == "head"
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -115,3 +114,12 @@ async def test_undismiss_reverses_rejection(client, db):
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id} f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
) )
assert resp2.status_code == 204 assert resp2.status_code == 204
@pytest.mark.asyncio
async def test_alias_requires_fields(client, db):
img = await _img(db)
resp = await client.post(
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
)
assert resp.status_code == 400
-65
View File
@@ -1,65 +0,0 @@
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
parser that gates the browser extension's "Add as source" button and pulls
the creator slug on probe/add.
Regression cover for #1485: Patreon serves the same creator under three URL
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
because the pattern only matched the bare root and excluded `c/`.
_derive is pure URL parsing (no DB / no async), so a session-less instance is
fine to exercise directly.
"""
import pytest
from backend.app.services.extension_service import (
ExtensionService,
InvalidUrlError,
UnknownPlatformError,
)
_svc = ExtensionService(None)
@pytest.mark.parametrize(
"url, slug",
[
# All three Patreon creator prefixes resolve to the same vanity slug.
("https://www.patreon.com/Atole", "Atole"),
("https://www.patreon.com/c/Atole", "Atole"),
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
# A creator's inner page still derives the slug (trailing sub-path).
("https://www.patreon.com/cw/Atole/posts", "Atole"),
("https://www.patreon.com/Atole/membership", "Atole"),
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
],
)
def test_derive_patreon_creator_urls(url, slug):
platform, got = _svc._derive(url)
assert platform == "patreon"
assert got == slug
@pytest.mark.parametrize(
"url",
[
# Patreon's own nav pages must never read as a creator slug.
"https://www.patreon.com/home",
"https://www.patreon.com/settings",
"https://www.patreon.com/search",
"https://www.patreon.com/messages",
"https://www.patreon.com/library",
"https://www.patreon.com/notifications",
"https://www.patreon.com/posts/12345", # post permalink
"https://www.patreon.com/settings/billing", # nav sub-page
],
)
def test_derive_patreon_nav_pages_rejected(url):
with pytest.raises(UnknownPlatformError):
_svc._derive(url)
def test_derive_rejects_missing_scheme():
with pytest.raises(InvalidUrlError):
_svc._derive("patreon.com/Atole")
+5 -9
View File
@@ -82,28 +82,24 @@ async def _system_tag(db, name):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scroll_hides_presentation_chrome_by_default(db): async def test_scroll_hides_presentation_chrome_by_default(db):
# banner (chrome) is hidden from the default gallery; wip AND editor screenshot # banner / editor screenshot (presentation system tags) are hidden from the
# (the PROCESS system tags) are NOT — they're real art / process shots that stay # default gallery; wip (also a system tag) is NOT — it's real, in-progress
# visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE. # art (milestone 141). Seeded system tags survive the harness TRUNCATE.
imgs = await _seed_images(db, 4, sha_prefix="p") imgs = await _seed_images(db, 3, sha_prefix="p")
banner = await _system_tag(db, "banner") banner = await _system_tag(db, "banner")
wip = await _system_tag(db, "wip") wip = await _system_tag(db, "wip")
editor = await _system_tag(db, "editor screenshot")
await db.execute(image_tag.insert().values( await db.execute(image_tag.insert().values(
image_record_id=imgs[0].id, tag_id=banner.id, source="manual")) image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
await db.execute(image_tag.insert().values( await db.execute(image_tag.insert().values(
image_record_id=imgs[1].id, tag_id=wip.id, source="manual")) image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=imgs[3].id, tag_id=editor.id, source="manual"))
await db.flush() await db.flush()
svc = GalleryService(db) svc = GalleryService(db)
# Default: only the banner image is hidden; wip + editor + plain all stay. # Default: the banner image is hidden; the wip image + the plain image stay.
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images} default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
assert imgs[0].id not in default_ids # banner hidden assert imgs[0].id not in default_ids # banner hidden
assert imgs[1].id in default_ids # wip visible assert imgs[1].id in default_ids # wip visible
assert imgs[2].id in default_ids # plain visible assert imgs[2].id in default_ids # plain visible
assert imgs[3].id in default_ids # editor screenshot visible (#1464)
# include_hidden surfaces the banner image (the Hidden view). # include_hidden surfaces the banner image (the Hidden view).
shown = {i.id for i in ( shown = {i.id for i in (
-17
View File
@@ -170,23 +170,6 @@ async def test_similar_respects_limit(db):
assert len(res) == 2 assert len(res) == 2
@pytest.mark.asyncio
async def test_similar_exclude_ids_drops_walked(db):
"""Explore passes its breadcrumb as exclude_ids so already-walked images
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
→ the sampler passes through)."""
src = await _img(db, 1, _vec(1, 0))
walked = await _img(db, 2, _vec(1, 0.05))
fresh = await _img(db, 3, _vec(1, 0.3))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
ids = {i.id for i in res}
assert walked.id not in ids
assert fresh.id in ids
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
assert fresh.id in {i.id for i in res_reach}
# --- API --- # --- API ---
@pytest.mark.asyncio @pytest.mark.asyncio
-4
View File
@@ -34,13 +34,9 @@ def test_translate_maps_batch_in_order(monkeypatch):
out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan") out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan")
assert out["translations"] == ["The cat is cute", "Blonde gal!"] assert out["translations"] == ["The cat is cute", "Blonde gal!"]
assert out["detected_lang"] == "ja" assert out["detected_lang"] == "ja"
assert out["detected_confidence"] == 0.98 # surfaced for the detection guard
assert out["engine_version"] == "ollama:x:12b" assert out["engine_version"] == "ollama:x:12b"
assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"] assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"]
assert captured["json"]["target"] == "en" assert captured["json"]["target"] == "en"
# Source MUST stay "auto": an explicit source makes Interpreter skip detection
# and report confidence 1.0, which would defeat the acceptance gate.
assert captured["json"]["source"] == "auto"
def test_translate_passthrough_unchanged(monkeypatch): def test_translate_passthrough_unchanged(monkeypatch):

Some files were not shown because too many files have changed in this diff Show More