Compare commits
246 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfba8045e4 | |||
| f08a64f7ae | |||
| dc7fa6eae2 | |||
| e039689eff | |||
| 5b34c9221c | |||
| 19b962f1a7 | |||
| 7c19ad91ed | |||
| a708357436 | |||
| eaea4308fc | |||
| a7abcc41ca | |||
| 1f27189b8f | |||
| 95d2ae1d58 | |||
| 31c416bc7b | |||
| 09e2772628 | |||
| 3d6201734c | |||
| 1b1d3732dc | |||
| d9354ac1e1 | |||
| 686808d3f3 | |||
| 3a683d7feb | |||
| 1d48770793 | |||
| 0a618db10c | |||
| 489e6aaaee | |||
| 713a11e394 | |||
| ed20df905b | |||
| 6282e753a9 | |||
| 91ea06be79 | |||
| 98b2ac90dd | |||
| 5ba9871ef0 | |||
| 8a0237eeea | |||
| 2a820d0848 | |||
| aa0605585b | |||
| 2f9aa3d86c | |||
| 0fe1674753 | |||
| 560a5000a2 | |||
| c22f37d64d | |||
| 7ddad231f8 | |||
| ccbb5cbc9e | |||
| a78f7eaace | |||
| ef3318aac1 | |||
| 71337b0ba4 | |||
| 7cdce0c474 | |||
| 3996205f3b | |||
| eaae896858 | |||
| 9f9db01456 | |||
| afef95a87d | |||
| 216b7fc743 | |||
| 83f1070a11 | |||
| fbb76e6f36 | |||
| c587ac667c | |||
| 7e74fa767c | |||
| 5ef1478ade | |||
| 9f1148b110 | |||
| 88ff4147e1 | |||
| f01b59f390 | |||
| 1ea02ad44c | |||
| 79269da802 | |||
| 937cfb65b4 | |||
| e6a7fe7d03 | |||
| baac851220 | |||
| 181f1c6a27 | |||
| a28c33281a | |||
| f0f031782d | |||
| 40be0a9323 | |||
| 82e1a4e127 | |||
| 2c6bf26bfc | |||
| c2e9157822 | |||
| 3c1e76bd44 | |||
| 3b34230fbd | |||
| 831c5b1c10 | |||
| c259d03618 | |||
| 76b6af4903 | |||
| 2713c3f773 | |||
| 9eaefac385 | |||
| 92494ec4ed | |||
| c1b099e5a3 | |||
| bdfc17477c | |||
| 6d7b17b0b5 | |||
| 6cd3153bf4 | |||
| 359bc5a283 | |||
| 5f2853168a | |||
| 80f8eb4756 | |||
| 9a979ee808 | |||
| ce5db5caaf | |||
| d5f29f7056 | |||
| 3d7f60a6e3 | |||
| 9a3cda007a | |||
| bc6d43d3f2 | |||
| 3d97667f5b | |||
| 485387ff0b | |||
| 3d77a38a25 | |||
| 4daa3f2790 | |||
| 0f472b2f9e | |||
| 3138f912fd | |||
| 715e276c03 | |||
| 9df874e396 | |||
| 6915a7590a | |||
| 5e8c28236a | |||
| 7e3c0f0b74 | |||
| 5d0c7ba706 | |||
| 18300e1f8a | |||
| d52ac0a0e2 | |||
| 401fe8213e | |||
| e8774d7953 | |||
| bc0f00c51b | |||
| 1bef68aa29 | |||
| 1a4bc2f981 | |||
| 862ace69d6 | |||
| abf88b1a15 | |||
| c05dcafbea | |||
| 55e8632dab | |||
| 825e6b90bf | |||
| fb012c557c | |||
| 66593ab895 | |||
| b266a54ad3 | |||
| ad803b646f | |||
| 1f5da3d283 | |||
| 93034f580d | |||
| 9b9b12f410 | |||
| 376d310693 | |||
| bc69495a16 | |||
| 478f898e72 | |||
| 38a5e7f332 | |||
| 57fe15c267 | |||
| eb3231ef10 | |||
| e9af459c0d | |||
| 6f02806aec | |||
| a1d19bd96a | |||
| 26827ff38f | |||
| 26dcfaf6c2 | |||
| 9b1b0369cc | |||
| 18123fb9cb | |||
| 2e806f202f | |||
| 18d5c05639 | |||
| 11ddfc3876 | |||
| 2b8ce86622 | |||
| 49bee77cdc | |||
| c209e3b37e | |||
| cffdd93418 | |||
| fd84be40dd | |||
| 79f510d7f8 | |||
| 59181069da | |||
| 428ecd8642 | |||
| ed1e04b831 | |||
| f5156bd847 | |||
| dfc3922d24 | |||
| 3eb08e926b | |||
| 9e81ced359 | |||
| 11e9f5af60 | |||
| 909fa37b15 | |||
| dfab8f65ff | |||
| 618f7cdc36 | |||
| 028ea33a7c | |||
| 444c1fb075 | |||
| 26c68b0a75 | |||
| e75427b19a | |||
| 5447fab987 | |||
| bad37e07b2 | |||
| 2bfc9936a1 | |||
| 4c6406ee18 | |||
| bb47e80b3e | |||
| dc1083b5e0 | |||
| e46893fefd | |||
| 0666e15211 | |||
| 747390631d | |||
| e0d2a20588 | |||
| 1d84f67418 | |||
| 91265df3d6 | |||
| 11acdb0322 | |||
| 2eb9fd5dd0 | |||
| 01e5ce1410 | |||
| 3bb94674cf | |||
| a75c602175 | |||
| ef8f4f7193 | |||
| ec3d27b219 | |||
| 03bd3b2eda | |||
| 7395e77d75 | |||
| 575d817919 | |||
| 2a8f7cd8b6 | |||
| 83f8af8090 | |||
| 9a2617c1a2 | |||
| 81688815a0 | |||
| 773128c3bf | |||
| ce7b154ae9 | |||
| 9430a9d9c3 | |||
| 23aee56ce3 | |||
| 711abea567 | |||
| 844bb86802 | |||
| a8f6a464aa | |||
| ab9922ad2e | |||
| 0533807669 | |||
| 279dff3fb6 | |||
| 37e66cddc4 | |||
| 9cf6b2d363 | |||
| 6ef0fed41f | |||
| 89b48f8f35 | |||
| d60e0b9494 | |||
| 9c27a2d3c7 | |||
| 93e37681b7 | |||
| 64ca858574 | |||
| 9d0c0b7da8 | |||
| 8e4d252ae4 | |||
| fdd3e01f56 | |||
| c82fb308b6 | |||
| 8cf8d2ca4d | |||
| b1d58bc3b8 | |||
| 65386f02a0 | |||
| 667b05f14e | |||
| 856e9104b4 | |||
| 0397642b21 | |||
| 237575447d | |||
| ed358757dc | |||
| d181f4afb8 | |||
| 2886fa4997 | |||
| f256f587ee | |||
| 384d8d5e50 | |||
| 319e8c1d18 | |||
| 9075d8eadd | |||
| 88e53e5b86 | |||
| 37e8b796a1 | |||
| 4e82208926 | |||
| 52fff00353 | |||
| c14338cbce | |||
| 8c36dd28b0 | |||
| 88cfb3dd02 | |||
| 5d4f223b71 | |||
| 05090c6e85 | |||
| 3a577d5ade | |||
| f4fe02e346 | |||
| e766197d99 | |||
| 3872e1dda9 | |||
| 9814f3dbaf | |||
| b214460fdb | |||
| ac55d0e8d8 | |||
| 89a89e0ded | |||
| 4e9aac2c05 | |||
| 2879ac6f2b | |||
| b8dce6c483 | |||
| d1c0b82a22 | |||
| 5526b8dc78 | |||
| 16eb7075c4 | |||
| 885dcf64f3 | |||
| f2f6b6d25e | |||
| 0822240fde | |||
| 27f7f3fd01 | |||
| c5bf564f53 | |||
| 602c7d275d |
@@ -27,7 +27,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ruff lint
|
||||
run: ruff check backend/ tests/ alembic/
|
||||
# agent/ included so the GPU-agent is linted before its image is built
|
||||
# (build.yml only `docker build`s it — this is where it gets checked).
|
||||
run: ruff check backend/ tests/ alembic/ agent/
|
||||
- name: Agent syntax check
|
||||
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
||||
# CI image, so we can't import it — but compileall parses every module,
|
||||
# catching syntax errors before the image build.
|
||||
run: python -m compileall -q agent/fc_agent
|
||||
|
||||
backend-lint-and-test:
|
||||
runs-on: python-ci
|
||||
|
||||
+14
-8
@@ -1,18 +1,24 @@
|
||||
# FabledCurator GPU agent — runs on the desktop with the GPU.
|
||||
# CUDA + cuDNN runtime so onnxruntime-gpu can use the card (it needs cuDNN 9 —
|
||||
# the plain -runtime image lacks it: "libcudnn.so.9: cannot open shared object
|
||||
# file"); ffmpeg for video frames.
|
||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
||||
# CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
|
||||
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
|
||||
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
|
||||
# Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
|
||||
# built against (CUDA 13 has only nascent ONNX Runtime support).
|
||||
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
|
||||
# managed (PEP 668), so a global `pip install` errors without this. It's a
|
||||
# single-purpose container — we own the whole environment, so installing into
|
||||
# the system site-packages is fine (and simplest — no venv on PATH to manage).
|
||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
# torch from the CUDA-12.4 wheel index (matches the base image); its wheels
|
||||
# bundle their own CUDA + cuDNN and coexist with onnxruntime-gpu. Installed
|
||||
# first + separately so the GPU build of torch is deterministic and layer-cached.
|
||||
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
|
||||
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
|
||||
# + separately so the GPU build of torch is deterministic and layer-cached.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -34,9 +34,29 @@ services:
|
||||
# Resume the worker automatically on container start (survive a reboot /
|
||||
# crash-restart while you're away). Set to 0 to require a manual Start.
|
||||
AUTO_START: ${AUTO_START:-1}
|
||||
# Autoscale the worker count (throughput hill-climb that finds the sweet
|
||||
# spot + backs off under VRAM pressure). On by default; toggle live in the
|
||||
# control UI. Set to 0 to start in manual mode.
|
||||
AUTO_SCALE: ${AUTO_SCALE:-1}
|
||||
# Aggregate download cap in MB/s (stills + video streams combined), so the
|
||||
# agent can't saturate the desktop's network and wreck browsing — WiFi
|
||||
# especially. 0 = unlimited; tunable live in the control UI.
|
||||
BANDWIDTH_LIMIT_MB_S: ${BANDWIDTH_LIMIT_MB_S:-8}
|
||||
# Crop embedder (SigLIP concept bag): float16 keeps VRAM low on a shared
|
||||
# desktop GPU; the model itself is announced by the server.
|
||||
SIGLIP_DTYPE: ${SIGLIP_DTYPE:-float16}
|
||||
# Crop PROPOSERS (extra YOLO detectors → more/better concept crops). Each
|
||||
# downloads its weights once (cached on the models volume) and self-disables
|
||||
# if the download/load fails. Blank any one to turn it off.
|
||||
# PERSON_WEIGHTS: general COCO person detector (Western/realistic figures),
|
||||
# merged with the anime detector. yolo11n.pt (~6 MB, auto-downloaded).
|
||||
# ANATOMY_WEIGHTS: booru_yolo anime/furry/NSFW components (~40 MB). NB the
|
||||
# repo states no license — fine for private use. yolov8n_as01.pt is the
|
||||
# 6 MB nano if you want lighter than yolov11m_aa22.pt.
|
||||
# PANEL_WEIGHTS: mosesb comic-panel detector (Apache-2.0), "hf_repo::file".
|
||||
PERSON_WEIGHTS: ${PERSON_WEIGHTS:-yolo11n.pt}
|
||||
ANATOMY_WEIGHTS: ${ANATOMY_WEIGHTS:-https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt}
|
||||
PANEL_WEIGHTS: ${PANEL_WEIGHTS:-mosesb/best-comic-panel-detection::best.pt}
|
||||
volumes:
|
||||
# Persist the downloaded ONNX models so restarts are fast.
|
||||
- fc-agent-models:/models
|
||||
|
||||
+331
-62
@@ -1,21 +1,45 @@
|
||||
"""FastAPI control surface for the agent (served on localhost).
|
||||
|
||||
Start / stop the worker pool, tune the worker count live (trades desktop
|
||||
responsiveness for throughput), and watch GPU load + progress + the server-side
|
||||
queue. Config is env-seeded; the worker count is adjustable here on the fly.
|
||||
Start / stop the download→GPU pipeline, tune the downloader count live (the
|
||||
workload is download-bound, so downloaders are the dial that trades desktop
|
||||
bandwidth for throughput), and watch GPU load + buffer occupancy + progress +
|
||||
the server-side queue. Config is env-seeded; the downloader count is adjustable
|
||||
here on the fly (GPU consumers autoscale between 1 and 2 on their own).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from . import logbuf
|
||||
from .config import Config
|
||||
from .gpu import read_gpu
|
||||
from .worker import Worker
|
||||
|
||||
log = logging.getLogger("fc_agent.app")
|
||||
|
||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
||||
# warns to reload when they differ — so a stale browser-cached page can't be
|
||||
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
|
||||
VERSION = "2026-07-02.5 · cap-aware autoscaler: downloaders stop growing (and shed) when the bandwidth cap — not concurrency — is the bottleneck"
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
worker = Worker(cfg)
|
||||
app = FastAPI(title="FabledCurator GPU agent")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_store(request, call_next):
|
||||
# The control page is a static string and the status/gpu/logs polls are
|
||||
# live data — never let the browser cache either, or a freshly-pulled agent
|
||||
# image still shows the OLD UI until a hard refresh (operator-flagged
|
||||
# 2026-06-30).
|
||||
resp = await call_next(request)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _maybe_autostart() -> None:
|
||||
# With AUTO_START set, a container restart (host reboot, or `restart:
|
||||
@@ -28,17 +52,19 @@ def _maybe_autostart() -> None:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return _PAGE
|
||||
return _PAGE.replace("__BUILD__", VERSION)
|
||||
|
||||
|
||||
@app.post("/start")
|
||||
def start():
|
||||
log.info("UI: Start button pressed") # the press; worker logs the transition
|
||||
worker.start()
|
||||
return JSONResponse(worker.status())
|
||||
|
||||
|
||||
@app.post("/stop")
|
||||
def stop():
|
||||
log.info("UI: Stop button pressed")
|
||||
worker.stop()
|
||||
return JSONResponse(worker.status())
|
||||
|
||||
@@ -50,85 +76,328 @@ async def concurrency(request: Request):
|
||||
return JSONResponse(worker.status())
|
||||
|
||||
|
||||
@app.post("/auto")
|
||||
async def auto(request: Request):
|
||||
body = await request.json()
|
||||
worker.set_auto(bool(body.get("value", True)))
|
||||
return JSONResponse(worker.status())
|
||||
|
||||
|
||||
@app.post("/bandwidth")
|
||||
async def bandwidth(request: Request):
|
||||
body = await request.json()
|
||||
worker.set_bandwidth(float(body.get("value", 0)))
|
||||
return JSONResponse(worker.status())
|
||||
|
||||
|
||||
@app.get("/gpu")
|
||||
def gpu():
|
||||
# GPU meters poll this on their own fast cadence. It only reads local
|
||||
# nvidia-smi — no curator round-trip — so the util/VRAM bars stay live even
|
||||
# when /status is slow waiting on the (sometimes busy) curator queue call.
|
||||
g = read_gpu() or {}
|
||||
us = worker.util_smooth()
|
||||
if us is not None:
|
||||
g["util_smooth"] = round(us, 1) # autoscaler's EWMA — the UI bar tracks this
|
||||
return JSONResponse(g)
|
||||
|
||||
|
||||
@app.get("/logs")
|
||||
def logs():
|
||||
return JSONResponse({"lines": list(logbuf.LINES)})
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
def status():
|
||||
# Pure in-memory read: worker.status() is lock-free and the queue snapshot is
|
||||
# kept fresh by a background poller — NO inline curator call, so this can't
|
||||
# stall the status view when curator is buried under a big backlog.
|
||||
worker.note_ui() # a browser is watching → keep the queue snapshot warm
|
||||
s = worker.status()
|
||||
s["fc_url"] = cfg.fc_url
|
||||
s["configured"] = bool(cfg.token)
|
||||
s["gpu"] = read_gpu()
|
||||
try:
|
||||
s["queue"] = worker.client.queue_status()
|
||||
except Exception:
|
||||
s["queue"] = None
|
||||
s["queue"] = worker.latest_queue()
|
||||
s["build"] = VERSION
|
||||
return JSONResponse(s)
|
||||
|
||||
|
||||
_PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<title>FabledCurator GPU agent</title>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<title>FabledCurator · GPU agent</title>
|
||||
<style>
|
||||
body{font:14px system-ui;margin:2rem;max-width:680px;background:#14171a;color:#e8e8e8}
|
||||
h1{font-size:18px} button{font:14px system-ui;padding:.5rem 1rem;border:0;border-radius:6px;
|
||||
margin-right:.5rem;cursor:pointer;color:#fff} .start{background:#2e7d32}.stop{background:#b3261e}
|
||||
.step{background:#33373b;padding:.4rem .7rem;font-weight:700}
|
||||
.stat{display:inline-block;margin-right:1.5rem;vertical-align:top}
|
||||
.n{font-size:22px;font-weight:700} code{background:#222;padding:2px 6px;border-radius:4px}
|
||||
.q,.gpu{margin-top:1rem;color:#9aa} .bar{height:8px;border-radius:4px;background:#222;overflow:hidden;
|
||||
max-width:320px;margin-top:4px} .bar>i{display:block;height:100%;background:#3f7d3f}
|
||||
.row{margin:.8rem 0}
|
||||
:root{--bg:#0f1216;--panel:#181c22;--panel2:#1e232b;--bd:#2a313b;--fg:#e9edf2;
|
||||
--mut:#8b97a6;--acc:#e8923a;--grn:#46c46a;--red:#e8584d;--amb:#e8b23a}
|
||||
*{box-sizing:border-box}
|
||||
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;
|
||||
background:radial-gradient(1200px 600px at 50% -10%,#1a2029,#0f1216);color:var(--fg)}
|
||||
.wrap{max-width:820px;margin:0 auto;padding:28px 20px 28px;height:100vh;
|
||||
box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column}
|
||||
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-size:19px;font-weight:700;letter-spacing:.2px}
|
||||
.logo{color:var(--acc);font-size:20px}
|
||||
.brand .sub{color:var(--mut);font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:.12em}
|
||||
.conn{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:13px;font-weight:600}
|
||||
.dot{width:9px;height:9px;border-radius:50%;background:var(--mut);box-shadow:0 0 0 0 rgba(0,0,0,0)}
|
||||
.dot.green{background:var(--grn);box-shadow:0 0 10px 1px rgba(70,196,106,.5)}
|
||||
.dot.amber{background:var(--amb)} .dot.red{background:var(--red)}
|
||||
.meta{color:var(--mut);margin:0 0 18px;font-size:13px}
|
||||
code{background:#11151a;border:1px solid var(--bd);padding:2px 7px;border-radius:6px;
|
||||
font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#cdd6e0}
|
||||
.card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--bd);
|
||||
border-radius:14px;padding:16px 18px;margin-bottom:14px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset}
|
||||
.card-h{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;
|
||||
color:var(--mut);margin-bottom:14px}
|
||||
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.spacer{flex:1}
|
||||
.btn{font:600 14px system-ui;padding:.5rem 1rem;border:1px solid transparent;border-radius:9px;
|
||||
cursor:pointer;color:#fff;transition:.12s}
|
||||
.btn:hover{transform:translateY(-1px)}
|
||||
.btn[disabled]{opacity:.45;pointer-events:none;transform:none}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.tile .n.busy{color:var(--acc);animation:pulse 1s ease-in-out infinite}
|
||||
.btn.start{background:linear-gradient(180deg,#2f9c4c,#247a3c)}
|
||||
.btn.stop{background:linear-gradient(180deg,#3a3f48,#2a2f37);color:#e9edf2;border-color:var(--bd)}
|
||||
.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-weight:600;user-select:none}
|
||||
.switch input{display:none}
|
||||
.switch .track{width:38px;height:22px;border-radius:11px;background:#2a313b;position:relative;transition:.15s}
|
||||
.switch .track:after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;
|
||||
background:#cdd6e0;transition:.15s}
|
||||
.switch input:checked+.track{background:var(--acc)}
|
||||
.switch input:checked+.track:after{transform:translateX(16px);background:#fff}
|
||||
.stepper{display:inline-flex;align-items:center;gap:6px}
|
||||
.step{background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:8px;
|
||||
width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
|
||||
.step:hover{border-color:var(--acc)}
|
||||
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a;
|
||||
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
|
||||
.unit{color:var(--mut);font-size:12px;font-weight:600}
|
||||
.hint{color:var(--mut);font-size:12px;margin-top:12px}
|
||||
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
||||
.tile{background:#13171d;border:1px solid var(--bd);border-radius:10px;padding:12px 8px;text-align:center}
|
||||
.tile .n{font:800 22px ui-monospace,monospace;line-height:1.1}
|
||||
.tile .n.warn{color:var(--red)} .tile .n.ok{color:var(--grn)}
|
||||
.tile .l{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-top:4px}
|
||||
.meters{display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
|
||||
.meter-h{display:flex;justify-content:space-between;font-size:12px;color:var(--mut);margin-bottom:4px}
|
||||
.meter-h b{color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.bar{height:9px;border-radius:5px;background:#11151a;border:1px solid var(--bd);overflow:hidden}
|
||||
.bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#3a7d57,var(--grn));transition:width .4s}
|
||||
#utilbar{background:linear-gradient(90deg,#9a5a1f,var(--acc))}
|
||||
#bufbar{background:linear-gradient(90deg,#2f5a9a,#4a86d8)}
|
||||
.queue{font:13px ui-monospace,monospace;color:var(--mut)}
|
||||
.banner{margin:0 0 14px;padding:.7rem .9rem;border-radius:10px;background:#3a2f12;
|
||||
border:1px solid #5a4a17;color:#ffd98a;font-size:13px}
|
||||
.logs-h{display:flex;align-items:center;justify-content:space-between}
|
||||
.grow{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.grow .logs{flex:1;min-height:0}
|
||||
.copybtn{font:600 11px system-ui;letter-spacing:.04em;text-transform:uppercase;
|
||||
background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:7px;
|
||||
padding:5px 11px;cursor:pointer}
|
||||
.copybtn:hover{border-color:var(--acc)}
|
||||
.logs{margin:0;background:#0b0e12;border:1px solid var(--bd);border-radius:10px;padding:12px;
|
||||
overflow:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
color:#b9c4d0;white-space:pre-wrap;word-break:break-word}
|
||||
</style></head><body>
|
||||
<h1>FabledCurator GPU agent</h1>
|
||||
<p>FC: <code id=fc>—</code> · token <code id=cfg>—</code></p>
|
||||
<div class=row>
|
||||
<button class=start onclick=act('start')>Start</button>
|
||||
<button class=stop onclick=act('stop')>Stop</button>
|
||||
<div class=wrap>
|
||||
<header>
|
||||
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
|
||||
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
|
||||
</header>
|
||||
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
|
||||
|
||||
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
|
||||
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
|
||||
</div>
|
||||
<div id=banner class=banner style=display:none>
|
||||
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
|
||||
</div>
|
||||
|
||||
<section class=card>
|
||||
<div class=card-h>Control</div>
|
||||
<div class=controls>
|
||||
<button class="btn start" id=startbtn onclick=act('start')>▶ Start</button>
|
||||
<button class="btn stop" id=stopbtn onclick=act('stop')>■ Stop</button>
|
||||
<div class=spacer></div>
|
||||
<label class=switch><input type=checkbox id=autochk onchange="setauto(this.checked)"><span class=track></span>Auto</label>
|
||||
<div class=stepper>
|
||||
<button class=step onclick=setc(-1)>−</button>
|
||||
<input id=conc type=number min=1 value=1 onchange="setv(this.value)">
|
||||
<button class=step onclick=setc(1)>+</button>
|
||||
</div>
|
||||
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
|
||||
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
|
||||
<span class=unit>MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=hint id=conchint>auto-tuning downloaders to keep the GPU fed · max 8</div>
|
||||
</section>
|
||||
|
||||
<section class=card>
|
||||
<div class=card-h>Status</div>
|
||||
<div class=tiles>
|
||||
<div class=tile><div class=n id=state>—</div><div class=l>state</div></div>
|
||||
<div class=tile><div class=n id=jpm>—</div><div class=l>jobs / min</div></div>
|
||||
<div class=tile><div class=n id=dpm>—</div><div class=l>downloads / min</div></div>
|
||||
<div class=tile><div class="n ok" id=done>0</div><div class=l>processed</div></div>
|
||||
<div class=tile><div class=n id=err>0</div><div class=l>errors</div></div>
|
||||
<div class=tile><div class=n id=waited>0</div><div class=l>waited out</div></div>
|
||||
</div>
|
||||
<div class=meters>
|
||||
<div class=meter><div class=meter-h><span>GPU util</span><b id=utillbl>—</b></div>
|
||||
<div class=bar><i id=utilbar></i></div></div>
|
||||
<div class=meter><div class=meter-h><span>VRAM</span><b id=vramlbl>—</b></div>
|
||||
<div class=bar><i id=gpubar></i></div></div>
|
||||
<div class=meter><div class=meter-h><span>buffer occupancy</span><b id=buflbl>—</b></div>
|
||||
<div class=bar><i id=bufbar></i></div></div>
|
||||
</div>
|
||||
<div class=queue id=pipe>downloaders — · consumers — · on GPU 0</div>
|
||||
<div class=queue id=queue>queue —</div>
|
||||
</section>
|
||||
|
||||
<section class="card grow">
|
||||
<div class="card-h logs-h">Logs
|
||||
<button class=copybtn id=copybtn onclick=copyLogs()>Copy</button>
|
||||
</div>
|
||||
<pre class=logs id=logs>waiting for activity…</pre>
|
||||
</section>
|
||||
</div>
|
||||
<div class=row>
|
||||
workers
|
||||
<button class=step onclick=setc(-1)>−</button>
|
||||
<input id=conc type=number min=1 value=1
|
||||
style="width:3.5rem;font:700 16px system-ui;text-align:center;background:#222;color:#e8e8e8;border:1px solid #444;border-radius:6px;padding:.3rem"
|
||||
onchange="setv(this.value)">
|
||||
<button class=step onclick=setc(1)>+</button>
|
||||
<span class=cap style=color:#9aa>(more = overlap I/O, fill the GPU) max <b id=capn>8</b></span>
|
||||
</div>
|
||||
<div class=row>
|
||||
<span class=stat><span class=n id=state>stopped</span><br>state</span>
|
||||
<span class=stat><span class=n id=active>0</span><br>active now</span>
|
||||
<span class=stat><span class=n id=done>0</span><br>processed</span>
|
||||
<span class=stat><span class=n id=err>0</span><br>errors</span>
|
||||
<span class=stat><span class=n id=wait>0</span><br>waited out</span>
|
||||
</div>
|
||||
<div id=banner style="display:none;margin:.6rem 0;padding:.5rem .8rem;border-radius:6px;background:#5a4a17;color:#ffe28a">
|
||||
curator unreachable — holding work + retrying, will resume on its own (no restart needed)
|
||||
</div>
|
||||
<div class=gpu id=gpu>GPU — …</div>
|
||||
<div class=bar><i id=gpubar style=width:0%></i></div>
|
||||
<div class=q id=queue></div>
|
||||
<script>
|
||||
const PAGE_BUILD="__BUILD__"
|
||||
let CAP=8
|
||||
async function act(p){await fetch('/'+p,{method:'POST'});refresh()}
|
||||
function setc(d){ setv((parseInt(conc.value||'1'))+d) }
|
||||
// Optimistic transitional state on click, then apply the POST's own status
|
||||
// response (it returns worker.status()) for instant feedback — don't wait on the
|
||||
// separate /status poll, which can lag behind the curator queue call.
|
||||
async function act(p){
|
||||
pending(p==='start'?'starting':'stopping')
|
||||
// Abort a slow POST after 8s so the buttons never stay stuck — the periodic
|
||||
// /status refresh (now always fast) recovers the true state either way.
|
||||
const ac=new AbortController(); const to=setTimeout(()=>ac.abort(),8000)
|
||||
try{ applyStatus(await (await fetch('/'+p,{method:'POST',signal:ac.signal})).json()) }
|
||||
catch{ refresh() /* on abort/error, repaint the real state from /status */ }
|
||||
finally{ clearTimeout(to) }
|
||||
}
|
||||
function pending(label){
|
||||
// Instant optimistic feedback on click; applyStatus (POST response, then the
|
||||
// periodic poll) then owns the real state + which buttons are enabled.
|
||||
state.textContent=label; state.className='n busy'
|
||||
dot.className='dot amber'
|
||||
startbtn.disabled=true; stopbtn.disabled=true
|
||||
}
|
||||
function setc(d){ if(conc.disabled)return; setv((parseInt(conc.value||'1'))+d) }
|
||||
async function setv(v){
|
||||
v=Math.max(1,Math.min(CAP,parseInt(v)||1)); conc.value=v
|
||||
await fetch('/concurrency',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({value:v})});refresh()
|
||||
}
|
||||
async function setauto(on){
|
||||
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({value:on})});refresh()
|
||||
}
|
||||
async function setbw(v){
|
||||
v=Math.max(0,parseFloat(v)||0); bw.value=v
|
||||
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({value:v})});refresh()
|
||||
}
|
||||
async function refresh(){
|
||||
const s=await (await fetch('/status')).json()
|
||||
CAP=s.max_concurrency||8; capn.textContent=CAP
|
||||
state.textContent=s.state; active.textContent=s.active; done.textContent=s.processed
|
||||
err.textContent=s.errors; fc.textContent=s.fc_url; wait.textContent=s.transient||0
|
||||
// Running but the queue read failed → curator is unreachable; show we're
|
||||
// riding it out rather than erroring.
|
||||
banner.style.display=(s.state==='running' && !s.queue)?'block':'none'
|
||||
let s; try{ s=await (await fetch('/status')).json() }catch{ return }
|
||||
applyStatus(s)
|
||||
}
|
||||
function applyStatus(s){
|
||||
// NB: don't write a separate `capn` element here — conchint.textContent below
|
||||
// rewrites the whole hint (incl. the max), and any child element nested in it
|
||||
// would be destroyed by that write, breaking the NEXT applyStatus call.
|
||||
CAP=s.max_concurrency||8
|
||||
// The backend owns the state now (stopped|starting|running|stopping) and drives
|
||||
// every transition, so the pill is always truthful — no client-side guessing
|
||||
// from active>0, which used to wedge on "stopping" forever.
|
||||
const st=s.state||'stopped'
|
||||
const running=st==='running'
|
||||
const busy=(st==='starting'||st==='stopping')
|
||||
// Stale-page guard: if the server is a newer build than this page, the cached
|
||||
// controls may misbehave — tell the operator to reload.
|
||||
if(s.build && s.build!==PAGE_BUILD) verbanner.style.display='block'
|
||||
state.textContent=st
|
||||
state.className='n'+(busy?' busy':'')
|
||||
// Buttons follow the real state so you can't fight a transition: Start only
|
||||
// from stopped; Stop only while up; both disabled through "stopping" until the
|
||||
// backend truthfully lands on "stopped".
|
||||
startbtn.disabled=(st!=='stopped')
|
||||
stopbtn.disabled=!(running||st==='starting')
|
||||
// Throughput rates arrive READY from the backend (jobs/min ≈ GPU throughput,
|
||||
// dl/min ≈ fetch throughput), computed there on a fixed cadence — so they show
|
||||
// a real number no matter how often this tab polls (a backgrounded tab throttles
|
||||
// its timers, which used to leave a client-side delta-rate blank forever).
|
||||
jpm.textContent=(s.jobs_per_min!=null)?Math.round(s.jobs_per_min):'—'
|
||||
dpm.textContent=(s.downloads_per_min!=null)?Math.round(s.downloads_per_min):'—'
|
||||
done.textContent=s.processed
|
||||
err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'')
|
||||
waited.textContent=s.transient||0
|
||||
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
|
||||
// as live churn rather than a "broken" headline metric.
|
||||
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'—')+' · consumers '+(s.consumers!=null?s.consumers:'—')+' · on GPU '+(s.active||0)
|
||||
+' · net '+(s.net_mb_s!=null?s.net_mb_s.toFixed(1):'—')+' MB/s'
|
||||
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
|
||||
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=s.bandwidth_limit_mb_s
|
||||
// Buffer occupancy bar (also driven here so it tracks the /status cadence).
|
||||
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
|
||||
buflbl.textContent=s.buffer+' / '+s.buffer_max; bufbar.style.width=p+'%' }
|
||||
// Auto on → dial reflects the auto-chosen count (read-only); off → manual.
|
||||
if(document.activeElement!==autochk) autochk.checked=!!s.auto
|
||||
conc.disabled=!!s.auto; conc.style.opacity=s.auto?0.55:1
|
||||
conchint.textContent=(s.auto?('auto-tuning downloaders to keep the GPU fed · max '+CAP):('manual downloaders · max '+CAP))
|
||||
+(s.bw_capped?' · holding at the bandwidth cap (more downloaders would not go faster)':'')
|
||||
if(document.activeElement!==conc) conc.value=s.concurrency
|
||||
conc.max=CAP
|
||||
cfg.textContent=s.configured?'set':'MISSING'
|
||||
if(s.gpu){
|
||||
gpu.textContent=`GPU — ${s.gpu.util_pct}% util · VRAM ${s.gpu.mem_used_mb}/${s.gpu.mem_total_mb} MB · ${s.gpu.temp_c}°C`
|
||||
gpubar.style.width=Math.round(100*s.gpu.mem_used_mb/s.gpu.mem_total_mb)+'%'
|
||||
} else { gpu.textContent='GPU — n/a (CPU fallback?)'; gpubar.style.width='0%' }
|
||||
queue.textContent=s.queue?`queue — pending ${s.queue.pending} · in flight ${s.queue.leased} · done ${s.queue.done} · errored ${s.queue.error}`:'queue — unreachable'
|
||||
// Connection pill + queue come only from the /status poll (the Start/Stop POST
|
||||
// responses skip the slow curator call to stay snappy) — guard so an action
|
||||
// response doesn't blank them.
|
||||
if('configured' in s){
|
||||
const ok=s.configured
|
||||
fc.textContent=s.fc_url; cfg.textContent=ok?'set':'MISSING'
|
||||
// Pill colour + label track the real state: green only when running AND
|
||||
// curator is answering; amber for the transient states + a running-but-
|
||||
// unreachable curator; grey when stopped; red with no token.
|
||||
let dc='dot', lbl='stopped'
|
||||
if(!ok){ dc='dot red'; lbl='no token' }
|
||||
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
|
||||
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
|
||||
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
|
||||
dot.className=dc; connlbl.textContent=lbl
|
||||
banner.style.display=(st==='running' && !s.queue)?'block':'none'
|
||||
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
|
||||
}
|
||||
}
|
||||
refresh(); setInterval(refresh,3000)
|
||||
// GPU meters poll their OWN endpoint on a fast cadence — kept off /status so a
|
||||
// slow curator queue call can't freeze the bars (they only stale on refresh).
|
||||
let UAVG=null // smoothed util for the bar (raw util swings 0↔99; show the trend)
|
||||
async function refreshGpu(){
|
||||
let g; try{ g=await (await fetch('/gpu')).json() }catch{ return }
|
||||
if(g && g.util_pct!=null){
|
||||
// Prefer the agent's own EWMA (util_smooth) when running; otherwise smooth
|
||||
// the raw reading here so a stopped agent's bar still glides, not jumps.
|
||||
const raw=g.util_pct
|
||||
UAVG = (g.util_smooth!=null) ? g.util_smooth
|
||||
: (UAVG==null ? raw : 0.25*raw + 0.75*UAVG)
|
||||
const used=g.mem_used_mb, tot=g.mem_total_mb||1
|
||||
utillbl.textContent=Math.round(UAVG)+'% · '+g.temp_c+'°C'; utilbar.style.width=Math.round(UAVG)+'%'
|
||||
vramlbl.textContent=used+' / '+tot+' MB'; gpubar.style.width=Math.round(100*used/tot)+'%'
|
||||
} else { UAVG=null; utillbl.textContent='n/a'; vramlbl.textContent='n/a (CPU?)'; utilbar.style.width='0%'; gpubar.style.width='0%' }
|
||||
}
|
||||
async function refreshLogs(){
|
||||
try{
|
||||
const r=await (await fetch('/logs')).json()
|
||||
const el=logs, atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40
|
||||
el.textContent=(r.lines&&r.lines.length)?r.lines.join('\\n'):'waiting for activity…'
|
||||
if(atBottom) el.scrollTop=el.scrollHeight
|
||||
}catch{}
|
||||
}
|
||||
async function copyLogs(){
|
||||
const txt=logs.textContent||''
|
||||
try{ await navigator.clipboard.writeText(txt) }
|
||||
catch{ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t);
|
||||
t.select(); try{document.execCommand('copy')}catch{}; t.remove() }
|
||||
copybtn.textContent='Copied'; setTimeout(()=>{copybtn.textContent='Copy'},1200)
|
||||
}
|
||||
refresh(); refreshGpu(); refreshLogs()
|
||||
setInterval(refresh,3000); setInterval(refreshGpu,1500); setInterval(refreshLogs,2500)
|
||||
</script></body></html>"""
|
||||
|
||||
+100
-45
@@ -5,19 +5,69 @@ bytes, all over HTTP with the bearer token. No DB/Redis.
|
||||
"""
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
class FcClient:
|
||||
def __init__(self, base_url: str, token: str, agent_id: str):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.agent_id = agent_id
|
||||
self.s = requests.Session()
|
||||
self.s.headers["Authorization"] = f"Bearer {token}"
|
||||
# Many worker threads share this Session; the default pool (10) would
|
||||
# Main session: NO in-request retry — lease/fetch are cheap to redo and
|
||||
# the worker loop already backs off + re-leases on failure. (Auto-retrying
|
||||
# a lease could double-claim a batch if a response is lost.)
|
||||
self.s = self._session(token)
|
||||
# Submit session: retry in-place, because by submit time the GPU work is
|
||||
# already DONE — a momentary blip (dropped connection, gateway 5xx during
|
||||
# a curator redeploy) must not throw that work away and force a full
|
||||
# re-download + recompute on another agent. A duplicate submit after a
|
||||
# lost response is harmless: the job is already closed, so it just returns
|
||||
# 409 lease_invalid (a no-op). Idempotent enough to retry POST safely.
|
||||
retry = Retry(
|
||||
total=3, connect=3, read=3, status=3,
|
||||
backoff_factor=0.5, # ~0.5s, 1s, 2s between tries
|
||||
status_forcelist=(500, 502, 503, 504), # transient server/gateway
|
||||
allowed_methods=frozenset({"POST"}),
|
||||
raise_on_status=False, # let raise_for_status decide
|
||||
)
|
||||
self._submit_s = self._session(token, retry)
|
||||
|
||||
@staticmethod
|
||||
def _session(token: str, retry: Retry | None = None) -> requests.Session:
|
||||
s = requests.Session()
|
||||
s.headers["Authorization"] = f"Bearer {token}"
|
||||
# Many worker threads share a Session; the default pool (10) would
|
||||
# throttle them + spam "connection pool is full". Size it for the cap.
|
||||
adapter = HTTPAdapter(pool_connections=64, pool_maxsize=64)
|
||||
self.s.mount("http://", adapter)
|
||||
self.s.mount("https://", adapter)
|
||||
adapter = HTTPAdapter(
|
||||
pool_connections=64, pool_maxsize=64, max_retries=retry or 0
|
||||
)
|
||||
s.mount("http://", adapter)
|
||||
s.mount("https://", adapter)
|
||||
return s
|
||||
|
||||
def _submit(self, path: str, payload: dict) -> dict:
|
||||
"""POST to a submit endpoint on the RETRYING session (by submit time the
|
||||
GPU work is done — a blip must not throw it away), raise on a hard error,
|
||||
and return the parsed JSON. `agent_id` is added to every body."""
|
||||
r = self._submit_s.post(
|
||||
f"{self.base}{path}",
|
||||
json={"agent_id": self.agent_id, **payload},
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _post_quiet(self, path: str, payload: dict) -> None:
|
||||
"""Fire-and-forget POST on the main session — heartbeat/fail/release are
|
||||
best-effort, so a transport error is swallowed (the worker's own retry and
|
||||
the server's orphan-recovery cover a lost call). `agent_id` is added."""
|
||||
try:
|
||||
self.s.post(
|
||||
f"{self.base}{path}",
|
||||
json={"agent_id": self.agent_id, **payload},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
def lease(self, batch_size: int) -> list[dict]:
|
||||
r = self.s.post(
|
||||
@@ -29,57 +79,62 @@ class FcClient:
|
||||
return r.json().get("jobs", [])
|
||||
|
||||
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
|
||||
r = self.s.post(
|
||||
f"{self.base}/api/gpu/jobs/submit",
|
||||
json={
|
||||
"agent_id": self.agent_id, "job_id": job_id,
|
||||
"regions": regions, "replace_kinds": replace_kinds,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return self._submit("/api/gpu/jobs/submit", {
|
||||
"job_id": job_id, "regions": regions, "replace_kinds": replace_kinds,
|
||||
})
|
||||
|
||||
def submit_embedding(self, job_id: int, embedding: list, version: str) -> dict:
|
||||
"""Post a whole-image SigLIP embedding (the 'embed' task) → image_record."""
|
||||
return self._submit("/api/gpu/jobs/submit_embedding", {
|
||||
"job_id": job_id, "embedding": embedding, "embedding_version": version,
|
||||
})
|
||||
|
||||
def heartbeat(self, job_ids: list[int]) -> None:
|
||||
try:
|
||||
self.s.post(
|
||||
f"{self.base}/api/gpu/jobs/heartbeat",
|
||||
json={"agent_id": self.agent_id, "job_ids": job_ids},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
|
||||
|
||||
def fail(self, job_id: int, error: str) -> None:
|
||||
try:
|
||||
self.s.post(
|
||||
f"{self.base}/api/gpu/jobs/fail",
|
||||
json={"agent_id": self.agent_id, "job_id": job_id, "error": error},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
|
||||
|
||||
def release(self, job_ids: list[int]) -> None:
|
||||
# Graceful hand-back on stop so orphaned work is re-leased at once.
|
||||
if not job_ids:
|
||||
return
|
||||
try:
|
||||
self.s.post(
|
||||
f"{self.base}/api/gpu/jobs/release",
|
||||
json={"agent_id": self.agent_id, "job_ids": job_ids},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
self._post_quiet("/api/gpu/jobs/release", {"job_ids": job_ids})
|
||||
|
||||
def fetch_image(self, image_url: str) -> bytes:
|
||||
def fetch_image(self, image_url: str, throttle=None) -> bytes:
|
||||
# image_url is a server-relative path ("/images/...").
|
||||
r = self.s.get(f"{self.base}{image_url}", timeout=180)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
# timeout=(connect, read): the read timeout is BETWEEN-BYTES, not total,
|
||||
# so a large-but-flowing download still completes — but a stuck/dead
|
||||
# connection (curator overloaded) fails in 60s instead of hanging a
|
||||
# downloader for 180s and piling up concurrent stuck requests on curator.
|
||||
# With a throttle (the worker's shared TokenBucket), the body is streamed
|
||||
# in chunks and each chunk is charged to the global bandwidth budget —
|
||||
# pausing between reads lets TCP flow control pace curator's send side.
|
||||
with self.s.get(
|
||||
f"{self.base}{image_url}", timeout=(10, 60), stream=throttle is not None
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
if throttle is None:
|
||||
return r.content
|
||||
buf = bytearray()
|
||||
for chunk in r.iter_content(chunk_size=262_144):
|
||||
throttle.take(len(chunk))
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
def is_reachable(self) -> bool:
|
||||
"""Cheap 'is curator responding at all right now?' check. Used to decide,
|
||||
when a video can't be sampled, between a transient outage (keep retrying —
|
||||
survives a redeploy) and an unprocessable file (fail it, don't loop)."""
|
||||
try:
|
||||
r = self.s.get(f"{self.base}/api/gpu/status", timeout=5)
|
||||
return r.status_code < 500
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
def queue_status(self) -> dict:
|
||||
r = self.s.get(f"{self.base}/api/gpu/status", timeout=15)
|
||||
# Short timeout: this backs the UI /status poll, so a busy curator must
|
||||
# not hang the page for long (the GPU meters poll /gpu separately).
|
||||
r = self.s.get(f"{self.base}/api/gpu/status", timeout=5)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"""Agent config, all from env (the control container is configured at run)."""
|
||||
# Lazy annotations so the `from_env(cls) -> Config` self-reference is a string,
|
||||
# not evaluated at class-definition time — otherwise it NameErrors on the agent's
|
||||
# Python 3.10 (CI lints on 3.14, where PEP 649 hides this).
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _bool_env(name: str, default: str = "") -> bool:
|
||||
"""A boolean env var — present + truthy ('1'/'true'/'yes') → True."""
|
||||
return os.environ.get(name, default).lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
fc_url: str # base URL of the FabledCurator web service
|
||||
@@ -18,9 +28,32 @@ class Config:
|
||||
# the server announces in the lease)
|
||||
auto_start: bool # start the worker pool on boot (so a container restart
|
||||
# resumes processing without anyone clicking Start)
|
||||
auto_scale: bool # autoscale the worker count (throughput hill-climb)
|
||||
# Crop PROPOSERS (extra YOLO detectors that say where to crop). Each weight
|
||||
# spec is an ultralytics name | http(s) URL | "hf_repo::file" ("" = off).
|
||||
person_weights: str # general COCO person detector (Western/realistic figs)
|
||||
person_conf: float
|
||||
anatomy_weights: str # booru_yolo anime/furry/NSFW components
|
||||
anatomy_conf: float
|
||||
panel_weights: str # comic-panel detector
|
||||
panel_conf: float
|
||||
max_components: int # cap anatomy component crops per frame
|
||||
max_panels: int # cap panel crops per frame
|
||||
max_figures: int # cap figure boxes per frame (each = a CCIP call + crop)
|
||||
max_regions: int # hard cap on total regions per JOB (submit-size backstop)
|
||||
dedupe_iou: float # crops overlapping >= this (same kind) are near-dupes,
|
||||
# dropped before the embed; >=1.0 disables it
|
||||
frame_dedupe_distance: int # video frames whose dHash differs by < this many
|
||||
# bits are near-duplicates, dropped before detect;
|
||||
# higher keeps more frames, 0 disables
|
||||
ffmpeg_timeout: float # hard ceiling (s) for ffmpeg-from-URL video sampling;
|
||||
# generous so a SLOW media link still completes
|
||||
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
|
||||
# all downloaders + video streams (0 = unlimited);
|
||||
# tunable live from the agent UI
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
def from_env(cls) -> Config:
|
||||
return cls(
|
||||
fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"),
|
||||
token=os.environ.get("FC_TOKEN", ""),
|
||||
@@ -32,5 +65,26 @@ class Config:
|
||||
poll_idle_seconds=float(os.environ.get("POLL_IDLE_SECONDS", "10")),
|
||||
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
|
||||
embed_model_override=os.environ.get("EMBED_MODEL_NAME", ""),
|
||||
auto_start=os.environ.get("AUTO_START", "").lower() in ("1", "true", "yes"),
|
||||
auto_start=_bool_env("AUTO_START"),
|
||||
auto_scale=_bool_env("AUTO_SCALE", "true"),
|
||||
person_weights=os.environ.get("PERSON_WEIGHTS", "yolo11n.pt"),
|
||||
person_conf=float(os.environ.get("PERSON_CONF", "0.35")),
|
||||
anatomy_weights=os.environ.get("ANATOMY_WEIGHTS", ""),
|
||||
anatomy_conf=float(os.environ.get("ANATOMY_CONF", "0.30")),
|
||||
panel_weights=os.environ.get("PANEL_WEIGHTS", ""),
|
||||
panel_conf=float(os.environ.get("PANEL_CONF", "0.30")),
|
||||
max_components=int(os.environ.get("MAX_COMPONENTS", "8")),
|
||||
max_panels=int(os.environ.get("MAX_PANELS", "8")),
|
||||
max_figures=int(os.environ.get("MAX_FIGURES", "8")),
|
||||
max_regions=int(os.environ.get("MAX_REGIONS", "128")),
|
||||
dedupe_iou=float(os.environ.get("DEDUPE_IOU", "0.85")),
|
||||
frame_dedupe_distance=int(os.environ.get("FRAME_DEDUPE_DISTANCE", "8")),
|
||||
ffmpeg_timeout=float(os.environ.get("FFMPEG_TIMEOUT", "1200")),
|
||||
# Default 8 MB/s (~64 Mbit/s): ~20% of the measured ~300 Mbit/s home
|
||||
# WiFi, so browsing stays snappy while the agent works — yet MORE
|
||||
# sweep throughput than the self-inflicted congestion collapse this
|
||||
# replaces (2026-07-02: 8 unthrottled downloaders bufferbloated the
|
||||
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
|
||||
# from the agent UI on wired/faster networks.
|
||||
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Region PROPOSERS — small YOLO detectors that decide WHERE to crop. They run
|
||||
on the agent GPU and their boxes feed the crop → SigLIP → max-over-bag pipeline:
|
||||
|
||||
- person (general COCO yolo11n): full-figure boxes for realistic / Western art
|
||||
the anime person-detector misses; NMS-merged with imgutils detect_person and
|
||||
fed to CCIP (identity) + a concept crop.
|
||||
- anatomy (booru_yolo): anime / furry / NSFW torso components (head, cat-head,
|
||||
boob, hip, …) — concept crops aligned to the operator's tag vocabulary.
|
||||
- panel (mosesb): a comic page → panel regions → concept crops.
|
||||
|
||||
Each proposer is INDEPENDENTLY optional + guarded: a bad weight path or an
|
||||
inference error disables just that proposer (logged) and never breaks the
|
||||
worker, which still falls back to imgutils detection. Weights resolve from an
|
||||
ultralytics builtin name ("yolo11n.pt"), an http(s) URL, or "hf_repo::file" —
|
||||
cached under HF_HOME so the download happens once.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("fc_agent.detectors")
|
||||
_CACHE = Path(os.environ.get("HF_HOME", "/models")) / "yolo"
|
||||
|
||||
|
||||
def _resolve(spec: str) -> str | None:
|
||||
"""A local weights path (downloading if needed) or an ultralytics builtin
|
||||
name. None if the spec is empty/unresolvable."""
|
||||
if not spec:
|
||||
return None
|
||||
if "::" in spec: # hf_repo::filename
|
||||
repo, _, fname = spec.partition("::")
|
||||
from huggingface_hub import hf_hub_download
|
||||
return hf_hub_download(
|
||||
repo_id=repo, filename=fname, cache_dir=str(_CACHE)
|
||||
)
|
||||
if spec.startswith(("http://", "https://")):
|
||||
_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
dest = _CACHE / spec.rsplit("/", 1)[-1]
|
||||
if not dest.is_file():
|
||||
import requests
|
||||
r = requests.get(spec, timeout=300)
|
||||
r.raise_for_status()
|
||||
dest.write_bytes(r.content)
|
||||
return str(dest)
|
||||
return spec # ultralytics builtin name
|
||||
|
||||
|
||||
def _iou(a, b) -> float:
|
||||
ax, ay, aw, ah = a
|
||||
bx, by, bw, bh = b
|
||||
ix = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
|
||||
iy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
|
||||
inter = ix * iy
|
||||
union = aw * ah + bw * bh - inter
|
||||
return inter / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def nms_merge(boxes, iou_thresh: float = 0.6):
|
||||
"""Greedy NMS over (bbox_norm, score, label) from possibly several detectors,
|
||||
so the same figure found by two of them collapses to one (higher-score) box."""
|
||||
kept = []
|
||||
for bb, sc, lb in sorted(boxes, key=lambda b: b[1], reverse=True):
|
||||
if all(_iou(bb, k[0]) < iou_thresh for k in kept):
|
||||
kept.append((bb, sc, lb))
|
||||
return kept
|
||||
|
||||
|
||||
def dedupe_crops(pending, iou_thresh: float = 0.85):
|
||||
"""Greedy high-IoU dedupe over a list of (crop, region_template) pairs, run
|
||||
just before the batched SigLIP embed so we never embed the same region twice.
|
||||
|
||||
Figure boxes are already NMS-merged and each YOLO self-NMSes, but the combined
|
||||
per-frame pile (figure→concept ∪ anatomy component→concept ∪ panel) can still
|
||||
carry genuine near-duplicates across proposers — e.g. a figure box that nearly
|
||||
coincides with an anatomy component on a solo bust, or overlapping booru head
|
||||
classes on one head. Those embed the same region twice, wasting GPU and a slot
|
||||
against max_regions.
|
||||
|
||||
Boxes are compared ONLY within the same output kind and dropped when they
|
||||
overlap at >= iou_thresh, keeping the highest-scoring one. The HIGH default
|
||||
threshold is deliberate: it collapses only true near-identical boxes while
|
||||
preserving intentional nested crops across scopes (a whole figure vs a small
|
||||
head component sit well below it) and distinct kinds (concept vs panel). A
|
||||
value >= 1.0 effectively disables it (nothing but an exact box matches)."""
|
||||
kept = []
|
||||
kept_boxes: dict = {} # kind -> [bbox, ...] already kept
|
||||
for crop, tmpl in sorted(
|
||||
pending, key=lambda p: p[1].get("score") or 0.0, reverse=True
|
||||
):
|
||||
bb = tmpl.get("bbox")
|
||||
prior = kept_boxes.setdefault(tmpl.get("kind"), [])
|
||||
if bb is not None and any(_iou(bb, kb) >= iou_thresh for kb in prior):
|
||||
continue
|
||||
prior.append(bb)
|
||||
kept.append((crop, tmpl))
|
||||
return kept
|
||||
|
||||
|
||||
class YoloProposer:
|
||||
"""One lazily-loaded ultralytics YOLO. detect(image) → [(bbox_norm, score,
|
||||
label)] with bbox normalized (x, y, w, h) in [0,1]. Self-disables on any
|
||||
load/inference failure."""
|
||||
|
||||
def __init__(self, name, weights, conf=0.25, keep_labels=None):
|
||||
self.name = name
|
||||
self._spec = weights
|
||||
self._conf = conf
|
||||
self._keep = [k.lower() for k in keep_labels] if keep_labels else None
|
||||
self._model = None
|
||||
self._ok = True
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _load(self):
|
||||
if self._model is not None or not self._ok:
|
||||
return
|
||||
with self._lock:
|
||||
if self._model is not None or not self._ok:
|
||||
return
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
path = _resolve(self._spec)
|
||||
if path is None:
|
||||
self._ok = False
|
||||
return
|
||||
self._model = YOLO(path)
|
||||
# Disable ultralytics' load-time Conv+BN fusion. AutoBackend fuses
|
||||
# the graph on the first predict; some checkpoints (yolo11n, the
|
||||
# comic-panel model) crash that step with "'Conv' object has no
|
||||
# attribute 'bn'" (a partially-fused / version-mismatched graph),
|
||||
# which silently disabled those proposers (operator-flagged
|
||||
# 2026-07-01). Unfused inference is correct — only marginally
|
||||
# slower — and this is robust across ultralytics versions; if a
|
||||
# future version ignores the override, the detect() guard below
|
||||
# still self-disables the proposer instead of spamming per image.
|
||||
inner = getattr(self._model, "model", None)
|
||||
if inner is not None:
|
||||
inner.fuse = types.MethodType(lambda self, *a, **k: self, inner)
|
||||
log.info("detector %s loaded (%s)", self.name, path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("detector %s disabled (load failed): %s", self.name, exc)
|
||||
self._ok = False
|
||||
|
||||
def detect(self, image):
|
||||
self._load()
|
||||
if self._model is None:
|
||||
return []
|
||||
try:
|
||||
res = self._model.predict(image, conf=self._conf, verbose=False)[0]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Permanently self-disable on the FIRST inference failure rather than
|
||||
# re-throwing (and re-logging) on every image forever — an unfixable
|
||||
# model fault degrades to "this proposer is off", logged once.
|
||||
log.warning("detector %s disabled (inference failed): %s", self.name, exc)
|
||||
self._ok = False
|
||||
self._model = None
|
||||
return []
|
||||
iw, ih = image.size
|
||||
names = getattr(res, "names", None) or {}
|
||||
out = []
|
||||
for b in res.boxes:
|
||||
label = str(names.get(int(b.cls), int(b.cls))).lower()
|
||||
if self._keep is not None and not any(k in label for k in self._keep):
|
||||
continue
|
||||
x0, y0, x1, y1 = (float(v) for v in b.xyxy[0].tolist())
|
||||
out.append((
|
||||
(x0 / iw, y0 / ih, (x1 - x0) / iw, (y1 - y0) / ih),
|
||||
float(b.conf), label,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
class Proposers:
|
||||
"""The agent's proposer set, built from config. Each detector is optional —
|
||||
an empty weight spec leaves that proposer off."""
|
||||
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self._person = (
|
||||
YoloProposer("person-coco", cfg.person_weights,
|
||||
conf=cfg.person_conf, keep_labels=["person"])
|
||||
if cfg.person_weights else None
|
||||
)
|
||||
self._anatomy = (
|
||||
YoloProposer("anatomy", cfg.anatomy_weights, conf=cfg.anatomy_conf)
|
||||
if cfg.anatomy_weights else None
|
||||
)
|
||||
self._panel = (
|
||||
YoloProposer("panel", cfg.panel_weights, conf=cfg.panel_conf)
|
||||
if cfg.panel_weights else None
|
||||
)
|
||||
|
||||
def figures(self, image, base_boxes):
|
||||
"""Merge imgutils person boxes (base_boxes: [(bbox, score)]) with the
|
||||
general COCO person detector → NMS'd figure boxes [(bbox, score, label)],
|
||||
capped to the highest-scoring max_figures. Uncapped, a busy/huge image
|
||||
(many characters) yields hundreds of boxes → hundreds of per-figure CCIP
|
||||
calls + crops → a 30s+ job and an oversized submit (operator-flagged)."""
|
||||
boxes = [(bb, sc if sc is not None else 1.0, "person") for bb, sc in base_boxes]
|
||||
if self._person is not None:
|
||||
boxes += self._person.detect(image)
|
||||
return nms_merge(boxes)[: self.cfg.max_figures] # nms_merge is score-desc
|
||||
|
||||
@staticmethod
|
||||
def _top(detector, image, cap: int):
|
||||
"""Top-`cap` detections by score from an optional proposer (None → the
|
||||
proposer is off → []). Shared by the anatomy + panel proposers, which
|
||||
differ only in which detector and which cap."""
|
||||
if detector is None:
|
||||
return []
|
||||
return sorted(detector.detect(image), key=lambda b: b[1], reverse=True)[:cap]
|
||||
|
||||
def components(self, image):
|
||||
return self._top(self._anatomy, image, self.cfg.max_components)
|
||||
|
||||
def panels(self, image):
|
||||
return self._top(self._panel, image, self.cfg.max_panels)
|
||||
@@ -58,12 +58,20 @@ class CropEmbedder:
|
||||
|
||||
def embed(self, image: Image.Image) -> list[float]:
|
||||
"""A crop → its embedding as a plain float list, ready to POST."""
|
||||
return self.embed_batch([image])[0]
|
||||
|
||||
def embed_batch(self, images: list) -> list[list[float]]:
|
||||
"""Embed many crops in ONE forward pass — far better GPU utilisation +
|
||||
only one lock acquisition than embedding each crop separately (which
|
||||
starved the GPU and serialised the whole pool)."""
|
||||
if not images:
|
||||
return []
|
||||
self.load()
|
||||
torch = self._torch
|
||||
enc = self._processor(images=image, return_tensors="pt")
|
||||
enc = self._processor(images=images, return_tensors="pt")
|
||||
pixel_values = enc["pixel_values"].to(self._device, self._dt)
|
||||
with self._infer_lock, torch.no_grad():
|
||||
out = self._model.get_image_features(pixel_values=pixel_values)
|
||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
||||
vec = pooled[0].float().cpu().numpy().astype(np.float32).reshape(-1)
|
||||
return vec.tolist()
|
||||
arr = pooled.float().cpu().numpy().astype(np.float32)
|
||||
return [row.reshape(-1).tolist() for row in arr]
|
||||
|
||||
+37
-2
@@ -1,10 +1,24 @@
|
||||
"""GPU load readout via nvidia-smi (present in the container thanks to the
|
||||
NVIDIA Container Toolkit's `utility` capability). Returns None if unavailable —
|
||||
the UI just shows n/a (e.g. CPU-fallback run)."""
|
||||
the UI just shows n/a (e.g. CPU-fallback run).
|
||||
|
||||
Reads are CACHED and de-duplicated: the UI meter polls fast, /status reads it,
|
||||
and the autoscaler samples it — if each spawned its own `nvidia-smi` (slow on a
|
||||
busy GPU) those blocking subprocesses would pile up in the server's thread pool
|
||||
and make the Start/Stop buttons feel dead. So a short TTL serves recent callers
|
||||
from cache, and only ONE probe runs at a time (others get the last value)."""
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
_TTL = 1.0 # seconds a sample is reused before re-probing
|
||||
_lock = threading.Lock()
|
||||
_cache: dict | None = None
|
||||
_cache_t = 0.0
|
||||
_probing = False
|
||||
|
||||
|
||||
def read_gpu() -> dict | None:
|
||||
def _probe() -> dict | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
@@ -28,3 +42,24 @@ def read_gpu() -> dict | None:
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def read_gpu(max_age: float = _TTL) -> dict | None:
|
||||
"""Latest GPU reading, cached. Serves from cache when fresh; when stale,
|
||||
exactly one caller re-probes while the rest get the last value — so request
|
||||
threads never block behind more than one `nvidia-smi`."""
|
||||
global _cache, _cache_t, _probing
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
fresh = _cache is not None and (now - _cache_t) < max_age
|
||||
if fresh or _probing: # fresh, or a probe is already running
|
||||
return _cache
|
||||
_probing = True
|
||||
try:
|
||||
val = _probe()
|
||||
finally:
|
||||
with _lock:
|
||||
_cache = val
|
||||
_cache_t = time.monotonic()
|
||||
_probing = False
|
||||
return val
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""In-memory log ring buffer so the control UI can show recent agent logs
|
||||
(detector loads, job errors, autoscaler decisions, outage back-offs) without
|
||||
needing `docker logs`. A bounded deque holds the last N formatted lines; a
|
||||
logging.Handler appends to it; the UI polls /logs."""
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
LINES: deque[str] = deque(maxlen=400)
|
||||
|
||||
|
||||
class RingHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
LINES.append(self.format(record))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_installed = False
|
||||
|
||||
|
||||
def install(level: int = logging.INFO) -> None:
|
||||
"""Attach the ring handler to the root logger once. fc_agent module loggers
|
||||
propagate to root, so their records land here."""
|
||||
global _installed
|
||||
if _installed:
|
||||
return
|
||||
_installed = True
|
||||
h = RingHandler()
|
||||
h.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S")
|
||||
)
|
||||
root = logging.getLogger()
|
||||
root.addHandler(h)
|
||||
if root.level == logging.NOTSET or root.level > level:
|
||||
root.setLevel(level)
|
||||
# Keep the buffer signal-rich: silence the chatty HTTP/download libs (every
|
||||
# HF model fetch logs per-request) so the console shows agent activity —
|
||||
# detector loads, job errors, autoscale moves — not request spam.
|
||||
for noisy in (
|
||||
"uvicorn.access", "ultralytics", "httpx", "httpcore",
|
||||
"huggingface_hub", "urllib3", "filelock",
|
||||
):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
+214
-24
@@ -2,17 +2,77 @@
|
||||
(ffmpeg) at the cadence FC sends — so a video becomes a bag of per-frame
|
||||
instances, each with a timestamp."""
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageFile
|
||||
|
||||
from .throttle import PidReadMeter
|
||||
|
||||
log = logging.getLogger("fc_agent.media")
|
||||
|
||||
# Load slightly-truncated images (a few missing trailing bytes) instead of
|
||||
# raising — matches the server embedder. These are common in scraped libraries
|
||||
# and would otherwise fail the job 3× then error (operator-flagged 2026-06-30).
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
# Disable PIL's decompression-bomb guard: this is a TRUSTED local library, not an
|
||||
# untrusted upload surface, so a legitimately huge image (high-res scans/prints,
|
||||
# 90M+ pixels) must load. The default 89M-pixel limit only WARNS, but PIL raises
|
||||
# DecompressionBombError at 2× (~179M px) — which would fail those jobs outright
|
||||
# (operator-flagged 2026-06-30, images of 90–95M px).
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
def is_video(mime: str) -> bool:
|
||||
return bool(mime) and (mime.startswith("video/") or mime in {"image/gif"})
|
||||
|
||||
|
||||
def _dhash(img: Image.Image, size: int = 8) -> int:
|
||||
"""Difference hash: compare adjacent pixels of a (size+1 × size) grayscale
|
||||
thumbnail → a `size*size`-bit fingerprint. Cheap (64 comparisons on a 72-px
|
||||
thumbnail) and robust to scaling/compression noise — near-identical frames
|
||||
hash within a few bits, a real scene change moves many."""
|
||||
small = img.convert("L").resize((size + 1, size))
|
||||
px = list(small.getdata())
|
||||
bits = 0
|
||||
for row in range(size):
|
||||
base = row * (size + 1)
|
||||
for col in range(size):
|
||||
bits = (bits << 1) | int(px[base + col] > px[base + col + 1])
|
||||
return bits
|
||||
|
||||
|
||||
def dedupe_frames(
|
||||
frames: list[tuple[float, Image.Image]], min_distance: int
|
||||
) -> list[tuple[float, Image.Image]]:
|
||||
"""Drop visually near-duplicate frames. A near-static video sampled into many
|
||||
frames re-runs the WHOLE detect→CCIP→SigLIP chain on ~identical frames — the
|
||||
dominant video load. Greedy perceptual-hash dedup: keep a frame only if its
|
||||
dHash differs from every already-kept frame by >= min_distance bits (Hamming),
|
||||
so a static run collapses to one frame while genuinely distinct scenes all
|
||||
survive. Order + timestamps preserved. CPU-only (64-bit int XORs), so it runs
|
||||
in the decode stage and spares the GPU the skipped frames entirely.
|
||||
|
||||
min_distance is the coarseness dial: higher keeps more frames (safer for brief
|
||||
localized changes an 8×8 hash can miss), 0 disables. The first frame is always
|
||||
kept (nothing to compare against)."""
|
||||
if min_distance <= 0 or len(frames) <= 1:
|
||||
return frames
|
||||
kept: list[tuple[float, Image.Image]] = []
|
||||
hashes: list[int] = []
|
||||
for t, frame in frames:
|
||||
h = _dhash(frame)
|
||||
if all(bin(h ^ k).count("1") >= min_distance for k in hashes):
|
||||
hashes.append(h)
|
||||
kept.append((t, frame))
|
||||
return kept
|
||||
|
||||
|
||||
def to_rgb(img: Image.Image) -> Image.Image:
|
||||
"""RGB, flattening any transparency onto white first. A naive convert('RGB')
|
||||
on a palette-with-transparency image (common for character PNGs on a clear
|
||||
@@ -32,32 +92,162 @@ def load_image(data: bytes) -> Image.Image:
|
||||
return to_rgb(Image.open(io.BytesIO(data)))
|
||||
|
||||
|
||||
def sample_frames(
|
||||
data: bytes, interval_seconds: float, max_frames: int
|
||||
# ffmpeg reconnect flags — resume a dropped HTTP transfer (a slow/contended media
|
||||
# store can cut a long stream) instead of failing the whole job. Relies only on
|
||||
# HTTP + Range, which every FC deployment serves → environment-agnostic.
|
||||
_RECONNECT = [
|
||||
"-reconnect", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_on_network_error", "1", "-reconnect_delay_max", "5",
|
||||
]
|
||||
|
||||
|
||||
def _collect_frames(
|
||||
tmp: str, interval: float, cap: int
|
||||
) -> list[tuple[float, Image.Image]]:
|
||||
"""Extract up to max_frames frames at one-every-interval_seconds via ffmpeg.
|
||||
Returns [(timestamp_seconds, frame)]. Empty on failure (caller falls back)."""
|
||||
out: list[tuple[float, Image.Image]] = []
|
||||
names = sorted(n for n in os.listdir(tmp) if n.startswith("f_"))
|
||||
for i, name in enumerate(names[:cap]):
|
||||
with Image.open(os.path.join(tmp, name)) as im:
|
||||
out.append((round(i * interval, 2), to_rgb(im)))
|
||||
return out
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen) -> None:
|
||||
"""Stop an ffmpeg cleanly, then hard-kill if it ignores SIGTERM."""
|
||||
try:
|
||||
# A bandwidth-paused (SIGSTOPped) process can't receive SIGTERM until it
|
||||
# resumes — always CONT first so termination is prompt, not queued.
|
||||
proc.send_signal(signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
|
||||
def _pause(proc: subprocess.Popen, seconds: float, should_stop) -> bool:
|
||||
"""SIGSTOP ffmpeg for ~`seconds` of bandwidth debt, staying responsive to
|
||||
Stop. While paused, the kernel socket buffer fills and TCP flow control
|
||||
stalls curator's send side — that's the throttle. SIGCONT is ALWAYS sent
|
||||
before returning. False = a Stop arrived mid-pause."""
|
||||
try:
|
||||
proc.send_signal(signal.SIGSTOP)
|
||||
except OSError:
|
||||
return True # already exited — nothing to pause
|
||||
try:
|
||||
end = time.monotonic() + seconds
|
||||
while (left := end - time.monotonic()) > 0:
|
||||
if should_stop and should_stop():
|
||||
return False
|
||||
time.sleep(min(0.5, left))
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
proc.send_signal(signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def sample_frames_from_url(
|
||||
url: str, interval_seconds: float, max_frames: int,
|
||||
*, headers: str = "", timeout: float = 1200.0, should_stop=None,
|
||||
governor=None,
|
||||
) -> tuple[list[tuple[float, Image.Image]], str | None]:
|
||||
"""Sample frames by pointing ffmpeg STRAIGHT at the media URL — it Range-reads
|
||||
only the video index + up to max_frames worth of content, so the agent never
|
||||
downloads the whole file (VR/4K originals run 800MB+ and would buffer ~1GB in
|
||||
RAM and get cut off mid-download). Reconnect flags resume a dropped transfer;
|
||||
the timeout is the per-video ceiling (a slow/reconnecting stream can otherwise
|
||||
run for minutes). `should_stop` is polled while ffmpeg runs so a Stop KILLS the
|
||||
subprocess at once — otherwise a downloader stuck in a long decode keeps the
|
||||
agent "working" long after Stop. `governor` (the worker's shared TokenBucket)
|
||||
meters ffmpeg's network reads from outside via /proc/<pid>/io and SIGSTOPs
|
||||
the process into budget, so video streaming honors the same aggregate
|
||||
bandwidth cap as still downloads.
|
||||
|
||||
Returns (frames, reason): frames is empty on failure/stop/timeout, and
|
||||
`reason` then carries the SPECIFIC cause (ffmpeg's stderr tail / timeout) so
|
||||
the caller can put it in the job's error — a bare "no frames" hid a filter
|
||||
bug as "unprocessable" for weeks. None reason on success."""
|
||||
interval = max(0.5, float(interval_seconds or 4.0))
|
||||
cap = max(1, int(max_frames or 64))
|
||||
hdr = ["-headers", headers] if headers else []
|
||||
# select (NOT the fps filter): always keep the FIRST frame, then one per
|
||||
# `interval` seconds of timestamp. fps=1/N emits round(duration/N) frames,
|
||||
# which is ZERO for any clip shorter than ~N/2 seconds — a whole class of
|
||||
# short animation loops failed as "unprocessable" that way (operator-flagged
|
||||
# 2026-07-02: 0.5s/1.75s clips). scale=out_range=full converts limited-range
|
||||
# yuv420p to full range so the mjpeg (jpg) encoder accepts it at default
|
||||
# strictness instead of erroring on "non full-range YUV".
|
||||
vf = (
|
||||
f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{interval})',"
|
||||
"scale=out_range=full"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = os.path.join(tmp, "in")
|
||||
with open(src, "wb") as fh:
|
||||
fh.write(data)
|
||||
pattern = os.path.join(tmp, "f_%05d.jpg")
|
||||
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", *_RECONNECT, *hdr,
|
||||
"-i", url, "-vf", vf, "-fps_mode", "vfr",
|
||||
"-frames:v", str(cap), "-q:v", "3", pattern]
|
||||
# ffmpeg's stderr goes to a file (not a PIPE, which could fill and
|
||||
# deadlock; not DEVNULL, which is how a filter bug hid as "unprocessable"
|
||||
# for weeks) — on failure its tail is logged so the operator can see WHY.
|
||||
errpath = os.path.join(tmp, "stderr.txt")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-loglevel", "error", "-i", src,
|
||||
"-vf", f"fps=1/{interval}", "-frames:v", str(cap),
|
||||
"-q:v", "3", pattern,
|
||||
],
|
||||
check=True, timeout=600,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return []
|
||||
out: list[tuple[float, Image.Image]] = []
|
||||
names = sorted(n for n in os.listdir(tmp) if n.startswith("f_"))
|
||||
for i, name in enumerate(names[:cap]):
|
||||
with Image.open(os.path.join(tmp, name)) as im:
|
||||
out.append((round(i * interval, 2), to_rgb(im)))
|
||||
return out
|
||||
with open(errpath, "wb") as errf:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=errf,
|
||||
)
|
||||
meter = PidReadMeter(proc.pid) if governor is not None else None
|
||||
# Poll rather than block, so a Stop (or the per-video timeout) can
|
||||
# kill a slow/wedged ffmpeg promptly instead of waiting it out.
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
proc.wait(timeout=0.5)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
stopped = should_stop and should_stop()
|
||||
if stopped or (time.monotonic() - start > timeout):
|
||||
_terminate(proc)
|
||||
if stopped:
|
||||
return [], "stopped"
|
||||
log.warning("ffmpeg timed out after %.0fs: %s",
|
||||
timeout, url)
|
||||
return [], f"ffmpeg timed out after {timeout:.0f}s"
|
||||
if meter is not None:
|
||||
read = meter.delta()
|
||||
if read is None: # /proc gone → stop governing
|
||||
meter = None
|
||||
elif (debt := governor.charge(read)) > 0:
|
||||
# Over budget: pause ffmpeg until the bucket
|
||||
# recovers. Pause time counts toward `timeout`
|
||||
# (it stays the wedge backstop either way).
|
||||
if not _pause(proc, debt, should_stop):
|
||||
_terminate(proc)
|
||||
return [], "stopped"
|
||||
except (OSError, ValueError) as exc:
|
||||
return [], f"ffmpeg not runnable: {exc}"
|
||||
frames = _collect_frames(tmp, interval, cap)
|
||||
if not frames:
|
||||
reason = f"ffmpeg exit {proc.returncode}: {_tail(errpath)}"
|
||||
log.warning("ffmpeg produced no frames for %s — %s", url, reason)
|
||||
return [], reason
|
||||
return frames, None
|
||||
|
||||
|
||||
def _tail(path: str, limit: int = 300) -> str:
|
||||
"""Last `limit` chars of a (stderr) file, flattened — for failure logs."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - limit))
|
||||
return f.read().decode("utf-8", "replace").replace("\n", " ").strip()
|
||||
except OSError:
|
||||
return "?"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Global download-bandwidth governor (one token bucket for the whole agent).
|
||||
|
||||
The agent lives on someone's desktop and shares that desktop's network —
|
||||
typically WiFi, where saturating the link doesn't just slow other apps: it
|
||||
bufferbloats the airtime (RTT 21→45ms) and collapses EVERY connection,
|
||||
the operator's browser included. Measured 2026-07-02: the idle link moved
|
||||
~38 MB/s single-stream, but under the 8-downloader sweep every stream on the
|
||||
machine crawled at ~1-1.5 MB/s. So the cap is on the AGGREGATE, not per
|
||||
stream: still downloads pump their chunks through take(), and ffmpeg video
|
||||
streams — whose sockets live in a subprocess we can't wrap — are metered from
|
||||
outside via /proc/<pid>/io and paused (SIGSTOP) into budget using charge()'s
|
||||
debt signal; TCP flow control then stalls the sender while ffmpeg sleeps.
|
||||
|
||||
Accounting is post-paid (charge the bytes first, then wait out any debt): the
|
||||
bytes have already crossed the network by the time we count them, and it means
|
||||
a chunk larger than one second of budget can never deadlock the bucket.
|
||||
Stdlib-only on purpose — unit-tested in CI, where the agent's ML deps
|
||||
don't exist.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Thread-safe token bucket in bytes/second. rate 0 = unlimited.
|
||||
|
||||
`consumed` is the monotonic total of bytes charged (throttled or not) —
|
||||
the worker's rate loop derives the UI's "net MB/s" readout from it.
|
||||
"""
|
||||
|
||||
def __init__(self, rate_bytes_per_s: float = 0.0):
|
||||
self._cond = threading.Condition()
|
||||
self._rate = max(0.0, float(rate_bytes_per_s))
|
||||
# Burst = one second of budget: enough that chunked reads stay smooth,
|
||||
# small enough that a burst can't meaningfully lift the average.
|
||||
self._level = self._rate
|
||||
self._stamp = time.monotonic()
|
||||
self.consumed = 0
|
||||
|
||||
@property
|
||||
def rate(self) -> float:
|
||||
return self._rate
|
||||
|
||||
def set_rate(self, rate_bytes_per_s: float) -> None:
|
||||
"""Retune live (the UI dial). Waiters re-check immediately, so raising
|
||||
the cap (or lifting it with 0) unblocks a mid-download wait at once."""
|
||||
with self._cond:
|
||||
self._refill_locked() # settle elapsed time at the OLD rate
|
||||
self._rate = max(0.0, float(rate_bytes_per_s))
|
||||
self._level = min(self._level, self._rate)
|
||||
self._cond.notify_all()
|
||||
|
||||
def _refill_locked(self) -> None:
|
||||
now = time.monotonic()
|
||||
self._level = min(self._rate, self._level + (now - self._stamp) * self._rate)
|
||||
self._stamp = now
|
||||
|
||||
def take(self, n: int) -> None:
|
||||
"""Charge n bytes and block until the budget recovers (stills path)."""
|
||||
with self._cond:
|
||||
self.consumed += n
|
||||
if self._rate <= 0:
|
||||
return
|
||||
self._refill_locked()
|
||||
self._level -= n
|
||||
while self._level < 0:
|
||||
# Wake early on set_rate; cap the wait so a big debt is paid in
|
||||
# re-checked slices rather than one long uninterruptible sleep.
|
||||
self._cond.wait(min(-self._level / self._rate, 0.5))
|
||||
if self._rate <= 0:
|
||||
return
|
||||
self._refill_locked()
|
||||
|
||||
def charge(self, n: int) -> float:
|
||||
"""Charge n bytes WITHOUT blocking; return seconds of debt (0 = within
|
||||
budget). The ffmpeg governor can't block the subprocess's own reads, so
|
||||
it SIGSTOPs the process for (about) the returned debt instead."""
|
||||
with self._cond:
|
||||
self.consumed += n
|
||||
if self._rate <= 0:
|
||||
return 0.0
|
||||
self._refill_locked()
|
||||
self._level -= n
|
||||
return max(0.0, -self._level / self._rate)
|
||||
|
||||
|
||||
class PidReadMeter:
|
||||
"""Cumulative read-bytes meter for a subprocess, via /proc/<pid>/io.
|
||||
|
||||
`rchar` counts every read() syscall's bytes — for a streaming ffmpeg the
|
||||
network reads dominate, so the delta is a good-enough aggregate-bandwidth
|
||||
signal (it's a governor, not a billing meter). Returns None when /proc is
|
||||
unavailable (process exited, or a non-Linux host): the caller then simply
|
||||
doesn't govern — degrade to unthrottled rather than break video sampling.
|
||||
"""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self._path = f"/proc/{pid}/io"
|
||||
self._last = 0
|
||||
|
||||
def delta(self) -> int | None:
|
||||
try:
|
||||
with open(self._path, "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith(b"rchar:"):
|
||||
total = int(line.split()[1])
|
||||
d, self._last = total - self._last, total
|
||||
return max(0, d)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
+963
-151
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@ onnxruntime-gpu
|
||||
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
|
||||
# transformers loads whatever SigLIP-family model the server announces.
|
||||
transformers>=4.45
|
||||
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
|
||||
# panel) that decide where to crop. Uses the torch already installed above.
|
||||
ultralytics>=8.3
|
||||
# Control surface + HTTP.
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# The agent runs on the CUDA base image's Python 3.12 (Ubuntu 24.04) — NOT the
|
||||
# 3.14 that CI's ci-python image and the repo-root ruff.toml target. Pin the
|
||||
# agent to py312 so ruff enforces 3.12 compatibility and never auto-applies a
|
||||
# 3.14-only fix (e.g. unquoting a self-referential annotation, which PEP 649
|
||||
# makes safe on 3.14 but NameErrors on 3.12). Inherit the root lint rules.
|
||||
extend = "../ruff.toml"
|
||||
target-version = "py312"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""ml_settings: embedder_model_name (#1190 operator model swap)
|
||||
|
||||
The embedder MODEL VERSION was already a setting (and stamps image_record.
|
||||
siglip_model_version); the HF model NAME was env-only, so an operator couldn't
|
||||
actually point the pipeline at a different embedder. Storing the name as a
|
||||
setting makes the model an operator choice: set name + version → re-embed (the
|
||||
GPU agent) → retrain heads. Default = the current SigLIP so400m.
|
||||
|
||||
Revision ID: 0065
|
||||
Revises: 0064
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0065"
|
||||
down_revision: Union[str, None] = "0064"
|
||||
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(
|
||||
"embedder_model_name", sa.String(length=128), nullable=False,
|
||||
server_default="google/siglip-so400m-patch14-384",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "embedder_model_name")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""drop the dead per-tag centroid subsystem (#1189 cleanup)
|
||||
|
||||
The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
|
||||
Nothing read the centroids anymore — they were recomputed (on merge + a daily
|
||||
beat) but never consumed for suggestions or auto-apply. Remove the storage +
|
||||
its two now-unused settings columns. (The recompute tasks, beat, endpoint,
|
||||
service, and UI card are removed in the same change.)
|
||||
|
||||
Revision ID: 0066
|
||||
Revises: 0065
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0066"
|
||||
down_revision: Union[str, None] = "0065"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("tag_reference_embedding")
|
||||
op.drop_column("ml_settings", "centroid_similarity_threshold")
|
||||
op.drop_column("ml_settings", "min_reference_images")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"min_reference_images", sa.Integer(), nullable=False,
|
||||
server_default="5",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"centroid_similarity_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.55",
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"tag_reference_embedding",
|
||||
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||
sa.Column("embedding", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("reference_count", sa.Integer(), nullable=False),
|
||||
sa.Column("model_version", sa.String(length=128), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("tag_id"),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""retire the Camie tagger + allowlist bulk-apply (#1189)
|
||||
|
||||
The v2 pivot made heads + CCIP the tag source and head auto-apply the earned
|
||||
propagation. The Camie tagger ran only to feed the allowlist bulk-apply (its
|
||||
predictions had no other consumer), and the allowlist was a second, un-earned
|
||||
auto-apply path parallel to heads. Both are retired — drop their storage.
|
||||
|
||||
(image_prediction = Camie's per-image predictions; tag_allowlist = the bulk-
|
||||
apply allowlist. Nothing references INTO these tables, so the drop is clean.)
|
||||
|
||||
Revision ID: 0067
|
||||
Revises: 0066
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0067"
|
||||
down_revision: Union[str, None] = "0066"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("image_prediction")
|
||||
op.drop_table("tag_allowlist")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"tag_allowlist",
|
||||
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"min_confidence", sa.Float(), nullable=False, server_default="0.9"
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("tag_id"),
|
||||
sa.CheckConstraint(
|
||||
"min_confidence >= 0 AND min_confidence <= 1",
|
||||
name="ck_tag_allowlist_confidence_range",
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"image_prediction",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("image_record_id", sa.Integer(), nullable=False),
|
||||
sa.Column("raw_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("category", sa.String(length=32), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["image_record_id"], ["image_record.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_image_prediction_image", "image_prediction", ["image_record_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_image_prediction_name_score", "image_prediction",
|
||||
["raw_name", "score"],
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""drop dead tagger/suggestion settings + columns left after Camie retirement (#1199)
|
||||
|
||||
Hygiene follow-up to #1189. These were left inert to bound that change; nothing
|
||||
reads them now:
|
||||
- ml_settings: tagger_store_floor + tagger_model_version (only the deleted Camie
|
||||
tagger used them), suggestion_threshold_character/general (already dead pre-
|
||||
retirement — scoring uses per-head thresholds), video_min_tag_frames (only the
|
||||
deleted video-prediction aggregator used it).
|
||||
- image_record: tagger_model_version (no writer now), centroid_scores (long-dead
|
||||
JSON cache, no reader).
|
||||
|
||||
Revision ID: 0068
|
||||
Revises: 0067
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0068"
|
||||
down_revision: Union[str, None] = "0067"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("ml_settings", "suggestion_threshold_character")
|
||||
op.drop_column("ml_settings", "suggestion_threshold_general")
|
||||
op.drop_column("ml_settings", "tagger_store_floor")
|
||||
op.drop_column("ml_settings", "video_min_tag_frames")
|
||||
op.drop_column("ml_settings", "tagger_model_version")
|
||||
op.drop_column("image_record", "tagger_model_version")
|
||||
op.drop_column("image_record", "centroid_scores")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"image_record",
|
||||
sa.Column("centroid_scores", sa.JSON(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"image_record",
|
||||
sa.Column("tagger_model_version", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"tagger_model_version", sa.String(length=128), nullable=False,
|
||||
server_default="camie-tagger-v2",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"video_min_tag_frames", sa.Integer(), nullable=False,
|
||||
server_default="3",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"tagger_store_floor", sa.Float(), nullable=False,
|
||||
server_default="0.7",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"suggestion_threshold_general", sa.Float(), nullable=False,
|
||||
server_default="0.7",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"suggestion_threshold_character", sa.Float(), nullable=False,
|
||||
server_default="0.7",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""default the embedder to SigLIP 2 — for FRESH installs only (#1203)
|
||||
|
||||
Make SigLIP 2 (so400m, 512px; a 1152-d drop-in) the default embedder. New
|
||||
installs start on it. An EXISTING library is NOT touched: flipping its stored
|
||||
embedder version would mark every embedding stale (the scorer is version-gated)
|
||||
and kill suggestions until a full re-embed+retrain — so an existing instance
|
||||
switches deliberately via Settings → GPU agent → Embedding model → Re-embed →
|
||||
Retrain. We detect "fresh" by the absence of any embedded image.
|
||||
|
||||
Revision ID: 0069
|
||||
Revises: 0068
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0069"
|
||||
down_revision: Union[str, None] = "0068"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NEW_NAME = "google/siglip2-so400m-patch16-512"
|
||||
_NEW_VERSION = "siglip2-so400m-patch16-512"
|
||||
_OLD_NAME = "google/siglip-so400m-patch14-384"
|
||||
_OLD_VERSION = "siglip-so400m-patch14-384"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Fresh install (nothing embedded yet) → adopt SigLIP 2.
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE ml_settings SET
|
||||
embedder_model_name = '{_NEW_NAME}',
|
||||
embedder_model_version = '{_NEW_VERSION}'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM image_record WHERE siglip_embedding IS NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.alter_column("ml_settings", "embedder_model_name", server_default=_NEW_NAME)
|
||||
op.alter_column(
|
||||
"ml_settings", "embedder_model_version", server_default=_NEW_VERSION
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("ml_settings", "embedder_model_name", server_default=_OLD_NAME)
|
||||
op.alter_column(
|
||||
"ml_settings", "embedder_model_version", server_default=_OLD_VERSION
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""partial indexes so GPU-job leasing stays O(batch), not O(completed)
|
||||
|
||||
The lease claims the lowest-id pending (or expired-leased) jobs. With only a
|
||||
plain `status` index, `... ORDER BY id LIMIT n` walked the primary-key index from
|
||||
the start, skipping the entire prefix of already-done/error rows before reaching
|
||||
pending ones — so leasing slowed to a crawl as `done` piled up (the whole reason
|
||||
throughput fell off a cliff mid-run and /status stalled). Two partial indexes fix
|
||||
it: the pending one is id-ordered so the hot path reads just the first n entries,
|
||||
and the leased-expiry one keeps the crash-recovery reclaim + the orphan sweep
|
||||
cheap. They cover only the small live slice of the table, so they stay tiny even
|
||||
as the done/error history grows to millions.
|
||||
|
||||
Revision ID: 0070
|
||||
Revises: 0069
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0070"
|
||||
down_revision: Union[str, None] = "0069"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Hot path: lowest-id pending jobs. Index on id, restricted to pending, so
|
||||
# `WHERE status='pending' ORDER BY id LIMIT n` is a short index-order scan.
|
||||
op.create_index(
|
||||
"ix_gpu_job_pending", "gpu_job", ["id"],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
# Crash-recovery: expired leases, for the lease backstop + recover_orphaned.
|
||||
op.create_index(
|
||||
"ix_gpu_job_leased_expires", "gpu_job", ["lease_expires_at"],
|
||||
postgresql_where=sa.text("status = 'leased'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_gpu_job_leased_expires", table_name="gpu_job")
|
||||
op.drop_index("ix_gpu_job_pending", table_name="gpu_job")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""image_record.earliest_post_date: original-publish gallery sort key + index
|
||||
|
||||
Revision ID: 0071
|
||||
Revises: 0070
|
||||
Create Date: 2026-07-01
|
||||
|
||||
effective_date (0035) keys off the PRIMARY post — which is often the repost /
|
||||
download the file actually came from — and falls back to created_at, so the
|
||||
gallery's default order surfaces download dates rather than when content was
|
||||
first posted (operator-flagged 2026-07-01). Materialize a second sort key,
|
||||
earliest_post_date = MIN(post_date) across ALL of an image's provenance posts
|
||||
(every post it appears in), falling back to created_at only when no linked post
|
||||
carries a date. Indexed (DESC, id DESC) so the "post date" gallery sort is an
|
||||
index range scan just like effective_date.
|
||||
|
||||
Backfill mirrors 0035: created_at baseline, then override with the MIN over
|
||||
image_provenance ⋈ post. New rows get the created_at-equivalent server default;
|
||||
services/importer.py recomputes it whenever a dated post is linked.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0071"
|
||||
down_revision: Union[str, None] = "0070"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add nullable first so the backfill can populate before NOT NULL.
|
||||
op.add_column(
|
||||
"image_record",
|
||||
sa.Column("earliest_post_date", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Baseline: download date. Set-based (no per-row binds) → immune to the
|
||||
# 65535 bind-parameter ceiling regardless of library size.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record
|
||||
SET earliest_post_date = created_at
|
||||
"""
|
||||
)
|
||||
# Override with the earliest post_date across EVERY post the image appears
|
||||
# in (image_provenance is the many-to-many edge; ignore posts with no date).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record AS ir
|
||||
SET earliest_post_date = sub.min_date
|
||||
FROM (
|
||||
SELECT ip.image_record_id AS iid, MIN(p.post_date) AS min_date
|
||||
FROM image_provenance AS ip
|
||||
JOIN post AS p ON p.id = ip.post_id
|
||||
WHERE p.post_date IS NOT NULL
|
||||
GROUP BY ip.image_record_id
|
||||
) AS sub
|
||||
WHERE ir.id = sub.iid
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"image_record",
|
||||
"earliest_post_date",
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
# DESC/DESC matches the gallery's ORDER BY earliest_post_date DESC, id DESC
|
||||
# so the "post date" scroll is a forward index scan; raw SQL because
|
||||
# alembic's column list doesn't express per-column DESC cleanly.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_image_record_earliest_post_date "
|
||||
"ON image_record (earliest_post_date DESC, id DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_image_record_earliest_post_date", table_name="image_record"
|
||||
)
|
||||
op.drop_column("image_record", "earliest_post_date")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""gpu_job.triage_status — the probe's verdict on an errored job's FILE
|
||||
|
||||
Failure triage (#125): a periodic sweep probes each errored image's file
|
||||
(sha256 + decode, verify_integrity's machinery) exactly once and stores the
|
||||
verdict here — 'defect' (the file is bad: recovery material, excluded from
|
||||
/retry_errors) or 'file_ok' (failure was operational, safe to retry). NULL
|
||||
means not yet probed; selecting on NULL is what makes the sweep resumable.
|
||||
No index: the errored slice the sweep scans is tiny by design (tombstones).
|
||||
|
||||
Revision ID: 0072
|
||||
Revises: 0071
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0072"
|
||||
down_revision: Union[str, None] = "0071"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"gpu_job", sa.Column("triage_status", sa.String(16), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("gpu_job", "triage_status")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""drop tag_eval_run — the head-vs-centroid eval harness is retired
|
||||
|
||||
The eval (#1130) existed to prove the heads tagging spine on the operator's own
|
||||
data. It did; the operator accepted the system and retired the harness
|
||||
(2026-07-02) — card, API, task, model and this table all go. The eval's data
|
||||
loaders + metric helpers live on in services/ml/training_data.py, where the
|
||||
production heads trainer uses them nightly.
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0073"
|
||||
down_revision: Union[str, None] = "0072"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_tag_eval_run_status", table_name="tag_eval_run")
|
||||
op.drop_table("tag_eval_run")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreates the shape from 0056 (data is not restorable).
|
||||
op.create_table(
|
||||
"tag_eval_run",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("params", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False,
|
||||
server_default="running"),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("report", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("last_progress_at", sa.DateTime(timezone=True),
|
||||
nullable=True),
|
||||
)
|
||||
op.create_index("ix_tag_eval_run_status", "tag_eval_run", ["status"])
|
||||
@@ -0,0 +1,35 @@
|
||||
"""ml_settings.cpu_embed_enabled — the CPU embed fallback becomes a switch
|
||||
|
||||
B3 (operator 2026-07-02): the ml-worker's only processing role is the CPU
|
||||
whole-image embed for stacks without a GPU agent. ON by default (a fresh
|
||||
install works agent-less); agent-equipped stacks that drop the ml-worker
|
||||
container turn it off so import hooks stop queueing embed work into a queue
|
||||
nothing consumes — the daily GPU 'embed' backfill covers those images.
|
||||
|
||||
Revision ID: 0074
|
||||
Revises: 0073
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0074"
|
||||
down_revision: Union[str, None] = "0073"
|
||||
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(
|
||||
"cpu_embed_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "cpu_embed_enabled")
|
||||
@@ -34,6 +34,23 @@ def create_app() -> Quart:
|
||||
app = Quart(__name__)
|
||||
app.secret_key = cfg.secret_key
|
||||
|
||||
# Stream files in 4 MiB chunks instead of Quart's 8 KiB default. The image
|
||||
# library lives on a CIFS/SMB share (mounted rsize=4 MiB), so 8 KiB reads
|
||||
# meant ~19k network round-trips for one large original — 30–58s downloads
|
||||
# that starved both the GPU agent and the browser (operator-flagged
|
||||
# 2026-07-01). 4 MiB matches the mount's read size → one round-trip per read,
|
||||
# ~500× fewer. buffer_size is the MAX read, so small thumbnails still read in
|
||||
# a single gulp, and Range/mime/ETag/conditional handling lives on Response,
|
||||
# so this keeps all of it. Guarded so a future Quart-internal change can't
|
||||
# break boot — worst case we fall back to the slow default.
|
||||
try:
|
||||
from quart.wrappers.response import FileBody
|
||||
FileBody.buffer_size = 4 * 1024 * 1024
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
"could not raise FileBody.buffer_size — file serving stays on 8 KiB chunks"
|
||||
)
|
||||
|
||||
for bp in all_blueprints():
|
||||
app.register_blueprint(bp)
|
||||
# Registered last so /api/* routes win over the SPA catch-all.
|
||||
|
||||
@@ -16,7 +16,6 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
|
||||
def all_blueprints() -> list[Blueprint]:
|
||||
from .admin import admin_bp
|
||||
from .aliases import aliases_bp
|
||||
from .allowlist import allowlist_bp
|
||||
from .artist import artist_bp
|
||||
from .artists import artists_bp
|
||||
from .attachments import attachments_bp
|
||||
@@ -39,7 +38,6 @@ def all_blueprints() -> list[Blueprint]:
|
||||
from .suggestions import suggestions_bp
|
||||
from .system_activity import system_activity_bp
|
||||
from .system_backup import system_backup_bp
|
||||
from .tag_eval import tag_eval_bp
|
||||
from .tags import tags_bp
|
||||
from .thumbnails import thumbnails_bp
|
||||
return [
|
||||
@@ -58,9 +56,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
cleanup_bp,
|
||||
import_admin_bp,
|
||||
suggestions_bp,
|
||||
allowlist_bp,
|
||||
aliases_bp,
|
||||
tag_eval_bp,
|
||||
heads_bp,
|
||||
gpu_bp,
|
||||
ccip_bp,
|
||||
|
||||
+37
-21
@@ -1,13 +1,12 @@
|
||||
"""FC-3k: /api/admin — destructive admin actions.
|
||||
|
||||
Five action surfaces:
|
||||
Action surfaces:
|
||||
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
||||
POST /api/admin/images/bulk-delete (Tier C)
|
||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
||||
POST /api/admin/tags/prune-unused (Tier A)
|
||||
POST /api/admin/posts/prune-bare (Tier A)
|
||||
POST /api/admin/tags/purge-legacy (Tier A)
|
||||
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
||||
|
||||
Tier-C ops take a dry_run body flag (returns projection inline,
|
||||
@@ -277,31 +276,48 @@ async def posts_reconcile_duplicates():
|
||||
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
||||
async def tags_purge_legacy():
|
||||
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
|
||||
kinds (e.g. `BlenderKnight:Hannah_BJ_Loops`) PLUS general tags with
|
||||
a legacy name prefix (`source:*`, from IR's source kind that fell
|
||||
back to general). dry-run preview returns per-kind + per-prefix
|
||||
counts + a sample so the UI shows exactly what'll go before the
|
||||
operator confirms with dry_run=false."""
|
||||
from ..services.cleanup_service import purge_legacy_tags
|
||||
|
||||
return await _run_dry_run_op(purge_legacy_tags)
|
||||
def _reset_content_confirm_token(projection: dict) -> str:
|
||||
"""Stable 8-hex token derived from the live counts (mirrors the Tier-C
|
||||
bulk-delete token): it changes whenever the data changes, so the apply can
|
||||
only ever run against numbers the operator just previewed."""
|
||||
canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
|
||||
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||
async def tags_reset_content():
|
||||
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
|
||||
content vocabulary) so the operator can re-tag from scratch via
|
||||
auto-suggest. fandom + series tags + series_page ordering are preserved,
|
||||
and image_prediction rows are untouched so suggestions repopulate.
|
||||
dry-run preview returns per-kind counts + applications + a sample so the
|
||||
UI shows exactly what'll go before the operator confirms (dry_run=false).
|
||||
Irreversible except via DB backup restore."""
|
||||
"""Full-instance reset of the CONTENT vocabulary: deletes ALL general +
|
||||
character tags and their image applications — INCLUDING the examples the
|
||||
tagging heads learned from. Suggestions do NOT repopulate on their own
|
||||
(the Camie predictions that once did are long retired): the operator
|
||||
re-tags from scratch and the heads retrain from the new signal. fandom +
|
||||
series tags + series_page ordering are preserved.
|
||||
|
||||
Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02:
|
||||
the full reset stays, but behind extra steps): dry_run returns the
|
||||
projection + a `confirm` token derived from the live counts; the apply
|
||||
must echo that token back or it is rejected."""
|
||||
from ..services.cleanup_service import reset_content_tagging
|
||||
|
||||
return await _run_dry_run_op(reset_content_tagging)
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", False))
|
||||
async with get_session() as session:
|
||||
projection = await session.run_sync(
|
||||
lambda s: reset_content_tagging(s, dry_run=True)
|
||||
)
|
||||
token = _reset_content_confirm_token(projection)
|
||||
if dry_run:
|
||||
projection["confirm"] = token
|
||||
return jsonify(projection)
|
||||
if str(body.get("confirm", "")) != token:
|
||||
return _bad(
|
||||
"confirm_mismatch",
|
||||
detail="run a fresh preview and echo its confirm token",
|
||||
)
|
||||
result = await session.run_sync(
|
||||
lambda s: reset_content_tagging(s, dry_run=False)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/normalize", methods=["POST"])
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Allowlist API: list, adjust threshold, remove."""
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import TagAllowlist
|
||||
from ..services.ml.allowlist import AllowlistService
|
||||
|
||||
allowlist_bp = Blueprint("allowlist", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@allowlist_bp.route("/allowlist", methods=["GET"])
|
||||
async def list_allowlist():
|
||||
async with get_session() as session:
|
||||
rows = await AllowlistService(session).list_all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"tag_id": r.tag_id,
|
||||
"tag_name": r.tag_name,
|
||||
"tag_kind": r.tag_kind,
|
||||
"min_confidence": r.min_confidence,
|
||||
"applied_count": r.applied_count,
|
||||
"coverage_count": r.coverage_count,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@allowlist_bp.route("/tags/<int:tag_id>/allowlist/coverage", methods=["GET"])
|
||||
async def coverage(tag_id: int):
|
||||
"""Live "at threshold T, a sweep would cover ~N images" projection for the
|
||||
allowlist tuning dashboard. Defaults to the tag's stored threshold."""
|
||||
raw = request.args.get("threshold")
|
||||
async with get_session() as session:
|
||||
svc = AllowlistService(session)
|
||||
if raw is not None:
|
||||
try:
|
||||
threshold = float(raw)
|
||||
except ValueError:
|
||||
return jsonify({"error": "threshold must be a float"}), 400
|
||||
if not (0 < threshold <= 1):
|
||||
return jsonify({"error": "threshold must be in (0, 1]"}), 400
|
||||
else:
|
||||
row = await session.get(TagAllowlist, tag_id)
|
||||
if row is None:
|
||||
return jsonify({"error": "not on allowlist"}), 404
|
||||
threshold = row.min_confidence
|
||||
count = await svc.coverage(tag_id, threshold)
|
||||
return jsonify({"count": count, "threshold": threshold})
|
||||
|
||||
|
||||
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
|
||||
async def get_one(tag_id: int):
|
||||
async with get_session() as session:
|
||||
row = await session.get(TagAllowlist, tag_id)
|
||||
if row is None:
|
||||
return jsonify({"error": "not on allowlist"}), 404
|
||||
return jsonify(
|
||||
{"min_confidence": row.min_confidence, "added_at": row.added_at.isoformat()}
|
||||
)
|
||||
|
||||
|
||||
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["PATCH"])
|
||||
async def patch_threshold(tag_id: int):
|
||||
body = await request.get_json()
|
||||
if not body or "min_confidence" not in body:
|
||||
return jsonify({"error": "min_confidence required"}), 400
|
||||
mc = float(body["min_confidence"])
|
||||
if not (0 < mc <= 1):
|
||||
return jsonify({"error": "min_confidence must be in (0, 1]"}), 400
|
||||
async with get_session() as session:
|
||||
await AllowlistService(session).update_threshold(tag_id, mc)
|
||||
await session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["DELETE"])
|
||||
async def remove(tag_id: int):
|
||||
async with get_session() as session:
|
||||
await AllowlistService(session).remove(tag_id)
|
||||
await session.commit()
|
||||
return "", 204
|
||||
@@ -44,7 +44,8 @@ def _parse_filters():
|
||||
the image must match at least one tag from EACH group (groups ANDed).
|
||||
- `tag_not` is a comma-separated exclude list (image must carry none).
|
||||
|
||||
`media` is image|video; `sort` is newest|oldest; `platform` selects one
|
||||
`media` is image|video; `sort` is newest|oldest|posted_new|posted_old
|
||||
(default posted_new); `platform` selects one
|
||||
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
|
||||
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
|
||||
(date_to is widened by a day so the whole day is covered by the service's
|
||||
@@ -67,8 +68,12 @@ def _parse_filters():
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
media = request.args.get("media")
|
||||
media_type = media if media in ("image", "video") else None
|
||||
# newest/oldest key off effective_date (primary post / download); posted_new/
|
||||
# posted_old off earliest_post_date (original publish across all posts). The
|
||||
# default is posted_new so the grid leads with original publish date, not the
|
||||
# download/repost the primary post points at (operator-flagged 2026-07-01).
|
||||
sort = request.args.get("sort")
|
||||
sort = sort if sort in ("newest", "oldest") else "newest"
|
||||
sort = sort if sort in ("newest", "oldest", "posted_new", "posted_old") else "posted_new"
|
||||
platform = request.args.get("platform") or None
|
||||
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
||||
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
|
||||
|
||||
+183
-9
@@ -9,20 +9,25 @@ homelab admin.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
|
||||
from ..services.gallery_service import image_url
|
||||
from ..services.ml.embedder import MODEL_NAME as EMBED_MODEL_NAME
|
||||
from ..services.ml.gpu_jobs import GpuJobService
|
||||
from ..services.ml.gpu_jobs import GpuJobService, error_dedupe_statements
|
||||
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
|
||||
from ..services.ml.regions import RegionService
|
||||
|
||||
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
|
||||
|
||||
# Same container mount the maintenance tasks use (tasks/admin.py) — recovery
|
||||
# deletes the defective original + thumbnail under it.
|
||||
_IMAGES_ROOT = Path("/images")
|
||||
|
||||
_TOKEN_KEY = "gpu_agent_token"
|
||||
|
||||
|
||||
@@ -91,12 +96,152 @@ async def backfill():
|
||||
"""Enqueue a job for every image that doesn't already have one for `task`."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
task = str(body.get("task") or "ccip")
|
||||
from ..tasks.ml import enqueue_gpu_backfill
|
||||
from ..tasks.gpu_queue import enqueue_gpu_backfill
|
||||
|
||||
r = enqueue_gpu_backfill.delay(task)
|
||||
return jsonify({"celery_task_id": r.id, "task": task}), 202
|
||||
|
||||
|
||||
@gpu_bp.route("/reprocess", methods=["POST"])
|
||||
async def reprocess():
|
||||
"""Reset every done/error job of `task` back to pending so the agent re-runs
|
||||
the WHOLE library under the current pipeline (e.g. after adding crop
|
||||
detectors). Heavy — the back-catalogue is otherwise skipped by the backfills."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
task = str(body.get("task") or "ccip")
|
||||
from ..tasks.gpu_queue import reprocess_gpu_jobs
|
||||
|
||||
r = reprocess_gpu_jobs.delay(task)
|
||||
return jsonify({"celery_task_id": r.id, "task": task}), 202
|
||||
|
||||
|
||||
@gpu_bp.route("/retry_errors", methods=["POST"])
|
||||
async def retry_errors():
|
||||
"""Requeue every ERRORED job (all task types) back to pending — the scoped
|
||||
recovery after an agent-side fix (e.g. the short-video sampler), where
|
||||
/reprocess would needlessly re-run the whole done library too. Attempts and
|
||||
the stored error reset so each job gets its full retry budget under the
|
||||
fixed pipeline. Stale tombstones are pruned FIRST (loop-era duplicates and
|
||||
rows a later success made moot — the same statements the backfills run), so
|
||||
one failing file requeues as ONE job, never a fan-out of duplicates. Small
|
||||
row count (errors only) → inline statements; the response carries the
|
||||
counts for the UI toast. Triage-confirmed defects are NOT requeued (see
|
||||
the WHERE below) — they stay on the recovery surface."""
|
||||
async with get_session() as session:
|
||||
pruned = 0
|
||||
for stmt in error_dedupe_statements():
|
||||
pruned += (await session.execute(stmt)).rowcount or 0
|
||||
res = await session.execute(
|
||||
update(GpuJob)
|
||||
.where(
|
||||
GpuJob.status == "error",
|
||||
# Triage-confirmed DEFECTS stay errored: the integrity probe
|
||||
# already proved the FILE is bad, so re-running the job just
|
||||
# burns agent time re-minting the same tombstone — those go
|
||||
# through /errors/<id>/recover instead.
|
||||
or_(GpuJob.triage_status.is_(None),
|
||||
GpuJob.triage_status != "defect"),
|
||||
)
|
||||
.values(
|
||||
status="pending", attempts=0, error=None, lease_token=None,
|
||||
leased_at=None, lease_expires_at=None, triage_status=None,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
kept = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(GpuJob)
|
||||
.where(GpuJob.status == "error")
|
||||
)
|
||||
).scalar_one()
|
||||
await session.commit()
|
||||
return jsonify({
|
||||
"requeued": res.rowcount or 0, "pruned": pruned, "defects_kept": kept,
|
||||
})
|
||||
|
||||
|
||||
# --- Failure triage + recovery (#125) ------------------------------------
|
||||
|
||||
@gpu_bp.route("/errors", methods=["GET"])
|
||||
async def errors():
|
||||
"""The triage view of the error tombstones: every errored job joined with
|
||||
its image's integrity verdict, bucketed by reason for the overview. The
|
||||
probe sweep (triage_gpu_errors, 15-min beat) fills triage_status; 'defect'
|
||||
rows are the recovery surface's list."""
|
||||
async with get_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
GpuJob.id, GpuJob.image_record_id, GpuJob.task,
|
||||
GpuJob.error, GpuJob.triage_status, GpuJob.updated_at,
|
||||
ImageRecord.integrity_status, ImageRecord.mime,
|
||||
ImageRecord.path, ImageRecord.thumbnail_path,
|
||||
)
|
||||
.join(ImageRecord, ImageRecord.id == GpuJob.image_record_id)
|
||||
.where(GpuJob.status == "error")
|
||||
.order_by(GpuJob.updated_at.desc())
|
||||
.limit(500)
|
||||
)
|
||||
).all()
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(GpuJob)
|
||||
.where(GpuJob.status == "error")
|
||||
)
|
||||
).scalar_one()
|
||||
by_class: dict[str, int] = {}
|
||||
triage = {"defect": 0, "file_ok": 0, "unclassified": 0}
|
||||
items = []
|
||||
for r in rows:
|
||||
cls = classify_reason(r.error)
|
||||
by_class[cls] = by_class.get(cls, 0) + 1
|
||||
bucket = r.triage_status or "unclassified"
|
||||
triage[bucket] = triage.get(bucket, 0) + 1
|
||||
items.append({
|
||||
"job_id": r.id,
|
||||
"image_id": r.image_record_id,
|
||||
"task": r.task,
|
||||
"error": r.error,
|
||||
"reason_class": cls,
|
||||
"triage_status": r.triage_status,
|
||||
"integrity_status": r.integrity_status,
|
||||
"mime": r.mime,
|
||||
"image_url": image_url(r.path),
|
||||
"thumbnail_url": (
|
||||
image_url(r.thumbnail_path) if r.thumbnail_path else None
|
||||
),
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
})
|
||||
return jsonify({
|
||||
"total": total, "by_class": by_class, "triage": triage, "items": items,
|
||||
})
|
||||
|
||||
|
||||
@gpu_bp.route("/errors/triage", methods=["POST"])
|
||||
async def errors_triage():
|
||||
"""Run the probe sweep NOW (the card's button) instead of waiting out the
|
||||
15-minute beat cadence."""
|
||||
from ..tasks.maintenance import triage_gpu_errors
|
||||
|
||||
r = triage_gpu_errors.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
|
||||
@gpu_bp.route("/errors/<int:image_id>/recover", methods=["POST"])
|
||||
async def errors_recover(image_id: int):
|
||||
"""Recover a defect-triaged original: delete the bad copy + record and
|
||||
re-poll its subscription Source (a fresh fetch re-imports the file, which
|
||||
re-enters the GPU pipeline). Returns status 'no_source' when nothing
|
||||
pollable resolves — the file needs manual replacement there."""
|
||||
async with get_session() as session:
|
||||
result = await session.run_sync(
|
||||
lambda s: recover_defective_image(
|
||||
s, image_id, images_root=_IMAGES_ROOT,
|
||||
)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
|
||||
|
||||
@gpu_bp.route("/jobs/lease", methods=["POST"])
|
||||
@@ -138,11 +283,12 @@ async def lease():
|
||||
# For video/animated: the agent samples at this cadence.
|
||||
"frame_interval_seconds": ml.video_frame_interval_seconds,
|
||||
"max_frames": ml.video_max_frames,
|
||||
# The embedding model the agent must use for concept crops, so
|
||||
# its region vectors land in the SAME space the heads trained in.
|
||||
# Server-announced → the agent stays model-agnostic; a swap is a
|
||||
# server setting + a re-embed migration, never an agent change.
|
||||
"embed_model_name": EMBED_MODEL_NAME,
|
||||
# The embedding model the agent must use for concept crops + the
|
||||
# whole-image 'embed' task, so its vectors land in the SAME space
|
||||
# the heads trained in. Server-announced FROM THE SETTING → the
|
||||
# agent stays model-agnostic; an operator swap is a setting + a
|
||||
# re-embed, never an agent change.
|
||||
"embed_model_name": ml.embedder_model_name,
|
||||
"embed_version": ml.embedder_model_version,
|
||||
})
|
||||
return jsonify({"jobs": out})
|
||||
@@ -188,6 +334,34 @@ async def submit():
|
||||
return jsonify({"ok": True, "stored": len(regions)})
|
||||
|
||||
|
||||
@gpu_bp.route("/jobs/submit_embedding", methods=["POST"])
|
||||
async def submit_embedding():
|
||||
"""Store a whole-image SigLIP embedding (the 'embed' task) on image_record +
|
||||
close the job. Body: {agent_id, job_id, embedding:[...], embedding_version}.
|
||||
This is how the GPU agent re-embeds the library under a new model (#1190) —
|
||||
much faster than the CPU ml-worker at higher resolutions."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
agent_id = str(body.get("agent_id") or "agent")
|
||||
job_id = body.get("job_id")
|
||||
embedding = body.get("embedding")
|
||||
version = body.get("embedding_version")
|
||||
if job_id is None or not embedding or not version:
|
||||
return jsonify({"error": "job_id, embedding, embedding_version required"}), 400
|
||||
async with get_session() as session:
|
||||
if not await _agent_authed(session):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
job = await session.get(GpuJob, int(job_id))
|
||||
if job is None or job.status != "leased" or job.lease_token != agent_id:
|
||||
return jsonify({"error": "lease_invalid"}), 409
|
||||
img = await session.get(ImageRecord, job.image_record_id)
|
||||
if img is not None:
|
||||
img.siglip_embedding = embedding
|
||||
img.siglip_model_version = version
|
||||
await GpuJobService(session).complete(agent_id, int(job_id))
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@gpu_bp.route("/jobs/fail", methods=["POST"])
|
||||
async def fail():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
|
||||
+43
-43
@@ -1,4 +1,4 @@
|
||||
"""ML admin API: settings, backfill trigger, centroid recompute trigger."""
|
||||
"""ML admin API: settings + backfill trigger."""
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
@@ -9,14 +9,9 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
||||
|
||||
|
||||
_EDITABLE = (
|
||||
"suggestion_threshold_character",
|
||||
"suggestion_threshold_general",
|
||||
"centroid_similarity_threshold",
|
||||
"min_reference_images",
|
||||
"tagger_store_floor",
|
||||
"cpu_embed_enabled",
|
||||
"video_frame_interval_seconds",
|
||||
"video_max_frames",
|
||||
"video_min_tag_frames",
|
||||
"head_min_positives",
|
||||
"head_auto_apply_precision",
|
||||
"head_auto_apply_enabled",
|
||||
@@ -24,9 +19,41 @@ _EDITABLE = (
|
||||
"ccip_match_threshold",
|
||||
"ccip_auto_apply_enabled",
|
||||
"ccip_auto_apply_threshold",
|
||||
"embedder_model_name",
|
||||
"embedder_model_version",
|
||||
)
|
||||
|
||||
|
||||
# Supported embedders for the Settings dropdown — all 1152-d so a swap is a
|
||||
# drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI
|
||||
# never free-types a model name.
|
||||
SUPPORTED_EMBEDDERS = (
|
||||
{
|
||||
"name": "google/siglip2-so400m-patch16-512",
|
||||
"version": "siglip2-so400m-patch16-512",
|
||||
"label": "SigLIP 2 · so400m · 512px (recommended)",
|
||||
"dim": 1152,
|
||||
},
|
||||
{
|
||||
"name": "google/siglip2-so400m-patch16-384",
|
||||
"version": "siglip2-so400m-patch16-384",
|
||||
"label": "SigLIP 2 · so400m · 384px (faster)",
|
||||
"dim": 1152,
|
||||
},
|
||||
{
|
||||
"name": "google/siglip-so400m-patch14-384",
|
||||
"version": "siglip-so400m-patch14-384",
|
||||
"label": "SigLIP 1 · so400m · 384px (original)",
|
||||
"dim": 1152,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ml_admin_bp.route("/embedder-models", methods=["GET"])
|
||||
async def embedder_models():
|
||||
return jsonify({"models": list(SUPPORTED_EMBEDDERS)})
|
||||
|
||||
|
||||
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||
async def get_settings():
|
||||
from sqlalchemy import select
|
||||
@@ -37,15 +64,9 @@ async def get_settings():
|
||||
).scalar_one()
|
||||
return jsonify(
|
||||
{
|
||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||
"min_reference_images": s.min_reference_images,
|
||||
"tagger_store_floor": s.tagger_store_floor,
|
||||
"cpu_embed_enabled": s.cpu_embed_enabled,
|
||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||
"video_max_frames": s.video_max_frames,
|
||||
"video_min_tag_frames": s.video_min_tag_frames,
|
||||
"tagger_model_version": s.tagger_model_version,
|
||||
"embedder_model_version": s.embedder_model_version,
|
||||
"head_min_positives": s.head_min_positives,
|
||||
"head_auto_apply_precision": s.head_auto_apply_precision,
|
||||
@@ -54,6 +75,7 @@ async def get_settings():
|
||||
"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,
|
||||
"embedder_model_name": s.embedder_model_name,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,31 +111,12 @@ async def patch_settings():
|
||||
|
||||
|
||||
def _validate(p: dict) -> str | None:
|
||||
"""Returns an error string if the proposed settings are invalid, else None.
|
||||
|
||||
Invariant (plan-task #764): the per-category suggestion thresholds can't
|
||||
drop below tagger_store_floor — nothing below the floor is stored, so a
|
||||
lower threshold would silently surface nothing in that gap. The UI clamps
|
||||
the sliders to the floor; this is the server-side backstop.
|
||||
"""
|
||||
floor = p["tagger_store_floor"]
|
||||
if not (0.0 <= floor <= 1.0):
|
||||
return "tagger_store_floor must be between 0 and 1"
|
||||
for cat in ("character", "general"):
|
||||
if p[f"suggestion_threshold_{cat}"] < floor:
|
||||
return (
|
||||
f"suggestion_threshold_{cat} cannot be below tagger_store_floor "
|
||||
f"({floor}) — predictions below the floor are not stored"
|
||||
)
|
||||
# Video tagging (#747).
|
||||
"""Returns an error string if the proposed settings are invalid, else None."""
|
||||
# Video embedding (#747).
|
||||
if p["video_frame_interval_seconds"] <= 0:
|
||||
return "video_frame_interval_seconds must be > 0"
|
||||
if p["video_max_frames"] < 1:
|
||||
return "video_max_frames must be >= 1"
|
||||
if p["video_min_tag_frames"] < 1:
|
||||
return "video_min_tag_frames must be >= 1"
|
||||
if p["video_min_tag_frames"] > p["video_max_frames"]:
|
||||
return "video_min_tag_frames cannot exceed video_max_frames"
|
||||
# Head training (#114).
|
||||
if int(p["head_min_positives"]) < 1:
|
||||
return "head_min_positives must be >= 1"
|
||||
@@ -125,6 +128,11 @@ def _validate(p: dict) -> str | None:
|
||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
|
||||
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
||||
# different embedding space — the operator must re-embed + retrain after.
|
||||
for key in ("embedder_model_name", "embedder_model_version"):
|
||||
if not str(p[key]).strip():
|
||||
return f"{key} must not be empty"
|
||||
return None
|
||||
|
||||
|
||||
@@ -134,11 +142,3 @@ async def trigger_backfill():
|
||||
|
||||
r = backfill.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
|
||||
@ml_admin_bp.route("/recompute-centroids", methods=["POST"])
|
||||
async def trigger_recompute():
|
||||
from ..tasks.ml import recompute_centroids
|
||||
|
||||
r = recompute_centroids.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
@@ -3,31 +3,12 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import Tag, TagAllowlist
|
||||
from ..services.ml.allowlist import AllowlistService
|
||||
from ..services.ml.suggestions import SuggestionService
|
||||
|
||||
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
|
||||
"""Shape the accept/alias response. When accepting newly allowlists a tag,
|
||||
include the coverage PROJECTION (at the tag's threshold) so the UI can show
|
||||
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
|
||||
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
|
||||
count (#7)."""
|
||||
payload = {"allowlisted": newly_added}
|
||||
if newly_added:
|
||||
tag = await session.get(Tag, tag_id)
|
||||
row = await session.get(TagAllowlist, tag_id)
|
||||
payload["tag_id"] = tag_id
|
||||
payload["tag_name"] = tag.name if tag is not None else None
|
||||
payload["projected_count"] = await svc.coverage(
|
||||
tag_id, row.min_confidence if row is not None else 0.90,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||
async def get_suggestions(image_id: int):
|
||||
# ?min=<float> overrides the configured per-category thresholds so the typed
|
||||
@@ -83,15 +64,9 @@ async def accept_suggestion(image_id: int):
|
||||
return jsonify({"error": "tag_id required"}), 400
|
||||
tag_id = body["tag_id"]
|
||||
async with get_session() as session:
|
||||
svc = AllowlistService(session)
|
||||
newly_added = await svc.accept(image_id, tag_id)
|
||||
payload = await _accept_payload(session, svc, newly_added, tag_id)
|
||||
await AllowlistService(session).accept(image_id, tag_id)
|
||||
await session.commit()
|
||||
if newly_added:
|
||||
from ..tasks.ml import apply_allowlist_tags
|
||||
|
||||
apply_allowlist_tags.delay(tag_id=tag_id)
|
||||
return jsonify(payload)
|
||||
return jsonify({"accepted": True, "tag_id": tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
@@ -104,22 +79,14 @@ async def alias_suggestion(image_id: int):
|
||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
||||
canonical_tag_id = body["canonical_tag_id"]
|
||||
async with get_session() as session:
|
||||
svc = AllowlistService(session)
|
||||
newly_added = await svc.add_alias_and_accept(
|
||||
await AllowlistService(session).add_alias_and_accept(
|
||||
image_id,
|
||||
body["alias_string"],
|
||||
body["alias_category"],
|
||||
canonical_tag_id,
|
||||
)
|
||||
payload = await _accept_payload(
|
||||
session, svc, newly_added, canonical_tag_id,
|
||||
)
|
||||
await session.commit()
|
||||
if newly_added:
|
||||
from ..tasks.ml import apply_allowlist_tags
|
||||
|
||||
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
|
||||
return jsonify(payload)
|
||||
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tag-eval API (#1130): trigger + revisit the head-vs-centroid eval.
|
||||
|
||||
The run + full report live in the tag_eval_run row, so the admin card rehydrates
|
||||
from GET (history / detail) on mount — the report survives navigation rather than
|
||||
living in transient frontend state.
|
||||
"""
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import TagEvalRun
|
||||
from ..services.ml.tag_eval import EvalAlreadyRunning, start_tag_eval_run
|
||||
|
||||
tag_eval_bp = Blueprint("tag_eval", __name__, url_prefix="/api/tag-eval")
|
||||
|
||||
|
||||
def _serialize(run: TagEvalRun, *, include_report: bool) -> dict:
|
||||
out = {
|
||||
"id": run.id,
|
||||
"params": run.params,
|
||||
"status": run.status,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"error": run.error,
|
||||
}
|
||||
if include_report:
|
||||
out["report"] = run.report
|
||||
return out
|
||||
|
||||
|
||||
@tag_eval_bp.route("", methods=["POST"])
|
||||
async def create():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
params = body.get("params") or body or {}
|
||||
async with get_session() as session:
|
||||
try:
|
||||
run_id = await session.run_sync(
|
||||
lambda s: start_tag_eval_run(s, params)
|
||||
)
|
||||
except EvalAlreadyRunning as running:
|
||||
return jsonify({
|
||||
"error": "eval_already_running",
|
||||
"running_id": int(running.args[0]),
|
||||
}), 409
|
||||
await session.commit()
|
||||
return jsonify({"run_id": run_id, "status": "running"}), 202
|
||||
|
||||
|
||||
@tag_eval_bp.route("", methods=["GET"])
|
||||
async def history():
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", "20")), 100)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid_limit"}), 400
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(TagEvalRun).order_by(TagEvalRun.id.desc()).limit(limit)
|
||||
)).scalars().all()
|
||||
# List is light — no full report (the detail endpoint carries it).
|
||||
return jsonify({"runs": [_serialize(r, include_report=False) for r in rows]})
|
||||
|
||||
|
||||
@tag_eval_bp.route("/<int:run_id>", methods=["GET"])
|
||||
async def detail(run_id: int):
|
||||
async with get_session() as session:
|
||||
run = await session.get(TagEvalRun, run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize(run, include_report=True))
|
||||
+115
-16
@@ -1,13 +1,14 @@
|
||||
"""Tags API: autocomplete, create, list/add/remove for an image."""
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import Tag, TagKind, TagPositiveConfirmation
|
||||
from ..models.tag_allowlist import TagAllowlist
|
||||
from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
|
||||
from ..models.tag import image_tag
|
||||
from ..models.tag_suggestion_rejection import TagSuggestionRejection
|
||||
from ..services.bulk_tag_service import BulkTagService
|
||||
from ..services.ml.aliases import AliasService
|
||||
from ..services.series_match_service import SeriesMatchService
|
||||
@@ -61,6 +62,117 @@ def _parse_bulk_ids(
|
||||
return ids, None
|
||||
|
||||
|
||||
# Application-source groupings (image_tag.source). HUMAN = operator signal;
|
||||
# AUTO = machine-applied (heads/CCIP, + legacy Camie ml_auto).
|
||||
_SOURCE_GROUPS = {
|
||||
"human": ("manual", "ml_accepted"),
|
||||
"manual": ("manual",),
|
||||
"accepted": ("ml_accepted",),
|
||||
"auto": ("head_auto", "ccip_auto", "ml_auto"),
|
||||
}
|
||||
|
||||
|
||||
@tags_bp.route("/tags/top", methods=["GET"])
|
||||
async def tags_top():
|
||||
"""Top tags by image count — a fast indexed aggregate for ANALYSIS (not the
|
||||
paged UI directory, which is alphabetical + builds previews). Params:
|
||||
?kind=general|character|fandom|… ?source=all|human|manual|accepted|auto
|
||||
?limit=50 (cap 500) ?min_count=N. → {tags:[{tag_id,name,kind,count}]} desc."""
|
||||
kind = _coerce_kind(request.args.get("kind"))
|
||||
try:
|
||||
limit = min(max(int(request.args.get("limit", "50")), 1), 500)
|
||||
except ValueError:
|
||||
return jsonify({"error": "limit must be an integer"}), 400
|
||||
min_count = None
|
||||
if "min_count" in request.args:
|
||||
try:
|
||||
min_count = int(request.args["min_count"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "min_count must be an integer"}), 400
|
||||
src_vals = _SOURCE_GROUPS.get((request.args.get("source") or "all").lower())
|
||||
|
||||
cnt = func.count(image_tag.c.image_record_id)
|
||||
stmt = (
|
||||
select(Tag.id, Tag.name, Tag.kind, cnt.label("count"))
|
||||
.select_from(Tag)
|
||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
.group_by(Tag.id, Tag.name, Tag.kind)
|
||||
.order_by(cnt.desc(), Tag.name.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
if src_vals is not None:
|
||||
stmt = stmt.where(image_tag.c.source.in_(src_vals))
|
||||
if min_count is not None:
|
||||
stmt = stmt.having(cnt >= min_count)
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return jsonify({"tags": [
|
||||
{
|
||||
"tag_id": r.id, "name": r.name,
|
||||
"kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
|
||||
"count": r.count,
|
||||
}
|
||||
for r in rows
|
||||
]})
|
||||
|
||||
|
||||
@tags_bp.route("/tags/<int:tag_id>/stats", methods=["GET"])
|
||||
async def tag_stats(tag_id: int):
|
||||
"""Per-tag dataset health: total + per-source application counts (human vs
|
||||
machine), rejection count, and whether a trained head exists. Read-only,
|
||||
analysis-shaped — backs concept-readiness + source-split decisions."""
|
||||
async with get_session() as session:
|
||||
tag = await session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
by_source = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(image_tag.c.source, func.count())
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
.group_by(image_tag.c.source)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
rejected = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(TagSuggestionRejection)
|
||||
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||
)
|
||||
).scalar_one()
|
||||
has_head = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(TagHead)
|
||||
.where(TagHead.tag_id == tag_id)
|
||||
)
|
||||
).scalar_one() > 0
|
||||
human = by_source.get("manual", 0) + by_source.get("ml_accepted", 0)
|
||||
auto = (
|
||||
by_source.get("head_auto", 0)
|
||||
+ by_source.get("ccip_auto", 0)
|
||||
+ by_source.get("ml_auto", 0)
|
||||
)
|
||||
return jsonify({
|
||||
"tag_id": tag_id,
|
||||
"name": tag.name,
|
||||
"kind": tag.kind.value if hasattr(tag.kind, "value") else str(tag.kind),
|
||||
"count_total": sum(by_source.values()),
|
||||
"count_human": human,
|
||||
"count_manual": by_source.get("manual", 0),
|
||||
"count_accepted": by_source.get("ml_accepted", 0),
|
||||
"count_auto": auto,
|
||||
"count_head_auto": by_source.get("head_auto", 0),
|
||||
"count_ccip_auto": by_source.get("ccip_auto", 0),
|
||||
"count_rejected": rejected,
|
||||
"by_source": by_source,
|
||||
"has_head": has_head,
|
||||
})
|
||||
|
||||
|
||||
@tags_bp.route("/tags/autocomplete", methods=["GET"])
|
||||
async def autocomplete():
|
||||
q = request.args.get("q", "")
|
||||
@@ -297,19 +409,6 @@ async def merge_tag(source_id: int):
|
||||
status = 404 if "not found" in msg else 400
|
||||
return jsonify({"error": msg}), status
|
||||
await session.commit()
|
||||
target_allowlisted = await session.scalar(
|
||||
select(exists().where(TagAllowlist.tag_id == result.target_id))
|
||||
)
|
||||
if target_allowlisted:
|
||||
from ..tasks.ml import apply_allowlist_tags
|
||||
|
||||
apply_allowlist_tags.delay(tag_id=result.target_id)
|
||||
# Tag merge invalidates the target's centroid (the merged-in source
|
||||
# tag's images now contribute to it). Daily list_drifted catches it
|
||||
# within 24h, but eager recompute closes the suggestion-quality dip
|
||||
# in the meantime. Audit 2026-06-02.
|
||||
from ..tasks.ml import recompute_centroid
|
||||
recompute_centroid.delay(result.target_id)
|
||||
return jsonify(
|
||||
{
|
||||
"target": {
|
||||
|
||||
+23
-24
@@ -7,7 +7,7 @@ Queues:
|
||||
download — gallery-dl tasks (FC-3)
|
||||
scan — periodic source checks (FC-3) — kept separate so long imports
|
||||
don't starve the scheduler
|
||||
maintenance — pHash recomputation, centroid rebuild, etc. (FC-2/FC-3)
|
||||
maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
|
||||
default — anything not explicitly routed
|
||||
"""
|
||||
|
||||
@@ -29,6 +29,7 @@ def make_celery() -> Celery:
|
||||
"backend.app.tasks.thumbnail",
|
||||
"backend.app.tasks.maintenance",
|
||||
"backend.app.tasks.ml",
|
||||
"backend.app.tasks.gpu_queue",
|
||||
"backend.app.tasks.download",
|
||||
"backend.app.tasks.external",
|
||||
"backend.app.tasks.backup",
|
||||
@@ -41,6 +42,11 @@ def make_celery() -> Celery:
|
||||
task_routes={
|
||||
"backend.app.tasks.import_file.*": {"queue": "import"},
|
||||
"backend.app.tasks.ml.*": {"queue": "ml"},
|
||||
# GPU-queue coordination (backfill enqueues, orphan recovery,
|
||||
# reprocess) is pure DB work — it rides the maintenance quick lane
|
||||
# so the GPU agent pipeline works even on stacks that drop the
|
||||
# (now-optional, B3) ml-worker container entirely.
|
||||
"backend.app.tasks.gpu_queue.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
|
||||
"backend.app.tasks.download.*": {"queue": "download"},
|
||||
# External file-host fetches are downloads — same lane (they can run
|
||||
@@ -97,18 +103,6 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||
"schedule": 86400.0, # daily
|
||||
},
|
||||
"ml-backfill-daily": {
|
||||
"task": "backend.app.tasks.ml.backfill",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"recompute-centroids-daily": {
|
||||
"task": "backend.app.tasks.ml.recompute_centroids",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"apply-allowlist-sweep-daily": {
|
||||
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"train-heads-nightly": {
|
||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||
@@ -118,18 +112,27 @@ def make_celery() -> Celery:
|
||||
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
|
||||
},
|
||||
"recover-orphaned-gpu-jobs": {
|
||||
"task": "backend.app.tasks.ml.recover_orphaned_gpu_jobs",
|
||||
"task": "backend.app.tasks.gpu_queue.recover_orphaned_gpu_jobs",
|
||||
"schedule": 60.0, # quick pickup of work a dead agent orphaned
|
||||
},
|
||||
"triage-gpu-errors": {
|
||||
"task": "backend.app.tasks.maintenance.triage_gpu_errors",
|
||||
"schedule": 900.0, # probe errored jobs' files → defect/file_ok
|
||||
},
|
||||
"enqueue-ccip-backfill-hourly": {
|
||||
"task": "backend.app.tasks.ml.enqueue_gpu_backfill",
|
||||
"schedule": 3600.0, # auto-feed new images (+ retry errored) so
|
||||
"args": ("ccip",), # the queue keeps moving without the button
|
||||
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
|
||||
"schedule": 3600.0, # auto-feed NEW images; errored are
|
||||
"args": ("ccip",), # tombstoned — retry is the button only
|
||||
},
|
||||
"enqueue-siglip-backfill-daily": {
|
||||
"task": "backend.app.tasks.ml.enqueue_gpu_backfill",
|
||||
"schedule": 86400.0, # drain the concept-crop back-catalogue +
|
||||
"args": ("siglip",), # retry failed embeds, no button needed
|
||||
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
|
||||
"schedule": 86400.0, # drain the concept-crop back-catalogue
|
||||
"args": ("siglip",), # (errored are tombstoned, not retried)
|
||||
},
|
||||
"enqueue-embed-backfill-daily": {
|
||||
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
|
||||
"schedule": 86400.0, # whole-image re-embed under the current
|
||||
"args": ("embed",), # model (an operator swap) drains via agent
|
||||
},
|
||||
"ccip-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||
@@ -186,10 +189,6 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
"recover-stalled-tag-eval-runs": {
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_tag_eval_runs",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
"recover-stalled-head-training-runs": {
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_head_training_runs",
|
||||
"schedule": 300.0,
|
||||
|
||||
@@ -13,7 +13,6 @@ from .head_auto_apply_run import HeadAutoApplyRun
|
||||
from .head_metric import HeadMetric
|
||||
from .head_metrics_snapshot import HeadMetricsSnapshot
|
||||
from .head_training_run import HeadTrainingRun
|
||||
from .image_prediction import ImagePrediction
|
||||
from .image_provenance import ImageProvenance
|
||||
from .image_record import ImageRecord
|
||||
from .image_region import ImageRegion
|
||||
@@ -34,11 +33,8 @@ from .subscribestar_failed_media import SubscribeStarFailedMedia
|
||||
from .subscribestar_seen_media import SubscribeStarSeenMedia
|
||||
from .tag import Tag, TagKind, image_tag
|
||||
from .tag_alias import TagAlias
|
||||
from .tag_allowlist import TagAllowlist
|
||||
from .tag_eval_run import TagEvalRun
|
||||
from .tag_head import TagHead
|
||||
from .tag_positive_confirmation import TagPositiveConfirmation
|
||||
from .tag_reference_embedding import TagReferenceEmbedding
|
||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||
from .task_run import TaskRun
|
||||
|
||||
@@ -60,7 +56,6 @@ __all__ = [
|
||||
"SeriesPage",
|
||||
"SeriesSuggestion",
|
||||
"ImageRecord",
|
||||
"ImagePrediction",
|
||||
"ImageProvenance",
|
||||
"ImageRegion",
|
||||
"Tag",
|
||||
@@ -79,11 +74,8 @@ __all__ = [
|
||||
"HeadMetricsSnapshot",
|
||||
"HeadTrainingRun",
|
||||
"TagAlias",
|
||||
"TagAllowlist",
|
||||
"TagEvalRun",
|
||||
"TagHead",
|
||||
"TagPositiveConfirmation",
|
||||
"TagReferenceEmbedding",
|
||||
"TagSuggestionRejection",
|
||||
"TaskRun",
|
||||
]
|
||||
|
||||
@@ -14,7 +14,16 @@ pending for another agent).
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -23,6 +32,17 @@ from .base import Base
|
||||
class GpuJob(Base):
|
||||
__tablename__ = "gpu_job"
|
||||
|
||||
# Partial indexes over just the live slice (see migration 0070): the lease
|
||||
# reads the lowest-id pending jobs on the hot path, and reclaims expired
|
||||
# leases as a backstop — both stay O(batch) as done/error history grows.
|
||||
__table_args__ = (
|
||||
Index("ix_gpu_job_pending", "id", postgresql_where=text("status = 'pending'")),
|
||||
Index(
|
||||
"ix_gpu_job_leased_expires", "lease_expires_at",
|
||||
postgresql_where=text("status = 'leased'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
image_record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
|
||||
@@ -42,6 +62,11 @@ class GpuJob(Base):
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Triage verdict for an ERRORED job (#125): NULL = not yet probed;
|
||||
# 'defect' = the integrity probe says the FILE itself is bad (surfaced for
|
||||
# recovery, excluded from /retry_errors); 'file_ok' = the file passes —
|
||||
# the failure was operational (timeout/transient), safe to retry.
|
||||
triage_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
|
||||
|
||||
Mirrors TagEvalRun so the run SURVIVES navigation and the admin card can show
|
||||
live + historical status instead of holding it in transient frontend state.
|
||||
A persisted run row (not transient frontend state) so the run SURVIVES
|
||||
navigation and the admin card can show live + historical status.
|
||||
Training is idempotent (it upserts tag_head rows), so a SIGKILL'd run is harmless
|
||||
— a maintenance recovery sweep flips a stalled `running` row to `error`, and the
|
||||
next run re-trains. State machine: running → ready / error.
|
||||
@@ -37,8 +37,8 @@ class HeadTrainingRun(Base):
|
||||
n_trained: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
n_skipped: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Last time the task made progress — the recovery sweep tells a live run from
|
||||
# a SIGKILL'd one by this (mirrors TagEvalRun).
|
||||
# Last time the task made progress — the recovery sweep tells a live run
|
||||
# from a SIGKILL'd one by this.
|
||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""ImagePrediction — one row per (image, tagger vocab prediction).
|
||||
|
||||
Replaces the image_record.tagger_predictions JSON blob (#768). Storing the
|
||||
raw Camie/booru vocab name (not a tag_id) preserves the suggestion read
|
||||
path's semantics: raw_name → canonical Tag resolution happens at read time
|
||||
via the alias map, and accepting a prediction can CREATE the Tag. The store
|
||||
floor (ml_settings.tagger_store_floor) is applied at WRITE time, so only
|
||||
predictions >= the floor land here.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ImagePrediction(Base):
|
||||
__tablename__ = "image_prediction"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"image_record_id", "raw_name", name="image_raw_name",
|
||||
),
|
||||
# Per-image read (suggestion build) and the "images with tag X above
|
||||
# Y" query the JSON blob never allowed.
|
||||
Index("ix_image_prediction_image", "image_record_id"),
|
||||
Index("ix_image_prediction_name_score", "raw_name", "score"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
image_record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
# The raw tagger vocab key (booru form) — NOT a tag_id. Resolved to a
|
||||
# canonical Tag at read time, exactly as the old JSON keys were.
|
||||
raw_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
@@ -9,7 +9,6 @@ from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
DateTime,
|
||||
Enum,
|
||||
@@ -77,19 +76,13 @@ class ImageRecord(Base):
|
||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# ML fields (populated by FC-2's ml-worker). Per-tag predictions live in the
|
||||
# normalized image_prediction table (#768) — the tagger_predictions JSON
|
||||
# column was dropped in migration 0046. tagger_model_version stays as the
|
||||
# "has this been tagged / is it current?" signal the backfill sweep reads.
|
||||
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
|
||||
# a column-width migration.
|
||||
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
|
||||
# embedding dim; siglip_model_version stamps which model produced it (so an
|
||||
# operator model swap, #1190, can re-embed the stale rows). A different-dim
|
||||
# model would need a column-width migration.
|
||||
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
||||
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# Centroid score cache (populated post-tagging)
|
||||
centroid_scores: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -104,6 +97,16 @@ class ImageRecord(Base):
|
||||
effective_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
# Denormalized ORIGINAL-publish sort key (alembic 0071) = MIN(post_date)
|
||||
# across ALL of the image's provenance posts, else created_at. effective_date
|
||||
# above keys off the PRIMARY post (often the repost/download the file came
|
||||
# from); this keys off the earliest publish across EVERY post the image
|
||||
# appears in, so the gallery can sort by when content was first posted rather
|
||||
# than when it was downloaded (operator-flagged 2026-07-01). Maintained by
|
||||
# services/importer.py, recomputed whenever a dated post is linked.
|
||||
earliest_post_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -31,7 +31,10 @@ class ImageRegion(Base):
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# 'frame' (a whole video frame → SigLIP bag) | 'face' | 'figure' (→ CCIP
|
||||
# character id) | 'concept' (→ SigLIP head bag).
|
||||
# character id) | 'concept' (→ SigLIP head bag) | 'panel' (a comic panel crop,
|
||||
# also SigLIP → the bag). Free String, not an enum — proposers can add kinds
|
||||
# without a migration; the bag scorer keys on a non-null siglip_embedding, not
|
||||
# the kind, so any SigLIP-embedded region joins the bag.
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# For video/animated media: the source frame's timestamp in SECONDS. NULL for
|
||||
# static images. Lets a video be a BAG of per-frame instances (fixes the
|
||||
|
||||
@@ -23,46 +23,25 @@ class MLSettings(Base):
|
||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
suggestion_threshold_character: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.70
|
||||
# CPU whole-image embedding (B3, operator 2026-07-02). The ml-worker's ONLY
|
||||
# processing role is the embed fallback for stacks WITHOUT a GPU agent — ON
|
||||
# by default so a fresh install works with no agent. Stacks that run the
|
||||
# agent and drop the ml-worker container turn this OFF so import hooks stop
|
||||
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
|
||||
# covers those images instead).
|
||||
cpu_embed_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
# Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
|
||||
# surfaced too many low-confidence picks; 0.70 keeps the rail
|
||||
# signal-rich while still surfacing more than the original 0.95
|
||||
# which hid almost everything. Operator-tunable via Settings → ML.
|
||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.70
|
||||
)
|
||||
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.55
|
||||
)
|
||||
# Ingest floor: tagger predictions below this confidence are not stored
|
||||
# (tagger.Tagger.infer). Default 0.70 — the suggestion path already
|
||||
# filters at 0.70 and the centroid/learned path covers low-confidence
|
||||
# preferred tags, so the sub-0.70 tail is redundant weight (it had
|
||||
# bloated image_record's TOAST to ~100 GB; plan-task #764). Operator-
|
||||
# tunable via Settings → ML; must stay ≤ the suggestion thresholds.
|
||||
tagger_store_floor: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.70
|
||||
)
|
||||
min_reference_images: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5
|
||||
)
|
||||
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
|
||||
# fixed count) so a tag's frame-presence reflects real screen time regardless
|
||||
# of video length; cap the total so a long video can't explode into hundreds
|
||||
# of inferences (the cadence stretches past the cap). A tag is kept only if it
|
||||
# appears in >= video_min_tag_frames sampled frames (≈ that many × interval
|
||||
# seconds on screen) — duration-independent noise rejection. Operator-tunable.
|
||||
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
|
||||
# a fixed count) so coverage reflects real screen time regardless of length;
|
||||
# cap the total so a long video can't explode into hundreds of embeds. The
|
||||
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
|
||||
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=4.0
|
||||
)
|
||||
video_max_frames: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=64
|
||||
)
|
||||
video_min_tag_frames: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=3
|
||||
)
|
||||
# Tagging-v2 head training (#114). The head is the suggestion source that
|
||||
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
||||
# needs >= head_min_positives labelled images before a head is trained;
|
||||
@@ -101,11 +80,16 @@ class MLSettings(Base):
|
||||
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.92
|
||||
)
|
||||
tagger_model_version: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="camie-tagger-v2"
|
||||
)
|
||||
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
||||
# existing libraries keep their stored value until the operator re-embeds.
|
||||
embedder_model_version: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="siglip-so400m-patch14-384"
|
||||
String(128), nullable=False, default="siglip2-so400m-patch16-512"
|
||||
)
|
||||
# The HF model NAME the embedder loads (server CPU embed + announced to the
|
||||
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
|
||||
# a hardcode (#1190): set name + version together, then re-embed + retrain.
|
||||
embedder_model_name: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""TagAllowlist — tags the operator opted-in to auto-apply via maintenance."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class TagAllowlist(Base):
|
||||
__tablename__ = "tag_allowlist"
|
||||
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
|
||||
# producing the final ck_tag_allowlist_confidence_range (matches migration 0003).
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"min_confidence > 0 AND min_confidence <= 1",
|
||||
name="confidence_range",
|
||||
),
|
||||
)
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
# Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
|
||||
# 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
|
||||
# confident-enough applications). Per-tag value is still tunable in the
|
||||
# allowlist table; existing rows keep whatever they were stored with.
|
||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""TagEvalRun — persisted lifecycle of a head-vs-centroid tagging eval (#1130).
|
||||
|
||||
Mirrors LibraryAuditRun so the result SURVIVES navigation: the run + its full
|
||||
report live in this row, and the admin card rehydrates from it on mount instead
|
||||
of holding the report in transient frontend state. State machine:
|
||||
running → ready / error. The async ml-queue task writes `report` (JSONB) when
|
||||
done; a maintenance recovery sweep flips a stalled `running` row to `error`.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class TagEvalRun(Base):
|
||||
__tablename__ = "tag_eval_run"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# The eval parameters: {concepts: [...], curve_points: [...], neg_ratio,
|
||||
# cv_folds, ...} — echoed back so the report is self-describing.
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
)
|
||||
# running | ready | error
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
# The full result: per-concept metrics (head vs centroid), learning-curve
|
||||
# points, and example image ids. Null until the task finishes.
|
||||
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Last time the task made progress — the recovery sweep tells a live run
|
||||
# from a SIGKILL'd one by this (mirrors LibraryAuditRun).
|
||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
"""TagReferenceEmbedding — per-tag centroid (mean SigLIP embedding of members)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class TagReferenceEmbedding(Base):
|
||||
__tablename__ = "tag_reference_embedding"
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
embedding: Mapped[list[float]] = mapped_column(Vector(1152), nullable=False)
|
||||
reference_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
model_version: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -7,7 +7,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
MODEL_ROOT = Path(os.environ.get("ML_MODEL_DIR", "/models"))
|
||||
CAMIE_REPO = os.environ.get("CAMIE_HF_REPO", "Camais03/camie-tagger-v2")
|
||||
SIGLIP_REPO = os.environ.get(
|
||||
"SIGLIP_HF_REPO", "google/siglip-so400m-patch14-384"
|
||||
)
|
||||
@@ -24,34 +23,6 @@ def _snapshot(repo_id: str, dest: Path, allow_patterns: list[str] | None) -> Non
|
||||
)
|
||||
|
||||
|
||||
def ensure_camie() -> None:
|
||||
"""Fetch Camie v2 weights + metadata.
|
||||
|
||||
v2 layout (HuggingFace Camais03/camie-tagger-v2): the ONNX file is
|
||||
named camie-tagger-v2.onnx (not model.onnx) and tags ship inside
|
||||
camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
|
||||
The repo also contains app/, game/, training/, images/ subdirs full
|
||||
of setup/demo files we don't need — allow_patterns scopes the fetch
|
||||
to just the inference essentials (~790 MB instead of ~2 GB).
|
||||
"""
|
||||
dest = MODEL_ROOT / "camie"
|
||||
model_file = dest / "camie-tagger-v2.onnx"
|
||||
meta_file = dest / "camie-tagger-v2-metadata.json"
|
||||
if model_file.is_file() and meta_file.is_file():
|
||||
print(f"[download_models] Camie present at {dest}")
|
||||
return
|
||||
print(f"[download_models] Fetching {CAMIE_REPO} -> {dest}")
|
||||
_snapshot(
|
||||
CAMIE_REPO, dest,
|
||||
[
|
||||
"camie-tagger-v2.onnx",
|
||||
"camie-tagger-v2-metadata.json",
|
||||
"config.json",
|
||||
"config.yaml",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def ensure_siglip() -> None:
|
||||
dest = MODEL_ROOT / "siglip"
|
||||
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
|
||||
@@ -62,7 +33,6 @@ def ensure_siglip() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_camie()
|
||||
ensure_siglip()
|
||||
print("[download_models] Done.")
|
||||
return 0
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Single-color audit: matches images where one color dominates beyond
|
||||
the threshold (within the given Euclidean RGB tolerance). The first
|
||||
canonical implementation — the import-side filter (SkipReason.single_color)
|
||||
was never wired; FC-Cleanup's audit module is the source of truth and a
|
||||
future spec can adopt it on the import path too.
|
||||
the threshold (within the given Euclidean RGB tolerance). The canonical
|
||||
predicate for BOTH surfaces: FC-Cleanup's retroactive audit and — since
|
||||
2026-07-02 — the import-side filter (Importer._single_color_hit /
|
||||
SkipReason.single_color), so what the audit flags and what the import
|
||||
skips can never disagree.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -395,9 +395,8 @@ def delete_images(
|
||||
def delete_tag(session: Session, *, tag_id: int) -> dict:
|
||||
"""Simple DELETE FROM tag WHERE id=?.
|
||||
|
||||
Postgres cascades the rest (image_tag, tag_alias, tag_allowlist,
|
||||
tag_reference_embedding, tag_suggestion_rejection, series_page).
|
||||
Returns counts BEFORE delete so the caller can surface them.
|
||||
Postgres cascades the rest (image_tag, tag_alias, tag_suggestion_rejection,
|
||||
series_page). Returns counts BEFORE delete so the caller can surface them.
|
||||
Raises LookupError if tag_id not found.
|
||||
"""
|
||||
tag = session.get(Tag, tag_id)
|
||||
@@ -719,89 +718,24 @@ def reconcile_duplicate_posts(
|
||||
return {"groups": len(groups), "merged": losers_total, "sample": sample}
|
||||
|
||||
|
||||
# Legacy tags FC no longer uses, in two shapes:
|
||||
# (1) kinds the tag input never produces — archive/post/artist.
|
||||
# provenance (post grouping) + archive membership are their own
|
||||
# systems now, and artists are first-class Artist/Source rows.
|
||||
# meta/rating were already hard-deleted by alembic 0023.
|
||||
# (2) name prefixes from IR kinds FC never adopted — `source:*`.
|
||||
# ImageRepo had a `source` kind; FC's enum doesn't, so ir_ingest
|
||||
# fell those back to `general` (kind=general, name="source:patreon"
|
||||
# etc.). They can't be caught by kind, so we match the name prefix.
|
||||
PURGEABLE_TAG_KINDS = ("archive", "post", "artist")
|
||||
LEGACY_NAME_PREFIXES = ("source:",)
|
||||
|
||||
|
||||
def _legacy_tag_predicate():
|
||||
name_clauses = [Tag.name.like(f"{p}%") for p in LEGACY_NAME_PREFIXES]
|
||||
return or_(Tag.kind.in_(PURGEABLE_TAG_KINDS), *name_clauses)
|
||||
|
||||
|
||||
def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
||||
"""Count (dry_run) or delete legacy IR-migration tags: archive/post/
|
||||
artist-kind tags PLUS general tags whose name matches a legacy
|
||||
prefix (source:*).
|
||||
|
||||
CASCADE on image_tag / tag_alias / tag_allowlist /
|
||||
tag_reference_embedding / tag_suggestion_rejection / series_page
|
||||
clears the related rows on the parent DELETE.
|
||||
|
||||
Returns:
|
||||
{"by_kind": {kind: count, ...}, # kind-matched rows
|
||||
"by_prefix": {"source:*": count}, # name-prefix-matched rows
|
||||
"count": total, "sample_names": [first 50],
|
||||
and on live runs "deleted": total}
|
||||
"""
|
||||
predicate = _legacy_tag_predicate()
|
||||
rows = session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind).where(predicate)
|
||||
).all()
|
||||
by_kind: dict[str, int] = {}
|
||||
by_prefix: dict[str, int] = {}
|
||||
for _id, name, kind in rows:
|
||||
# Classify by name-prefix first so a source:* row counts once,
|
||||
# under the prefix bucket, regardless of its (general) kind.
|
||||
matched_prefix = next(
|
||||
(p for p in LEGACY_NAME_PREFIXES if name.startswith(p)), None,
|
||||
)
|
||||
if matched_prefix is not None:
|
||||
label = f"{matched_prefix}*"
|
||||
by_prefix[label] = by_prefix.get(label, 0) + 1
|
||||
else:
|
||||
key = kind.value if hasattr(kind, "value") else str(kind)
|
||||
by_kind[key] = by_kind.get(key, 0) + 1
|
||||
sample = [name for _id, name, _kind in rows[:50]]
|
||||
total = len(rows)
|
||||
result = {
|
||||
"by_kind": by_kind, "by_prefix": by_prefix,
|
||||
"count": total, "sample_names": sample,
|
||||
}
|
||||
if dry_run:
|
||||
return result
|
||||
if total:
|
||||
session.execute(Tag.__table__.delete().where(predicate))
|
||||
session.commit()
|
||||
result["deleted"] = total
|
||||
return result
|
||||
|
||||
|
||||
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
|
||||
# these so the operator can re-tag from scratch via auto-suggest. fandom +
|
||||
# series (and series_page ordering) are deliberately NOT here — they're kept.
|
||||
# The CONTENT vocabulary. "Reset content tagging" wipes these so the operator
|
||||
# can re-tag from scratch. fandom + series (and series_page ordering) are
|
||||
# deliberately NOT here — they're kept.
|
||||
RESETTABLE_TAG_KINDS = ("general", "character")
|
||||
|
||||
|
||||
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||
"""Count (dry_run) or DELETE every general + character tag so the operator
|
||||
can re-tag from scratch via the Camie auto-suggest.
|
||||
can re-tag from scratch. NB: the deleted applications include the tagging
|
||||
heads' training positives — suggestions do NOT repopulate on their own; the
|
||||
heads retrain from whatever the operator re-tags. (The API route gates the
|
||||
live run behind a preview-derived confirm token for exactly this reason.)
|
||||
|
||||
PRESERVED: fandom + series tags and their series_page ordering, plus every
|
||||
image's image_prediction rows (untouched) so suggestions
|
||||
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
|
||||
tag_reference_embedding / tag_suggestion_rejection clears each deleted
|
||||
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
|
||||
character tags never touches the fandom rows. Irreversible except via DB
|
||||
backup restore.
|
||||
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
|
||||
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
|
||||
applications + metadata. Tag.fandom_id is SET NULL, so deleting character
|
||||
tags never touches the fandom rows. Irreversible except via DB backup
|
||||
restore.
|
||||
|
||||
Returns:
|
||||
{"by_kind": {"general": N, "character": M},
|
||||
@@ -1074,7 +1008,7 @@ def reextract_archive_attachments(
|
||||
still an archive on disk, so the cursor is what guarantees forward progress.
|
||||
"""
|
||||
from ..models import ImportSettings, Post, PostAttachment, Source
|
||||
from ..tasks.ml import tag_and_embed
|
||||
from ..tasks.ml import cpu_embed_enabled, embed_image
|
||||
from ..tasks.thumbnail import generate_thumbnail
|
||||
from .archive_extractor import is_archive
|
||||
from .importer import Importer
|
||||
@@ -1155,10 +1089,12 @@ def reextract_archive_attachments(
|
||||
|
||||
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
||||
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||
do_embed = cpu_embed_enabled()
|
||||
for img_id in enqueue_ids:
|
||||
try:
|
||||
generate_thumbnail.delay(img_id)
|
||||
tag_and_embed.delay(img_id)
|
||||
if do_embed:
|
||||
embed_image.delay(img_id)
|
||||
except Exception as exc:
|
||||
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
||||
return summary
|
||||
|
||||
@@ -326,14 +326,16 @@ class DownloadService:
|
||||
# for hours after a download landed. Lazy import to avoid
|
||||
# circular-import risk between this service and the
|
||||
# tasks/* modules that import it.
|
||||
from ..tasks.ml import tag_and_embed
|
||||
from ..tasks.ml import cpu_embed_enabled, embed_image
|
||||
from ..tasks.thumbnail import generate_thumbnail
|
||||
do_embed = cpu_embed_enabled()
|
||||
ids = list(result.member_image_ids)
|
||||
if result.image_id is not None and result.image_id not in ids:
|
||||
ids.append(result.image_id)
|
||||
for img_id in ids:
|
||||
generate_thumbnail.delay(img_id)
|
||||
tag_and_embed.delay(img_id)
|
||||
if do_embed:
|
||||
embed_image.delay(img_id)
|
||||
elif result.status == "attached":
|
||||
# Non-media or extracted archive captured as PostAttachment
|
||||
# (FC-2d-iii). The canonical copy lives in the attachments
|
||||
|
||||
@@ -55,6 +55,25 @@ def _effective_date_col():
|
||||
return ImageRecord.effective_date
|
||||
|
||||
|
||||
# Sort key -> the materialized column the gallery orders + cursors on. Both are
|
||||
# indexed (DESC, id DESC), so every sort is a forward index range scan.
|
||||
# newest/oldest → effective_date (primary post's date, else download)
|
||||
# posted_new/_old → earliest_post_date (earliest publish across ALL posts)
|
||||
_SORT_COLUMNS = {
|
||||
"newest": ImageRecord.effective_date,
|
||||
"oldest": ImageRecord.effective_date,
|
||||
"posted_new": ImageRecord.earliest_post_date,
|
||||
"posted_old": ImageRecord.earliest_post_date,
|
||||
}
|
||||
_ASCENDING_SORTS = {"oldest", "posted_old"}
|
||||
|
||||
|
||||
def _sort_column(sort: str):
|
||||
"""The materialized date column a gallery sort orders/cursors on (falls back
|
||||
to effective_date for any unknown sort)."""
|
||||
return _SORT_COLUMNS.get(sort, ImageRecord.effective_date)
|
||||
|
||||
|
||||
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
|
||||
above sees Post.post_date when available. Images without a post
|
||||
@@ -289,6 +308,80 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
||||
]
|
||||
|
||||
|
||||
def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
||||
"""Trim a nearest-cosine candidate pool down to `limit` diverse picks.
|
||||
|
||||
1. pHash collapse: drop any candidate whose perceptual hash is within
|
||||
`dup_threshold` Hamming bits of the anchor or an already-kept candidate —
|
||||
so a reposted banner (and the anchor's own clones) appears at most once.
|
||||
2. MMR (Maximal Marginal Relevance): greedily pick the candidate maximising
|
||||
`lam * sim_to_anchor - (1 - lam) * max_sim_to_already_picked`. This keeps
|
||||
the most relevant up top but pushes the selection to SPAN clusters
|
||||
instead of returning 40 variations of one image.
|
||||
|
||||
`lam` is the variance dial: lower = weight the diversity penalty harder, so
|
||||
the rail reaches further across clusters (operator wanted MORE variance,
|
||||
2026-07-01 — dropped 0.55→0.40, dup 6→8, paired with a wider pool in
|
||||
`similar()`).
|
||||
|
||||
Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool.
|
||||
"""
|
||||
if len(rows) <= 1:
|
||||
return rows[:limit]
|
||||
try:
|
||||
import imagehash
|
||||
import numpy as np
|
||||
except Exception:
|
||||
return rows[:limit]
|
||||
|
||||
# --- 1. pHash near-duplicate collapse (videos/NULL phash pass through) ---
|
||||
kept = []
|
||||
seen = []
|
||||
if src.phash:
|
||||
try:
|
||||
seen.append(imagehash.hex_to_hash(src.phash))
|
||||
except Exception:
|
||||
pass
|
||||
for row in rows:
|
||||
ph = row[0].phash
|
||||
if ph:
|
||||
try:
|
||||
h = imagehash.hex_to_hash(ph)
|
||||
if any((h - k) <= dup_threshold for k in seen):
|
||||
continue
|
||||
seen.append(h)
|
||||
except Exception:
|
||||
pass
|
||||
kept.append(row)
|
||||
if len(kept) <= limit:
|
||||
return kept
|
||||
|
||||
# --- 2. MMR re-rank on the L2-normalised SigLIP embeddings ---
|
||||
try:
|
||||
a = np.asarray(src.siglip_embedding, dtype=np.float32)
|
||||
a = a / (np.linalg.norm(a) or 1.0)
|
||||
V = np.vstack([
|
||||
np.asarray(row[0].siglip_embedding, dtype=np.float32) for row in kept
|
||||
])
|
||||
V = V / np.clip(np.linalg.norm(V, axis=1, keepdims=True), 1e-8, None)
|
||||
except Exception:
|
||||
return kept[:limit]
|
||||
|
||||
rel = V @ a # (N,) cosine to the anchor
|
||||
n = len(kept)
|
||||
picked_mask = np.zeros(n, dtype=bool)
|
||||
max_sim = np.zeros(n, dtype=np.float32) # max sim to anything picked yet
|
||||
order = []
|
||||
for _ in range(min(limit, n)):
|
||||
scores = lam * rel - (1.0 - lam) * max_sim
|
||||
scores[picked_mask] = -np.inf
|
||||
i = int(np.argmax(scores))
|
||||
order.append(i)
|
||||
picked_mask[i] = True
|
||||
max_sim = np.maximum(max_sim, V @ V[i])
|
||||
return [kept[i] for i in order]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -332,7 +425,10 @@ class GalleryService:
|
||||
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
||||
)
|
||||
|
||||
eff = _effective_date_col()
|
||||
# eff is the ACTIVE sort column (effective_date or earliest_post_date);
|
||||
# the cursor, ordering and year/month grouping all key off it, so the
|
||||
# 'post date' sort paginates + buckets by original publish transparently.
|
||||
eff = _sort_column(sort)
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
stmt = _apply_scope(
|
||||
@@ -343,7 +439,7 @@ class GalleryService:
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
|
||||
descending = sort != "oldest"
|
||||
descending = sort not in _ASCENDING_SORTS
|
||||
if cursor:
|
||||
cur_ts, cur_id = decode_cursor(cursor)
|
||||
# The cursor is just (last eff, last id); the request's sort
|
||||
@@ -565,14 +661,20 @@ class GalleryService:
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images ranked by cosine distance to
|
||||
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
||||
No ML inference here; the embedding was computed at import.
|
||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||
doesn't collapse into one cluster. No ML inference here.
|
||||
|
||||
Returns None if the source image doesn't exist (→ 404), [] if it has
|
||||
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
||||
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
||||
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
||||
Pure nearest-cosine piles up near-identical images — a reposted banner
|
||||
fills the whole grid, and once you wander into a B&W / comic-panel
|
||||
cluster every neighbour is more of the same with no way back to colour
|
||||
(operator-reported 2026-06-30). So we pull a WIDER candidate pool, then:
|
||||
1. collapse near-duplicate pHashes (and drop clones of the anchor),
|
||||
2. MMR re-rank — pick for closeness-to-anchor but penalise similarity
|
||||
to what's already picked, so the result SPANS clusters.
|
||||
|
||||
Returns None if the source doesn't exist (→ 404), [] if it has no
|
||||
embedding. Composes with the scope filters (AND); REPLACES the date sort.
|
||||
"""
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
@@ -582,6 +684,11 @@ class GalleryService:
|
||||
if src.siglip_embedding is None:
|
||||
return []
|
||||
|
||||
# Over-fetch so diversification has clusters to spread across — without a
|
||||
# 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
|
||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
@@ -597,8 +704,9 @@ class GalleryService:
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(limit)
|
||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
rows = _diversify_similar(src, rows, limit)
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -44,6 +44,7 @@ from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||
from ..utils.slug import slugify
|
||||
from .archive_extractor import extract_archive, is_archive
|
||||
from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
from .thumbnailer import Thumbnailer
|
||||
|
||||
@@ -790,6 +791,13 @@ class Importer:
|
||||
error=f"{pct:.2%} transparent",
|
||||
)
|
||||
|
||||
if self.settings.skip_single_color and self._single_color_hit(source):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.single_color,
|
||||
error=(f"one color dominates >"
|
||||
f"{self.settings.single_color_threshold:.0%}"),
|
||||
)
|
||||
|
||||
# Artist anchored to the attribution path (folder→artist), resolved
|
||||
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
|
||||
# right artist even when the sidecar carries none — which is now the norm
|
||||
@@ -1123,6 +1131,13 @@ class Importer:
|
||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||
error=f"{pct:.2%} transparent",
|
||||
)
|
||||
|
||||
if self.settings.skip_single_color and self._single_color_hit(path):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.single_color,
|
||||
error=(f"one color dominates >"
|
||||
f"{self.settings.single_color_threshold:.0%}"),
|
||||
)
|
||||
else:
|
||||
# Best-effort probe for dims + duration so downloaded videos can dedup
|
||||
# (#871). LENIENT: unlike _import_media this path does not reject on a
|
||||
@@ -1411,6 +1426,24 @@ class Importer:
|
||||
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||
if record.primary_post_id == post.id and post.post_date is not None:
|
||||
record.effective_date = post.post_date
|
||||
# earliest_post_date (alembic 0071) = MIN(post_date) across ALL of this
|
||||
# image's provenance posts, not just the primary — so the gallery can
|
||||
# sort by original publish rather than the download/repost the primary
|
||||
# points at. Recompute from provenance whenever a dated post is linked;
|
||||
# the provenance row for THIS post was committed above, so the MIN
|
||||
# includes it. Leaves the created_at default when no linked post is dated.
|
||||
if post.post_date is not None:
|
||||
earliest = self.session.execute(
|
||||
select(func.min(Post.post_date))
|
||||
.select_from(ImageProvenance)
|
||||
.join(Post, Post.id == ImageProvenance.post_id)
|
||||
.where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
Post.post_date.is_not(None),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if earliest is not None:
|
||||
record.earliest_post_date = earliest
|
||||
self.session.flush()
|
||||
|
||||
def _copy_to_library(
|
||||
@@ -1475,20 +1508,8 @@ class Importer:
|
||||
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
||||
existing.thumbnail_path = None
|
||||
existing.integrity_status = "unknown"
|
||||
existing.tagger_model_version = None
|
||||
existing.siglip_embedding = None
|
||||
existing.siglip_model_version = None
|
||||
existing.centroid_scores = None
|
||||
# #768: predictions also live in the normalized image_prediction table
|
||||
# now — clear them so a re-imported file re-derives a fresh set.
|
||||
from sqlalchemy import delete as _delete
|
||||
|
||||
from ..models import ImagePrediction as _ImagePrediction
|
||||
self.session.execute(
|
||||
_delete(_ImagePrediction).where(
|
||||
_ImagePrediction.image_record_id == existing.id
|
||||
)
|
||||
)
|
||||
# created_at intentionally preserved; updated_at auto-bumps.
|
||||
self.session.flush()
|
||||
self.session.commit()
|
||||
@@ -1532,6 +1553,33 @@ class Importer:
|
||||
# Benign orphan; the DB swap already committed. Don't undo it.
|
||||
pass
|
||||
|
||||
# Matches the Cleanup audit card's default tolerance: the import-side
|
||||
# filter and the retroactive audit must agree on what "single color" MEANS
|
||||
# (Euclidean RGB distance to the dominant color); only the match threshold
|
||||
# is operator-tunable per surface.
|
||||
_SINGLE_COLOR_TOLERANCE = 30
|
||||
|
||||
def _single_color_hit(self, source: Path) -> bool:
|
||||
"""True when one color dominates beyond the configured threshold — the
|
||||
same canonical predicate the Cleanup audit runs (audits.single_color,
|
||||
whose docstring anticipated this adoption; the skip_single_color
|
||||
setting existed but was never wired until 2026-07-02). Never raises:
|
||||
unreadable files were already rejected by verify() upstream, and a
|
||||
residual decode error just declines to match (the import proceeds)."""
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
if getattr(im, "is_animated", False):
|
||||
# Frame 0 only would misjudge animations; skip like the
|
||||
# transparency check does.
|
||||
return False
|
||||
return single_color.evaluate(
|
||||
im,
|
||||
threshold=self.settings.single_color_threshold,
|
||||
tolerance=self._SINGLE_COLOR_TOLERANCE,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _transparency_pct(self, source: Path) -> float:
|
||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha.
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""ML pipeline services: tagger, embedder, suggestions, centroids, allowlist, aliases."""
|
||||
"""ML pipeline services: embedders, heads (the learning suggester), suggestions,
|
||||
GPU-job queue + failure triage, CCIP characters, crops/regions, allowlist and
|
||||
aliases."""
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
"""Allowlist semantics: accepting a suggestion adds the canonical tag to
|
||||
image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
|
||||
"""Suggestion actions: accept applies the canonical tag to an image (which
|
||||
feeds head training); dismiss / reject record a per-image rejection.
|
||||
|
||||
(The Camie allowlist bulk-apply was retired #1189 — heads + CCIP are the tag
|
||||
source, and head auto-apply is the earned propagation. Accept no longer
|
||||
allowlists or fans a tag out across the library.)
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, delete, distinct, func, or_, select
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import (
|
||||
ImagePrediction,
|
||||
MLSettings,
|
||||
Tag,
|
||||
TagAlias,
|
||||
TagAllowlist,
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models import TagSuggestionRejection
|
||||
from ...models.tag import image_tag
|
||||
from .aliases import AliasService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AllowlistRow:
|
||||
tag_id: int
|
||||
tag_name: str
|
||||
tag_kind: str
|
||||
min_confidence: float
|
||||
applied_count: int # image_tag rows currently carrying this tag
|
||||
coverage_count: int # images a sweep WOULD cover at min_confidence
|
||||
|
||||
|
||||
class AllowlistService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
@@ -39,21 +23,11 @@ class AllowlistService:
|
||||
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
||||
stmt = insert(image_tag).values(
|
||||
image_record_id=image_id, tag_id=tag_id, source=source
|
||||
)
|
||||
stmt = stmt.on_conflict_do_nothing(
|
||||
).on_conflict_do_nothing(
|
||||
index_elements=["image_record_id", "tag_id"]
|
||||
)
|
||||
await self.session.execute(stmt)
|
||||
|
||||
async def _add_to_allowlist(self, tag_id: int) -> bool:
|
||||
"""Returns True if newly added (caller should kick off retro-apply)."""
|
||||
exists = await self.session.get(TagAllowlist, tag_id)
|
||||
if exists is not None:
|
||||
return False
|
||||
self.session.add(TagAllowlist(tag_id=tag_id))
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def _clear_rejection(self, image_id: int, tag_id: int):
|
||||
await self.session.execute(
|
||||
delete(TagSuggestionRejection)
|
||||
@@ -61,12 +35,11 @@ class AllowlistService:
|
||||
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||
)
|
||||
|
||||
async def accept(self, image_id: int, tag_id: int) -> bool:
|
||||
"""Accept a suggestion. Returns True if the tag was newly added to
|
||||
the allowlist (the API layer enqueues apply_allowlist_tags then)."""
|
||||
async def accept(self, image_id: int, tag_id: int) -> None:
|
||||
"""Apply the accepted tag to this image (source='ml_accepted', a head
|
||||
training positive) and clear any prior rejection."""
|
||||
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
||||
await self._clear_rejection(image_id, tag_id)
|
||||
return await self._add_to_allowlist(tag_id)
|
||||
|
||||
async def add_alias_and_accept(
|
||||
self,
|
||||
@@ -74,17 +47,16 @@ class AllowlistService:
|
||||
alias_string: str,
|
||||
alias_category: str,
|
||||
canonical_tag_id: int,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
await self.aliases.create(
|
||||
alias_string, alias_category, canonical_tag_id
|
||||
)
|
||||
return await self.accept(image_id, canonical_tag_id)
|
||||
await self.accept(image_id, canonical_tag_id)
|
||||
|
||||
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
||||
stmt = insert(TagSuggestionRejection).values(
|
||||
image_record_id=image_id, tag_id=tag_id
|
||||
)
|
||||
stmt = stmt.on_conflict_do_nothing(
|
||||
).on_conflict_do_nothing(
|
||||
index_elements=["image_record_id", "tag_id"]
|
||||
)
|
||||
await self.session.execute(stmt)
|
||||
@@ -96,118 +68,11 @@ class AllowlistService:
|
||||
await self._clear_rejection(image_id, tag_id)
|
||||
|
||||
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
||||
"""Operator removed an applied tag from an image. Remove the
|
||||
image_tag row AND record a rejection so the allowlist won't
|
||||
re-apply it on the next maintenance sweep."""
|
||||
"""Operator removed an applied tag from an image. Remove the image_tag
|
||||
row AND record a rejection so head auto-apply won't re-apply it."""
|
||||
await self.session.execute(
|
||||
image_tag.delete()
|
||||
.where(image_tag.c.image_record_id == image_id)
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
)
|
||||
await self.dismiss(image_id, tag_id)
|
||||
|
||||
async def _store_floor(self) -> float:
|
||||
return (
|
||||
await self.session.execute(
|
||||
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def update_threshold(
|
||||
self, tag_id: int, min_confidence: float
|
||||
) -> None:
|
||||
row = await self.session.get(TagAllowlist, tag_id)
|
||||
if row is not None:
|
||||
# An allowlist tag can't auto-apply more permissively than the
|
||||
# ingest store floor — predictions below tagger_store_floor aren't
|
||||
# stored, so a lower min_confidence would behave identically to the
|
||||
# floor. Clamp so the stored threshold matches actual behavior
|
||||
# (#764).
|
||||
floor = await self._store_floor()
|
||||
row.min_confidence = max(min_confidence, floor)
|
||||
|
||||
async def remove(self, tag_id: int) -> None:
|
||||
await self.session.execute(
|
||||
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
|
||||
)
|
||||
|
||||
async def _coverage_match(self, tag: Tag):
|
||||
"""The predicate over image_prediction rows that resolve to `tag`,
|
||||
mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
|
||||
raw_name equals the tag name (any category), OR an alias maps
|
||||
(raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
|
||||
"""
|
||||
alias_rows = (
|
||||
await self.session.execute(
|
||||
select(TagAlias.alias_string, TagAlias.alias_category).where(
|
||||
TagAlias.canonical_tag_id == tag.id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
name_clause = ImagePrediction.raw_name == tag.name
|
||||
alias_clauses = [
|
||||
and_(
|
||||
ImagePrediction.raw_name == a,
|
||||
ImagePrediction.category == c,
|
||||
)
|
||||
for a, c in alias_rows
|
||||
]
|
||||
return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
|
||||
|
||||
async def coverage(self, tag_id: int, threshold: float) -> int:
|
||||
"""How many distinct images a sweep WOULD cover for this tag at
|
||||
`threshold`: images with a resolving prediction scoring >= threshold.
|
||||
The gross candidate pool (NOT minus already-applied/rejected) — it's
|
||||
the tuning signal for "lower the threshold and ~N more images qualify".
|
||||
"""
|
||||
tag = await self.session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
return 0
|
||||
match = await self._coverage_match(tag)
|
||||
stmt = select(
|
||||
func.count(distinct(ImagePrediction.image_record_id))
|
||||
).where(ImagePrediction.score >= threshold, match)
|
||||
return (await self.session.execute(stmt)).scalar_one()
|
||||
|
||||
async def list_all(self) -> Sequence[AllowlistRow]:
|
||||
stmt = (
|
||||
select(
|
||||
TagAllowlist.tag_id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
TagAllowlist.min_confidence,
|
||||
)
|
||||
.join(Tag, Tag.id == TagAllowlist.tag_id)
|
||||
.order_by(Tag.name.asc())
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
tag_ids = [r[0] for r in rows]
|
||||
|
||||
# Applied counts in ONE grouped query (vs N per-row counts).
|
||||
applied: dict[int, int] = {}
|
||||
if tag_ids:
|
||||
applied = dict(
|
||||
(
|
||||
await self.session.execute(
|
||||
select(image_tag.c.tag_id, func.count())
|
||||
.where(image_tag.c.tag_id.in_(tag_ids))
|
||||
.group_by(image_tag.c.tag_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
# Coverage is per-tag (alias set differs); allowlist is small.
|
||||
cov = await self.coverage(r[0], r[3])
|
||||
result.append(
|
||||
AllowlistRow(
|
||||
tag_id=r[0],
|
||||
tag_name=r[1],
|
||||
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
|
||||
min_confidence=r[3],
|
||||
applied_count=applied.get(r[0], 0),
|
||||
coverage_count=cov,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tag centroids: the mean SigLIP embedding of a tag's member images.
|
||||
|
||||
Powers centroid-augmented suggestions (a tag whose centroid is close to an
|
||||
image's embedding becomes a suggestion even if Camie didn't predict it).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import (
|
||||
ImageRecord,
|
||||
MLSettings,
|
||||
Tag,
|
||||
TagKind,
|
||||
TagReferenceEmbedding,
|
||||
)
|
||||
from ...models.tag import image_tag
|
||||
|
||||
ELIGIBLE_KINDS = {
|
||||
TagKind.character,
|
||||
TagKind.fandom,
|
||||
TagKind.general,
|
||||
TagKind.series,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CentroidHit:
|
||||
tag_id: int
|
||||
similarity: float
|
||||
|
||||
|
||||
class CentroidService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _min_reference_images(self) -> int:
|
||||
return (
|
||||
await self.session.execute(
|
||||
select(MLSettings.min_reference_images).where(MLSettings.id == 1)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def _model_version(self) -> str:
|
||||
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
|
||||
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
|
||||
already reads from MLSettings.embedder_model_version, so by
|
||||
sourcing centroid stamps + drift checks from the same row, we
|
||||
eliminate the silent-drift case the audit flagged. env
|
||||
SIGLIP_MODEL_VERSION still drives which model embedder.py
|
||||
loads at runtime; the version stamp is purely the operator-
|
||||
controlled identifier."""
|
||||
return (
|
||||
await self.session.execute(
|
||||
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def recompute_for_tag(self, tag_id: int) -> bool:
|
||||
"""Recompute one tag's centroid. Returns True if a centroid was
|
||||
written, False if skipped (ineligible kind or too few members)."""
|
||||
tag = await self.session.get(Tag, tag_id)
|
||||
if tag is None or tag.kind not in ELIGIBLE_KINDS:
|
||||
return False
|
||||
|
||||
min_refs = await self._min_reference_images()
|
||||
|
||||
stmt = (
|
||||
select(ImageRecord.siglip_embedding)
|
||||
.join(image_tag, image_tag.c.image_record_id == ImageRecord.id)
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
)
|
||||
embeddings = [
|
||||
np.array(e, dtype=np.float32)
|
||||
for e in (await self.session.execute(stmt)).scalars().all()
|
||||
]
|
||||
if len(embeddings) < min_refs:
|
||||
return False
|
||||
|
||||
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
|
||||
model_version = await self._model_version()
|
||||
|
||||
stmt = insert(TagReferenceEmbedding).values(
|
||||
tag_id=tag_id,
|
||||
embedding=centroid.tolist(),
|
||||
reference_count=len(embeddings),
|
||||
model_version=model_version,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["tag_id"],
|
||||
set_={
|
||||
"embedding": centroid.tolist(),
|
||||
"reference_count": len(embeddings),
|
||||
"model_version": model_version,
|
||||
"updated_at": func.now(),
|
||||
},
|
||||
)
|
||||
await self.session.execute(stmt)
|
||||
return True
|
||||
|
||||
async def list_drifted(self) -> list[int]:
|
||||
"""Tag ids whose centroid is stale: member count != reference_count,
|
||||
OR no centroid row, OR centroid built on a different SigLIP version.
|
||||
Only considers eligible-kind tags with embeddings present."""
|
||||
current_model_version = await self._model_version()
|
||||
member_counts = (
|
||||
select(
|
||||
image_tag.c.tag_id.label("tag_id"),
|
||||
func.count(image_tag.c.image_record_id).label("members"),
|
||||
)
|
||||
.join(ImageRecord, ImageRecord.id == image_tag.c.image_record_id)
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
.group_by(image_tag.c.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Tag.id)
|
||||
.join(member_counts, member_counts.c.tag_id == Tag.id)
|
||||
.outerjoin(
|
||||
TagReferenceEmbedding,
|
||||
TagReferenceEmbedding.tag_id == Tag.id,
|
||||
)
|
||||
.where(Tag.kind.in_(ELIGIBLE_KINDS))
|
||||
.where(
|
||||
(TagReferenceEmbedding.tag_id.is_(None))
|
||||
| (
|
||||
TagReferenceEmbedding.reference_count
|
||||
!= member_counts.c.members
|
||||
)
|
||||
| (TagReferenceEmbedding.model_version != current_model_version)
|
||||
)
|
||||
)
|
||||
return list((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
async def find_similar_tags(
|
||||
self, image_id: int, limit: int = 20
|
||||
) -> list[CentroidHit]:
|
||||
"""Cosine similarity between an image's embedding and stored
|
||||
centroids. Returns top-`limit` by similarity DESC. pgvector's
|
||||
cosine_distance gives 1 - cosine_similarity."""
|
||||
img = await self.session.get(ImageRecord, image_id)
|
||||
if img is None or img.siglip_embedding is None:
|
||||
return []
|
||||
emb = img.siglip_embedding
|
||||
distance = TagReferenceEmbedding.embedding.cosine_distance(emb)
|
||||
stmt = (
|
||||
select(
|
||||
TagReferenceEmbedding.tag_id,
|
||||
(1 - distance).label("similarity"),
|
||||
)
|
||||
.order_by(distance.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [
|
||||
CentroidHit(tag_id=r.tag_id, similarity=float(r.similarity))
|
||||
for r in rows
|
||||
]
|
||||
@@ -18,9 +18,11 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
|
||||
_INTRA_OP_THREADS = 4
|
||||
|
||||
MODEL_NAME = os.environ.get(
|
||||
DEFAULT_MODEL_NAME = os.environ.get(
|
||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
||||
)
|
||||
# Back-compat alias (api/gpu imported this name as the fallback embedder id).
|
||||
MODEL_NAME = DEFAULT_MODEL_NAME
|
||||
MODEL_VERSION = os.environ.get(
|
||||
"SIGLIP_MODEL_VERSION", "siglip-so400m-patch14-384"
|
||||
)
|
||||
@@ -29,35 +31,42 @@ _LOCAL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "siglip"
|
||||
|
||||
|
||||
class Embedder:
|
||||
def __init__(self, model_dir: Path | None = None):
|
||||
self._model_dir = model_dir or _LOCAL_DIR
|
||||
"""Loads whatever SigLIP-family model it's given by HF NAME. For the default
|
||||
model it prefers the pre-downloaded local dir (no re-download on existing
|
||||
deploys); any other name resolves as an HF repo id (downloaded + cached on
|
||||
first use), so an operator model swap (#1190) just works server-side."""
|
||||
|
||||
def __init__(self, model_name: str | None = None, model_dir: Path | None = None):
|
||||
self.model_name = model_name or DEFAULT_MODEL_NAME
|
||||
self._explicit_dir = model_dir
|
||||
self._model = None
|
||||
self._processor = None
|
||||
self._torch = None
|
||||
|
||||
def _source(self) -> str:
|
||||
if self._explicit_dir is not None:
|
||||
return str(self._explicit_dir)
|
||||
if self.model_name == DEFAULT_MODEL_NAME and _LOCAL_DIR.exists():
|
||||
return str(_LOCAL_DIR)
|
||||
return self.model_name
|
||||
|
||||
def load(self) -> None:
|
||||
if self._model is not None:
|
||||
return
|
||||
import torch
|
||||
from transformers import AutoModel, SiglipImageProcessor
|
||||
from transformers import AutoImageProcessor, AutoModel
|
||||
|
||||
self._torch = torch
|
||||
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
|
||||
# stays a predictable core consumer on a shared node.
|
||||
torch.set_num_threads(_INTRA_OP_THREADS)
|
||||
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||
# loads the full processor including SiglipTokenizer, which requires
|
||||
# the sentencepiece library at import time even if we never call it.
|
||||
# SiglipImageProcessor loads ONLY preprocessor_config.json (image
|
||||
# side) and skips the tokenizer config entirely. Operator hit the
|
||||
# ImportError 2026-05-25 once the ml-worker started actually running
|
||||
# tag_and_embed; switching to the image-only loader avoids the
|
||||
# tokenizer dep without adding ~30 MB of unused C++ build to the
|
||||
# lean ml-worker image.
|
||||
self._processor = SiglipImageProcessor.from_pretrained(
|
||||
str(self._model_dir)
|
||||
)
|
||||
self._model = AutoModel.from_pretrained(str(self._model_dir))
|
||||
# IMAGE inference only — AutoImageProcessor loads just the image side
|
||||
# (preprocessor_config.json), skipping the SigLIP tokenizer + its
|
||||
# sentencepiece dep (operator hit that ImportError 2026-05-25). Works
|
||||
# for any SigLIP-family model, keeping the embedder model-agnostic.
|
||||
src = self._source()
|
||||
self._processor = AutoImageProcessor.from_pretrained(src)
|
||||
self._model = AutoModel.from_pretrained(src)
|
||||
self._model.eval()
|
||||
|
||||
def infer(self, image_path: Path) -> np.ndarray:
|
||||
@@ -74,8 +83,12 @@ class Embedder:
|
||||
_default_embedder: Embedder | None = None
|
||||
|
||||
|
||||
def get_embedder() -> Embedder:
|
||||
def get_embedder(model_name: str | None = None) -> Embedder:
|
||||
"""Cached embedder for `model_name` (default if None). Rebuilds the singleton
|
||||
when the requested name changes, so an operator model swap takes effect
|
||||
without restarting the worker."""
|
||||
global _default_embedder
|
||||
if _default_embedder is None:
|
||||
_default_embedder = Embedder()
|
||||
name = model_name or DEFAULT_MODEL_NAME
|
||||
if _default_embedder is None or _default_embedder.model_name != name:
|
||||
_default_embedder = Embedder(model_name=name)
|
||||
return _default_embedder
|
||||
|
||||
@@ -12,8 +12,9 @@ and the lease itself reclaims expired leases as a final backstop. Result-writing
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import and_, or_, select, update
|
||||
from sqlalchemy import and_, delete, exists, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from ...models import GpuJob
|
||||
|
||||
@@ -24,6 +25,107 @@ DEFAULT_LEASE_TTL = 180 # seconds an agent holds a job before it can be re-l
|
||||
DEFAULT_BATCH = 8
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
# Poison-loop backstops. `attempts` counts LEASES GRANTED (incremented in
|
||||
# lease()), but fail()'s MAX_ATTEMPTS cap only fires when the agent reports a
|
||||
# failure — a job that keeps coming back via release() (transient handback) or
|
||||
# lease expiry (agent crash/wedge) never gets a verdict and would cycle forever.
|
||||
# The orphan sweep converts those to 'error': an expired lease that has already
|
||||
# been granted EXPIRED_POISON_CAP leases is presumed to kill/wedge the agent,
|
||||
# and a pending job granted PENDING_POISON_CAP leases without ever completing is
|
||||
# presumed poisoned (e.g. a transfer that stalls every time). Both stay
|
||||
# resurrectable via /retry_errors, which resets attempts.
|
||||
EXPIRED_POISON_CAP = MAX_ATTEMPTS + 2
|
||||
PENDING_POISON_CAP = 10
|
||||
|
||||
|
||||
def error_dedupe_statements():
|
||||
"""DELETEs enforcing: at most ONE error row per (image, task), and none that
|
||||
a live or succeeded row makes moot. The 2026-07-02 tombstone loop (backfill
|
||||
skip-lists lacked 'error') minted a duplicate error row per bad file per
|
||||
hour; running these before every backfill and inside /retry_errors keeps the
|
||||
error count reading as "distinct failing files" and stops a retry fanning
|
||||
one file out into several duplicate pending jobs. Shared by the sync beat
|
||||
task and the async API route so both prune by the SAME predicate.
|
||||
Execution order matters: moot rows first, then older duplicates (the newest
|
||||
error — the freshest reason — survives)."""
|
||||
other = aliased(GpuJob)
|
||||
same_pair = and_(
|
||||
other.image_record_id == GpuJob.image_record_id,
|
||||
other.task == GpuJob.task,
|
||||
)
|
||||
moot = (
|
||||
delete(GpuJob)
|
||||
.where(
|
||||
GpuJob.status == "error",
|
||||
exists().where(
|
||||
same_pair, other.status.in_(["pending", "leased", "done"]),
|
||||
),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
older_dupe = (
|
||||
delete(GpuJob)
|
||||
.where(
|
||||
GpuJob.status == "error",
|
||||
exists().where(
|
||||
same_pair,
|
||||
other.status == "error",
|
||||
or_(
|
||||
other.updated_at > GpuJob.updated_at,
|
||||
and_(other.updated_at == GpuJob.updated_at,
|
||||
other.id > GpuJob.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
return [moot, older_dupe]
|
||||
|
||||
|
||||
def recover_statements(now: datetime) -> dict:
|
||||
"""UPDATEs for the orphan sweep, keyed by outcome; insertion order IS the
|
||||
required execution order ('recovered' must run after 'poison_expired', which
|
||||
claims the crash-loopers out of the same expired-lease pool)."""
|
||||
expired = and_(GpuJob.status == "leased", GpuJob.lease_expires_at < now)
|
||||
unlease = {"lease_token": None, "leased_at": None, "lease_expires_at": None,
|
||||
"updated_at": now}
|
||||
return {
|
||||
"poison_expired": (
|
||||
update(GpuJob)
|
||||
.where(expired, GpuJob.attempts >= EXPIRED_POISON_CAP)
|
||||
.values(
|
||||
status="error",
|
||||
# Keep the job's last stored failure reason — it's the triage
|
||||
# signal for WHY the loop happened.
|
||||
error=func.concat(
|
||||
f"poisoned: lease expired after {EXPIRED_POISON_CAP}+ lease "
|
||||
"attempts (job repeatedly crashes or wedges the agent?); "
|
||||
"last error: ",
|
||||
func.coalesce(GpuJob.error, "none"),
|
||||
),
|
||||
**unlease,
|
||||
)
|
||||
),
|
||||
"recovered": update(GpuJob).where(expired).values(
|
||||
status="pending", **unlease,
|
||||
),
|
||||
"poison_pending": (
|
||||
update(GpuJob)
|
||||
.where(GpuJob.status == "pending",
|
||||
GpuJob.attempts >= PENDING_POISON_CAP)
|
||||
.values(
|
||||
status="error",
|
||||
error=func.concat(
|
||||
f"poisoned: {PENDING_POISON_CAP}+ lease attempts without "
|
||||
"ever completing (transfer stalls every time?); "
|
||||
"last error: ",
|
||||
func.coalesce(GpuJob.error, "none"),
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class GpuJobService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
@@ -51,25 +153,33 @@ class GpuJobService:
|
||||
async def lease(
|
||||
self, token: str, batch_size: int = DEFAULT_BATCH, ttl: int = DEFAULT_LEASE_TTL
|
||||
) -> list[GpuJob]:
|
||||
"""Claim up to batch_size pending (or expired-leased) jobs for `token`."""
|
||||
"""Claim up to batch_size pending (or expired-leased) jobs for `token`.
|
||||
|
||||
Two phases so each hits a partial index (0070) and stays O(batch) no
|
||||
matter how many done/error rows have accumulated: the pending pool is the
|
||||
hot path; expired leases are reclaimed only when pending can't fill the
|
||||
batch (a crashed agent's work — rare). The old single OR-query walked the
|
||||
primary key past the whole done-prefix in id order → O(done), which is
|
||||
why leasing crawled — and the DB saturated — as the run progressed."""
|
||||
now = datetime.now(UTC)
|
||||
picked = (
|
||||
await self.session.execute(
|
||||
select(GpuJob.id)
|
||||
.where(
|
||||
or_(
|
||||
GpuJob.status == "pending",
|
||||
and_(
|
||||
GpuJob.status == "leased",
|
||||
GpuJob.lease_expires_at < now,
|
||||
),
|
||||
|
||||
async def _claim(condition, limit: int) -> list[int]:
|
||||
return list(
|
||||
(
|
||||
await self.session.execute(
|
||||
select(GpuJob.id).where(condition)
|
||||
.order_by(GpuJob.id).limit(limit)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
)
|
||||
.order_by(GpuJob.id)
|
||||
.limit(batch_size)
|
||||
.with_for_update(skip_locked=True)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
picked = await _claim(GpuJob.status == "pending", batch_size)
|
||||
if len(picked) < batch_size: # pending exhausted → reclaim expired leases
|
||||
picked += await _claim(
|
||||
and_(GpuJob.status == "leased", GpuJob.lease_expires_at < now),
|
||||
batch_size - len(picked),
|
||||
)
|
||||
).scalars().all()
|
||||
if not picked:
|
||||
return []
|
||||
await self.session.execute(
|
||||
@@ -162,16 +272,11 @@ class GpuJobService:
|
||||
|
||||
async def recover_orphaned(self) -> int:
|
||||
"""Reset every expired lease back to pending — catches agents that died
|
||||
mid-job (no graceful release). Run on a short beat so the queue recovers
|
||||
+ reads honestly even when no worker is actively leasing. Returns rows
|
||||
recovered."""
|
||||
now = datetime.now(UTC)
|
||||
res = await self.session.execute(
|
||||
update(GpuJob)
|
||||
.where(GpuJob.status == "leased", GpuJob.lease_expires_at < now)
|
||||
.values(
|
||||
status="pending", lease_token=None, leased_at=None,
|
||||
lease_expires_at=None, updated_at=now,
|
||||
)
|
||||
)
|
||||
return res.rowcount or 0
|
||||
mid-job (no graceful release) — and convert poison-loopers to 'error'
|
||||
(see the *_POISON_CAP rationale above). Run on a short beat so the queue
|
||||
recovers + reads honestly even when no worker is actively leasing.
|
||||
Returns rows recovered to pending (poison conversions are extra)."""
|
||||
counts = {}
|
||||
for name, stmt in recover_statements(datetime.now(UTC)).items():
|
||||
counts[name] = (await self.session.execute(stmt)).rowcount or 0
|
||||
return counts["recovered"]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""GPU-failure triage (#125): classify errored jobs, PROBE the file, recover.
|
||||
|
||||
An errored GPU job is a tombstone with a stored reason, but the reason alone is
|
||||
a suspicion, not a verdict — a timeout can hit a perfectly fine file, and
|
||||
"moov atom not found" can mean a truncated download OR a one-off transfer
|
||||
fault. So triage EVALUATES: it runs the real integrity probe (sha256 recompute
|
||||
+ PIL/ffprobe — verify_integrity's own machinery) on each errored image ONCE
|
||||
and records both verdicts:
|
||||
|
||||
ImageRecord.integrity_status <- file-level verdict (ok / corrupt / ...)
|
||||
GpuJob.triage_status <- 'defect' (file is bad: recovery material,
|
||||
excluded from /retry_errors)
|
||||
'file_ok' (file passes: the failure was
|
||||
operational, safe to retry)
|
||||
|
||||
Recovery reuses established primitives: delete the defective copy + record
|
||||
(cleanup_service.delete_images — full cascade) and re-poll the image's
|
||||
subscription Source (the Layer-2 refetch pattern: gallery-dl re-fetches the
|
||||
now-absent file on the next source check). Images without a pollable Source
|
||||
report 'no_source' — manual remediation. Every classification is logged at
|
||||
WARNING so the operator notices in Logs / System Activity.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...models import GpuJob, ImageProvenance, ImageRecord, Source
|
||||
from ..cleanup_service import delete_images
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Reason buckets for the triage overview (reporting only — the PROBE decides
|
||||
# 'defect', never the string). Ordered: first match wins.
|
||||
_REASON_BUCKETS = (
|
||||
("poisoned", ("poisoned:",)),
|
||||
("transient", ("gave up after repeated transient", "curator unreachable",
|
||||
"connection", "read timed out")),
|
||||
("timeout", ("timed out", "timeout")),
|
||||
("truncated_or_corrupt", ("moov atom", "invalid data", "end of file",
|
||||
"header missing", "error reading header",
|
||||
"truncated", "premature", "corrupt",
|
||||
"no frames sampled")),
|
||||
("decode", ("cannot identify", "decompression", "broken data stream",
|
||||
"unrecognized data")),
|
||||
)
|
||||
|
||||
|
||||
def classify_reason(error: str | None) -> str:
|
||||
"""Bucket a stored job-error string for the overview table."""
|
||||
text = (error or "").lower()
|
||||
if not text:
|
||||
return "other"
|
||||
for bucket, needles in _REASON_BUCKETS:
|
||||
if any(n in text for n in needles):
|
||||
return bucket
|
||||
return "other"
|
||||
|
||||
|
||||
def triage_errored_jobs(
|
||||
session: Session, *, time_budget_seconds: float = 300.0,
|
||||
) -> dict:
|
||||
"""Probe every not-yet-triaged errored image and write both verdicts.
|
||||
|
||||
Time-boxed (sha256 of a large original over NFS can take tens of seconds)
|
||||
and inherently resumable: rows are selected by `triage_status IS NULL`, so
|
||||
the next sweep continues exactly where a budget cut stopped. Commits per
|
||||
image so a mid-run crash keeps completed verdicts."""
|
||||
image_ids = session.execute(
|
||||
select(GpuJob.image_record_id)
|
||||
.where(GpuJob.status == "error", GpuJob.triage_status.is_(None))
|
||||
.group_by(GpuJob.image_record_id)
|
||||
.order_by(GpuJob.image_record_id)
|
||||
).scalars().all()
|
||||
counts = {"probed": 0, "defect": 0, "file_ok": 0, "partial": False}
|
||||
if not image_ids:
|
||||
return counts
|
||||
# Lazy imports: the probe helper lives in the maintenance task module and
|
||||
# the hasher in the importer — importing either at module load would pull
|
||||
# celery into every service consumer.
|
||||
from ...tasks.maintenance import _verify_one
|
||||
from ..importer import _sha256_of
|
||||
|
||||
started = time.monotonic()
|
||||
for image_id in image_ids:
|
||||
if time.monotonic() - started > time_budget_seconds:
|
||||
counts["partial"] = True
|
||||
break
|
||||
rec = session.get(ImageRecord, image_id)
|
||||
if rec is None: # record deleted since the job errored
|
||||
continue
|
||||
verdict = _verify_one(Path(rec.path), rec.sha256, rec.mime, _sha256_of)
|
||||
# 'ok' means the failure was operational; anything else (corrupt /
|
||||
# failed_verification = missing/unreadable) makes the file itself the
|
||||
# problem — recovery material.
|
||||
triage = "file_ok" if verdict == "ok" else "defect"
|
||||
reason = session.execute(
|
||||
select(GpuJob.error)
|
||||
.where(GpuJob.image_record_id == image_id, GpuJob.status == "error")
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
rec.integrity_status = verdict
|
||||
session.execute(
|
||||
update(GpuJob)
|
||||
.where(GpuJob.image_record_id == image_id, GpuJob.status == "error")
|
||||
.values(triage_status=triage, updated_at=datetime.now(UTC))
|
||||
)
|
||||
session.commit()
|
||||
counts["probed"] += 1
|
||||
counts[triage] += 1
|
||||
log.warning(
|
||||
"gpu triage: image %s (%s) job error %r -> integrity probe %r -> %s",
|
||||
image_id, rec.path, (reason or "")[:120], verdict, triage,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def recover_defective_image(
|
||||
session: Session, image_id: int, *, images_root: Path,
|
||||
) -> dict:
|
||||
"""Delete the defective copy + record and re-poll its subscription Source.
|
||||
|
||||
Mirrors the Layer-2 import refetch: with the bad file gone, the source's
|
||||
next gallery-dl run re-fetches a fresh copy, which re-imports as a new
|
||||
record and re-enters the GPU pipeline. The record delete cascades the
|
||||
error tombstones with it. 'no_source' when no enabled, real-URL Source is
|
||||
reachable via the image's provenance — manual remediation there."""
|
||||
rec = session.get(ImageRecord, image_id)
|
||||
if rec is None:
|
||||
return {"status": "not_found"}
|
||||
src_id = session.execute(
|
||||
select(Source.id)
|
||||
.join(ImageProvenance, ImageProvenance.source_id == Source.id)
|
||||
.where(
|
||||
ImageProvenance.image_record_id == image_id,
|
||||
Source.enabled.is_(True),
|
||||
~Source.url.like("sidecar:%"), # synthetic anchor — not pollable
|
||||
)
|
||||
.order_by(Source.id.asc())
|
||||
).scalars().first()
|
||||
if src_id is None:
|
||||
return {"status": "no_source"}
|
||||
path = rec.path
|
||||
summary = delete_images(session, image_ids=[image_id], images_root=images_root)
|
||||
# Lazy import (services -> tasks would cycle at module load).
|
||||
from ...tasks.download import download_source
|
||||
|
||||
download_source.delay(src_id)
|
||||
log.warning(
|
||||
"gpu triage recovery: deleted defective image %s (%s) and queued a "
|
||||
"re-check of source %s to re-fetch it", image_id, path, src_id,
|
||||
)
|
||||
return {"status": "refetch_queued", "source_id": src_id, **summary}
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Production heads: train + score the per-concept classifiers (#114).
|
||||
|
||||
The eval (#1130, tag_eval.py) proved the spine; this is its production form.
|
||||
The eval harness (#1130) proved the spine, then retired 2026-07-02 once the
|
||||
tagging system was accepted; this is the production form.
|
||||
- TRAIN (sync, ml worker — needs scikit-learn): for every general/character tag
|
||||
with enough labelled positives, fit a logistic-regression head on the FROZEN
|
||||
SigLIP embeddings (positives + negatives = rejections + sampled unlabeled),
|
||||
derive an honest suggest threshold + earned-auto-apply point from CROSS-
|
||||
VALIDATED scores, and upsert a TagHead row. Reuses tag_eval's proven data
|
||||
loaders + metric helpers so production heads match the eval's measured numbers.
|
||||
VALIDATED scores, and upsert a TagHead row. Uses the eval-proven data loaders
|
||||
+ metric helpers (training_data.py) so heads match the measured numbers.
|
||||
- SCORE (async, API worker — numpy via pgvector, NO scikit-learn): score one
|
||||
image's embedding against all current heads → the suggestions the rail shows,
|
||||
REPLACING Camie predictions + per-tag centroids.
|
||||
@@ -37,7 +38,7 @@ from ...models import (
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models.tag import image_tag
|
||||
from .tag_eval import (
|
||||
from .training_data import (
|
||||
_auto_apply_point,
|
||||
_ids_with_tag,
|
||||
_l2norm,
|
||||
@@ -308,25 +309,36 @@ async def score_image(
|
||||
import numpy as np
|
||||
|
||||
img = await session.get(ImageRecord, image_id)
|
||||
if img is None or img.siglip_embedding is None:
|
||||
if img is None:
|
||||
return []
|
||||
settings = await _settings_async(session)
|
||||
heads = await _current_heads(session, settings.embedder_model_version)
|
||||
cur_version = settings.embedder_model_version
|
||||
heads = await _current_heads(session, cur_version)
|
||||
if heads["W"] is None:
|
||||
return []
|
||||
|
||||
bag = [np.asarray(img.siglip_embedding, dtype=np.float32)]
|
||||
# Only embeddings in the CURRENT model's space enter the bag. Mid model-swap
|
||||
# (#1190), an image still carrying the OLD-version whole-image vector is
|
||||
# skipped rather than scored by heads trained in a different space; a legacy
|
||||
# NULL version is treated as current (those predate per-row stamping).
|
||||
bag = []
|
||||
if img.siglip_embedding is not None and img.siglip_model_version in (
|
||||
cur_version, None,
|
||||
):
|
||||
bag.append(np.asarray(img.siglip_embedding, dtype=np.float32))
|
||||
region_vecs = (
|
||||
await session.execute(
|
||||
select(ImageRegion.siglip_embedding)
|
||||
.where(ImageRegion.image_record_id == image_id)
|
||||
.where(ImageRegion.siglip_embedding.is_not(None))
|
||||
.where(ImageRegion.embedding_version == settings.embedder_model_version)
|
||||
.where(ImageRegion.embedding_version == cur_version)
|
||||
)
|
||||
).all()
|
||||
for (vec,) in region_vecs:
|
||||
if vec is not None:
|
||||
bag.append(np.asarray(vec, dtype=np.float32))
|
||||
if not bag:
|
||||
return []
|
||||
|
||||
X = np.vstack(bag) # (B, D)
|
||||
norms = np.linalg.norm(X, axis=1, keepdims=True)
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
"""Head-vs-centroid tagging eval (#1130, milestone #114 slice 1).
|
||||
|
||||
Proves the "frozen embedding + small trained head (with negatives)" spine on the
|
||||
operator's OWN data, reusing the SigLIP embeddings already stored on
|
||||
image_record. For each concept tag it compares:
|
||||
- CENTROID baseline (the old approach): cosine to the mean of positive vectors.
|
||||
- HEAD (the new approach): logistic regression trained on positives + negatives.
|
||||
and reports cross-validated precision/recall/AP for both, a LEARNING CURVE
|
||||
(accuracy as the number of tagged positives grows), and example image ids to
|
||||
eyeball.
|
||||
|
||||
numpy + scikit-learn are imported LAZILY inside run_eval so the API worker (base
|
||||
image, no ML stack) can still import start_tag_eval_run to enqueue the ml-queue
|
||||
task — the heavy compute only runs on the ml worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...models import (
|
||||
ImageRecord,
|
||||
Tag,
|
||||
TagEvalRun,
|
||||
TagKind,
|
||||
TagPositiveConfirmation,
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models.tag import image_tag
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The operator's real concept list (mix of whole-ish + small/local cues). The
|
||||
# admin trigger can override; this is the default eval set.
|
||||
DEFAULT_CONCEPTS = [
|
||||
"glasses", "cat", "dog", "horse", "goblin",
|
||||
"cum", "lactation", "fellatio", "xray", "stomach bulge",
|
||||
]
|
||||
DEFAULT_CURVE_POINTS = [10, 30, 100, 300]
|
||||
DEFAULT_NEG_RATIO = 3 # negatives per positive (rejections + sampled unlabeled)
|
||||
DEFAULT_CV_FOLDS = 5
|
||||
MIN_POSITIVES = 8 # below this, a concept can't be evaluated meaningfully
|
||||
_UNLABELED_POOL = 4000 # cap on sampled unlabeled rows pulled per concept
|
||||
_EXAMPLES_K = 12
|
||||
|
||||
|
||||
def start_tag_eval_run(session: Session, params: dict[str, Any]) -> int:
|
||||
"""Create a TagEvalRun (status='running') and dispatch the ml-queue task.
|
||||
Returns the new run id. Light guard: one running eval at a time."""
|
||||
existing = session.execute(
|
||||
select(TagEvalRun.id).where(TagEvalRun.status == "running")
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise EvalAlreadyRunning(existing)
|
||||
norm = _normalize_params(params)
|
||||
run = TagEvalRun(params=norm, status="running", last_progress_at=datetime.now(UTC))
|
||||
session.add(run)
|
||||
session.flush()
|
||||
run_id = run.id
|
||||
# Same enqueue-by-import pattern api/suggestions.py uses for ml tasks; the
|
||||
# commit happens in the API handler so row + dispatch are visible together.
|
||||
from ...tasks.ml import tag_eval_run as _task
|
||||
_task.delay(run_id)
|
||||
return run_id
|
||||
|
||||
|
||||
class EvalAlreadyRunning(Exception):
|
||||
"""Raised by start_tag_eval_run when an eval is already in flight."""
|
||||
|
||||
|
||||
def _normalize_params(params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
concepts = [str(c).strip() for c in (params.get("concepts") or []) if str(c).strip()]
|
||||
try:
|
||||
neg_ratio = max(1, int(params.get("neg_ratio", DEFAULT_NEG_RATIO)))
|
||||
except (TypeError, ValueError):
|
||||
neg_ratio = DEFAULT_NEG_RATIO
|
||||
try:
|
||||
cv_folds = max(2, int(params.get("cv_folds", DEFAULT_CV_FOLDS)))
|
||||
except (TypeError, ValueError):
|
||||
cv_folds = DEFAULT_CV_FOLDS
|
||||
try:
|
||||
auto_top_n = min(max(int(params.get("auto_top_n", 0) or 0), 0), 200)
|
||||
except (TypeError, ValueError):
|
||||
auto_top_n = 0
|
||||
try:
|
||||
precision_target = min(max(float(params.get("precision_target", 0.97)), 0.5), 0.999)
|
||||
except (TypeError, ValueError):
|
||||
precision_target = 0.97
|
||||
# No explicit concepts and auto-discovery off → fall back to the hand list.
|
||||
if not concepts and not auto_top_n:
|
||||
concepts = list(DEFAULT_CONCEPTS)
|
||||
curve = params.get("curve_points") or DEFAULT_CURVE_POINTS
|
||||
curve = sorted({int(n) for n in curve if int(n) > 0})
|
||||
return {
|
||||
"concepts": concepts,
|
||||
"neg_ratio": neg_ratio,
|
||||
"cv_folds": cv_folds,
|
||||
"auto_top_n": auto_top_n,
|
||||
"precision_target": round(precision_target, 4),
|
||||
"curve_points": curve,
|
||||
}
|
||||
|
||||
|
||||
def _top_general_concepts(session: Session, n: int, min_count: int) -> list[str]:
|
||||
"""The n most-tagged general (concept) tags with >= min_count images — a fast
|
||||
server-side way to broaden the eval beyond the hand-picked list (counts all
|
||||
sources; source-aware filtering is a separate concern)."""
|
||||
rows = session.execute(
|
||||
select(Tag.name)
|
||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
.where(Tag.kind == TagKind.general)
|
||||
.group_by(Tag.id)
|
||||
.having(func.count(image_tag.c.image_record_id) >= min_count)
|
||||
.order_by(func.count(image_tag.c.image_record_id).desc())
|
||||
.limit(n)
|
||||
).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _resolve_tag_id(session: Session, name: str) -> int | None:
|
||||
"""Case-insensitive tag-name match; if several share a name, take the one
|
||||
applied to the most images (the one the operator actually uses)."""
|
||||
rows = session.execute(
|
||||
select(Tag.id, func.count(image_tag.c.image_record_id))
|
||||
.outerjoin(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
.where(func.lower(Tag.name) == name.lower())
|
||||
.group_by(Tag.id)
|
||||
.order_by(func.count(image_tag.c.image_record_id).desc())
|
||||
).all()
|
||||
return rows[0][0] if rows else None
|
||||
|
||||
|
||||
def _ids_with_tag(session: Session, tag_id: int) -> list[int]:
|
||||
return [
|
||||
r[0] for r in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
||||
return [
|
||||
r[0] for r in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id)
|
||||
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _confirmed_ids(session: Session, tag_id: int) -> set[int]:
|
||||
"""Positives the operator explicitly affirmed ('keep') — excluded from the
|
||||
doubts list so confirmed-correct images don't resurface every run."""
|
||||
return {
|
||||
r[0] for r in session.execute(
|
||||
select(TagPositiveConfirmation.image_record_id)
|
||||
.where(TagPositiveConfirmation.tag_id == tag_id)
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
||||
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||
sparse, so an untagged image is almost always a true negative."""
|
||||
stmt = (
|
||||
select(ImageRecord.id)
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
.order_by(func.random())
|
||||
.limit(limit)
|
||||
)
|
||||
if exclude:
|
||||
stmt = stmt.where(ImageRecord.id.not_in(exclude))
|
||||
return [r[0] for r in session.execute(stmt).all()]
|
||||
|
||||
|
||||
def _load_embeddings(session: Session, ids: list[int]) -> dict[int, Any]:
|
||||
import numpy as np
|
||||
|
||||
out: dict[int, Any] = {}
|
||||
if not ids:
|
||||
return out
|
||||
# Chunk the IN list to stay well under psycopg's parameter ceiling.
|
||||
for i in range(0, len(ids), 2000):
|
||||
chunk = ids[i:i + 2000]
|
||||
for rid, emb in session.execute(
|
||||
select(ImageRecord.id, ImageRecord.siglip_embedding)
|
||||
.where(ImageRecord.id.in_(chunk))
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
).all():
|
||||
out[rid] = np.asarray(emb, dtype=np.float32)
|
||||
return out
|
||||
|
||||
|
||||
def run_eval(session: Session, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compute the full report. Per-concept failures are captured, not fatal."""
|
||||
import numpy as np
|
||||
|
||||
cfg = _normalize_params(params)
|
||||
# Auto-discovery: union the explicit concepts with the top-N most-tagged
|
||||
# general tags (server-side, fast) so the eval can broaden itself.
|
||||
concepts = list(cfg["concepts"])
|
||||
if cfg["auto_top_n"]:
|
||||
seen = {c.lower() for c in concepts}
|
||||
for name in _top_general_concepts(session, cfg["auto_top_n"], MIN_POSITIVES):
|
||||
if name.lower() not in seen:
|
||||
concepts.append(name)
|
||||
seen.add(name.lower())
|
||||
cfg["concepts"] = concepts
|
||||
concepts_out = []
|
||||
for name in cfg["concepts"]:
|
||||
try:
|
||||
concepts_out.append(_eval_concept(session, name, cfg, np))
|
||||
except Exception as exc: # one bad concept shouldn't kill the run
|
||||
log.exception("tag-eval concept %r failed", name)
|
||||
concepts_out.append({"name": name, "skipped": f"error: {exc}"})
|
||||
return {
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"params": cfg,
|
||||
"concepts": concepts_out,
|
||||
}
|
||||
|
||||
|
||||
def _eval_concept(session: Session, name: str, cfg: dict, np) -> dict[str, Any]:
|
||||
tag_id = _resolve_tag_id(session, name)
|
||||
if tag_id is None:
|
||||
return {"name": name, "skipped": "no such tag"}
|
||||
pos_ids = _ids_with_tag(session, tag_id)
|
||||
if len(pos_ids) < MIN_POSITIVES:
|
||||
return {"name": name, "tag_id": tag_id, "n_pos": len(pos_ids),
|
||||
"skipped": f"too few positives (<{MIN_POSITIVES})"}
|
||||
|
||||
neg_ratio = cfg["neg_ratio"]
|
||||
pos_set = set(pos_ids)
|
||||
rejected = [i for i in _rejected_ids(session, tag_id) if i not in pos_set]
|
||||
want_neg = max(len(pos_ids) * neg_ratio, _EXAMPLES_K * 4)
|
||||
sampled = _sample_unlabeled(session, pos_set | set(rejected),
|
||||
min(_UNLABELED_POOL, want_neg))
|
||||
neg_ids = rejected + [i for i in sampled if i not in pos_set]
|
||||
|
||||
emb = _load_embeddings(session, pos_ids + neg_ids)
|
||||
pos = [(i, emb[i]) for i in pos_ids if i in emb]
|
||||
neg = [(i, emb[i]) for i in neg_ids if i in emb]
|
||||
if len(pos) < MIN_POSITIVES or len(neg) < MIN_POSITIVES:
|
||||
return {"name": name, "tag_id": tag_id, "n_pos": len(pos),
|
||||
"n_neg": len(neg), "skipped": "too few embedded examples"}
|
||||
|
||||
ids = np.array([i for i, _ in pos] + [i for i, _ in neg])
|
||||
X = np.vstack([v for _, v in pos] + [v for _, v in neg]).astype(np.float32)
|
||||
y = np.array([1] * len(pos) + [0] * len(neg))
|
||||
Xn = _l2norm(X, np)
|
||||
|
||||
head = _eval_head(Xn, y, cfg["cv_folds"], cfg["precision_target"], np)
|
||||
centroid = _eval_centroid(Xn, y, cfg["cv_folds"], np)
|
||||
curve = _learning_curve(Xn, y, cfg["curve_points"], neg_ratio, np)
|
||||
confirmed = _confirmed_ids(session, tag_id)
|
||||
examples = _examples(session, Xn, y, ids, np, set(rejected), confirmed)
|
||||
|
||||
return {
|
||||
"name": name, "tag_id": tag_id,
|
||||
"n_pos": len(pos), "n_neg": len(neg),
|
||||
"n_rejected": len(rejected),
|
||||
"head": head, "centroid": centroid,
|
||||
"curve": curve, "examples": examples,
|
||||
}
|
||||
|
||||
|
||||
def _l2norm(X, np):
|
||||
n = np.linalg.norm(X, axis=1, keepdims=True)
|
||||
n[n == 0] = 1.0
|
||||
return X / n
|
||||
|
||||
|
||||
def _metrics_from_scores(y, scores, np) -> dict[str, float]:
|
||||
from sklearn.metrics import average_precision_score, precision_recall_curve
|
||||
|
||||
ap = float(average_precision_score(y, scores))
|
||||
prec, rec, thr = precision_recall_curve(y, scores)
|
||||
f1 = (2 * prec * rec) / np.clip(prec + rec, 1e-9, None)
|
||||
best = int(np.argmax(f1))
|
||||
# thr has len = len(prec)-1; map best index safely.
|
||||
t = float(thr[min(best, len(thr) - 1)]) if len(thr) else 0.5
|
||||
return {
|
||||
"ap": round(ap, 4),
|
||||
"precision": round(float(prec[best]), 4),
|
||||
"recall": round(float(rec[best]), 4),
|
||||
"f1": round(float(f1[best]), 4),
|
||||
"threshold": round(t, 4),
|
||||
}
|
||||
|
||||
|
||||
def _safe_folds(y, folds, np) -> int:
|
||||
minority = int(min(np.bincount(y)))
|
||||
return max(2, min(folds, minority))
|
||||
|
||||
|
||||
def _eval_head(Xn, y, folds, target, np) -> dict[str, float]:
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
||||
|
||||
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||
cv = StratifiedKFold(n_splits=_safe_folds(y, folds, np), shuffle=True,
|
||||
random_state=0)
|
||||
probs = cross_val_predict(clf, Xn, y, cv=cv, method="predict_proba")[:, 1]
|
||||
m = _metrics_from_scores(y, probs, np)
|
||||
m["auto_apply"] = _auto_apply_point(y, probs, target, np)
|
||||
return m
|
||||
|
||||
|
||||
def _auto_apply_point(y, scores, target, np) -> dict | None:
|
||||
"""The auto-apply operating point: the threshold that yields the MOST recall
|
||||
while holding precision >= target. This answers 'could this concept fire
|
||||
without a human, and how much would it catch?' Returns None if no threshold
|
||||
reaches the precision target (concept not auto-apply-ready)."""
|
||||
from sklearn.metrics import precision_recall_curve
|
||||
|
||||
prec, rec, thr = precision_recall_curve(y, scores)
|
||||
best = None # (threshold, precision, recall) maximizing recall s.t. prec>=target
|
||||
for i in range(len(thr)): # thr[i] corresponds to prec[i], rec[i]
|
||||
if prec[i] >= target and (best is None or rec[i] > best[2]):
|
||||
best = (float(thr[i]), float(prec[i]), float(rec[i]))
|
||||
if best is None:
|
||||
return None
|
||||
return {
|
||||
"target": round(float(target), 4),
|
||||
"threshold": round(best[0], 4),
|
||||
"precision": round(best[1], 4),
|
||||
"recall": round(best[2], 4),
|
||||
}
|
||||
|
||||
|
||||
def _eval_centroid(Xn, y, folds, np) -> dict[str, float]:
|
||||
"""Cross-validated cosine-to-positive-mean — the OLD method's quality."""
|
||||
from sklearn.model_selection import StratifiedKFold
|
||||
|
||||
cv = StratifiedKFold(n_splits=_safe_folds(y, folds, np), shuffle=True,
|
||||
random_state=0)
|
||||
scores = np.zeros(len(y), dtype=np.float32)
|
||||
for train, test in cv.split(Xn, y):
|
||||
c = Xn[train][y[train] == 1].mean(axis=0)
|
||||
cn = c / (np.linalg.norm(c) or 1.0)
|
||||
scores[test] = Xn[test] @ cn
|
||||
return _metrics_from_scores(y, scores, np)
|
||||
|
||||
|
||||
def _learning_curve(Xn, y, points, neg_ratio, np) -> list[dict[str, float]]:
|
||||
"""Hold out a fixed test split; train the head on a growing number of
|
||||
positives and watch AP/F1 climb — answers 'does tagging more sharpen it?'"""
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
idx = np.arange(len(y))
|
||||
try:
|
||||
tr, te = train_test_split(idx, test_size=0.3, stratify=y, random_state=0)
|
||||
except ValueError:
|
||||
return []
|
||||
tr_pos = tr[y[tr] == 1]
|
||||
tr_neg = tr[y[tr] == 0]
|
||||
out = []
|
||||
for n in points:
|
||||
if n > len(tr_pos):
|
||||
break
|
||||
sp = rng.choice(tr_pos, size=n, replace=False)
|
||||
nn = min(len(tr_neg), n * neg_ratio)
|
||||
sn = rng.choice(tr_neg, size=nn, replace=False)
|
||||
sub = np.concatenate([sp, sn])
|
||||
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||
clf.fit(Xn[sub], y[sub])
|
||||
prob = clf.predict_proba(Xn[te])[:, 1]
|
||||
m = _metrics_from_scores(y[te], prob, np)
|
||||
out.append({"n_pos": int(n), "ap": m["ap"], "f1": m["f1"]})
|
||||
return out
|
||||
|
||||
|
||||
def _examples(session, Xn, y, ids, np, rejected_set, confirmed_set) -> dict[str, list[dict]]:
|
||||
"""Train on all data, then surface: top-scoring negatives the operator has
|
||||
NOT already rejected (= fresh suggestions) and lowest-scoring POSITIVES the
|
||||
operator has NOT already confirmed (= unreviewed doubts). Excluding rejected
|
||||
ids stops an adjudicated near-miss from resurfacing in 'would suggest';
|
||||
excluding confirmed ids stops a 'kept' correct positive from resurfacing in
|
||||
'head doubts' every run. Resolves thumbnail urls for a self-contained report."""
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
|
||||
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||
clf.fit(Xn, y)
|
||||
s = clf.predict_proba(Xn)[:, 1]
|
||||
neg_idx = np.where(y == 0)[0]
|
||||
pos_idx = np.where(y == 1)[0]
|
||||
top_neg = []
|
||||
for i in neg_idx[np.argsort(s[neg_idx])[::-1]]: # high score → low
|
||||
rid = int(ids[i])
|
||||
if rid in rejected_set:
|
||||
continue # already told the head 'no' — don't re-suggest it
|
||||
top_neg.append(rid)
|
||||
if len(top_neg) >= _EXAMPLES_K:
|
||||
break
|
||||
low_pos = []
|
||||
for i in pos_idx[np.argsort(s[pos_idx])]: # low score → high
|
||||
rid = int(ids[i])
|
||||
if rid in confirmed_set:
|
||||
continue # already kept/confirmed — don't re-doubt it
|
||||
low_pos.append(rid)
|
||||
if len(low_pos) >= _EXAMPLES_K:
|
||||
break
|
||||
thumbs = _resolve_thumbs(session, top_neg + low_pos)
|
||||
return {
|
||||
"head_would_suggest": [thumbs[i] for i in top_neg if i in thumbs],
|
||||
"head_doubts_positive": [thumbs[i] for i in low_pos if i in thumbs],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_thumbs(session, ids: list[int]) -> dict[int, dict]:
|
||||
from ..gallery_service import thumbnail_url
|
||||
|
||||
out: dict[int, dict] = {}
|
||||
if not ids:
|
||||
return out
|
||||
for rid, tp, sha, mime in session.execute(
|
||||
select(
|
||||
ImageRecord.id, ImageRecord.thumbnail_path,
|
||||
ImageRecord.sha256, ImageRecord.mime,
|
||||
).where(ImageRecord.id.in_(ids))
|
||||
).all():
|
||||
out[rid] = {"id": rid, "thumbnail_url": thumbnail_url(tp, sha, mime)}
|
||||
return out
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Camie-tagger-v2 ONNX wrapper (CPU).
|
||||
|
||||
Single-image at a time. Loaded lazily inside the ml-worker process; NOT
|
||||
thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
|
||||
running multiple worker replicas, not threads).
|
||||
|
||||
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
||||
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
||||
+ config.json. Tags ship as nested JSON, not CSV. Preprocessing and
|
||||
output handling follow the published onnx_inference.py reference:
|
||||
ImageNet normalize, NCHW layout, sigmoid on refined logits (output[1]).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFile
|
||||
|
||||
# Cap inference threads (see Tagger.load) so each ml-worker replica is a bounded
|
||||
# core consumer on a shared node — keep N_replicas × this within the cores
|
||||
# allotted to ML so replicas don't oversubscribe the box / starve the DB.
|
||||
_INTRA_OP_THREADS = 4
|
||||
|
||||
# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
|
||||
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
|
||||
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
||||
# from here in the web container, and CI collects the pure-logic tests).
|
||||
|
||||
# Tolerate minutely-truncated source images (same rationale as IR's wd14.py:
|
||||
# a few missing bytes at the JPEG EOI shouldn't block tagging the whole image).
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
MODEL_NAME = os.environ.get("CAMIE_MODEL_NAME", "camie-tagger-v2")
|
||||
_MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
|
||||
_MODEL_FILE = f"{MODEL_NAME}.onnx"
|
||||
_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
|
||||
|
||||
# Ingest floor below which predictions aren't stored (keeps the JSON compact).
|
||||
# DEFAULT/fallback only — the live value is DB-backed
|
||||
# (ml_settings.tagger_store_floor) and passed into infer() per call by the ml
|
||||
# task. 0.70: the suggestion path already filters there and the centroid path
|
||||
# covers lower-confidence preferred tags, so the sub-0.70 tail is redundant
|
||||
# (it had bloated image_record's TOAST to ~100 GB; plan-task #764).
|
||||
DEFAULT_STORE_FLOOR = 0.70
|
||||
|
||||
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
||||
# still stored but the suggestion service filters them out.
|
||||
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred. 'copyright' retired
|
||||
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
|
||||
# this app's franchise/series concept (per TagsView.vue's doc comment).
|
||||
# Raw predictions for both categories still get stored at STORE_FLOOR but
|
||||
# don't surface in suggestions.
|
||||
SURFACED_CATEGORIES = {"character", "general"}
|
||||
|
||||
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
|
||||
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||
# Square-pad color ≈ ImageNet mean × 255 (matches reference inference).
|
||||
_PAD_COLOR = (124, 116, 104)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TagPrediction:
|
||||
name: str
|
||||
category: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class Tagger:
|
||||
def __init__(self, model_dir: Path | None = None):
|
||||
self._model_dir = model_dir or _MODEL_DIR
|
||||
self._session = None # onnxruntime.InferenceSession once load()ed
|
||||
self._tag_names: list[str] | None = None
|
||||
self._tag_categories: list[str] | None = None
|
||||
self._input_name: str | None = None
|
||||
self._input_size: int = 512
|
||||
|
||||
def load(self) -> None:
|
||||
if self._session is not None:
|
||||
return
|
||||
model_path = self._model_dir / _MODEL_FILE
|
||||
meta_path = self._model_dir / _METADATA_FILE
|
||||
if not model_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"Camie {_MODEL_FILE} missing at {model_path}. "
|
||||
f"Populate /models via the ml-worker downloader."
|
||||
)
|
||||
if not meta_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"Camie {_METADATA_FILE} missing at {meta_path}. "
|
||||
f"Populate /models via the ml-worker downloader."
|
||||
)
|
||||
|
||||
with open(meta_path) as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
# Per Camie v2 onnx_inference.py: idx_to_tag is keyed by str(idx);
|
||||
# tag_to_category maps tag_name -> category. Project to two parallel
|
||||
# lists indexed by output position for O(1) lookup in the hot path.
|
||||
ds = metadata["dataset_info"]
|
||||
idx_to_tag = ds["tag_mapping"]["idx_to_tag"]
|
||||
tag_to_category = ds["tag_mapping"]["tag_to_category"]
|
||||
total = ds["total_tags"]
|
||||
names: list[str] = []
|
||||
cats: list[str] = []
|
||||
for i in range(total):
|
||||
name = idx_to_tag.get(str(i), f"unknown-{i}")
|
||||
names.append(name)
|
||||
cats.append(tag_to_category.get(name, "general"))
|
||||
|
||||
# Input size from metadata; fall back to 512 (the v2 default).
|
||||
self._input_size = int(
|
||||
metadata.get("model_info", {}).get("img_size", 512)
|
||||
)
|
||||
|
||||
# Lazy import — kept after the file-existence checks so the
|
||||
# missing-model RuntimeError still fires first in environments
|
||||
# without onnxruntime (CI / lean web image).
|
||||
import onnxruntime as ort
|
||||
|
||||
# Cap the intra-op thread pool. ONNX Runtime otherwise sizes it to ALL
|
||||
# host cores, so on a shared node each ml-worker replica would grab every
|
||||
# core and oversubscribe (and starve the co-located DB/web). Bounding it
|
||||
# makes each replica a predictable core consumer — run N replicas where
|
||||
# N × _INTRA_OP_THREADS stays within the cores you allot to ML.
|
||||
opts = ort.SessionOptions()
|
||||
opts.intra_op_num_threads = _INTRA_OP_THREADS
|
||||
session = ort.InferenceSession(
|
||||
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
|
||||
)
|
||||
self._input_name = session.get_inputs()[0].name
|
||||
# Assign sentinels last so a partial load isn't observable.
|
||||
self._tag_names = names
|
||||
self._tag_categories = cats
|
||||
self._session = session
|
||||
|
||||
def _preprocess(self, image_path: Path) -> np.ndarray:
|
||||
img = Image.open(image_path)
|
||||
# Composite RGBA onto neutral so transparency doesn't bias the model.
|
||||
if img.mode == "RGBA":
|
||||
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
img = bg.convert("RGB")
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Pad to square with ImageNet-mean color, then bicubic resize.
|
||||
w, h = img.size
|
||||
side = max(w, h)
|
||||
square = Image.new("RGB", (side, side), _PAD_COLOR)
|
||||
square.paste(img, ((side - w) // 2, (side - h) // 2))
|
||||
square = square.resize(
|
||||
(self._input_size, self._input_size), Image.BICUBIC
|
||||
)
|
||||
|
||||
arr = np.array(square, dtype=np.float32) / 255.0 # HWC, [0,1]
|
||||
arr = (arr - _IMAGENET_MEAN) / _IMAGENET_STD # ImageNet normalize
|
||||
arr = arr.transpose(2, 0, 1) # HWC -> CHW
|
||||
return arr[np.newaxis, :, :, :] # NCHW
|
||||
|
||||
def infer(
|
||||
self, image_path: Path, *, store_floor: float = DEFAULT_STORE_FLOOR,
|
||||
) -> dict[str, TagPrediction]:
|
||||
"""Run Camie v2 on one image. Returns {name: TagPrediction} with
|
||||
confidence >= store_floor (across all categories — the suggestion
|
||||
service does category filtering later). store_floor is the DB-backed
|
||||
ml_settings.tagger_store_floor, passed in by the ml task.
|
||||
|
||||
v2 emits multiple outputs; we use the refined predictions
|
||||
(output[1] per onnx_inference.py). Sigmoid is applied to raw
|
||||
logits to produce [0,1] confidence scores.
|
||||
"""
|
||||
self.load()
|
||||
x = self._preprocess(image_path)
|
||||
outputs = self._session.run(None, {self._input_name: x})
|
||||
# Refined predictions if present (v2 emits initial + refined),
|
||||
# fall back to initial for single-output forks.
|
||||
logits = outputs[1] if len(outputs) > 1 else outputs[0]
|
||||
# Squeeze batch dim, apply sigmoid.
|
||||
probs = 1.0 / (1.0 + np.exp(-logits[0]))
|
||||
results: dict[str, TagPrediction] = {}
|
||||
names = self._tag_names
|
||||
cats = self._tag_categories
|
||||
for idx, score in enumerate(probs):
|
||||
conf = float(score)
|
||||
if conf < store_floor:
|
||||
continue
|
||||
if idx >= len(names):
|
||||
# Output longer than metadata declared — shouldn't happen but
|
||||
# don't crash the import pipeline if v2 metadata desynchronizes.
|
||||
continue
|
||||
results[names[idx]] = TagPrediction(
|
||||
name=names[idx], category=cats[idx], confidence=conf
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
_default_tagger: Tagger | None = None
|
||||
|
||||
|
||||
def get_tagger() -> Tagger:
|
||||
"""Process-level singleton so the ONNX session loads once per worker."""
|
||||
global _default_tagger
|
||||
if _default_tagger is None:
|
||||
_default_tagger = Tagger()
|
||||
return _default_tagger
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Shared data-selection + validated-metric helpers for the heads trainer.
|
||||
|
||||
Born in the head-vs-centroid eval harness (#1130, tag_eval.py) that proved the
|
||||
"frozen embedding + small trained head (with negatives)" spine; the harness was
|
||||
retired 2026-07-02 (operator: the tagging system is proven, the eval isn't
|
||||
needed) and these survivors moved here — they ARE the heads' production data
|
||||
pipeline (heads.py trains and scores with them nightly).
|
||||
|
||||
numpy/scikit-learn are imported lazily inside the functions that need them so
|
||||
the API worker (base image, no ML stack) can import this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...models import ImageRecord, TagSuggestionRejection
|
||||
from ...models.tag import image_tag
|
||||
|
||||
|
||||
def _ids_with_tag(session: Session, tag_id: int) -> list[int]:
|
||||
return [
|
||||
r[0] for r in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
||||
return [
|
||||
r[0] for r in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id)
|
||||
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
||||
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||
sparse, so an untagged image is almost always a true negative."""
|
||||
stmt = (
|
||||
select(ImageRecord.id)
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
.order_by(func.random())
|
||||
.limit(limit)
|
||||
)
|
||||
if exclude:
|
||||
stmt = stmt.where(ImageRecord.id.not_in(exclude))
|
||||
return [r[0] for r in session.execute(stmt).all()]
|
||||
|
||||
|
||||
def _load_embeddings(session: Session, ids: list[int]) -> dict[int, Any]:
|
||||
import numpy as np
|
||||
|
||||
out: dict[int, Any] = {}
|
||||
if not ids:
|
||||
return out
|
||||
# Chunk the IN list to stay well under psycopg's parameter ceiling.
|
||||
for i in range(0, len(ids), 2000):
|
||||
chunk = ids[i:i + 2000]
|
||||
for rid, emb in session.execute(
|
||||
select(ImageRecord.id, ImageRecord.siglip_embedding)
|
||||
.where(ImageRecord.id.in_(chunk))
|
||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||
).all():
|
||||
out[rid] = np.asarray(emb, dtype=np.float32)
|
||||
return out
|
||||
|
||||
|
||||
def _l2norm(X, np):
|
||||
n = np.linalg.norm(X, axis=1, keepdims=True)
|
||||
n[n == 0] = 1.0
|
||||
return X / n
|
||||
|
||||
|
||||
def _metrics_from_scores(y, scores, np) -> dict[str, float]:
|
||||
from sklearn.metrics import average_precision_score, precision_recall_curve
|
||||
|
||||
ap = float(average_precision_score(y, scores))
|
||||
prec, rec, thr = precision_recall_curve(y, scores)
|
||||
f1 = (2 * prec * rec) / np.clip(prec + rec, 1e-9, None)
|
||||
best = int(np.argmax(f1))
|
||||
# thr has len = len(prec)-1; map best index safely.
|
||||
t = float(thr[min(best, len(thr) - 1)]) if len(thr) else 0.5
|
||||
return {
|
||||
"ap": round(ap, 4),
|
||||
"precision": round(float(prec[best]), 4),
|
||||
"recall": round(float(rec[best]), 4),
|
||||
"f1": round(float(f1[best]), 4),
|
||||
"threshold": round(t, 4),
|
||||
}
|
||||
|
||||
|
||||
def _safe_folds(y, folds, np) -> int:
|
||||
minority = int(min(np.bincount(y)))
|
||||
return max(2, min(folds, minority))
|
||||
|
||||
|
||||
def _auto_apply_point(y, scores, target, np) -> dict | None:
|
||||
"""The auto-apply operating point: the threshold that yields the MOST recall
|
||||
while holding precision >= target. This answers 'could this concept fire
|
||||
without a human, and how much would it catch?' Returns None if no threshold
|
||||
reaches the precision target (concept not auto-apply-ready)."""
|
||||
from sklearn.metrics import precision_recall_curve
|
||||
|
||||
prec, rec, thr = precision_recall_curve(y, scores)
|
||||
best = None # (threshold, precision, recall) maximizing recall s.t. prec>=target
|
||||
for i in range(len(thr)): # thr[i] corresponds to prec[i], rec[i]
|
||||
if prec[i] >= target and (best is None or rec[i] > best[2]):
|
||||
best = (float(thr[i]), float(prec[i]), float(rec[i]))
|
||||
if best is None:
|
||||
return None
|
||||
return {
|
||||
"target": round(float(target), 4),
|
||||
"threshold": round(best[0], 4),
|
||||
"precision": round(best[1], 4),
|
||||
"recall": round(best[2], 4),
|
||||
}
|
||||
@@ -10,8 +10,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
|
||||
from ..models.tag_allowlist import TagAllowlist
|
||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
||||
from .db_helpers import get_or_create
|
||||
from .tag_query import fandom_join_alias, tag_columns
|
||||
|
||||
@@ -304,35 +302,22 @@ class TagService:
|
||||
|
||||
async def _keep_as_alias(self, tag_id: int) -> bool:
|
||||
"""A merged-away tag's old name must survive as an alias iff the ML
|
||||
pipeline has ever applied it OR could re-emit it (allowlisted / has
|
||||
a centroid) — otherwise the proactive apply_allowlist_tags worker
|
||||
would silently regenerate it. Purely-manual, ML-unknown tags are
|
||||
deleted outright (no DB bloat)."""
|
||||
pipeline has ever applied it (manual accept or head auto-apply) — so a
|
||||
re-application or an alias remap resolves the canonical name. Purely-
|
||||
manual, ML-unknown tags are deleted outright (no DB bloat)."""
|
||||
is_machine = await self.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
and_(
|
||||
image_tag.c.tag_id == tag_id,
|
||||
image_tag.c.source.in_(
|
||||
("ml_auto", "ml_accepted", "auto")
|
||||
("ml_auto", "ml_accepted", "head_auto", "auto")
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_machine:
|
||||
return True
|
||||
allowlisted = await self.session.scalar(
|
||||
select(exists().where(TagAllowlist.tag_id == tag_id))
|
||||
)
|
||||
if allowlisted:
|
||||
return True
|
||||
has_centroid = await self.session.scalar(
|
||||
select(
|
||||
exists().where(TagReferenceEmbedding.tag_id == tag_id)
|
||||
)
|
||||
)
|
||||
return bool(has_centroid)
|
||||
return bool(is_machine)
|
||||
|
||||
async def rename(self, tag_id: int, new_name: str) -> Tag:
|
||||
"""Rename a tag. Raises TagMergeConflict if the new name collides
|
||||
@@ -572,8 +557,6 @@ class TagService:
|
||||
|
||||
merged_count = await self._repoint_image_tags(source_id, target_id)
|
||||
await self._repoint_rejections(source_id, target_id)
|
||||
await self._repoint_allowlist(source_id, target_id)
|
||||
await self._repoint_embedding(source_id)
|
||||
await self._repoint_aliases(source_id, target_id)
|
||||
await self._repoint_fandom_children(
|
||||
source_id, target_id, source_kind
|
||||
@@ -639,30 +622,6 @@ class TagService:
|
||||
.values(tag_id=tgt)
|
||||
)
|
||||
|
||||
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
|
||||
tgt_has = await self.session.scalar(
|
||||
select(exists().where(TagAllowlist.tag_id == tgt))
|
||||
)
|
||||
if tgt_has:
|
||||
await self.session.execute(
|
||||
text("DELETE FROM tag_allowlist WHERE tag_id = :src"),
|
||||
{"src": src},
|
||||
)
|
||||
else:
|
||||
await self.session.execute(
|
||||
update(TagAllowlist)
|
||||
.where(TagAllowlist.tag_id == src)
|
||||
.values(tag_id=tgt)
|
||||
)
|
||||
|
||||
async def _repoint_embedding(self, src: int) -> None:
|
||||
await self.session.execute(
|
||||
text(
|
||||
"DELETE FROM tag_reference_embedding WHERE tag_id = :src"
|
||||
),
|
||||
{"src": src},
|
||||
)
|
||||
|
||||
async def _repoint_aliases(self, src: int, tgt: int) -> None:
|
||||
from ..models.tag_alias import TagAlias
|
||||
|
||||
|
||||
@@ -216,11 +216,13 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
|
||||
# Thumbnails + ML for any newly-attached images (mirrors the download
|
||||
# path). Lazy import to dodge a task-module import cycle.
|
||||
if image_ids:
|
||||
from .ml import tag_and_embed
|
||||
from .ml import cpu_embed_enabled, embed_image
|
||||
from .thumbnail import generate_thumbnail
|
||||
do_embed = cpu_embed_enabled()
|
||||
for img_id in image_ids:
|
||||
generate_thumbnail.delay(img_id)
|
||||
tag_and_embed.delay(img_id)
|
||||
if do_embed:
|
||||
embed_image.delay(img_id)
|
||||
return {"link_id": link_id, "files": len(result.files), "images": len(image_ids)}
|
||||
except Exception as exc: # never leave a link stuck in 'downloading'
|
||||
log.exception("external fetch task failed for link %s", link_id)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""GPU-job queue coordination: backfill enqueues, orphan recovery, reprocess.
|
||||
|
||||
These are pure-DB sweeps (INSERT…SELECT / UPDATE) — no torch, no sklearn —
|
||||
that keep the desktop GPU agent's work queue fed and self-healing. They lived
|
||||
in tasks/ml.py (routed to the 'ml' queue) purely by colocation, which made the
|
||||
ml-worker container a hard dependency of the GPU pipeline; under B3 the
|
||||
ml-worker is OPTIONAL (its only processing role is the CPU embed fallback), so
|
||||
these moved here and route to the 'maintenance' quick lane with the other
|
||||
recovery sweeps. A stack with no ml-worker keeps a fully-working GPU pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..celery_app import celery
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.gpu_queue.enqueue_gpu_backfill")
|
||||
def enqueue_gpu_backfill(task_name: str) -> int:
|
||||
"""Enqueue a gpu_job for every image that still needs `task_name` (one
|
||||
INSERT…SELECT, so it scales to a full library). The desktop agent drains the
|
||||
queue over HTTP. Returns the number enqueued.
|
||||
|
||||
Completion is judged PER PIPELINE, never across them (B3, operator
|
||||
2026-07-02): 'ccip' by prior gpu_job rows, 'siglip' by concept regions at
|
||||
the current model version, and only 'embed' by image_record's whole-image
|
||||
embedding — the one artifact the CPU fallback also produces. A CPU embed
|
||||
therefore never closes crop/detect work for the agent.
|
||||
|
||||
An ERRORED job is a tombstone for its (image, task): no variant re-enqueues
|
||||
it. Retry is deliberate-only (/retry_errors), which also means an errored
|
||||
back-catalogue needs one "Retry errored jobs" press after a model swap.
|
||||
Before the tombstone rule, this loop re-minted a fresh doomed job for every
|
||||
permanently-bad file each run — ~24 duplicate error rows/day per file (the
|
||||
2026-07-02 "unprocessable" flood)."""
|
||||
from sqlalchemy import exists, insert, literal, or_
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from ..models import GpuJob, ImageRecord, ImageRegion, MLSettings
|
||||
from ..services.ml.gpu_jobs import error_dedupe_statements
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
# Prune stale tombstones first (loop-era duplicates + rows made moot by
|
||||
# a later success), so 'error' reads as one row per distinct failing
|
||||
# file and the skip-guards below see a clean picture.
|
||||
pruned = sum(
|
||||
session.execute(s).rowcount or 0 for s in error_dedupe_statements()
|
||||
)
|
||||
if pruned:
|
||||
log.info("gpu backfill: pruned %d stale/duplicate error rows", pruned)
|
||||
cur_version = session.execute(
|
||||
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
if task_name == "embed":
|
||||
# Whole-image GPU re-embed (#1190): images with no embedding, or one
|
||||
# stamped under a DIFFERENT model version (an operator model swap).
|
||||
stale = or_(
|
||||
ImageRecord.siglip_embedding.is_(None),
|
||||
ImageRecord.siglip_model_version.is_(None),
|
||||
ImageRecord.siglip_model_version != cur_version,
|
||||
)
|
||||
# 'error' blocks too — tombstone rule, see docstring.
|
||||
blocked = exists().where(
|
||||
GpuJob.image_record_id == ImageRecord.id,
|
||||
GpuJob.task == "embed",
|
||||
GpuJob.status.in_(["pending", "leased", "error"]),
|
||||
)
|
||||
sel = sa_select(
|
||||
ImageRecord.id, literal("embed"), literal("pending")
|
||||
).where(stale).where(~blocked)
|
||||
elif task_name == "siglip":
|
||||
# Concept-crop re-embed: enqueue when there's no concept region AT THE
|
||||
# CURRENT model version — so a model swap re-triggers crops too, not
|
||||
# only the never-embedded back-catalogue.
|
||||
has_current_concept = exists().where(
|
||||
ImageRegion.image_record_id == ImageRecord.id,
|
||||
ImageRegion.kind == "concept",
|
||||
ImageRegion.embedding_version == cur_version,
|
||||
)
|
||||
# 'error' blocks too — tombstone rule, see docstring.
|
||||
blocked = exists().where(
|
||||
GpuJob.image_record_id == ImageRecord.id,
|
||||
GpuJob.task == "siglip",
|
||||
GpuJob.status.in_(["pending", "leased", "error"]),
|
||||
)
|
||||
sel = sa_select(
|
||||
ImageRecord.id, literal("siglip"), literal("pending")
|
||||
).where(~has_current_concept).where(~blocked)
|
||||
else:
|
||||
# ANY prior row blocks — including 'error' (tombstone rule, see
|
||||
# docstring): pre-fix this branch ran HOURLY and was the loop.
|
||||
already = exists().where(
|
||||
GpuJob.image_record_id == ImageRecord.id,
|
||||
GpuJob.task == task_name,
|
||||
GpuJob.status.in_(["pending", "leased", "done", "error"]),
|
||||
)
|
||||
sel = sa_select(
|
||||
ImageRecord.id, literal(task_name), literal("pending")
|
||||
).where(~already)
|
||||
# RETURNING + count: result.rowcount is unreliable for INSERT…SELECT.
|
||||
rows = session.execute(
|
||||
insert(GpuJob)
|
||||
.from_select(["image_record_id", "task", "status"], sel)
|
||||
.returning(GpuJob.id)
|
||||
).fetchall()
|
||||
session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.gpu_queue.recover_orphaned_gpu_jobs")
|
||||
def recover_orphaned_gpu_jobs() -> int:
|
||||
"""Reset expired GPU-job leases back to pending — recovers work orphaned by an
|
||||
agent that died mid-job (no graceful release) — and convert poison-loopers
|
||||
(release/expiry cycles that never reach fail()'s attempt cap) to 'error'.
|
||||
Statements are shared with GpuJobService.recover_orphaned so the sweep and
|
||||
the service can't drift. Short beat cadence so orphans get picked back up
|
||||
quickly + the queue counts read honestly. Returns the number recovered."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..services.ml.gpu_jobs import recover_statements
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
counts = {
|
||||
name: session.execute(stmt).rowcount or 0
|
||||
for name, stmt in recover_statements(datetime.now(UTC)).items()
|
||||
}
|
||||
session.commit()
|
||||
if counts["poison_expired"] or counts["poison_pending"]:
|
||||
log.warning(
|
||||
"gpu jobs poisoned -> error: %d crash-loop (expired lease), "
|
||||
"%d never-complete (pending)",
|
||||
counts["poison_expired"], counts["poison_pending"],
|
||||
)
|
||||
return counts["recovered"]
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.gpu_queue.reprocess_gpu_jobs")
|
||||
def reprocess_gpu_jobs(task_name: str = "ccip") -> int:
|
||||
"""Reset every done/error job of `task_name` back to pending so the agent
|
||||
re-runs the WHOLE library under the CURRENT pipeline — e.g. after adding crop
|
||||
detectors (#1202), re-cropping existing images. Heavy + operator-triggered;
|
||||
the back-catalogue won't otherwise re-process (the backfills skip images that
|
||||
already have current-version regions). Returns the number reset."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from ..models import GpuJob
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
now = datetime.now(UTC)
|
||||
res = session.execute(
|
||||
update(GpuJob)
|
||||
.where(
|
||||
GpuJob.task == task_name,
|
||||
GpuJob.status.in_(["done", "error"]),
|
||||
)
|
||||
.values(
|
||||
status="pending", attempts=0, lease_token=None, leased_at=None,
|
||||
lease_expires_at=None, updated_at=now,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return res.rowcount or 0
|
||||
@@ -228,15 +228,17 @@ def _do_import(session, task, import_task_id: int) -> dict:
|
||||
# Enqueue thumbnail + ML for newly imported AND superseded images
|
||||
# (a superseded row has cleared ML + no thumbnail).
|
||||
if result.status in ("imported", "superseded"):
|
||||
from .ml import tag_and_embed
|
||||
from .ml import cpu_embed_enabled, embed_image
|
||||
from .thumbnail import generate_thumbnail
|
||||
|
||||
do_embed = cpu_embed_enabled()
|
||||
ids = list(result.member_image_ids)
|
||||
if result.image_id is not None and result.image_id not in ids:
|
||||
ids.append(result.image_id)
|
||||
for img_id in ids:
|
||||
generate_thumbnail.delay(img_id)
|
||||
tag_and_embed.delay(img_id)
|
||||
if do_embed:
|
||||
embed_image.delay(img_id)
|
||||
|
||||
# If this was the last task in the batch, mark the batch complete.
|
||||
remaining = session.execute(
|
||||
|
||||
@@ -21,7 +21,6 @@ from ..models import (
|
||||
ImportTask,
|
||||
LibraryAuditRun,
|
||||
Source,
|
||||
TagEvalRun,
|
||||
TaskRun,
|
||||
)
|
||||
from ..utils.phash import compute_phash
|
||||
@@ -96,9 +95,6 @@ BACKUP_DB_STALL_THRESHOLD_MINUTES = 40
|
||||
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
|
||||
# 2h15m gives a 10-min buffer.
|
||||
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
|
||||
# tag-eval (#1130) has a 30-min soft limit; flag a run with no progress past 40.
|
||||
TAG_EVAL_STALL_THRESHOLD_MINUTES = 40
|
||||
TAG_EVAL_KEEP_RUNS = 20
|
||||
# head training (#114) has a 60-min soft limit; flag no-progress past 75.
|
||||
HEAD_TRAINING_STALL_THRESHOLD_MINUTES = 75
|
||||
HEAD_TRAINING_KEEP_RUNS = 20
|
||||
@@ -125,7 +121,7 @@ IMPORT_BATCH_KEEP_DAYS = 30
|
||||
# task.time_limit + a small buffer. task_name overrides take precedence
|
||||
# over queue overrides.
|
||||
#
|
||||
# ml queue: tag_and_embed video branch (≈20 GPU ops); time_limit=1200.
|
||||
# ml queue: embed_image video branch (≈20 GPU ops); time_limit=1200.
|
||||
# import_archive_file: shares the 'import' queue with the fast
|
||||
# single-file import_media_file, so it needs a task-name override
|
||||
# (the import queue itself stays at the 5-min default for single
|
||||
@@ -143,10 +139,9 @@ QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
"download": 30,
|
||||
# Audit 2026-06-02 — maintenance/scan queues run tasks that
|
||||
# legitimately exceed the 5-min default (verify_integrity at 70m
|
||||
# hard, scan_directory at 70m hard, apply_allowlist_tags /
|
||||
# recompute_centroids / backfill_phash at 35m hard). 75 min lives
|
||||
# above the longest of those and the per-task overrides below
|
||||
# cover the outliers (backups, library audit).
|
||||
# hard, scan_directory at 70m hard, backfill_phash at 35m hard).
|
||||
# 75 min lives above the longest of those and the per-task
|
||||
# overrides below cover the outliers (backups, library audit).
|
||||
"maintenance": 75,
|
||||
"scan": 75,
|
||||
}
|
||||
@@ -582,6 +577,28 @@ def verify_integrity() -> int:
|
||||
return total
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.maintenance.triage_gpu_errors",
|
||||
# Bounded small-set probe (only errored images, once each), but a single
|
||||
# large original's sha256 over NFS can run tens of seconds — same quick-lane
|
||||
# tolerance rationale as verify_integrity above.
|
||||
soft_time_limit=600, time_limit=900,
|
||||
)
|
||||
def triage_gpu_errors() -> dict:
|
||||
"""Failure triage (#125): probe each errored GPU job's file once and write
|
||||
the verdicts (ImageRecord.integrity_status + GpuJob.triage_status) — see
|
||||
services/ml/gpu_triage.py. Time-boxed + resumable; no-op when every errored
|
||||
job is already triaged."""
|
||||
from ..services.ml.gpu_triage import triage_errored_jobs
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
summary = triage_errored_jobs(session, time_budget_seconds=300.0)
|
||||
if summary["probed"]:
|
||||
log.info("triage_gpu_errors: %s", summary)
|
||||
return summary
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_download_events")
|
||||
def recover_stalled_download_events() -> int:
|
||||
"""Recover DownloadEvent rows stuck pending/running past the worker hard kill.
|
||||
@@ -721,46 +738,6 @@ def recover_stalled_library_audit_runs() -> int:
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_tag_eval_runs")
|
||||
def recover_stalled_tag_eval_runs() -> int:
|
||||
"""Flip TagEvalRun rows stuck in 'running' past the stall threshold to
|
||||
'error', and prune old runs to the last TAG_EVAL_KEEP_RUNS (retention,
|
||||
rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=TAG_EVAL_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(TagEvalRun)
|
||||
.where(TagEvalRun.status == "running")
|
||||
.where(
|
||||
func.coalesce(TagEvalRun.last_progress_at, TagEvalRun.started_at)
|
||||
< cutoff
|
||||
)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=(
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{TAG_EVAL_STALL_THRESHOLD_MINUTES} min)"
|
||||
),
|
||||
)
|
||||
)
|
||||
# Retention: keep only the most recent N runs.
|
||||
keep = session.execute(
|
||||
select(TagEvalRun.id).order_by(TagEvalRun.id.desc())
|
||||
.limit(TAG_EVAL_KEEP_RUNS)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(
|
||||
delete(TagEvalRun).where(TagEvalRun.id.not_in(keep))
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info("recover_stalled_tag_eval_runs: recovered %d rows", recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
|
||||
def recover_stalled_head_training_runs() -> int:
|
||||
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
||||
|
||||
+69
-445
@@ -1,20 +1,26 @@
|
||||
"""ML Celery tasks: per-image inference, backfill discovery, centroid
|
||||
recompute, allowlist auto-apply, model self-heal.
|
||||
"""ML Celery tasks: per-image embedding, backfill discovery, head training,
|
||||
model self-heal.
|
||||
|
||||
All run on the ml-worker (queue 'ml') except recompute_centroids and
|
||||
apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
|
||||
(Celery workers are sync processes), same pattern as FC-2a tasks.
|
||||
All run on the ml-worker (queue 'ml'), which under B3 (2026-07-02) is an
|
||||
OPTIONAL container: its only processing role is the CPU whole-image embed
|
||||
fallback (gated by ml_settings.cpu_embed_enabled) for stacks without a GPU
|
||||
agent — plus head training / auto-apply, which need sklearn/numpy and so
|
||||
live on this image. GPU-queue coordination (backfill enqueues, orphan
|
||||
recovery, reprocess) deliberately does NOT live here — see tasks/gpu_queue.py
|
||||
(maintenance lane), so the agent pipeline works with no ml-worker at all.
|
||||
Sync sessions (Celery workers are sync processes), same pattern as FC-2a
|
||||
tasks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import ImagePrediction, ImageRecord, MLSettings
|
||||
from ..models import ImageRecord, MLSettings
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -27,8 +33,24 @@ def _is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTS
|
||||
|
||||
|
||||
def cpu_embed_enabled() -> bool:
|
||||
"""Dispatch gate for the CPU embed fallback (B3, operator 2026-07-02):
|
||||
stacks that run a GPU agent and DROP the (optional) ml-worker container
|
||||
turn ml_settings.cpu_embed_enabled off, so the import hooks stop queueing
|
||||
embed work into a queue nothing consumes — the daily GPU 'embed' backfill
|
||||
covers those images instead. Opens its own short session because the four
|
||||
dispatch sites sit in different session scopes; defaults ON when the
|
||||
settings row is missing (a fresh install must work agent-less)."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
val = session.execute(
|
||||
select(MLSettings.cpu_embed_enabled).where(MLSettings.id == 1)
|
||||
).scalar_one_or_none()
|
||||
return True if val is None else bool(val)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.tag_and_embed",
|
||||
name="backend.app.tasks.ml.embed_image",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||
retry_backoff=5,
|
||||
@@ -45,20 +67,25 @@ def _is_video(path: Path) -> bool:
|
||||
soft_time_limit=900, # 15 min
|
||||
time_limit=1200, # 20 min hard
|
||||
)
|
||||
def tag_and_embed(self, image_id: int) -> dict:
|
||||
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
||||
then enqueue per-image allowlist application.
|
||||
def embed_image(self, image_id: int) -> dict:
|
||||
"""Compute + store one image's whole-image SigLIP embedding — the CPU
|
||||
fallback path (B3, operator 2026-07-02): this is the ml-worker's ONLY
|
||||
processing role, keeping search/similarity/head-suggestions alive on
|
||||
deployments without a GPU agent. Detection, cropping and CCIP are
|
||||
deliberately agent-only, and their backfill predicates read image_region /
|
||||
gpu_job state — never image_record.siglip_embedding — so a CPU whole-image
|
||||
embed can NEVER mark crop work as done. (Renamed from tag_and_embed —
|
||||
Camie tagging was retired #1189; the old name kept implying a tagging step
|
||||
that no longer exists.)
|
||||
|
||||
Video (#747): sample frames at a fixed cadence (ml_settings
|
||||
video_frame_interval_seconds, capped at video_max_frames), keep a tag only if
|
||||
it appears in >= video_min_tag_frames frames and average its confidence over
|
||||
those frames (mean-pool, not max — kills one-frame noise); mean-pool the
|
||||
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
||||
video_frame_interval_seconds, capped at video_max_frames) and mean-pool the
|
||||
per-frame SigLIP embeddings — the same shape as the GPU agent's video
|
||||
handling. On no-frames returns status='no_frames' (not an error).
|
||||
"""
|
||||
import time
|
||||
|
||||
from ..services.ml.embedder import get_embedder
|
||||
from ..services.ml.tagger import get_tagger
|
||||
|
||||
# Phase + file context, so a timeout/crash names WHICH file and WHERE it
|
||||
# died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
|
||||
@@ -88,110 +115,64 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
f"image_id={image_id} path={record.path} mime={record.mime} "
|
||||
f"bytes={record.size_bytes} video={is_vid}"
|
||||
)
|
||||
log.info("tag_and_embed start: %s", ctx)
|
||||
log.info("embed_image start: %s", ctx)
|
||||
if not src.is_file():
|
||||
log.warning("tag_and_embed file missing on disk: %s", ctx)
|
||||
log.warning("embed_image file missing on disk: %s", ctx)
|
||||
return {"status": "file_missing", "image_id": image_id}
|
||||
|
||||
phase = "load_models"
|
||||
tagger = get_tagger()
|
||||
embedder = get_embedder()
|
||||
embedder = get_embedder(settings.embedder_model_name)
|
||||
|
||||
if is_vid:
|
||||
# Layer-3 isolation: ffprobe (a separate process) validates
|
||||
# the container before we burn ~20 GPU ops sampling frames
|
||||
# from it. A corrupt video that would crash the frame
|
||||
# decoder is rejected cleanly here instead of taking down
|
||||
# the ml-worker. Operator-flagged 2026-05-28.
|
||||
# the container before we burn GPU ops sampling frames from it.
|
||||
# A corrupt video that would crash the frame decoder is rejected
|
||||
# cleanly here instead of taking down the ml-worker.
|
||||
phase = "video_probe"
|
||||
from ..utils import safe_probe
|
||||
vprobe = safe_probe.probe_video(src)
|
||||
if not vprobe.ok:
|
||||
log.warning(
|
||||
"tag_and_embed bad video (%s): %s", vprobe.reason, ctx
|
||||
"embed_image bad video (%s): %s", vprobe.reason, ctx
|
||||
)
|
||||
return {
|
||||
"status": "bad_video", "image_id": image_id,
|
||||
"reason": vprobe.reason,
|
||||
}
|
||||
phase = "video_sample_frames"
|
||||
t0 = time.monotonic()
|
||||
frames = _sample_video_frames(
|
||||
src,
|
||||
interval=settings.video_frame_interval_seconds,
|
||||
max_frames=settings.video_max_frames,
|
||||
)
|
||||
log.info(
|
||||
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
|
||||
len(frames), time.monotonic() - t0, ctx,
|
||||
)
|
||||
if not frames:
|
||||
return {"status": "no_frames", "image_id": image_id}
|
||||
phase = "video_infer"
|
||||
phase = "video_embed"
|
||||
import numpy as np
|
||||
|
||||
preds = _aggregate_video_predictions(
|
||||
[tagger.infer(f, store_floor=settings.tagger_store_floor)
|
||||
for f in frames],
|
||||
min_frames=settings.video_min_tag_frames,
|
||||
)
|
||||
# Mean-pool the per-frame SigLIP embeddings into one vector.
|
||||
embedding = np.mean(
|
||||
[embedder.infer(f) for f in frames], axis=0
|
||||
).astype("float32")
|
||||
log.info(
|
||||
"tag_and_embed video aggregated %d tag(s) from %d frame(s) "
|
||||
"(min_frames=%d): %s",
|
||||
len(preds), len(frames), settings.video_min_tag_frames, ctx,
|
||||
)
|
||||
for f in frames:
|
||||
f.unlink(missing_ok=True)
|
||||
else:
|
||||
phase = "tag"
|
||||
t0 = time.monotonic()
|
||||
raw = tagger.infer(src, store_floor=settings.tagger_store_floor)
|
||||
log.info(
|
||||
"tag_and_embed tagged in %.1fs (%d tags): %s",
|
||||
time.monotonic() - t0, len(raw), ctx,
|
||||
)
|
||||
preds = {
|
||||
name: {"category": p.category, "confidence": p.confidence}
|
||||
for name, p in raw.items()
|
||||
}
|
||||
phase = "embed"
|
||||
t0 = time.monotonic()
|
||||
embedding = embedder.infer(src)
|
||||
log.info(
|
||||
"tag_and_embed embedded in %.1fs: %s",
|
||||
"embed_image embedded in %.1fs: %s",
|
||||
time.monotonic() - t0, ctx,
|
||||
)
|
||||
|
||||
phase = "persist"
|
||||
record.tagger_model_version = settings.tagger_model_version
|
||||
record.siglip_embedding = embedding.tolist()
|
||||
record.siglip_model_version = settings.embedder_model_version
|
||||
session.add(record)
|
||||
# Write the normalized image_prediction rows (#768) — the sole home
|
||||
# for predictions now (image_record.tagger_predictions was dropped in
|
||||
# migration 0046). Delete-then-insert keeps a re-tag idempotent;
|
||||
# tagger_store_floor was already applied in tagger.infer, so preds is
|
||||
# the >=floor set.
|
||||
session.execute(
|
||||
delete(ImagePrediction).where(
|
||||
ImagePrediction.image_record_id == image_id
|
||||
)
|
||||
)
|
||||
session.add_all([
|
||||
ImagePrediction(
|
||||
image_record_id=image_id, raw_name=name,
|
||||
category=p.get("category", "general"),
|
||||
score=float(p.get("confidence", 0.0)),
|
||||
)
|
||||
for name, p in preds.items()
|
||||
])
|
||||
session.commit()
|
||||
except SoftTimeLimitExceeded:
|
||||
log.error(
|
||||
"tag_and_embed TIMED OUT after %.0fs in phase=%s: %s",
|
||||
"embed_image TIMED OUT after %.0fs in phase=%s: %s",
|
||||
_elapsed(), phase, ctx,
|
||||
)
|
||||
# Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in
|
||||
@@ -205,16 +186,13 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
# ORIGINAL so the type is preserved; just make sure it's logged with
|
||||
# context first.
|
||||
log.exception(
|
||||
"tag_and_embed FAILED in phase=%s after %.0fs: %s",
|
||||
"embed_image FAILED in phase=%s after %.0fs: %s",
|
||||
phase, _elapsed(), ctx,
|
||||
)
|
||||
raise
|
||||
|
||||
log.info(
|
||||
"tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx
|
||||
)
|
||||
apply_allowlist_tags.delay(image_id=image_id)
|
||||
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
||||
log.info("embed_image ok in %.1fs: %s", _elapsed(), ctx)
|
||||
return {"status": "ok", "image_id": image_id}
|
||||
|
||||
|
||||
def _sample_video_frames(
|
||||
@@ -273,318 +251,40 @@ def _sample_video_frames(
|
||||
return out
|
||||
|
||||
|
||||
def _aggregate_video_predictions(per_frame: list[dict], *, min_frames: int) -> dict:
|
||||
"""Aggregate per-frame {name: TagPrediction} into one prediction set (#747).
|
||||
|
||||
A tag is kept only if it appears (≥ the tagger store floor, already applied)
|
||||
in at least `min_frames` of the sampled frames — because sampling is at a
|
||||
fixed cadence, that means it was on screen for roughly min_frames×interval
|
||||
seconds, so a single-frame flicker / scene-transition artifact is dropped
|
||||
while a genuine scene-local tag in a long video survives. Confidence is the
|
||||
MEAN over the frames where the tag appears (not max — max re-inflated the
|
||||
one-frame noise this whole change exists to remove).
|
||||
|
||||
`min_frames` is clamped to the number of frames actually sampled so a very
|
||||
short video (1–2 frames) still tags instead of dropping everything.
|
||||
"""
|
||||
n = len(per_frame)
|
||||
if n == 0:
|
||||
return {}
|
||||
threshold = max(1, min(min_frames, n))
|
||||
agg: dict[str, dict] = {}
|
||||
for frame_preds in per_frame:
|
||||
for name, p in frame_preds.items():
|
||||
cur = agg.get(name)
|
||||
if cur is None:
|
||||
agg[name] = {"category": p.category, "sum": p.confidence, "count": 1}
|
||||
else:
|
||||
cur["sum"] += p.confidence
|
||||
cur["count"] += 1
|
||||
return {
|
||||
name: {"category": v["category"], "confidence": v["sum"] / v["count"]}
|
||||
for name, v in agg.items()
|
||||
if v["count"] >= threshold
|
||||
}
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.backfill", bind=True)
|
||||
def backfill(self) -> int:
|
||||
"""Enqueue tag_and_embed for images missing predictions/embeddings for
|
||||
the current model versions. Keyset pagination by id ASC (restart-safe).
|
||||
"""Enqueue embed_image (embed-only) for images with no SigLIP embedding.
|
||||
Keyset pagination by id ASC (restart-safe).
|
||||
|
||||
NB: a siglip MODEL-VERSION mismatch (an operator model swap, #1190) is NOT
|
||||
re-embedded here — the CPU ml-worker can't churn the library at 384/512px;
|
||||
the GPU agent owns version re-embeds via the 'embed' job.
|
||||
"""
|
||||
if not cpu_embed_enabled():
|
||||
log.info("cpu backfill skipped: cpu_embed_enabled is off (B3 — the "
|
||||
"GPU 'embed' backfill owns whole-image embeds on this stack)")
|
||||
return 0
|
||||
SessionLocal = _sync_session_factory()
|
||||
enqueued = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id)
|
||||
.where(ImageRecord.id > last_id)
|
||||
.where(
|
||||
(ImageRecord.tagger_model_version.is_(None))
|
||||
| (
|
||||
ImageRecord.tagger_model_version
|
||||
!= settings.tagger_model_version
|
||||
)
|
||||
| (ImageRecord.siglip_embedding.is_(None))
|
||||
| (
|
||||
ImageRecord.siglip_model_version
|
||||
!= settings.embedder_model_version
|
||||
)
|
||||
)
|
||||
.where(ImageRecord.siglip_embedding.is_(None))
|
||||
.order_by(ImageRecord.id.asc())
|
||||
.limit(500)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
break
|
||||
for image_id in rows:
|
||||
tag_and_embed.delay(image_id)
|
||||
embed_image.delay(image_id)
|
||||
enqueued += 1
|
||||
last_id = rows[-1]
|
||||
return enqueued
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.apply_allowlist_tags",
|
||||
bind=True,
|
||||
# Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
|
||||
# is O(images × allowlist) and legitimately runs >5 min on large
|
||||
# libraries. Cap matches the maintenance queue's recovery threshold.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def apply_allowlist_tags(self, tag_id: int | None = None,
|
||||
image_id: int | None = None) -> int:
|
||||
"""Retroactively apply allowlisted tags.
|
||||
|
||||
Modes:
|
||||
- tag_id only : scan all images for this tag.
|
||||
- image_id only : scan all allowlisted tags for this image.
|
||||
- both : just the (image, tag) pair.
|
||||
- neither : full sweep (daily beat).
|
||||
|
||||
Skips: already-applied, rejected (tag_suggestion_rejection), or
|
||||
confidence below the tag's allowlist min_confidence. Applied with
|
||||
source='ml_auto'.
|
||||
"""
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import TagAllowlist, TagSuggestionRejection
|
||||
from ..models.tag import image_tag
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
applied = 0
|
||||
with SessionLocal() as session:
|
||||
allow_rows = session.execute(
|
||||
sa_select(TagAllowlist.tag_id, TagAllowlist.min_confidence)
|
||||
if tag_id is None
|
||||
else sa_select(
|
||||
TagAllowlist.tag_id, TagAllowlist.min_confidence
|
||||
).where(TagAllowlist.tag_id == tag_id)
|
||||
).all()
|
||||
allow = {r[0]: r[1] for r in allow_rows}
|
||||
if not allow:
|
||||
return 0
|
||||
|
||||
# Images that have any predictions (#768: from image_prediction, not
|
||||
# the old JSON column), optionally narrowed to one image.
|
||||
img_ids_query = sa_select(ImagePrediction.image_record_id).distinct()
|
||||
if image_id is not None:
|
||||
img_ids_query = img_ids_query.where(
|
||||
ImagePrediction.image_record_id == image_id
|
||||
)
|
||||
|
||||
for (img_id,) in session.execute(img_ids_query).all():
|
||||
preds = _load_predictions_sync(session, img_id)
|
||||
for a_tag_id, min_conf in allow.items():
|
||||
exists = session.execute(
|
||||
sa_select(image_tag.c.tag_id).where(
|
||||
and_(
|
||||
image_tag.c.image_record_id == img_id,
|
||||
image_tag.c.tag_id == a_tag_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
rej = session.get(
|
||||
TagSuggestionRejection, (img_id, a_tag_id)
|
||||
)
|
||||
if rej is not None:
|
||||
continue
|
||||
from ..models import Tag
|
||||
|
||||
tag = session.get(Tag, a_tag_id)
|
||||
if tag is None:
|
||||
continue
|
||||
conf = _confidence_for_tag(session, tag, preds)
|
||||
if conf is None or conf < min_conf:
|
||||
continue
|
||||
stmt = pg_insert(image_tag).values(
|
||||
image_record_id=img_id,
|
||||
tag_id=a_tag_id,
|
||||
source="ml_auto",
|
||||
)
|
||||
stmt = stmt.on_conflict_do_nothing(
|
||||
index_elements=["image_record_id", "tag_id"]
|
||||
)
|
||||
session.execute(stmt)
|
||||
applied += 1
|
||||
session.commit()
|
||||
return applied
|
||||
|
||||
|
||||
def _load_predictions_sync(session, image_id: int) -> dict:
|
||||
"""Predictions for one image from image_prediction (#768), in the
|
||||
{raw_name: {category, confidence}} shape _confidence_for_tag consumes —
|
||||
keeps the allowlist resolution logic unchanged."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
rows = session.execute(
|
||||
sa_select(
|
||||
ImagePrediction.raw_name,
|
||||
ImagePrediction.category,
|
||||
ImagePrediction.score,
|
||||
).where(ImagePrediction.image_record_id == image_id)
|
||||
).all()
|
||||
return {
|
||||
r.raw_name: {"category": r.category, "confidence": r.score}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
def _confidence_for_tag(session, tag, preds: dict) -> float | None:
|
||||
"""Highest confidence among predictions that resolve to `tag` —
|
||||
either the prediction name equals the tag name, or an alias maps
|
||||
(prediction name, category) -> tag.id.
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from ..models import TagAlias
|
||||
|
||||
best: float | None = None
|
||||
direct = preds.get(tag.name)
|
||||
if direct is not None:
|
||||
best = float(direct.get("confidence", 0.0))
|
||||
alias_rows = session.execute(
|
||||
sa_select(TagAlias.alias_string, TagAlias.alias_category).where(
|
||||
TagAlias.canonical_tag_id == tag.id
|
||||
)
|
||||
).all()
|
||||
for alias_string, alias_category in alias_rows:
|
||||
p = preds.get(alias_string)
|
||||
if p is None:
|
||||
continue
|
||||
if p.get("category") != alias_category:
|
||||
continue
|
||||
c = float(p.get("confidence", 0.0))
|
||||
if best is None or c > best:
|
||||
best = c
|
||||
return best
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.recompute_centroid", bind=True)
|
||||
def recompute_centroid(self, tag_id: int) -> bool:
|
||||
import asyncio
|
||||
|
||||
from ..services.ml.centroids import CentroidService
|
||||
from ._async_session import async_session_factory
|
||||
|
||||
async def _run() -> bool:
|
||||
# Per-task NullPool engine bound to THIS asyncio.run loop — the shared
|
||||
# process-wide engine reuses connections across loops and raises
|
||||
# "Future attached to a different loop" on every call after the first.
|
||||
async_factory, async_engine = async_session_factory()
|
||||
try:
|
||||
async with async_factory() as session:
|
||||
svc = CentroidService(session)
|
||||
result = await svc.recompute_for_tag(tag_id)
|
||||
await session.commit()
|
||||
return result
|
||||
finally:
|
||||
await async_engine.dispose()
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.recompute_centroids",
|
||||
bind=True,
|
||||
# Audit 2026-06-02 — drifted-centroid rebuild over potentially
|
||||
# hundreds of tags.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def recompute_centroids(self) -> int:
|
||||
"""Daily: find drifted centroids, enqueue recompute_centroid for each."""
|
||||
import asyncio
|
||||
|
||||
from ..services.ml.centroids import CentroidService
|
||||
from ._async_session import async_session_factory
|
||||
|
||||
async def _list() -> list[int]:
|
||||
# Per-task NullPool engine bound to this loop (see recompute_centroid).
|
||||
async_factory, async_engine = async_session_factory()
|
||||
try:
|
||||
async with async_factory() as session:
|
||||
return await CentroidService(session).list_drifted()
|
||||
finally:
|
||||
await async_engine.dispose()
|
||||
|
||||
drifted = asyncio.run(_list())
|
||||
for tid in drifted:
|
||||
recompute_centroid.delay(tid)
|
||||
return len(drifted)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.tag_eval_run",
|
||||
bind=True,
|
||||
# The head-vs-centroid eval (#1130) loads embeddings + fits sklearn heads
|
||||
# for several concepts — minutes, not seconds. Runs on the ml queue because
|
||||
# only that worker has numpy/scikit-learn.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def tag_eval_run(self, run_id: int) -> str:
|
||||
"""Compute the eval report into the persisted TagEvalRun row so it survives
|
||||
navigation (the admin card rehydrates from the row, not transient state)."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..models import TagEvalRun
|
||||
from ..services.ml.tag_eval import run_eval
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
run = session.get(TagEvalRun, run_id)
|
||||
if run is None:
|
||||
return "missing"
|
||||
run.last_progress_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
try:
|
||||
report = run_eval(session, run.params)
|
||||
except SoftTimeLimitExceeded:
|
||||
run.status = "error"
|
||||
run.error = "timed out"
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception("tag_eval_run %d failed", run_id)
|
||||
run.status = "error"
|
||||
run.error = str(exc)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return "error"
|
||||
run.report = report
|
||||
run.status = "ready"
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return "ready"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.train_heads",
|
||||
bind=True,
|
||||
@@ -740,82 +440,6 @@ def scheduled_apply_head_tags() -> str:
|
||||
return "dispatched"
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.enqueue_gpu_backfill")
|
||||
def enqueue_gpu_backfill(task_name: str) -> int:
|
||||
"""Enqueue a gpu_job for every image that still needs `task_name` (one
|
||||
INSERT…SELECT, so it scales to a full library). The desktop agent drains the
|
||||
queue over HTTP. Returns the number enqueued.
|
||||
|
||||
'siglip' gates on the RESULT (no concept region yet) rather than on a prior
|
||||
job, so it picks up the back-catalogue of images that were CCIP-embedded
|
||||
before concept crops existed, and retries images whose concept embed failed —
|
||||
without re-touching their figure/CCIP regions."""
|
||||
from sqlalchemy import exists, insert, literal
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from ..models import GpuJob, ImageRecord, ImageRegion
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
if task_name == "siglip":
|
||||
has_concept = exists().where(
|
||||
ImageRegion.image_record_id == ImageRecord.id,
|
||||
ImageRegion.kind == "concept",
|
||||
)
|
||||
queued = exists().where(
|
||||
GpuJob.image_record_id == ImageRecord.id,
|
||||
GpuJob.task == "siglip",
|
||||
GpuJob.status.in_(["pending", "leased"]),
|
||||
)
|
||||
sel = sa_select(
|
||||
ImageRecord.id, literal("siglip"), literal("pending")
|
||||
).where(~has_concept).where(~queued)
|
||||
else:
|
||||
already = exists().where(
|
||||
GpuJob.image_record_id == ImageRecord.id,
|
||||
GpuJob.task == task_name,
|
||||
GpuJob.status.in_(["pending", "leased", "done"]),
|
||||
)
|
||||
sel = sa_select(
|
||||
ImageRecord.id, literal(task_name), literal("pending")
|
||||
).where(~already)
|
||||
# RETURNING + count: result.rowcount is unreliable for INSERT…SELECT.
|
||||
rows = session.execute(
|
||||
insert(GpuJob)
|
||||
.from_select(["image_record_id", "task", "status"], sel)
|
||||
.returning(GpuJob.id)
|
||||
).fetchall()
|
||||
session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.recover_orphaned_gpu_jobs")
|
||||
def recover_orphaned_gpu_jobs() -> int:
|
||||
"""Reset expired GPU-job leases back to pending — recovers work orphaned by an
|
||||
agent that died mid-job (no graceful release). Short beat cadence so orphans
|
||||
get picked back up quickly + the queue counts read honestly. Returns the
|
||||
number recovered."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from ..models import GpuJob
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
now = datetime.now(UTC)
|
||||
res = session.execute(
|
||||
update(GpuJob)
|
||||
.where(GpuJob.status == "leased", GpuJob.lease_expires_at < now)
|
||||
.values(
|
||||
status="pending", lease_token=None, leased_at=None,
|
||||
lease_expires_at=None, updated_at=now,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return res.rowcount or 0
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
|
||||
+5
-1
@@ -12,9 +12,13 @@ case "$ROLE" in
|
||||
# create_app is a factory — the `()` tells hypercorn to call it once
|
||||
# and serve the returned Quart (ASGI) app, rather than treating the
|
||||
# function itself as the application (which it then mis-invokes as WSGI).
|
||||
# Default 4 workers (was 2): each worker is one asyncio loop, and a large
|
||||
# file download occupies its worker for the transfer — 2 was too few once the
|
||||
# GPU agent + the browser's thumbnail grid hit /images concurrently (they
|
||||
# queued behind each other). Env-tunable via HYPERCORN_WORKERS.
|
||||
exec hypercorn \
|
||||
--bind 0.0.0.0:8080 \
|
||||
--workers "${HYPERCORN_WORKERS:-2}" \
|
||||
--workers "${HYPERCORN_WORKERS:-4}" \
|
||||
--access-logfile - \
|
||||
"backend.app:create_app()"
|
||||
;;
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
Usage: wrap a card's action body in the default slot; pass icon/title/blurb.
|
||||
`destructive` tints the icon error-red for delete actions. `open` can be forced
|
||||
(e.g. keep a running task's tile expanded). Keyboard accessible: the header is a
|
||||
real <button> with aria-expanded + focus ring.
|
||||
(e.g. keep a running task's tile expanded). Manual expand/collapse persists per
|
||||
tile in localStorage, so the page reloads the way the operator left it.
|
||||
Keyboard accessible: the header is a real <button> with aria-expanded + focus
|
||||
ring.
|
||||
-->
|
||||
<template>
|
||||
<v-card class="fc-tile" :class="{ 'fc-tile--open': isOpen }">
|
||||
@@ -53,12 +55,21 @@ const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const local = ref(props.open)
|
||||
watch(() => props.open, (v) => { local.value = v })
|
||||
// Only MANUAL toggles are saved (keyed by tile title): a forced `open` while a
|
||||
// task is mid-run is transient state, not a preference — persisting it would
|
||||
// resurrect the "several tiles open by default" bug this replaces. When the
|
||||
// force clears, the tile falls back to the operator's saved choice.
|
||||
const storeKey = `fc.tile.${props.title}`
|
||||
function savedOpen() {
|
||||
try { return localStorage.getItem(storeKey) === '1' } catch { return false }
|
||||
}
|
||||
const local = ref(props.open || savedOpen())
|
||||
watch(() => props.open, (v) => { local.value = v || savedOpen() })
|
||||
const isOpen = computed(() => local.value)
|
||||
|
||||
function toggle() {
|
||||
local.value = !local.value
|
||||
try { localStorage.setItem(storeKey, local.value ? '1' : '0') } catch { /* non-fatal */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -142,9 +142,13 @@ const tagStore = useTagStore()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
|
||||
// posted_* sort by earliest publish across ALL of an image's posts (original
|
||||
// post date); newest/oldest sort by the primary post's date, else download date.
|
||||
const SORTS = [
|
||||
{ title: 'Newest first', value: 'newest' },
|
||||
{ title: 'Oldest first', value: 'oldest' },
|
||||
{ title: 'Newest post date', value: 'posted_new' },
|
||||
{ title: 'Oldest post date', value: 'posted_old' },
|
||||
{ title: 'Newest added', value: 'newest' },
|
||||
{ title: 'Oldest added', value: 'oldest' },
|
||||
]
|
||||
|
||||
const selected = ref(null)
|
||||
@@ -175,7 +179,7 @@ const hasActiveFilters = computed(() =>
|
||||
store.filter.tag_or.length > 0 ||
|
||||
store.filter.artist_id != null ||
|
||||
store.filter.media_type != null ||
|
||||
store.filter.sort !== 'newest' ||
|
||||
store.filter.sort !== 'posted_new' ||
|
||||
store.filter.similar_to != null ||
|
||||
hasRefineFilters.value
|
||||
)
|
||||
|
||||
@@ -132,8 +132,8 @@ const hasMenu = computed(() =>
|
||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
/* Green ✓ / red ✗ verdict pair — same circular language as the eval card
|
||||
(TagEvalCard .fc-act) so accept/reject read identically across surfaces. */
|
||||
/* Green ✓ / red ✗ verdict pair — circular buttons so accept/reject read
|
||||
identically across surfaces. */
|
||||
.fc-suggestion__acts {
|
||||
flex: 0 0 auto; display: flex; gap: 4px;
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-playlist-check"
|
||||
:title="`Allowlisted tags (${store.rows.length})`"
|
||||
blurb="Tags auto-applied to images that score above their threshold. Tune the
|
||||
threshold and see how many images it would cover."
|
||||
>
|
||||
<v-data-table-virtual
|
||||
:headers="headers" :items="store.rows" :loading="store.loading"
|
||||
height="360" density="compact" fixed-header
|
||||
no-data-text="No tags on the allowlist yet — accept a suggestion to add one."
|
||||
>
|
||||
<template #item.applied_count="{ item }">
|
||||
<span class="fc-num">{{ item.applied_count ?? '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.min_confidence="{ item }">
|
||||
<div class="fc-thr">
|
||||
<v-text-field
|
||||
:model-value="item.min_confidence" type="number"
|
||||
density="compact" hide-details style="max-width: 100px;"
|
||||
:min="floor" max="1" step="0.05"
|
||||
:aria-label="`Auto-apply threshold for ${item.tag_name}`"
|
||||
@update:model-value="(v) => onThreshold(item, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="proj[item.tag_id]"
|
||||
class="fc-thr__proj"
|
||||
:class="{ 'fc-thr__proj--loading': proj[item.tag_id].loading }"
|
||||
:title="`At ${proj[item.tag_id].threshold}, a sweep would cover this many images`"
|
||||
>≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.coverage_count="{ item }">
|
||||
<span class="fc-num" :title="`Images a sweep covers at ${item.min_confidence}`">
|
||||
{{ item.coverage_count ?? '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-delete" size="x-small" variant="text" color="error"
|
||||
:aria-label="`Remove ${item.tag_name} from the allowlist`"
|
||||
@click="store.remove(item.tag_id)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
<p class="fc-muted text-caption mt-2">
|
||||
<strong>Applied</strong> = images currently carrying the tag.
|
||||
<strong>Covers</strong> = images a sweep would auto-apply it to at the
|
||||
current threshold. Lower the threshold to cover more (less certain) images.
|
||||
</p>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { useAllowlistStore } from '../../stores/allowlist.js'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const store = useAllowlistStore()
|
||||
const ml = useMLStore()
|
||||
// min_confidence can't be set below the tagger store floor — predictions
|
||||
// below it aren't stored, so a lower threshold would behave identically to
|
||||
// the floor. The backend clamps too (#764).
|
||||
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
|
||||
const headers = [
|
||||
{ title: 'Tag', key: 'tag_name', sortable: true },
|
||||
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 100 },
|
||||
{ title: 'Applied', key: 'applied_count', sortable: true, width: 90 },
|
||||
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 220 },
|
||||
{ title: 'Covers', key: 'coverage_count', sortable: true, width: 90 },
|
||||
{ title: '', key: 'actions', sortable: false, width: 56 }
|
||||
]
|
||||
|
||||
// Per-row live projection while the operator drags a threshold:
|
||||
// proj[tagId] = { threshold, count, loading }
|
||||
const proj = reactive({})
|
||||
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
if (!ml.settings) ml.loadSettings()
|
||||
})
|
||||
|
||||
const debounces = {}
|
||||
function onThreshold(item, value) {
|
||||
const tagId = item.tag_id
|
||||
const v = Math.max(parseFloat(value), floor.value)
|
||||
if (!(v > 0 && v <= 1)) return
|
||||
const shown = Number(v.toFixed(2))
|
||||
// Optimistic live projection box (loading until the count returns).
|
||||
proj[tagId] = { threshold: shown, count: proj[tagId]?.count ?? '…', loading: true }
|
||||
if (debounces[tagId]) clearTimeout(debounces[tagId])
|
||||
debounces[tagId] = setTimeout(async () => {
|
||||
try {
|
||||
const { count } = await store.coverage(tagId, v)
|
||||
proj[tagId] = { threshold: shown, count, loading: false }
|
||||
} catch {
|
||||
delete proj[tagId] // drop the projection rather than show a wrong number
|
||||
}
|
||||
// Commit the new threshold (also refreshes the row's stored coverage_count).
|
||||
store.updateThreshold(tagId, v)
|
||||
}, 500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-num { font-variant-numeric: tabular-nums; }
|
||||
.fc-thr { display: flex; align-items: center; gap: 10px; }
|
||||
.fc-thr__proj {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-thr__proj--loading { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-vector-triangle"
|
||||
title="Tag centroids"
|
||||
blurb="Rebuild SigLIP centroids for similarity suggestions."
|
||||
:open="busy"
|
||||
>
|
||||
<p class="text-body-2 mb-3">
|
||||
Rebuild the per-tag SigLIP centroids that power similarity-based
|
||||
suggestions. Runs nightly automatically; trigger manually after a
|
||||
large tagging session.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-vector-triangle</v-icon> Recompute centroids
|
||||
</v-btn>
|
||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||
<QueueStatusBar queue="ml" queue-label="ML" />
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
const store = useMLStore()
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
async function run() {
|
||||
busy.value = true
|
||||
try { await store.triggerRecomputeCentroids(); done.value = true }
|
||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-nuke"
|
||||
title="Reset content tagging (whole instance)"
|
||||
blurb="Delete ALL general/character tags and their applications — a start-over. Requires a confirmation code."
|
||||
destructive
|
||||
>
|
||||
<p class="text-body-2 mb-2">
|
||||
Deletes every <code>general</code> and <code>character</code> tag and
|
||||
removes them from every image — <strong>including the examples the
|
||||
tagging heads learned from</strong>. Suggestions will <strong>not</strong>
|
||||
repopulate on their own: you re-tag from scratch, and the heads retrain
|
||||
from your new tags as they accumulate. Fandoms and series (with their
|
||||
page order) are kept.
|
||||
</p>
|
||||
<v-alert type="error" variant="tonal" density="compact" class="mb-3">
|
||||
Irreversible — no undo except restoring a DB backup
|
||||
(Settings → Maintenance → Backup). Back one up first.
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview content-tag reset</v-btn>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ preview.count }}</strong> content tag(s)
|
||||
<span v-for="(n, k) in preview.by_kind" :key="k" class="fc-muted">
|
||||
({{ k }}: {{ n }})
|
||||
</span>
|
||||
across <strong>{{ preview.applications }}</strong> image
|
||||
application(s).
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="preview.sample_names?.length"
|
||||
:names="preview.sample_names" class="mb-3"
|
||||
/>
|
||||
<template v-if="preview.count">
|
||||
<p class="text-body-2 mb-2">
|
||||
To arm the reset, type the confirmation code
|
||||
<code class="fc-code">{{ preview.confirm }}</code> below.
|
||||
</p>
|
||||
<div class="d-flex align-center mb-1" style="gap: 12px">
|
||||
<v-text-field
|
||||
v-model="typed" density="compact" hide-details variant="outlined"
|
||||
label="Confirmation code" style="max-width: 200px"
|
||||
autocomplete="off" spellcheck="false"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-alert"
|
||||
:disabled="typed !== preview.confirm"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} tag(s) +
|
||||
{{ preview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mb-0">
|
||||
The code is derived from the counts above — if tagging changes
|
||||
between preview and apply, the server rejects the stale code.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||
import { useAdminStore } from '../../stores/admin.js'
|
||||
|
||||
const store = useAdminStore()
|
||||
const preview = ref(null)
|
||||
const loadingPreview = ref(false)
|
||||
const committing = ref(false)
|
||||
const typed = ref('')
|
||||
|
||||
async function onPreview() {
|
||||
loadingPreview.value = true
|
||||
typed.value = ''
|
||||
try {
|
||||
preview.value = await store.resetContentTagging({ dryRun: true })
|
||||
} catch (e) {
|
||||
toast({ text: `Preview failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
loadingPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCommit() {
|
||||
committing.value = true
|
||||
try {
|
||||
const res = await store.resetContentTagging({
|
||||
dryRun: false, confirm: typed.value,
|
||||
})
|
||||
toast({ text: `Deleted ${res.deleted} content tag(s) — re-tagging starts fresh`, type: 'success' })
|
||||
preview.value = null
|
||||
typed.value = ''
|
||||
} catch (e) {
|
||||
toast({ text: `Reset rejected: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-code {
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border-radius: 4px; padding: 2px 8px;
|
||||
font-family: 'JetBrains Mono', monospace; font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<v-card class="mb-4">
|
||||
<CardHeading icon="mdi-download" title="Downloads (last 24h)">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text" size="small" rounded="pill"
|
||||
to="/subscriptions?tab=downloads"
|
||||
>
|
||||
Open subscriptions
|
||||
<v-icon end size="small">mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
</CardHeading>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px">
|
||||
<v-chip size="small" variant="tonal" color="success">
|
||||
{{ stats.ok }} ok
|
||||
</v-chip>
|
||||
<v-chip
|
||||
size="small" variant="tonal"
|
||||
:color="stats.error ? 'error' : undefined"
|
||||
>
|
||||
{{ stats.error }} failed
|
||||
</v-chip>
|
||||
<v-chip v-if="stats.running" size="small" variant="tonal" color="accent">
|
||||
{{ stats.running }} running
|
||||
</v-chip>
|
||||
<v-chip v-if="stats.pending" size="small" variant="tonal">
|
||||
{{ stats.pending }} pending
|
||||
</v-chip>
|
||||
<v-chip v-if="stats.skipped" size="small" variant="tonal">
|
||||
{{ stats.skipped }} skipped
|
||||
</v-chip>
|
||||
</div>
|
||||
<p v-if="!failing.length" class="fc-muted text-body-2 mb-0">
|
||||
All subscription sources healthy.
|
||||
</p>
|
||||
<p v-else class="text-body-2 mb-0">
|
||||
<b class="fc-bad">{{ failing.length }}</b> failing source(s):
|
||||
<span class="fc-muted">{{ failingNames }}</span>
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
|
||||
const store = useDownloadsStore()
|
||||
const { stats, failing } = storeToRefs(store)
|
||||
let pollId = null
|
||||
|
||||
const failingNames = computed(() => {
|
||||
const names = failing.value.map((s) => s.artist_name || s.url).slice(0, 3)
|
||||
const extra = failing.value.length - names.length
|
||||
return names.join(', ') + (extra > 0 ? ` +${extra} more` : '')
|
||||
})
|
||||
|
||||
function poll() {
|
||||
store.loadStats(24)
|
||||
store.loadFailing()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
poll()
|
||||
pollId = setInterval(() => { if (!document.hidden) poll() }, 30000)
|
||||
})
|
||||
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<v-card class="mb-4">
|
||||
<CardHeading icon="mdi-expansion-card" title="GPU agent pipeline">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text" size="small" rounded="pill"
|
||||
@click="$emit('open-maintenance')"
|
||||
>
|
||||
Open maintenance
|
||||
<v-icon end size="small">mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
</CardHeading>
|
||||
<v-card-text>
|
||||
<div class="fc-cells mb-2">
|
||||
<div class="fc-cell">
|
||||
<div class="fc-cell__n">{{ q.pending }}</div>
|
||||
<div class="fc-cell__l">pending</div>
|
||||
</div>
|
||||
<div class="fc-cell">
|
||||
<div class="fc-cell__n">{{ q.leased }}</div>
|
||||
<div class="fc-cell__l">in flight</div>
|
||||
</div>
|
||||
<div class="fc-cell">
|
||||
<div class="fc-cell__n fc-good">{{ q.done }}</div>
|
||||
<div class="fc-cell__l">done</div>
|
||||
</div>
|
||||
<div class="fc-cell">
|
||||
<div class="fc-cell__n" :class="q.error ? 'fc-bad' : ''">{{ q.error }}</div>
|
||||
<div class="fc-cell__l">errored</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!q.error" class="fc-muted text-body-2 mb-0">
|
||||
No failed jobs — the pipeline is clean. Work drains whenever the
|
||||
desktop agent is running.
|
||||
</p>
|
||||
<p v-else class="text-body-2 mb-0">
|
||||
Triage: <b>{{ triage.defect }}</b> defective file(s) ·
|
||||
{{ triage.file_ok }} file-ok · {{ triage.unclassified }} unprobed
|
||||
<span v-if="reasonSummary" class="fc-muted">— {{ reasonSummary }}</span>
|
||||
<br>
|
||||
<span class="fc-muted text-caption">
|
||||
Recover defective files from Maintenance → Failed processing.
|
||||
</span>
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import { useGpuStore } from '../../stores/gpu.js'
|
||||
|
||||
defineEmits(['open-maintenance'])
|
||||
|
||||
const store = useGpuStore()
|
||||
const q = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||
const triage = ref({ defect: 0, file_ok: 0, unclassified: 0 })
|
||||
const byClass = ref({})
|
||||
let pollId = null
|
||||
let lastErrorCount = -1
|
||||
|
||||
const reasonSummary = computed(() =>
|
||||
Object.entries(byClass.value)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([k, n]) => `${k.replaceAll('_', ' ')} ${n}`)
|
||||
.join(' · '))
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
q.value = await store.status()
|
||||
// The triage detail is only worth a second call when the error count
|
||||
// actually moved (it's a 500-row join server-side).
|
||||
if (q.value.error !== lastErrorCount) {
|
||||
lastErrorCount = q.value.error
|
||||
if (q.value.error > 0) {
|
||||
const body = await store.errors()
|
||||
triage.value = body.triage
|
||||
byClass.value = body.by_class
|
||||
} else {
|
||||
triage.value = { defect: 0, file_ok: 0, unclassified: 0 }
|
||||
byClass.value = {}
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal — panel just shows the last snapshot */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
poll()
|
||||
pollId = setInterval(() => { if (!document.hidden) poll() }, 5000)
|
||||
})
|
||||
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-cells { display: flex; gap: 28px; }
|
||||
.fc-cell__n {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.fc-cell__l {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
@@ -3,7 +3,6 @@
|
||||
icon="mdi-expansion-card"
|
||||
title="GPU agent (CCIP + crops)"
|
||||
blurb="Connect a desktop-GPU agent to embed characters (CCIP) and crops. It pulls work over HTTP — your database and Redis stay private."
|
||||
:open="true"
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
The agent is a container you run on the machine with the GPU. It
|
||||
@@ -52,6 +51,17 @@
|
||||
<div class="fc-q"><div class="fc-q__n" :class="queue.error ? 'fc-weak' : ''">{{ queue.error }}</div><div class="fc-q__l">errored</div></div>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
class="mt-3" color="accent" variant="tonal" rounded="pill" size="small"
|
||||
prepend-icon="mdi-restart-alert" :loading="retrying"
|
||||
:disabled="!queue.error" @click="onRetryErrors"
|
||||
>Retry errored jobs</v-btn>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Errored jobs park after 3 failed attempts. This requeues just those (their
|
||||
errors cleared, attempts reset) — use after updating the agent, without
|
||||
re-running the whole done library.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
class="mt-4" color="accent" variant="tonal" rounded="pill" size="small"
|
||||
prepend-icon="mdi-account-box-multiple" :loading="backfilling" @click="onBackfill"
|
||||
@@ -71,6 +81,16 @@
|
||||
images get these automatically; this catches the back-catalogue.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
class="mt-3" color="warning" variant="tonal" rounded="pill" size="small"
|
||||
prepend-icon="mdi-backup-restore" :loading="reprocessing" @click="onReprocess"
|
||||
>Re-process library (re-detect + re-crop)</v-btn>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Re-runs the FULL pipeline (figure detection + CCIP + concept/panel crops) on
|
||||
<b>every</b> image — use after changing crop detectors so the back-catalogue
|
||||
gets re-cropped, not just new images. Heavy: re-processes the whole library.
|
||||
</p>
|
||||
|
||||
<!-- Match strictness -->
|
||||
<div class="fc-section-h mt-5 mb-1">Character-match strictness</div>
|
||||
<div v-if="ml.settings" class="d-flex align-center" style="gap:12px">
|
||||
@@ -106,6 +126,33 @@
|
||||
reversible) — so identity tags keep flowing without review. Stricter than
|
||||
the suggest cut; 0.92 recommended.
|
||||
</p>
|
||||
|
||||
<!-- Embedding model -->
|
||||
<div v-if="ml.settings" class="fc-section-h mt-5 mb-1">Embedding model</div>
|
||||
<div v-if="ml.settings">
|
||||
<v-select
|
||||
v-model="selectedModel" :items="modelItems" item-title="label"
|
||||
item-value="name" label="Model" density="compact" hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
<div class="d-flex mt-3" style="gap:8px">
|
||||
<v-btn
|
||||
size="small" variant="tonal" rounded="pill" :loading="savingModel"
|
||||
prepend-icon="mdi-content-save" @click="onSaveModel"
|
||||
>Save model</v-btn>
|
||||
<v-btn
|
||||
size="small" color="accent" variant="flat" rounded="pill"
|
||||
:loading="reembedding" prepend-icon="mdi-backup-restore" @click="onReembed"
|
||||
>Re-embed library (GPU)</v-btn>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Switching the model is a DIFFERENT embedding space. After <b>Save model</b>,
|
||||
run <b>Re-embed library</b> (the GPU agent re-embeds whole images + concept
|
||||
crops), then <b>Retrain heads</b> — suggestions degrade until both finish.
|
||||
SigLIP 2 (512px) is a 1152-d drop-in over SigLIP 1; new installs default to
|
||||
it. Your existing library stays on its current model until you re-embed.
|
||||
</p>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
@@ -126,11 +173,17 @@ const masked = ref(true)
|
||||
const rotating = ref(false)
|
||||
const backfilling = ref(false)
|
||||
const backfillingSiglip = ref(false)
|
||||
const reprocessing = ref(false)
|
||||
const retrying = ref(false)
|
||||
const threshold = ref(0.85)
|
||||
const savingThreshold = ref(false)
|
||||
const autoApply = ref(true)
|
||||
const autoThreshold = ref(0.92)
|
||||
const savingAuto = ref(false)
|
||||
const modelItems = ref([])
|
||||
const selectedModel = ref(null)
|
||||
const savingModel = ref(false)
|
||||
const reembedding = ref(false)
|
||||
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||
let pollTimer = null
|
||||
|
||||
@@ -157,9 +210,50 @@ onMounted(async () => {
|
||||
autoApply.value = ml.settings.ccip_auto_apply_enabled
|
||||
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
|
||||
}
|
||||
if (ml.settings?.embedder_model_name != null) {
|
||||
const items = await ml.embedderModels()
|
||||
// Make sure the current model is selectable even if it's not in the list.
|
||||
const cur = ml.settings.embedder_model_name
|
||||
if (!items.some((m) => m.name === cur)) {
|
||||
items.push({ name: cur, version: ml.settings.embedder_model_version, label: `${cur} (current)` })
|
||||
}
|
||||
modelItems.value = items
|
||||
selectedModel.value = cur
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
|
||||
async function onSaveModel() {
|
||||
const opt = modelItems.value.find((m) => m.name === selectedModel.value)
|
||||
if (!opt) return
|
||||
savingModel.value = true
|
||||
try {
|
||||
await ml.patchSettings({
|
||||
embedder_model_name: opt.name,
|
||||
embedder_model_version: opt.version,
|
||||
})
|
||||
toast({ text: 'Embedding model saved — now Re-embed library, then Retrain heads', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save model: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
savingModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReembed() {
|
||||
reembedding.value = true
|
||||
try {
|
||||
await store.backfill('embed')
|
||||
await store.backfill('siglip')
|
||||
toast({ text: 'Queued whole-image + concept re-embed — run the agent, then Retrain heads', type: 'success' })
|
||||
await refreshQueue()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not queue re-embed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
reembedding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveAuto() {
|
||||
savingAuto.value = true
|
||||
try {
|
||||
@@ -239,6 +333,37 @@ async function onBackfillSiglip() {
|
||||
backfillingSiglip.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRetryErrors() {
|
||||
retrying.value = true
|
||||
try {
|
||||
const { requeued, pruned, defects_kept: kept } = await store.retryErrors()
|
||||
const extras = []
|
||||
if (pruned) extras.push(`${pruned} stale duplicate${pruned === 1 ? '' : 's'} pruned`)
|
||||
if (kept) extras.push(`${kept} known-bad file${kept === 1 ? '' : 's'} kept for recovery`)
|
||||
const extra = extras.length ? ` (${extras.join(', ')})` : ''
|
||||
toast({ text: `Requeued ${requeued} errored job${requeued === 1 ? '' : 's'}${extra} — run the agent to process them`, type: 'success' })
|
||||
await refreshQueue()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not retry errored jobs: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReprocess() {
|
||||
if (!window.confirm('Re-process the ENTIRE library (re-detect + re-crop every image)? This is heavy and runs on the GPU agent.')) return
|
||||
reprocessing.value = true
|
||||
try {
|
||||
await store.reprocess('ccip')
|
||||
toast({ text: 'Library queued for re-processing — run the agent to drain it', type: 'success' })
|
||||
await refreshQueue()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not start re-process: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
reprocessing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-file-alert"
|
||||
title="Failed processing"
|
||||
blurb="Triage originals that failed GPU processing — probe the files, flag defects, recover them."
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
A job that keeps failing parks as an error with its reason. A background
|
||||
probe then checks the FILE itself (checksum + decode) and splits the
|
||||
errors: <b>defective files</b> (truncated/corrupt originals — listed below
|
||||
for recovery) vs <b>file OK</b> (the failure was operational; requeue
|
||||
those with <i>Retry errored jobs</i> on the GPU agent card).
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="fc-muted text-body-2">Loading…</div>
|
||||
<template v-else-if="overview">
|
||||
<div class="fc-queue mb-3">
|
||||
<div class="fc-q"><div class="fc-q__n">{{ overview.total }}</div><div class="fc-q__l">errored</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n" :class="overview.triage.defect ? 'fc-weak' : ''">{{ overview.triage.defect }}</div><div class="fc-q__l">defective</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n fc-good">{{ overview.triage.file_ok }}</div><div class="fc-q__l">file ok</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n">{{ overview.triage.unclassified }}</div><div class="fc-q__l">unprobed</div></div>
|
||||
</div>
|
||||
|
||||
<p v-if="classSummary" class="fc-muted text-caption mb-3">
|
||||
Reasons: {{ classSummary }}
|
||||
</p>
|
||||
|
||||
<div class="d-flex mb-4" style="gap:8px">
|
||||
<v-btn
|
||||
size="small" color="accent" variant="tonal" rounded="pill"
|
||||
prepend-icon="mdi-magnify-scan" :loading="probing"
|
||||
:disabled="!overview.triage.unclassified" @click="onProbe"
|
||||
>Probe unclassified now</v-btn>
|
||||
<v-btn
|
||||
size="small" variant="text" rounded="pill"
|
||||
prepend-icon="mdi-refresh" @click="refresh"
|
||||
>Refresh</v-btn>
|
||||
</div>
|
||||
|
||||
<template v-if="defects.length">
|
||||
<div class="fc-section-h mb-2">Defective originals</div>
|
||||
<div v-for="it in defects" :key="it.job_id" class="fc-defect mb-2">
|
||||
<a :href="it.image_url" target="_blank" rel="noopener" class="fc-defect__thumb">
|
||||
<img v-if="it.thumbnail_url" :src="it.thumbnail_url" alt="">
|
||||
<v-icon v-else icon="mdi-file-question" size="28" />
|
||||
</a>
|
||||
<div class="fc-defect__meta">
|
||||
<div class="text-body-2">
|
||||
image <b>{{ it.image_id }}</b> · {{ it.task }} ·
|
||||
<span class="fc-weak">{{ it.integrity_status }}</span>
|
||||
</div>
|
||||
<div class="fc-muted text-caption fc-defect__err" :title="it.error || ''">
|
||||
{{ it.error || 'no stored reason' }}
|
||||
</div>
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="recovered[it.image_id] !== 'no_source'"
|
||||
size="small" color="accent" variant="tonal" rounded="pill"
|
||||
prepend-icon="mdi-cloud-download" :loading="recovering === it.image_id"
|
||||
@click="onRecover(it)"
|
||||
>Recover</v-btn>
|
||||
<span v-else class="fc-muted text-caption">
|
||||
no pollable source — replace the file manually
|
||||
</span>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Recover deletes the bad copy (and its record) and re-checks its
|
||||
subscription source, so a fresh download re-imports it and re-enters
|
||||
processing. Files without a pollable source need manual replacement.
|
||||
</p>
|
||||
</template>
|
||||
<p v-else-if="!overview.total" class="fc-muted text-body-2 mb-0">
|
||||
No failed jobs — the pipeline is clean.
|
||||
</p>
|
||||
<p v-else-if="!overview.triage.unclassified" class="fc-muted text-body-2 mb-0">
|
||||
No defective files — every probed failure was operational
|
||||
(file OK). Requeue them from the GPU agent card.
|
||||
</p>
|
||||
</template>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { useGpuStore } from '../../stores/gpu.js'
|
||||
|
||||
const store = useGpuStore()
|
||||
const loading = ref(true)
|
||||
const overview = ref(null)
|
||||
const probing = ref(false)
|
||||
const recovering = ref(null)
|
||||
// image_id -> 'no_source' for rows recovery already declined; keeps the
|
||||
// verdict visible instead of a button that fails the same way again.
|
||||
const recovered = ref({})
|
||||
|
||||
const defects = computed(() =>
|
||||
(overview.value?.items || []).filter((i) => i.triage_status === 'defect'))
|
||||
|
||||
const classSummary = computed(() => {
|
||||
const bc = overview.value?.by_class || {}
|
||||
return Object.entries(bc)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, n]) => `${k.replaceAll('_', ' ')} ${n}`)
|
||||
.join(' · ')
|
||||
})
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
overview.value = await store.errors()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not load failed jobs: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onProbe() {
|
||||
probing.value = true
|
||||
try {
|
||||
await store.triageErrors()
|
||||
toast({ text: 'Probe queued — verdicts appear here as files are checked (large videos take a while)', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Could not start the probe: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
probing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRecover(it) {
|
||||
recovering.value = it.image_id
|
||||
try {
|
||||
const res = await store.recoverImage(it.image_id)
|
||||
if (res.status === 'refetch_queued') {
|
||||
toast({ text: `Deleted the bad copy and queued a re-check of source #${res.source_id} — it re-imports on the next fetch`, type: 'success' })
|
||||
await refresh()
|
||||
} else if (res.status === 'no_source') {
|
||||
recovered.value = { ...recovered.value, [it.image_id]: 'no_source' }
|
||||
toast({ text: 'No enabled subscription source covers this file — replace it manually', type: 'warning' })
|
||||
} else {
|
||||
toast({ text: 'Image record no longer exists — refreshing', type: 'warning' })
|
||||
await refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ text: `Recovery failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
recovering.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-q__n {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.fc-q__l {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
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 {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.fc-defect__thumb {
|
||||
flex: 0 0 44px; width: 44px; height: 44px; border-radius: 6px;
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.fc-defect__thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-defect__meta { flex: 1; min-width: 0; }
|
||||
.fc-defect__err {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
<MaintenanceTile
|
||||
icon="mdi-brain"
|
||||
title="Concept heads (the learning suggester)"
|
||||
blurb="Train the per-concept heads that turn your tags into suggestions — they replace Camie and sharpen every time you accept or reject."
|
||||
:open="headCount > 0 || running"
|
||||
blurb="Train the per-concept heads that turn your tags into suggestions — they learn from your library and sharpen every time you accept or reject."
|
||||
:open="running"
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
A <strong>head</strong> is a tiny classifier trained on the SigLIP
|
||||
|
||||
@@ -90,10 +90,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { onMounted, reactive, watch } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
// Self-sufficient since the Import tab dissolved (2026-07-02): this form now
|
||||
// lives in Maintenance → Ingestion & filters and loads its own settings
|
||||
// instead of relying on the old tab's mount hook.
|
||||
onMounted(() => { if (!store.settings) store.loadSettings() })
|
||||
// Labelled stops so the less-initiated get the gist without knowing what a
|
||||
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
|
||||
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<CardHeading title="Recent import tasks">
|
||||
<v-spacer />
|
||||
<v-select
|
||||
v-model="statusFilter" :items="statusOptions" density="compact"
|
||||
hide-details style="max-width: 180px;" @update:model-value="onFilterChange"
|
||||
/>
|
||||
<v-btn variant="text" rounded="pill" size="small" @click="onRefresh">
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
Refresh
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text" rounded="pill" size="small" color="warning"
|
||||
:disabled="!hasFailed" @click="onRetryFailed"
|
||||
>
|
||||
Retry failed
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text" rounded="pill" size="small" color="warning"
|
||||
:disabled="!hasStuck" @click="onClearStuckOpen"
|
||||
>
|
||||
Clear stuck…
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text" rounded="pill" size="small" color="error"
|
||||
@click="onClearOpen"
|
||||
>
|
||||
Clear completed…
|
||||
</v-btn>
|
||||
</CardHeading>
|
||||
<v-data-table-virtual
|
||||
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
|
||||
height="480" density="compact" fixed-header no-data-text="No tasks yet — trigger a scan above."
|
||||
>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.source_path="{ item }">
|
||||
<span :title="item.source_path">{{ shorten(item.source_path) }}</span>
|
||||
</template>
|
||||
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
||||
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
||||
<template #item.error="{ item }">
|
||||
<button
|
||||
v-if="item.error" type="button" class="fc-err-link text-caption"
|
||||
@click="openError(`Task ${item.id} failed`, item.error)"
|
||||
title="Click for full error"
|
||||
>{{ shorten(item.error, 60) }}</button>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
v-if="item.status === 'failed'"
|
||||
icon size="x-small" variant="text"
|
||||
:loading="refetching === item.id"
|
||||
@click="onRefetch(item)"
|
||||
>
|
||||
<v-icon size="small">mdi-cloud-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
Re-fetch original (re-download from source)
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
||||
<v-btn variant="text" size="small" @click="onLoadMore">Load more</v-btn>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="clearDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Clear completed tasks</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select
|
||||
v-model="clearAgeDays" label="Older than"
|
||||
:items="[
|
||||
{ title: 'All finished', value: 0 },
|
||||
{ title: '1 day', value: 1 },
|
||||
{ title: '7 days', value: 7 },
|
||||
{ title: '30 days', value: 30 }
|
||||
]"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="clearDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" rounded="pill" @click="onClearConfirm">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="clearStuckDialog" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title>Clear stuck tasks</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Force every <strong>pending / queued / processing</strong> task to
|
||||
<strong>failed</strong> and finalize any active batch that
|
||||
has no remaining work. Use this when the automatic recovery
|
||||
sweep keeps re-queueing the same row (e.g., corrupt file in
|
||||
an autoretry loop, or worker model missing).
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Tasks remain in the database with status=<code>failed</code>;
|
||||
click <em>Retry failed</em> once the underlying cause is
|
||||
resolved to re-queue them.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="clearStuckDialog = false">Cancel</v-btn>
|
||||
<v-btn color="warning" rounded="pill" @click="onClearStuckConfirm">Clear stuck</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<ErrorDetailModal
|
||||
v-model="showErrorModal"
|
||||
:title="errorModalTitle"
|
||||
:message="errorModalMessage"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
|
||||
const store = useImportStore()
|
||||
const statusFilter = ref(null)
|
||||
const clearDialog = ref(false)
|
||||
// Click-to-open modal for full error text (operator-flagged 2026-05-26
|
||||
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
|
||||
// tracebacks into an unusable popup with no copy-paste affordance).
|
||||
const showErrorModal = ref(false)
|
||||
const errorModalTitle = ref('')
|
||||
const errorModalMessage = ref('')
|
||||
|
||||
function openError(title, message) {
|
||||
errorModalTitle.value = title
|
||||
errorModalMessage.value = message || ''
|
||||
showErrorModal.value = true
|
||||
}
|
||||
const clearAgeDays = ref(7)
|
||||
const clearStuckDialog = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'All', value: null },
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Queued', value: 'queued' },
|
||||
{ title: 'Processing', value: 'processing' },
|
||||
{ title: 'Complete', value: 'complete' },
|
||||
{ title: 'Skipped', value: 'skipped' },
|
||||
{ title: 'Failed', value: 'failed' }
|
||||
]
|
||||
|
||||
const headers = [
|
||||
{ title: 'Status', key: 'status', sortable: false, width: 120 },
|
||||
{ title: 'Source', key: 'source_path', sortable: false },
|
||||
{ title: 'Size', key: 'size_bytes', sortable: false, width: 90 },
|
||||
{ title: 'Created', key: 'created_at', sortable: false, width: 150 },
|
||||
{ title: 'Note', key: 'error', sortable: false },
|
||||
{ title: '', key: 'actions', sortable: false, width: 56 }
|
||||
]
|
||||
|
||||
const refetching = ref(null)
|
||||
const _REFETCH_MSG = {
|
||||
refetch_queued: { text: 'Re-fetch queued — re-downloading from source', type: 'success' },
|
||||
no_source: { text: 'No re-fetchable source (filesystem import — replace the file manually)', type: 'info' },
|
||||
already_refetched: { text: 'Already re-fetched once', type: 'info' },
|
||||
}
|
||||
async function onRefetch(item) {
|
||||
refetching.value = item.id
|
||||
try {
|
||||
const res = await store.refetchTask(item.id)
|
||||
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
|
||||
toast(msg)
|
||||
} catch (e) {
|
||||
toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
refetching.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
|
||||
const hasStuck = computed(() => store.tasks.some(
|
||||
t => t.status === 'pending' || t.status === 'queued' || t.status === 'processing'
|
||||
))
|
||||
|
||||
function statusColor(s) {
|
||||
return {
|
||||
complete: 'success',
|
||||
skipped: 'warning',
|
||||
failed: 'error',
|
||||
processing: 'accent',
|
||||
queued: 'info',
|
||||
pending: 'info'
|
||||
}[s] || 'default'
|
||||
}
|
||||
function shorten(s, max = 90) {
|
||||
if (!s) return ''
|
||||
if (s.length <= max) return s
|
||||
const head = Math.floor((max - 3) * 0.6)
|
||||
const tail = max - 3 - head
|
||||
return s.slice(0, head) + '...' + s.slice(-tail)
|
||||
}
|
||||
function formatBytes(b) {
|
||||
if (!b) return ''
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB']
|
||||
let i = 0; let v = b
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
function formatDate(s) {
|
||||
try { return new Date(s).toLocaleString() } catch { return s }
|
||||
}
|
||||
|
||||
async function onRefresh() { await store.loadTasks(true) }
|
||||
function onFilterChange() { store.setStatusFilter(statusFilter.value); store.loadTasks(true) }
|
||||
async function onLoadMore() { await store.loadTasks(false) }
|
||||
async function onRetryFailed() { await store.retryFailed() }
|
||||
function onClearOpen() { clearDialog.value = true }
|
||||
async function onClearConfirm() {
|
||||
await store.clearCompleted(clearAgeDays.value)
|
||||
clearDialog.value = false
|
||||
}
|
||||
function onClearStuckOpen() { clearStuckDialog.value = true }
|
||||
async function onClearStuckConfirm() {
|
||||
await store.clearStuck()
|
||||
clearStuckDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-err-link {
|
||||
/* Truncated error preview as a clickable button — opens
|
||||
ErrorDetailModal with the full text. Inherits the row's font
|
||||
sizing so it doesn't visually drift from the prior tooltip-bearing
|
||||
span. */
|
||||
color: rgb(var(--v-theme-error, 220 80 80));
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-err-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Trigger scan</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="store.activeBatch" class="d-flex align-center mb-3" style="gap: 12px;">
|
||||
<v-progress-circular
|
||||
indeterminate color="accent" size="20"
|
||||
/>
|
||||
<span>
|
||||
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
||||
{{ store.activeBatch.source_path || '/import' }} —
|
||||
imported {{ store.activeBatch.imported }},
|
||||
<template v-if="store.activeBatch.scan_mode === 'deep'">
|
||||
refreshed {{ store.activeBatch.refreshed || 0 }},
|
||||
</template>
|
||||
skipped {{ store.activeBatch.skipped }},
|
||||
failed {{ store.activeBatch.failed }} /
|
||||
{{ store.activeBatch.total_files }} files
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text" rounded="pill" size="small" color="warning"
|
||||
:loading="clearing" @click="onClearStuck"
|
||||
>
|
||||
Clear stuck
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 mb-3">
|
||||
<span v-if="!store.activeBatch">
|
||||
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
||||
new files only.
|
||||
<strong>Deep scan</strong> additionally re-walks already-imported
|
||||
files so updated sidecar metadata (post title/date/attribution) and
|
||||
previously-NULL phashes / artist links get refreshed. Use after
|
||||
bulk-downloading fresh sidecars for existing content. Both modes
|
||||
route non-media + sidecar pairs through PostAttachment capture.
|
||||
</span>
|
||||
<span v-else>
|
||||
An active batch is in progress. Wait for it to finish, or click
|
||||
<em>Clear stuck</em> above if it has been wedged with no
|
||||
measurable progress.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!!store.activeBatch"
|
||||
:loading="busy === 'quick'"
|
||||
@click="trigger('quick')"
|
||||
>
|
||||
<v-icon start>mdi-magnify-scan</v-icon>
|
||||
Quick scan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary" rounded="pill" variant="tonal"
|
||||
:disabled="!!store.activeBatch"
|
||||
:loading="busy === 'deep'"
|
||||
@click="trigger('deep')"
|
||||
>
|
||||
<v-icon start>mdi-magnify-plus-outline</v-icon>
|
||||
Deep scan
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
||||
{{ store.triggerError }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
const busy = ref(null)
|
||||
const clearing = ref(false)
|
||||
|
||||
async function trigger(mode) {
|
||||
busy.value = mode
|
||||
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
|
||||
}
|
||||
|
||||
async function onClearStuck() {
|
||||
clearing.value = true
|
||||
try {
|
||||
await store.clearStuck()
|
||||
} catch {
|
||||
// store surfaces error via triggerError if needed
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,33 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-refresh"
|
||||
title="ML backfill"
|
||||
blurb="Re-run tagging + embeddings on images missing them."
|
||||
title="CPU embedding backfill"
|
||||
blurb="Whole-image embeddings without a GPU agent — the built-in fallback."
|
||||
:open="busy"
|
||||
>
|
||||
<p class="text-body-2 mb-3">
|
||||
Re-run Camie + SigLIP on images missing predictions or embeddings
|
||||
for the current model versions. Safe to re-run.
|
||||
Computes the whole-image SigLIP embedding for anything missing one —
|
||||
images directly, videos by sampling frames (the same approach as the
|
||||
GPU agent). Runs on the ml-worker's CPU, so search, similarity and
|
||||
head suggestions work <strong>without</strong> a GPU agent; new imports
|
||||
are embedded this way automatically. Detection, cropping and character
|
||||
(CCIP) embeddings are GPU-agent-only. Safe to re-run. To re-embed under
|
||||
a NEW model, use the GPU agent's "Re-embed library" instead.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-switch
|
||||
v-model="enabled" color="accent" hide-details density="compact"
|
||||
:loading="saving" label="CPU embedding enabled"
|
||||
class="mb-1" @update:model-value="onToggle"
|
||||
/>
|
||||
<p class="fc-muted text-caption mb-3">
|
||||
Turn OFF if you run the GPU agent and removed the ml-worker container —
|
||||
imports then stop queueing CPU embed work nothing will consume (the
|
||||
daily GPU embed backfill covers those images instead).
|
||||
</p>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :loading="busy" :disabled="!enabled"
|
||||
@click="run"
|
||||
>
|
||||
<v-icon start>mdi-refresh</v-icon> Run backfill now
|
||||
</v-btn>
|
||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||
@@ -19,13 +37,40 @@
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
const store = useMLStore()
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
const enabled = ref(true)
|
||||
const saving = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
if (store.settings?.cpu_embed_enabled != null) {
|
||||
enabled.value = store.settings.cpu_embed_enabled
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
async function onToggle() {
|
||||
saving.value = true
|
||||
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 off — the GPU embed backfill owns whole-image embeds',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
enabled.value = !enabled.value
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
busy.value = true
|
||||
try { await store.triggerBackfill(); done.value = true }
|
||||
@@ -33,3 +78,7 @@ async function run() {
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
|
||||
@@ -1,70 +1,30 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-tune"
|
||||
title="Suggestion thresholds"
|
||||
blurb="Confidence cutoffs that gate auto-suggested tags + video sampling."
|
||||
icon="mdi-filmstrip"
|
||||
title="Video embedding"
|
||||
blurb="How videos are sampled into frames before embedding."
|
||||
>
|
||||
<div v-if="store.settings">
|
||||
<v-row v-for="f in fields" :key="f.key">
|
||||
<v-col cols="12">
|
||||
<v-slider
|
||||
v-model="local[f.key]" :label="f.label"
|
||||
:min="f.floorMin ? local.tagger_store_floor : 0" max="1" step="0.05"
|
||||
thumb-label hide-details
|
||||
color="accent" @end="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-slider
|
||||
v-model="local.tagger_store_floor" label="Tagger store floor"
|
||||
min="0" max="1" step="0.05" thumb-label hide-details
|
||||
color="accent" @end="save"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
Tagger predictions below this confidence aren't stored — raising it
|
||||
keeps the image library lean. Suggestions can't be shown below the
|
||||
floor; lower-confidence tags you actually want still surface through
|
||||
the learned centroid path.
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div class="text-subtitle-2 mb-1">Video tagging</div>
|
||||
<div class="text-caption fc-muted mb-3">
|
||||
Videos are tagged by sampling frames at a fixed cadence. A tag is kept
|
||||
only if it shows up in enough frames (≈ that many × the interval in
|
||||
seconds of screen time), which filters one-frame noise without losing
|
||||
tags that only appear in part of a longer video.
|
||||
Videos are embedded by sampling frames at a fixed cadence and mean-pooling
|
||||
their SigLIP embeddings. The interval sets the cadence; the cap bounds how
|
||||
many frames a long video samples.
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_max_frames"
|
||||
label="Max frames" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="local.video_min_tag_frames"
|
||||
label="Min frames per tag" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div v-else><v-skeleton-loader type="paragraph" /></div>
|
||||
@@ -78,32 +38,14 @@ import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const store = useMLStore()
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||
// suggestion categories; their threshold rows are gone.
|
||||
// floorMin: the per-category suggestion thresholds can't drop below the
|
||||
// tagger store floor (nothing below the floor is stored to surface).
|
||||
const fields = [
|
||||
{ key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
|
||||
{ key: 'suggestion_threshold_general', label: 'General', floorMin: true },
|
||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||
]
|
||||
const local = reactive({})
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
async function save() {
|
||||
// Mirror the server invariant: keep the category thresholds at or above the
|
||||
// store floor so a raised floor doesn't leave a threshold stranded below it.
|
||||
const floor = local.tagger_store_floor
|
||||
local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor)
|
||||
local.suggestion_threshold_general = Math.max(local.suggestion_threshold_general, floor)
|
||||
// Mirror the server invariant: a tag can't require more frames than are sampled.
|
||||
local.video_min_tag_frames = Math.min(local.video_min_tag_frames, local.video_max_frames)
|
||||
const patch = {}
|
||||
for (const f of fields) patch[f.key] = local[f.key]
|
||||
patch.tagger_store_floor = local.tagger_store_floor
|
||||
patch.video_frame_interval_seconds = local.video_frame_interval_seconds
|
||||
patch.video_max_frames = local.video_max_frames
|
||||
patch.video_min_tag_frames = local.video_min_tag_frames
|
||||
const patch = {
|
||||
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
||||
video_max_frames: local.video_max_frames
|
||||
}
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||
}
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
<template>
|
||||
<div class="fc-maint">
|
||||
<p class="fc-muted text-body-2 mb-5">
|
||||
One-off backfills, tagging config and storage tools. The ML backfill and
|
||||
centroid recompute also run nightly; the allowlist auto-applies accepted
|
||||
tags. Click a tile to open it.
|
||||
Processing, tagging and storage tools, grouped by system. Heads train
|
||||
nightly and auto-apply earned tags. Click a tile to open it.
|
||||
</p>
|
||||
|
||||
<section class="fc-section">
|
||||
<h3 class="fc-section__title">Backfills & reprocessing</h3>
|
||||
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
|
||||
<div class="fc-tile-grid">
|
||||
<h3 class="fc-section__title">Ingestion & filters</h3>
|
||||
<p class="fc-section__hint">
|
||||
What gets imported — dedup sensitivity, size/transparency/solid-color
|
||||
filters. Applies to downloads and folder imports alike.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<ImportFiltersForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fc-section">
|
||||
<h3 class="fc-section__title">GPU agent & embeddings</h3>
|
||||
<p class="fc-section__hint">
|
||||
The desktop agent that does the heavy lifting, its failure triage, and
|
||||
the CPU fallback.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<GpuAgentCard />
|
||||
<GpuTriageCard />
|
||||
<MLBackfillCard />
|
||||
<CentroidRecomputeCard />
|
||||
<ThumbnailBackfillCard />
|
||||
<ArchiveReextractCard />
|
||||
<MissingFileRepairCard />
|
||||
<DbMaintenanceCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fc-section">
|
||||
<h3 class="fc-section__title">Tagging</h3>
|
||||
<p class="fc-section__hint">
|
||||
Suggestion thresholds, the auto-apply allowlist and tag aliases.
|
||||
Suggestion thresholds, trained heads and tag aliases.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<MLThresholdSliders />
|
||||
<HeadsCard />
|
||||
<GpuAgentCard />
|
||||
<AllowlistTable />
|
||||
<AliasTable />
|
||||
<TagEvalCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fc-section">
|
||||
<h3 class="fc-section__title">Library health</h3>
|
||||
<p class="fc-section__hint">
|
||||
Self-healing and repair: missing files, thumbnails, database upkeep.
|
||||
</p>
|
||||
<div class="fc-tile-grid">
|
||||
<MissingFileRepairCard />
|
||||
<ThumbnailBackfillCard />
|
||||
<DbMaintenanceCard />
|
||||
<ArchiveReextractCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -47,18 +67,17 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import ImportFiltersForm from './ImportFiltersForm.vue'
|
||||
import MLBackfillCard from './MLBackfillCard.vue'
|
||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||
import GpuTriageCard from './GpuTriageCard.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import TagEvalCard from './TagEvalCard.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- The non-Celery halves of the app (2026-07-02): the GPU agent does the
|
||||
majority of processing and downloads feed the library — Activity is
|
||||
the whole-app pulse, not just the worker queues. -->
|
||||
<GpuActivityPanel @open-maintenance="$emit('open-maintenance')" />
|
||||
<DownloadsActivityPanel />
|
||||
|
||||
<!-- Recent failures pane -->
|
||||
<v-card class="mb-4">
|
||||
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
|
||||
@@ -167,6 +173,10 @@ import { formatRelative as fmtRelative } from '../../utils/date.js'
|
||||
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||
import QueuesTable from './QueuesTable.vue'
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import GpuActivityPanel from './GpuActivityPanel.vue'
|
||||
import DownloadsActivityPanel from './DownloadsActivityPanel.vue'
|
||||
|
||||
defineEmits(['open-maintenance'])
|
||||
|
||||
// Click-to-open modal for full error text. Replaces the unusable
|
||||
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-flask-outline"
|
||||
title="Tagging eval (heads vs centroid)"
|
||||
blurb="Measure whether a trained head beats the old centroid on your own tags — and whether tagging more sharpens it."
|
||||
:open="!!run"
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Reuses the SigLIP embeddings already stored on your images (no re-embed, no
|
||||
GPU). For each concept it trains a logistic-regression <strong>head</strong>
|
||||
on your positives + negatives and compares it to the old single
|
||||
<strong>centroid</strong>, with cross-validated AP/F1 and a learning curve.
|
||||
Runs as a background task; the result is saved and reloads here.
|
||||
</p>
|
||||
|
||||
<v-textarea
|
||||
v-model="conceptsText" label="Concepts (comma-separated)"
|
||||
rows="2" auto-grow density="compact" hide-details class="mb-3"
|
||||
:disabled="running"
|
||||
/>
|
||||
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="autoTopN" label="+ auto-add top-N concepts"
|
||||
type="number" min="0" max="200" density="compact" hide-details
|
||||
:disabled="running" style="max-width: 220px;"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="precisionTarget" label="Auto-apply precision target"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact" hide-details
|
||||
:disabled="running" style="max-width: 220px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="!running"
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-play" :loading="busy" @click="onStart"
|
||||
>Run eval</v-btn>
|
||||
|
||||
<div v-if="running" class="mt-3">
|
||||
<v-progress-linear indeterminate color="accent" />
|
||||
<div class="text-body-2 mt-2 fc-muted">Running… (started {{ startedAgo }})</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="run && run.status === 'error'"
|
||||
type="error" variant="tonal" density="compact" class="mt-3"
|
||||
>Eval failed: {{ run.error }}</v-alert>
|
||||
|
||||
<div v-if="report" class="mt-4">
|
||||
<div class="fc-muted text-caption mb-2">
|
||||
Ran {{ formatTime(report.generated_at) }} ·
|
||||
{{ report.concepts.length }} concept(s) ·
|
||||
neg ratio {{ report.params.neg_ratio }}, {{ report.params.cv_folds }}-fold CV
|
||||
</div>
|
||||
|
||||
<div v-for="c in report.concepts" :key="c.name" class="fc-cc">
|
||||
<div class="fc-cc__head">
|
||||
<span class="fc-cc__name">{{ c.name }}</span>
|
||||
<span v-if="c.skipped" class="fc-muted text-caption">— skipped: {{ c.skipped }}</span>
|
||||
<span v-else class="fc-muted text-caption">
|
||||
{{ c.n_pos }} pos · {{ c.n_neg }} neg<span v-if="c.n_rejected"> ({{ c.n_rejected }} rejected)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!c.skipped">
|
||||
<table class="fc-metrics">
|
||||
<thead>
|
||||
<tr><th></th><th>AP</th><th>F1</th><th>Prec</th><th>Rec</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fc-metrics__lbl">Head</td>
|
||||
<td class="fc-num fc-win">{{ c.head.ap }}</td>
|
||||
<td class="fc-num">{{ c.head.f1 }}</td>
|
||||
<td class="fc-num">{{ c.head.precision }}</td>
|
||||
<td class="fc-num">{{ c.head.recall }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fc-metrics__lbl fc-muted">Centroid</td>
|
||||
<td class="fc-num fc-muted">{{ c.centroid.ap }}</td>
|
||||
<td class="fc-num fc-muted">{{ c.centroid.f1 }}</td>
|
||||
<td class="fc-num fc-muted">{{ c.centroid.precision }}</td>
|
||||
<td class="fc-num fc-muted">{{ c.centroid.recall }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-caption mb-2" :class="apDelta(c) >= 0 ? 'fc-up' : 'fc-down'">
|
||||
Δ AP {{ apDelta(c) >= 0 ? '+' : '' }}{{ apDelta(c).toFixed(3) }}
|
||||
(head − centroid)
|
||||
</div>
|
||||
|
||||
<div class="text-caption mb-2">
|
||||
<span class="fc-muted">Auto-apply:</span>
|
||||
<template v-if="c.head.auto_apply">
|
||||
<span class="fc-up">ready</span> — at P≥{{ c.head.auto_apply.target }}
|
||||
catches recall <strong>{{ c.head.auto_apply.recall }}</strong>
|
||||
(thr {{ c.head.auto_apply.threshold }})
|
||||
</template>
|
||||
<span v-else class="fc-down">not reachable at P≥{{ report.params.precision_target }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="c.curve && c.curve.length" class="fc-curve">
|
||||
<span class="fc-muted text-caption">Learning curve (AP @ N positives):</span>
|
||||
<span v-for="p in c.curve" :key="p.n_pos" class="fc-curve__pt">
|
||||
{{ p.n_pos }}→<strong>{{ p.ap }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="c.examples" class="fc-ex">
|
||||
<div
|
||||
v-for="grp in [
|
||||
{ dir: 'suggest', items: c.examples.head_would_suggest,
|
||||
label: `Head would suggest — ✓ tag it, ✗ not ${c.name}` },
|
||||
{ dir: 'doubts', items: c.examples.head_doubts_positive,
|
||||
label: `Head doubts your tag — ✓ keep, ✗ remove (not ${c.name})` },
|
||||
]" :key="grp.dir" class="fc-ex__row"
|
||||
>
|
||||
<div class="fc-muted text-caption mb-1">{{ grp.label }}</div>
|
||||
<div class="fc-ex__thumbs">
|
||||
<div
|
||||
v-for="it in grp.items" :key="`${grp.dir}${it.id}`"
|
||||
class="fc-ex__item"
|
||||
:class="actedLabel(c, grp.dir, it) ? 'fc-ex__item--acted' : ''"
|
||||
>
|
||||
<button
|
||||
type="button" class="fc-ex__thumb"
|
||||
:title="`#${it.id} — click to enlarge`" @click="modal.open(it.id)"
|
||||
>
|
||||
<img :src="it.thumbnail_url" loading="lazy" />
|
||||
</button>
|
||||
<div v-if="actedLabel(c, grp.dir, it)" class="fc-ex__badge">
|
||||
{{ actedLabel(c, grp.dir, it) }}
|
||||
</div>
|
||||
<div v-else class="fc-ex__acts">
|
||||
<button
|
||||
class="fc-act fc-act--yes" type="button"
|
||||
:title="`Yes — it is ${c.name}`" @click="act(c, it, grp.dir, 'yes')"
|
||||
><v-icon size="15">mdi-check</v-icon></button>
|
||||
<button
|
||||
class="fc-act fc-act--no" type="button"
|
||||
:title="`No — not ${c.name}`" @click="act(c, it, grp.dir, 'no')"
|
||||
><v-icon size="15">mdi-close</v-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { useTagEvalStore } from '../../stores/tagEval.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
|
||||
const DEFAULT_CONCEPTS =
|
||||
'glasses, cat, dog, horse, goblin, cum, lactation, fellatio, xray, stomach bulge'
|
||||
|
||||
const store = useTagEvalStore()
|
||||
const modal = useModalStore()
|
||||
const run = ref(null)
|
||||
const conceptsText = ref(DEFAULT_CONCEPTS)
|
||||
const autoTopN = ref(0)
|
||||
const precisionTarget = ref(0.97)
|
||||
const busy = ref(false)
|
||||
let pollTimer = null
|
||||
|
||||
const running = computed(() => run.value?.status === 'running')
|
||||
const report = computed(() => (run.value?.status === 'ready' ? run.value.report : null))
|
||||
const startedAgo = computed(() =>
|
||||
run.value?.started_at ? formatTime(run.value.started_at) : '')
|
||||
|
||||
// Rehydrate the persisted run on mount so the report survives navigation — the
|
||||
// task runs backend-side regardless; we just reconnect to its row.
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const latest = await store.latest()
|
||||
if (latest) {
|
||||
run.value = await store.getRun(latest.id)
|
||||
if (run.value.status === 'running') startPoll(latest.id)
|
||||
}
|
||||
} catch { /* non-fatal — card still works for a fresh run */ }
|
||||
})
|
||||
onUnmounted(stopPoll)
|
||||
|
||||
function startPoll(id) {
|
||||
stopPoll()
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
run.value = await store.getRun(id)
|
||||
if (run.value.status !== 'running') stopPoll()
|
||||
} catch (e) {
|
||||
stopPoll()
|
||||
toast({ text: `Eval poll failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
busy.value = true
|
||||
try {
|
||||
const concepts = conceptsText.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const res = await store.start({
|
||||
concepts,
|
||||
auto_top_n: Number(autoTopN.value) || 0,
|
||||
precision_target: Number(precisionTarget.value) || 0.97,
|
||||
})
|
||||
run.value = await store.getRun(res.run_id)
|
||||
startPoll(res.run_id)
|
||||
} catch (e) {
|
||||
const msg = e.body?.running_id
|
||||
? 'An eval is already running.'
|
||||
: e.message
|
||||
toast({ text: `Could not start eval: ${msg}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apDelta(c) { return (c.head?.ap ?? 0) - (c.centroid?.ap ?? 0) }
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Acting on an example writes the SAME tables the head trains on, so a re-run
|
||||
// reflects the correction. Keyed per (concept, list, image); the report ids are
|
||||
// frozen at run time, so we just grey out what's been handled in this view.
|
||||
const acted = ref({})
|
||||
const actedKey = (c, dir, it) => `${c.tag_id}:${dir}:${it.id}`
|
||||
const actedLabel = (c, dir, it) => acted.value[actedKey(c, dir, it)] || ''
|
||||
|
||||
async function act(c, it, dir, verdict) {
|
||||
const key = actedKey(c, dir, it)
|
||||
let call, label
|
||||
if (dir === 'suggest' && verdict === 'yes') { call = store.applyTag(it.id, c.tag_id); label = 'tagged' }
|
||||
else if (dir === 'suggest' && verdict === 'no') { call = store.rejectTag(it.id, c.tag_id); label = 'rejected' }
|
||||
else if (dir === 'doubts' && verdict === 'no') { call = store.removeTag(it.id, c.tag_id); label = 'removed' }
|
||||
else { call = store.confirmTag(it.id, c.tag_id); label = 'kept' } // doubt + yes = keep (confirm)
|
||||
try {
|
||||
await call
|
||||
acted.value[key] = label
|
||||
} catch (e) {
|
||||
toast({ text: `Action failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-cc {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-cc__head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
||||
.fc-cc__name { font-weight: 600; }
|
||||
.fc-metrics { width: 100%; max-width: 360px; border-collapse: collapse; font-size: 13px; }
|
||||
.fc-metrics th { text-align: right; font-weight: 600; color: rgb(var(--v-theme-on-surface-variant)); padding: 0 8px; }
|
||||
.fc-metrics__lbl { text-align: left; }
|
||||
.fc-num { text-align: right; font-variant-numeric: tabular-nums; padding: 1px 8px; }
|
||||
.fc-win { color: rgb(var(--v-theme-accent)); font-weight: 600; }
|
||||
.fc-up { color: rgb(var(--v-theme-success)); }
|
||||
.fc-down { color: rgb(var(--v-theme-error)); }
|
||||
.fc-curve { margin-bottom: 8px; }
|
||||
.fc-curve__pt { margin-left: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.fc-ex__row { margin-top: 8px; }
|
||||
.fc-ex__thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fc-ex__item { position: relative; width: 120px; height: 120px; }
|
||||
.fc-ex__item--acted { opacity: 0.45; }
|
||||
.fc-ex__thumb {
|
||||
display: block; width: 100%; height: 100%; border-radius: 6px;
|
||||
overflow: hidden; background: rgb(var(--v-theme-surface-light));
|
||||
outline: 1px solid transparent; transition: outline-color 0.12s;
|
||||
border: none; padding: 0; cursor: pointer;
|
||||
}
|
||||
.fc-ex__thumb:hover { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-ex__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.fc-ex__acts { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
|
||||
.fc-act {
|
||||
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; color: #fff;
|
||||
opacity: 0.9; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); transition: transform 0.1s;
|
||||
}
|
||||
.fc-act:hover { opacity: 1; transform: scale(1.1); }
|
||||
.fc-act--yes { background: rgb(var(--v-theme-success)); }
|
||||
.fc-act--no { background: rgb(var(--v-theme-error)); }
|
||||
.fc-ex__badge {
|
||||
position: absolute; bottom: 4px; left: 4px; right: 4px; text-align: center;
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
background: rgba(0, 0, 0, 0.65); color: #fff; border-radius: 3px; padding: 1px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,103 +42,6 @@
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<MaintenanceTile
|
||||
icon="mdi-tag-off"
|
||||
title="Legacy migration tags"
|
||||
blurb="Purge retired archive/post/artist + source:* tags."
|
||||
destructive
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Purge legacy IR-migration tags FC no longer uses: retired/system
|
||||
kinds (<code>archive</code>, <code>post</code>, <code>artist</code> — e.g.
|
||||
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus <code>source:*</code> tags
|
||||
(ImageRepo's old <code>source</code> kind, migrated to <code>general</code>).
|
||||
Provenance and artists are their own systems now, so these are pure noise.
|
||||
Removes them from every image.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingKindPreview"
|
||||
class="mb-3"
|
||||
@click="onKindPreview"
|
||||
>Preview legacy tags</v-btn>
|
||||
|
||||
<div v-if="kindPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
|
||||
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
|
||||
{{ k }}: {{ n }}
|
||||
</span>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="kindPreview.sample_names?.length"
|
||||
:names="kindPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!kindPreview.count"
|
||||
:loading="kindCommitting"
|
||||
@click="onKindCommit"
|
||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<MaintenanceTile
|
||||
icon="mdi-tag-multiple"
|
||||
title="Reset content tagging"
|
||||
blurb="Delete all general/character tags to re-tag from scratch."
|
||||
destructive
|
||||
>
|
||||
<p class="text-body-2 mb-2">
|
||||
Deletes every <code>general</code> and <code>character</code> tag and
|
||||
removes them from every image, so you can re-tag from scratch with the
|
||||
auto-suggest. <strong>Fandoms and series (with their page order) are
|
||||
kept</strong>, and each image's saved predictions are untouched — open
|
||||
an image and its suggestions reappear.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Irreversible — there's no undo except restoring a DB backup.
|
||||
Back one up first (Settings → Maintenance → Backup).
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingResetPreview"
|
||||
class="mb-3"
|
||||
@click="onResetPreview"
|
||||
>Preview content-tag reset</v-btn>
|
||||
|
||||
<div v-if="resetPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ resetPreview.count }}</strong> content tag(s)
|
||||
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
|
||||
({{ k }}: {{ n }})
|
||||
</span>
|
||||
across <strong>{{ resetPreview.applications }}</strong> image
|
||||
application(s).
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="resetPreview.sample_names?.length"
|
||||
:names="resetPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-alert"
|
||||
:disabled="!resetPreview.count"
|
||||
:loading="resetCommitting"
|
||||
@click="onResetCommit"
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<MaintenanceTile
|
||||
icon="mdi-format-letter-case"
|
||||
title="Standardize tag casing"
|
||||
@@ -216,26 +119,6 @@ const {
|
||||
emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names || [] }),
|
||||
})
|
||||
|
||||
// Legacy migration-tag purge.
|
||||
const {
|
||||
previewData: kindPreview, previewing: loadingKindPreview,
|
||||
committing: kindCommitting, runPreview: onKindPreview, runCommit: onKindCommit,
|
||||
} = usePreviewCommit({
|
||||
preview: () => store.purgeLegacyTags({ dryRun: true }),
|
||||
commit: () => store.purgeLegacyTags({ dryRun: false }),
|
||||
emptyPreview: { count: 0, by_kind: {}, by_prefix: {}, sample_names: [] },
|
||||
})
|
||||
|
||||
// Reset content tagging (general + character).
|
||||
const {
|
||||
previewData: resetPreview, previewing: loadingResetPreview,
|
||||
committing: resetCommitting, runPreview: onResetPreview, runCommit: onResetCommit,
|
||||
} = usePreviewCommit({
|
||||
preview: () => store.resetContentTagging({ dryRun: true }),
|
||||
commit: () => store.resetContentTagging({ dryRun: false }),
|
||||
emptyPreview: { count: 0, by_kind: {}, applications: 0, sample_names: [] },
|
||||
})
|
||||
|
||||
// Standardize casing. The apply DISPATCHES a self-resuming background task (no
|
||||
// poll-until-done — that would falsely report complete after the first chunk),
|
||||
// so there's no emptyPreview: leave the projection up; a truthy normResult means
|
||||
|
||||
@@ -52,14 +52,9 @@
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<FailingSourcesCard
|
||||
:sources="store.failing"
|
||||
:retrying-ids="sourcesStore.checkingIds"
|
||||
:retrying-all="retryingAll"
|
||||
@retry="onRetrySource"
|
||||
@retry-all="onRetryAll"
|
||||
@view-logs="onViewFailingLogs"
|
||||
/>
|
||||
<!-- The failing-sources rollup moved to the Subscriptions landing tab
|
||||
(needs-attention strip, 2026-07-02); the maintenance menu above keeps
|
||||
its bulk-retry via the same shared store action. -->
|
||||
|
||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
@@ -124,21 +119,17 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
||||
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
||||
import DownloadStatChips from './DownloadStatChips.vue'
|
||||
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
||||
import FailingSourcesCard from './FailingSourcesCard.vue'
|
||||
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const filterModel = ref({ ...store.filter })
|
||||
const retryingAll = ref(false)
|
||||
|
||||
// Free-text search (client-side over the loaded events) + a toggle to
|
||||
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
|
||||
@@ -175,51 +166,18 @@ async function refresh() {
|
||||
// rollup + stats so the operator sees it move. Passes force=true so the
|
||||
// platform cooldown is bypassed — single-source click is an explicit
|
||||
// operator override, useful for rapid auth-fix or fixture testing.
|
||||
async function onRetrySource(source) {
|
||||
try {
|
||||
await sourcesStore.checkNow(source.id, { force: true })
|
||||
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) {
|
||||
toast({ text: 'Already running — see below', type: 'info' })
|
||||
} else {
|
||||
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk retry — leaves cooldown enforcement ON so N failing sources on
|
||||
// the same platform don't all retry into the rate limit the cooldown is
|
||||
// preventing. Sources deferred by cooldown will be picked up by the
|
||||
// next scan tick after the AppSetting expires. Toast tallies the three
|
||||
// outcomes so the operator can quickly read whether cooldown is the
|
||||
// dominant failure mode ("12 deferred (cooldown)" → yes, rate limit is
|
||||
// the issue).
|
||||
// Bulk retry for the maintenance menu — the shared store action keeps the
|
||||
// cooldown semantics + tally shape identical to the needs-attention card on
|
||||
// the Subscriptions tab. Toast tallies the three outcomes so the operator
|
||||
// can read whether cooldown is the dominant failure mode ("12 deferred
|
||||
// (cooldown)" → yes, rate limit is the issue).
|
||||
async function onRetryAll(sources) {
|
||||
retryingAll.value = true
|
||||
let ok = 0
|
||||
let conflict = 0
|
||||
let deferred = 0
|
||||
try {
|
||||
for (const s of sources) {
|
||||
try {
|
||||
const body = await sourcesStore.checkNow(s.id)
|
||||
if (body?.status === 'deferred') deferred += 1
|
||||
else ok += 1
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) conflict += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
retryingAll.value = false
|
||||
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
|
||||
}
|
||||
const t = await store.retryAllFailing(sources)
|
||||
await store.loadFirst()
|
||||
const parts = []
|
||||
if (ok) parts.push(`${ok} queued`)
|
||||
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
|
||||
if (conflict) parts.push(`${conflict} already running`)
|
||||
if (t.ok) parts.push(`${t.ok} queued`)
|
||||
if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
|
||||
if (t.conflict) parts.push(`${t.conflict} already running`)
|
||||
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
||||
}
|
||||
|
||||
@@ -276,6 +234,16 @@ onMounted(() => {
|
||||
})
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
// The needs-attention card (Subscriptions tab) deep-links here with
|
||||
// ?source_id= while this tab may ALREADY be mounted (v-window keeps tabs
|
||||
// alive) — react to the query, not just the mount.
|
||||
watch(() => route.query.source_id, (v) => {
|
||||
if (v) {
|
||||
filterModel.value = { ...filterModel.value, source_id: Number(v) }
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||
// these as proper query params later if a UX need shows up).
|
||||
@@ -366,22 +334,6 @@ async function openDetail(id) {
|
||||
await store.loadOne(id)
|
||||
}
|
||||
|
||||
async function onViewFailingLogs(source) {
|
||||
// Find and open the most recent DownloadEvent for this source.
|
||||
// Reuses the existing DownloadDetailModal — same stdout/stderr/error
|
||||
// surface the row-click in the events feed shows.
|
||||
try {
|
||||
const ev = await store.loadLastForSource(source.id)
|
||||
if (!ev) {
|
||||
toast({
|
||||
text: `No download events recorded for ${source.artist_name || source.platform} yet.`,
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ text: `Failed to load logs: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -18,14 +18,6 @@
|
||||
subtitle="Mark stranded pending/running events as error (also runs every 5 min)"
|
||||
@click="emit('recover-stalled')"
|
||||
/>
|
||||
<v-list-item
|
||||
:disabled="true"
|
||||
prepend-icon="mdi-download-box"
|
||||
title="Export failed logs"
|
||||
subtitle="CSV dump — v2"
|
||||
>
|
||||
<v-tooltip activator="parent" location="start">Deferred to a future release</v-tooltip>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
@@ -33,7 +25,8 @@
|
||||
<script setup>
|
||||
// The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep
|
||||
// handlers — same toast + refresh logic already used by the failing-sources
|
||||
// RETRY ALL button. We just emit. Import-pipeline maintenance lives in
|
||||
// Settings → Imports (ImportTaskList.vue), not here.
|
||||
// RETRY ALL button. We just emit. (A permanently-disabled "Export failed
|
||||
// logs" stub sat here until 2026-07-02 — retired; the event list + detail
|
||||
// modal cover forensics.)
|
||||
const emit = defineEmits(['retry-failed', 'recover-stalled'])
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<!-- Renders nothing when everything is healthy — the daily answer to
|
||||
"does anything need me?" should be silence, not an empty card. -->
|
||||
<FailingSourcesCard
|
||||
v-if="failing.length"
|
||||
class="mb-4"
|
||||
:sources="failing"
|
||||
:retrying-ids="sourcesStore.checkingIds"
|
||||
:retrying-all="retryingAll"
|
||||
@retry="onRetry"
|
||||
@retry-all="onRetryAll"
|
||||
@view-logs="onViewLogs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import FailingSourcesCard from './FailingSourcesCard.vue'
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
|
||||
// The needs-attention strip on the Subscriptions LANDING tab (2026-07-02):
|
||||
// failing sources used to live below the fold of the Downloads tab, so a
|
||||
// broken subscription was invisible unless you went looking. Retry logic is
|
||||
// shared with the Downloads maintenance menu via the downloads store.
|
||||
const store = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const router = useRouter()
|
||||
const { failing } = storeToRefs(store)
|
||||
const retryingAll = ref(false)
|
||||
let pollId = null
|
||||
|
||||
async function onRetry(source) {
|
||||
try {
|
||||
const res = await store.retrySource(source)
|
||||
toast(res === 'queued'
|
||||
? { text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }
|
||||
: { text: 'Already running — check Downloads', type: 'info' })
|
||||
} catch (e) {
|
||||
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onRetryAll(sources) {
|
||||
retryingAll.value = true
|
||||
try {
|
||||
const t = await store.retryAllFailing(sources)
|
||||
const parts = []
|
||||
if (t.ok) parts.push(`${t.ok} queued`)
|
||||
if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
|
||||
if (t.conflict) parts.push(`${t.conflict} already running`)
|
||||
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
||||
} finally {
|
||||
retryingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onViewLogs(source) {
|
||||
// The Downloads tab owns the log/detail surface — deep-link into it
|
||||
// pre-filtered to this source (it watches ?source_id).
|
||||
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.loadFailing()
|
||||
pollId = setInterval(() => { if (!document.hidden) store.loadFailing() }, 60000)
|
||||
})
|
||||
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-card v-if="arrivals.length" class="mb-4">
|
||||
<CardHeading icon="mdi-new-box" title="Recent arrivals">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text" size="small" rounded="pill"
|
||||
to="/subscriptions?tab=downloads"
|
||||
>
|
||||
All downloads
|
||||
<v-icon end size="small">mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
</CardHeading>
|
||||
<v-card-text class="pt-0">
|
||||
<div
|
||||
v-for="ev in arrivals" :key="ev.id"
|
||||
class="fc-arrival"
|
||||
>
|
||||
<router-link
|
||||
v-if="ev.artist_slug"
|
||||
:to="`/artist/${ev.artist_slug}`"
|
||||
class="fc-arrival__artist"
|
||||
>{{ ev.artist_name || ev.artist_slug }}</router-link>
|
||||
<span v-else class="fc-arrival__artist">{{ ev.artist_name || '—' }}</span>
|
||||
<span class="fc-arrival__meta">
|
||||
{{ ev.platform }} ·
|
||||
{{ ev.files_count ? `${ev.files_count} file(s)` : 'no new files' }}
|
||||
· {{ formatRelative(ev.started_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
|
||||
// "What came in?" — the other half of the landing tab's daily answer
|
||||
// (2026-07-02). Own fetch, own state: the downloads store's event list is
|
||||
// the Downloads tab's FILTERED feed; borrowing it would couple this card to
|
||||
// whatever filter the operator left that tab on.
|
||||
const api = useApi()
|
||||
const arrivals = ref([])
|
||||
let pollId = null
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const events = await api.get('/api/downloads', {
|
||||
params: { status: 'ok', limit: 25 },
|
||||
})
|
||||
// Real arrivals only — scheduled scans that found nothing are noise here.
|
||||
arrivals.value = events.filter((e) => (e.files_count || 0) > 0).slice(0, 6)
|
||||
} catch { /* non-fatal — the card just hides */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
pollId = setInterval(() => { if (!document.hidden) load() }, 60000)
|
||||
})
|
||||
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-arrival {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.fc-arrival__artist {
|
||||
font-weight: 600; text-decoration: none;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-arrival__artist:hover { text-decoration: underline; }
|
||||
.fc-arrival__meta {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- One extension home (2026-07-02): install/manifest card (moved here
|
||||
from Settings → Overview) + the API key bar it authenticates with.
|
||||
The extension feeds THIS view — cookies + one-click sources — so its
|
||||
setup lives with the rest of the ingestion config. -->
|
||||
<h3 class="fc-section__title">Browser extension</h3>
|
||||
<p class="fc-section__hint">Install, session cookies and the API key it authenticates with.</p>
|
||||
<BrowserExtensionCard class="mb-3" />
|
||||
<ExtensionKeyBar class="mb-4" />
|
||||
|
||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||
{{ String(credentialsStore.error) }}
|
||||
</v-alert>
|
||||
|
||||
<h3 class="text-h6 mb-3">Platform credentials</h3>
|
||||
<h3 class="fc-section__title mt-6">Platform credentials</h3>
|
||||
<p class="fc-section__hint">Per-platform cookies/tokens the downloader uses.</p>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="p in platformsStore.list"
|
||||
@@ -23,7 +31,8 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<h3 class="text-h6 mb-3 mt-6">Downloader</h3>
|
||||
<h3 class="fc-section__title mt-6">Downloader</h3>
|
||||
<p class="fc-section__hint">Rate limits and gallery-dl behavior for source checks.</p>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text v-if="importStore.settings">
|
||||
<v-row>
|
||||
@@ -52,7 +61,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<h3 class="text-h6 mb-3 mt-6">External file-host downloads</h3>
|
||||
<h3 class="fc-section__title mt-6">External file-host downloads</h3>
|
||||
<p class="fc-section__hint">Post-linked mega/gdrive/file-host fetches.</p>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text v-if="importStore.settings">
|
||||
<div class="fc-help mb-2">
|
||||
@@ -98,7 +108,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3>
|
||||
<h3 class="fc-section__title mt-6">Schedule defaults</h3>
|
||||
<p class="fc-section__hint">Check cadence and failure-badge thresholds for new sources.</p>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text v-if="importStore.settings">
|
||||
<v-row>
|
||||
@@ -179,6 +190,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import BrowserExtensionCard from '../settings/BrowserExtensionCard.vue'
|
||||
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
|
||||
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
|
||||
import CredentialCard from './CredentialCard.vue'
|
||||
@@ -240,6 +252,21 @@ async function saveDownloader() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Same section-header language as Settings -> Maintenance/Cleanup (2026-07-02
|
||||
theme unification) so every admin surface reads identically. */
|
||||
.fc-section__title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.fc-section__hint {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fc-help {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<div>
|
||||
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
||||
|
||||
<!-- Daily-use ordering (2026-07-02): what needs me → what came in →
|
||||
the full source list. Both cards render nothing when there's
|
||||
nothing to say. -->
|
||||
<NeedsAttentionCard />
|
||||
<RecentArrivalsCard />
|
||||
|
||||
<div class="fc-subs__bar">
|
||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||
Add subscription
|
||||
@@ -319,6 +325,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
||||
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
||||
import SourceRow from './SourceRow.vue'
|
||||
import SourceCard from './SourceCard.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
|
||||
@@ -101,12 +101,10 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function purgeLegacyTags(opts = {}) {
|
||||
return _dryRunPost('/api/admin/tags/purge-legacy', opts)
|
||||
}
|
||||
|
||||
// Destructive: deletes ALL general + character tags so the operator can
|
||||
// re-tag from scratch via auto-suggest. fandom + series preserved.
|
||||
// Destructive whole-instance reset: deletes ALL general + character tags AND
|
||||
// their applications (the heads' training data included) — fandom + series
|
||||
// preserved. dry-run returns a `confirm` token; the apply must pass it back
|
||||
// ({ dryRun: false, confirm }) or the server rejects it.
|
||||
function resetContentTagging(opts = {}) {
|
||||
return _dryRunPost('/api/admin/tags/reset-content', opts)
|
||||
}
|
||||
@@ -154,7 +152,6 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
pruneUnusedTags,
|
||||
pruneBarePosts,
|
||||
reconcileDuplicatePosts,
|
||||
purgeLegacyTags,
|
||||
resetContentTagging,
|
||||
normalizeTags,
|
||||
pollTaskUntilDone,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
export const useAllowlistStore = defineStore('allowlist', () => {
|
||||
const api = useApi()
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { rows.value = await api.get('/api/allowlist') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function updateThreshold(tagId, minConfidence) {
|
||||
await api.patch(`/api/tags/${tagId}/allowlist`, {
|
||||
body: { min_confidence: minConfidence }
|
||||
})
|
||||
const r = rows.value.find(x => x.tag_id === tagId)
|
||||
if (r) {
|
||||
r.min_confidence = minConfidence
|
||||
// The committed threshold changed the covered pool — refresh the row's
|
||||
// coverage so the table stays truthful after a save.
|
||||
try { r.coverage_count = (await coverage(tagId, minConfidence)).count }
|
||||
catch { /* leave the stale count rather than blank it */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Live "at threshold T, a sweep would cover ~N images" projection for the
|
||||
// tuning dashboard. Returns { count, threshold }.
|
||||
async function coverage(tagId, threshold) {
|
||||
return api.get(`/api/tags/${tagId}/allowlist/coverage`, {
|
||||
params: { threshold }
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(tagId) {
|
||||
await api.delete(`/api/tags/${tagId}/allowlist`)
|
||||
rows.value = rows.value.filter(x => x.tag_id !== tagId)
|
||||
}
|
||||
|
||||
return { rows, loading, load, updateThreshold, coverage, remove }
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
import { useSourcesStore } from './sources.js'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const api = useApi()
|
||||
@@ -106,6 +107,44 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
return failing.value
|
||||
}
|
||||
|
||||
// --- Failing-source retries (shared by the Subscriptions needs-attention
|
||||
// card and the Downloads maintenance menu — one implementation for both
|
||||
// surfaces). Data-only: callers own the toasts, per this store's style.
|
||||
// A single deliberate retry forces past cooldown; BULK retries keep
|
||||
// cooldown enforcement ON so N failing sources on one platform don't all
|
||||
// retry into the very rate limit the cooldown is preventing.
|
||||
async function retrySource(source) {
|
||||
const sourcesStore = useSourcesStore()
|
||||
try {
|
||||
await sourcesStore.checkNow(source.id, { force: true })
|
||||
return 'queued'
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) return 'already_running'
|
||||
throw e
|
||||
} finally {
|
||||
await Promise.all([loadFailing(), loadStats(24)])
|
||||
}
|
||||
}
|
||||
|
||||
async function retryAllFailing(sources) {
|
||||
const sourcesStore = useSourcesStore()
|
||||
const tally = { ok: 0, deferred: 0, conflict: 0 }
|
||||
try {
|
||||
for (const s of sources) {
|
||||
try {
|
||||
const body = await sourcesStore.checkNow(s.id)
|
||||
if (body?.status === 'deferred') tally.deferred += 1
|
||||
else tally.ok += 1
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) tally.conflict += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([loadFailing(), loadStats(24)])
|
||||
}
|
||||
return tally
|
||||
}
|
||||
|
||||
async function loadActive() {
|
||||
const [running, pending] = await Promise.all([
|
||||
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
||||
@@ -128,5 +167,6 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||
closeDetail, loadStats,
|
||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||
retrySource, retryAllFailing,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -93,13 +93,11 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
// a crumb (which snaps the cursor back into the trail — the "loops back"
|
||||
// report). Fall back to the full set only if every neighbour's been seen.
|
||||
const seen = new Set(breadcrumb.value.map((c) => c.id))
|
||||
let pool = neighbors.value.filter((n) => !seen.has(n.id))
|
||||
if (!pool.length) pool = neighbors.value
|
||||
// neighbors come similarity-sorted (nearest first). Skip the closest slice —
|
||||
// those near-duplicates are exactly what you get stuck cycling through — and
|
||||
// pick from the more-varied remainder, for real variance in the walk.
|
||||
const skip = pool.length >= 6 ? Math.floor(pool.length / 3) : 0
|
||||
const cands = pool.slice(skip)
|
||||
const pool = neighbors.value.filter((n) => !seen.has(n.id))
|
||||
const cands = pool.length ? pool : neighbors.value
|
||||
// The list is already pHash-deduped + MMR-diversified server-side (it spans
|
||||
// clusters, not 40 near-dupes), so a plain random pick gives real variance —
|
||||
// no need to skip the nearest slice the way the raw nearest-list required.
|
||||
return cands[Math.floor(Math.random() * cands.length)].id
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const error = ref(null)
|
||||
const filter = ref({
|
||||
tag_ids: [], artist_id: null, media_type: null,
|
||||
sort: 'newest', post_id: null,
|
||||
sort: 'posted_new', post_id: null,
|
||||
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
|
||||
// "include" singletons (light editor + back-compat); tag_or is a list of
|
||||
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
|
||||
@@ -154,7 +154,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',')
|
||||
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
|
||||
if (filter.value.media_type) p.media = filter.value.media_type
|
||||
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
|
||||
if (filter.value.sort && filter.value.sort !== 'posted_new') p.sort = filter.value.sort
|
||||
if (filter.value.platform) p.platform = filter.value.platform
|
||||
if (filter.value.untagged) p.untagged = '1'
|
||||
if (filter.value.no_artist) p.no_artist = '1'
|
||||
@@ -191,7 +191,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
tag_exclude: _parseIds(q.tag_not),
|
||||
artist_id: _toId(q.artist_id),
|
||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||||
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
||||
sort: ['newest', 'oldest', 'posted_new', 'posted_old'].includes(q.sort) ? q.sort : 'posted_new',
|
||||
post_id: _toId(q.post_id),
|
||||
platform: q.platform || null,
|
||||
untagged: _truthy(q.untagged),
|
||||
@@ -297,7 +297,7 @@ export function filterToQuery(f) {
|
||||
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
|
||||
if (f.artist_id) q.artist_id = String(f.artist_id)
|
||||
if (f.media_type) q.media = f.media_type
|
||||
if (f.sort && f.sort !== 'newest') q.sort = f.sort
|
||||
if (f.sort && f.sort !== 'posted_new') q.sort = f.sort
|
||||
if (f.platform) q.platform = f.platform
|
||||
if (f.untagged) q.untagged = '1'
|
||||
if (f.no_artist) q.no_artist = '1'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user