Compare commits
248 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e52090a4cf | |||
| b1cfbcc06a | |||
| 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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Ruff lint
|
- 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:
|
backend-lint-and-test:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
|
|||||||
+14
-8
@@ -1,18 +1,24 @@
|
|||||||
# FabledCurator GPU agent — runs on the desktop with the GPU.
|
# FabledCurator GPU agent — runs on the desktop with the GPU.
|
||||||
# CUDA + cuDNN runtime so onnxruntime-gpu can use the card (it needs cuDNN 9 —
|
# CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
|
||||||
# the plain -runtime image lacks it: "libcudnn.so.9: cannot open shared object
|
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
|
||||||
# file"); ffmpeg for video frames.
|
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
|
||||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
# 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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends python3 python3-pip ffmpeg \
|
&& apt-get install -y --no-install-recommends python3 python3-pip ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# torch from the CUDA-12.4 wheel index (matches the base image); its wheels
|
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
|
||||||
# bundle their own CUDA + cuDNN and coexist with onnxruntime-gpu. Installed
|
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
|
||||||
# first + separately so the GPU build of torch is deterministic and layer-cached.
|
# + 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
|
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip3 install --no-cache-dir -r 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 /
|
# Resume the worker automatically on container start (survive a reboot /
|
||||||
# crash-restart while you're away). Set to 0 to require a manual Start.
|
# crash-restart while you're away). Set to 0 to require a manual Start.
|
||||||
AUTO_START: ${AUTO_START:-1}
|
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
|
# Crop embedder (SigLIP concept bag): float16 keeps VRAM low on a shared
|
||||||
# desktop GPU; the model itself is announced by the server.
|
# desktop GPU; the model itself is announced by the server.
|
||||||
SIGLIP_DTYPE: ${SIGLIP_DTYPE:-float16}
|
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:
|
volumes:
|
||||||
# Persist the downloaded ONNX models so restarts are fast.
|
# Persist the downloaded ONNX models so restarts are fast.
|
||||||
- fc-agent-models:/models
|
- fc-agent-models:/models
|
||||||
|
|||||||
+332
-62
@@ -1,21 +1,45 @@
|
|||||||
"""FastAPI control surface for the agent (served on localhost).
|
"""FastAPI control surface for the agent (served on localhost).
|
||||||
|
|
||||||
Start / stop the worker pool, tune the worker count live (trades desktop
|
Start / stop the download→GPU pipeline, tune the downloader count live (the
|
||||||
responsiveness for throughput), and watch GPU load + progress + the server-side
|
workload is download-bound, so downloaders are the dial that trades desktop
|
||||||
queue. Config is env-seeded; the worker count is adjustable here on the fly.
|
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 import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from . import logbuf
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gpu import read_gpu
|
from .gpu import read_gpu
|
||||||
from .worker import Worker
|
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.6 · sleep mode: an empty queue sheds to one downloader and backs the lease poll off to 15 min"
|
||||||
|
|
||||||
|
logbuf.install()
|
||||||
cfg = Config.from_env()
|
cfg = Config.from_env()
|
||||||
worker = Worker(cfg)
|
worker = Worker(cfg)
|
||||||
app = FastAPI(title="FabledCurator GPU agent")
|
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")
|
@app.on_event("startup")
|
||||||
def _maybe_autostart() -> None:
|
def _maybe_autostart() -> None:
|
||||||
# With AUTO_START set, a container restart (host reboot, or `restart:
|
# With AUTO_START set, a container restart (host reboot, or `restart:
|
||||||
@@ -28,17 +52,19 @@ def _maybe_autostart() -> None:
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
return _PAGE
|
return _PAGE.replace("__BUILD__", VERSION)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/start")
|
@app.post("/start")
|
||||||
def start():
|
def start():
|
||||||
|
log.info("UI: Start button pressed") # the press; worker logs the transition
|
||||||
worker.start()
|
worker.start()
|
||||||
return JSONResponse(worker.status())
|
return JSONResponse(worker.status())
|
||||||
|
|
||||||
|
|
||||||
@app.post("/stop")
|
@app.post("/stop")
|
||||||
def stop():
|
def stop():
|
||||||
|
log.info("UI: Stop button pressed")
|
||||||
worker.stop()
|
worker.stop()
|
||||||
return JSONResponse(worker.status())
|
return JSONResponse(worker.status())
|
||||||
|
|
||||||
@@ -50,85 +76,329 @@ async def concurrency(request: Request):
|
|||||||
return JSONResponse(worker.status())
|
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")
|
@app.get("/status")
|
||||||
def 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 = worker.status()
|
||||||
s["fc_url"] = cfg.fc_url
|
s["fc_url"] = cfg.fc_url
|
||||||
s["configured"] = bool(cfg.token)
|
s["configured"] = bool(cfg.token)
|
||||||
s["gpu"] = read_gpu()
|
s["queue"] = worker.latest_queue()
|
||||||
try:
|
s["build"] = VERSION
|
||||||
s["queue"] = worker.client.queue_status()
|
|
||||||
except Exception:
|
|
||||||
s["queue"] = None
|
|
||||||
return JSONResponse(s)
|
return JSONResponse(s)
|
||||||
|
|
||||||
|
|
||||||
_PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
_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>
|
<style>
|
||||||
body{font:14px system-ui;margin:2rem;max-width:680px;background:#14171a;color:#e8e8e8}
|
:root{--bg:#0f1216;--panel:#181c22;--panel2:#1e232b;--bd:#2a313b;--fg:#e9edf2;
|
||||||
h1{font-size:18px} button{font:14px system-ui;padding:.5rem 1rem;border:0;border-radius:6px;
|
--mut:#8b97a6;--acc:#e8923a;--grn:#46c46a;--red:#e8584d;--amb:#e8b23a}
|
||||||
margin-right:.5rem;cursor:pointer;color:#fff} .start{background:#2e7d32}.stop{background:#b3261e}
|
*{box-sizing:border-box}
|
||||||
.step{background:#33373b;padding:.4rem .7rem;font-weight:700}
|
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;
|
||||||
.stat{display:inline-block;margin-right:1.5rem;vertical-align:top}
|
background:radial-gradient(1200px 600px at 50% -10%,#1a2029,#0f1216);color:var(--fg)}
|
||||||
.n{font-size:22px;font-weight:700} code{background:#222;padding:2px 6px;border-radius:4px}
|
.wrap{max-width:820px;margin:0 auto;padding:28px 20px 28px;height:100vh;
|
||||||
.q,.gpu{margin-top:1rem;color:#9aa} .bar{height:8px;border-radius:4px;background:#222;overflow:hidden;
|
box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column}
|
||||||
max-width:320px;margin-top:4px} .bar>i{display:block;height:100%;background:#3f7d3f}
|
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
||||||
.row{margin:.8rem 0}
|
.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>
|
</style></head><body>
|
||||||
<h1>FabledCurator GPU agent</h1>
|
<div class=wrap>
|
||||||
<p>FC: <code id=fc>—</code> · token <code id=cfg>—</code></p>
|
<header>
|
||||||
<div class=row>
|
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
|
||||||
<button class=start onclick=act('start')>Start</button>
|
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
|
||||||
<button class=stop onclick=act('stop')>Stop</button>
|
</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>
|
||||||
<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>
|
<script>
|
||||||
|
const PAGE_BUILD="__BUILD__"
|
||||||
let CAP=8
|
let CAP=8
|
||||||
async function act(p){await fetch('/'+p,{method:'POST'});refresh()}
|
// Optimistic transitional state on click, then apply the POST's own status
|
||||||
function setc(d){ setv((parseInt(conc.value||'1'))+d) }
|
// 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){
|
async function setv(v){
|
||||||
v=Math.max(1,Math.min(CAP,parseInt(v)||1)); conc.value=v
|
v=Math.max(1,Math.min(CAP,parseInt(v)||1)); conc.value=v
|
||||||
await fetch('/concurrency',{method:'POST',headers:{'Content-Type':'application/json'},
|
await fetch('/concurrency',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({value:v})});refresh()
|
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(){
|
async function refresh(){
|
||||||
const s=await (await fetch('/status')).json()
|
let s; try{ s=await (await fetch('/status')).json() }catch{ return }
|
||||||
CAP=s.max_concurrency||8; capn.textContent=CAP
|
applyStatus(s)
|
||||||
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
|
function applyStatus(s){
|
||||||
// Running but the queue read failed → curator is unreachable; show we're
|
// NB: don't write a separate `capn` element here — conchint.textContent below
|
||||||
// riding it out rather than erroring.
|
// rewrites the whole hint (incl. the max), and any child element nested in it
|
||||||
banner.style.display=(s.state==='running' && !s.queue)?'block':'none'
|
// 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.idle?' · idle — queue empty, lease poll backed off (new work noticed within ~15 min)'
|
||||||
|
:(s.bw_capped?' · holding at the bandwidth cap (more downloaders would not go faster)':''))
|
||||||
if(document.activeElement!==conc) conc.value=s.concurrency
|
if(document.activeElement!==conc) conc.value=s.concurrency
|
||||||
conc.max=CAP
|
conc.max=CAP
|
||||||
cfg.textContent=s.configured?'set':'MISSING'
|
// Connection pill + queue come only from the /status poll (the Start/Stop POST
|
||||||
if(s.gpu){
|
// responses skip the slow curator call to stay snappy) — guard so an action
|
||||||
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`
|
// response doesn't blank them.
|
||||||
gpubar.style.width=Math.round(100*s.gpu.mem_used_mb/s.gpu.mem_total_mb)+'%'
|
if('configured' in s){
|
||||||
} else { gpu.textContent='GPU — n/a (CPU fallback?)'; gpubar.style.width='0%' }
|
const ok=s.configured
|
||||||
queue.textContent=s.queue?`queue — pending ${s.queue.pending} · in flight ${s.queue.leased} · done ${s.queue.done} · errored ${s.queue.error}`:'queue — unreachable'
|
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>"""
|
</script></body></html>"""
|
||||||
|
|||||||
+100
-45
@@ -5,19 +5,69 @@ bytes, all over HTTP with the bearer token. No DB/Redis.
|
|||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
|
||||||
class FcClient:
|
class FcClient:
|
||||||
def __init__(self, base_url: str, token: str, agent_id: str):
|
def __init__(self, base_url: str, token: str, agent_id: str):
|
||||||
self.base = base_url.rstrip("/")
|
self.base = base_url.rstrip("/")
|
||||||
self.agent_id = agent_id
|
self.agent_id = agent_id
|
||||||
self.s = requests.Session()
|
# Main session: NO in-request retry — lease/fetch are cheap to redo and
|
||||||
self.s.headers["Authorization"] = f"Bearer {token}"
|
# the worker loop already backs off + re-leases on failure. (Auto-retrying
|
||||||
# Many worker threads share this Session; the default pool (10) would
|
# 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.
|
# throttle them + spam "connection pool is full". Size it for the cap.
|
||||||
adapter = HTTPAdapter(pool_connections=64, pool_maxsize=64)
|
adapter = HTTPAdapter(
|
||||||
self.s.mount("http://", adapter)
|
pool_connections=64, pool_maxsize=64, max_retries=retry or 0
|
||||||
self.s.mount("https://", adapter)
|
)
|
||||||
|
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]:
|
def lease(self, batch_size: int) -> list[dict]:
|
||||||
r = self.s.post(
|
r = self.s.post(
|
||||||
@@ -29,57 +79,62 @@ class FcClient:
|
|||||||
return r.json().get("jobs", [])
|
return r.json().get("jobs", [])
|
||||||
|
|
||||||
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
|
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
|
||||||
r = self.s.post(
|
return self._submit("/api/gpu/jobs/submit", {
|
||||||
f"{self.base}/api/gpu/jobs/submit",
|
"job_id": job_id, "regions": regions, "replace_kinds": replace_kinds,
|
||||||
json={
|
})
|
||||||
"agent_id": self.agent_id, "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."""
|
||||||
timeout=120,
|
return self._submit("/api/gpu/jobs/submit_embedding", {
|
||||||
)
|
"job_id": job_id, "embedding": embedding, "embedding_version": version,
|
||||||
r.raise_for_status()
|
})
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def heartbeat(self, job_ids: list[int]) -> None:
|
def heartbeat(self, job_ids: list[int]) -> None:
|
||||||
try:
|
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
|
||||||
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
|
|
||||||
|
|
||||||
def fail(self, job_id: int, error: str) -> None:
|
def fail(self, job_id: int, error: str) -> None:
|
||||||
try:
|
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
|
||||||
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
|
|
||||||
|
|
||||||
def release(self, job_ids: list[int]) -> None:
|
def release(self, job_ids: list[int]) -> None:
|
||||||
# Graceful hand-back on stop so orphaned work is re-leased at once.
|
# Graceful hand-back on stop so orphaned work is re-leased at once.
|
||||||
if not job_ids:
|
if not job_ids:
|
||||||
return
|
return
|
||||||
try:
|
self._post_quiet("/api/gpu/jobs/release", {"job_ids": job_ids})
|
||||||
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
|
|
||||||
|
|
||||||
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/...").
|
# image_url is a server-relative path ("/images/...").
|
||||||
r = self.s.get(f"{self.base}{image_url}", timeout=180)
|
# timeout=(connect, read): the read timeout is BETWEEN-BYTES, not total,
|
||||||
r.raise_for_status()
|
# so a large-but-flowing download still completes — but a stuck/dead
|
||||||
return r.content
|
# 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:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
"""Agent config, all from env (the control container is configured at run)."""
|
"""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
|
import os
|
||||||
from dataclasses import dataclass
|
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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
fc_url: str # base URL of the FabledCurator web service
|
fc_url: str # base URL of the FabledCurator web service
|
||||||
@@ -18,9 +28,32 @@ class Config:
|
|||||||
# the server announces in the lease)
|
# the server announces in the lease)
|
||||||
auto_start: bool # start the worker pool on boot (so a container restart
|
auto_start: bool # start the worker pool on boot (so a container restart
|
||||||
# resumes processing without anyone clicking Start)
|
# 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
|
@classmethod
|
||||||
def from_env(cls) -> "Config":
|
def from_env(cls) -> Config:
|
||||||
return cls(
|
return cls(
|
||||||
fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"),
|
fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"),
|
||||||
token=os.environ.get("FC_TOKEN", ""),
|
token=os.environ.get("FC_TOKEN", ""),
|
||||||
@@ -32,5 +65,26 @@ class Config:
|
|||||||
poll_idle_seconds=float(os.environ.get("POLL_IDLE_SECONDS", "10")),
|
poll_idle_seconds=float(os.environ.get("POLL_IDLE_SECONDS", "10")),
|
||||||
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
|
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
|
||||||
embed_model_override=os.environ.get("EMBED_MODEL_NAME", ""),
|
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]:
|
def embed(self, image: Image.Image) -> list[float]:
|
||||||
"""A crop → its embedding as a plain float list, ready to POST."""
|
"""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()
|
self.load()
|
||||||
torch = self._torch
|
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)
|
pixel_values = enc["pixel_values"].to(self._device, self._dt)
|
||||||
with self._infer_lock, torch.no_grad():
|
with self._infer_lock, torch.no_grad():
|
||||||
out = self._model.get_image_features(pixel_values=pixel_values)
|
out = self._model.get_image_features(pixel_values=pixel_values)
|
||||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
||||||
vec = pooled[0].float().cpu().numpy().astype(np.float32).reshape(-1)
|
arr = pooled.float().cpu().numpy().astype(np.float32)
|
||||||
return vec.tolist()
|
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
|
"""GPU load readout via nvidia-smi (present in the container thanks to the
|
||||||
NVIDIA Container Toolkit's `utility` capability). Returns None if unavailable —
|
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 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:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -28,3 +42,24 @@ def read_gpu() -> dict | None:
|
|||||||
}
|
}
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return None
|
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
|
(ffmpeg) at the cadence FC sends — so a video becomes a bag of per-frame
|
||||||
instances, each with a timestamp."""
|
instances, each with a timestamp."""
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
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:
|
def is_video(mime: str) -> bool:
|
||||||
return bool(mime) and (mime.startswith("video/") or mime in {"image/gif"})
|
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:
|
def to_rgb(img: Image.Image) -> Image.Image:
|
||||||
"""RGB, flattening any transparency onto white first. A naive convert('RGB')
|
"""RGB, flattening any transparency onto white first. A naive convert('RGB')
|
||||||
on a palette-with-transparency image (common for character PNGs on a clear
|
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)))
|
return to_rgb(Image.open(io.BytesIO(data)))
|
||||||
|
|
||||||
|
|
||||||
def sample_frames(
|
# ffmpeg reconnect flags — resume a dropped HTTP transfer (a slow/contended media
|
||||||
data: bytes, interval_seconds: float, max_frames: int
|
# 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]]:
|
) -> list[tuple[float, Image.Image]]:
|
||||||
"""Extract up to max_frames frames at one-every-interval_seconds via ffmpeg.
|
out: list[tuple[float, Image.Image]] = []
|
||||||
Returns [(timestamp_seconds, frame)]. Empty on failure (caller falls back)."""
|
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))
|
interval = max(0.5, float(interval_seconds or 4.0))
|
||||||
cap = max(1, int(max_frames or 64))
|
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:
|
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")
|
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:
|
try:
|
||||||
subprocess.run(
|
with open(errpath, "wb") as errf:
|
||||||
[
|
proc = subprocess.Popen(
|
||||||
"ffmpeg", "-nostdin", "-loglevel", "error", "-i", src,
|
cmd, stdin=subprocess.DEVNULL,
|
||||||
"-vf", f"fps=1/{interval}", "-frames:v", str(cap),
|
stdout=subprocess.DEVNULL, stderr=errf,
|
||||||
"-q:v", "3", pattern,
|
)
|
||||||
],
|
meter = PidReadMeter(proc.pid) if governor is not None else None
|
||||||
check=True, timeout=600,
|
# Poll rather than block, so a Stop (or the per-video timeout) can
|
||||||
)
|
# kill a slow/wedged ffmpeg promptly instead of waiting it out.
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
start = time.monotonic()
|
||||||
return []
|
while True:
|
||||||
out: list[tuple[float, Image.Image]] = []
|
try:
|
||||||
names = sorted(n for n in os.listdir(tmp) if n.startswith("f_"))
|
proc.wait(timeout=0.5)
|
||||||
for i, name in enumerate(names[:cap]):
|
break
|
||||||
with Image.open(os.path.join(tmp, name)) as im:
|
except subprocess.TimeoutExpired:
|
||||||
out.append((round(i * interval, 2), to_rgb(im)))
|
stopped = should_stop and should_stop()
|
||||||
return out
|
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
|
||||||
+989
-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;
|
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
|
||||||
# transformers loads whatever SigLIP-family model the server announces.
|
# transformers loads whatever SigLIP-family model the server announces.
|
||||||
transformers>=4.45
|
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.
|
# Control surface + HTTP.
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
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 = Quart(__name__)
|
||||||
app.secret_key = cfg.secret_key
|
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():
|
for bp in all_blueprints():
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
# Registered last so /api/* routes win over the SPA catch-all.
|
# 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]:
|
def all_blueprints() -> list[Blueprint]:
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
from .aliases import aliases_bp
|
from .aliases import aliases_bp
|
||||||
from .allowlist import allowlist_bp
|
|
||||||
from .artist import artist_bp
|
from .artist import artist_bp
|
||||||
from .artists import artists_bp
|
from .artists import artists_bp
|
||||||
from .attachments import attachments_bp
|
from .attachments import attachments_bp
|
||||||
@@ -39,7 +38,6 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .suggestions import suggestions_bp
|
from .suggestions import suggestions_bp
|
||||||
from .system_activity import system_activity_bp
|
from .system_activity import system_activity_bp
|
||||||
from .system_backup import system_backup_bp
|
from .system_backup import system_backup_bp
|
||||||
from .tag_eval import tag_eval_bp
|
|
||||||
from .tags import tags_bp
|
from .tags import tags_bp
|
||||||
from .thumbnails import thumbnails_bp
|
from .thumbnails import thumbnails_bp
|
||||||
return [
|
return [
|
||||||
@@ -58,9 +56,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
cleanup_bp,
|
cleanup_bp,
|
||||||
import_admin_bp,
|
import_admin_bp,
|
||||||
suggestions_bp,
|
suggestions_bp,
|
||||||
allowlist_bp,
|
|
||||||
aliases_bp,
|
aliases_bp,
|
||||||
tag_eval_bp,
|
|
||||||
heads_bp,
|
heads_bp,
|
||||||
gpu_bp,
|
gpu_bp,
|
||||||
ccip_bp,
|
ccip_bp,
|
||||||
|
|||||||
+37
-21
@@ -1,13 +1,12 @@
|
|||||||
"""FC-3k: /api/admin — destructive admin actions.
|
"""FC-3k: /api/admin — destructive admin actions.
|
||||||
|
|
||||||
Five action surfaces:
|
Action surfaces:
|
||||||
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
||||||
POST /api/admin/images/bulk-delete (Tier C)
|
POST /api/admin/images/bulk-delete (Tier C)
|
||||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||||
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
||||||
POST /api/admin/tags/prune-unused (Tier A)
|
POST /api/admin/tags/prune-unused (Tier A)
|
||||||
POST /api/admin/posts/prune-bare (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)
|
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
||||||
|
|
||||||
Tier-C ops take a dry_run body flag (returns projection inline,
|
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)
|
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
def _reset_content_confirm_token(projection: dict) -> str:
|
||||||
async def tags_purge_legacy():
|
"""Stable 8-hex token derived from the live counts (mirrors the Tier-C
|
||||||
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
|
bulk-delete token): it changes whenever the data changes, so the apply can
|
||||||
kinds (e.g. `BlenderKnight:Hannah_BJ_Loops`) PLUS general tags with
|
only ever run against numbers the operator just previewed."""
|
||||||
a legacy name prefix (`source:*`, from IR's source kind that fell
|
canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
|
||||||
back to general). dry-run preview returns per-kind + per-prefix
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||||
async def tags_reset_content():
|
async def tags_reset_content():
|
||||||
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
|
"""Full-instance reset of the CONTENT vocabulary: deletes ALL general +
|
||||||
content vocabulary) so the operator can re-tag from scratch via
|
character tags and their image applications — INCLUDING the examples the
|
||||||
auto-suggest. fandom + series tags + series_page ordering are preserved,
|
tagging heads learned from. Suggestions do NOT repopulate on their own
|
||||||
and image_prediction rows are untouched so suggestions repopulate.
|
(the Camie predictions that once did are long retired): the operator
|
||||||
dry-run preview returns per-kind counts + applications + a sample so the
|
re-tags from scratch and the heads retrain from the new signal. fandom +
|
||||||
UI shows exactly what'll go before the operator confirms (dry_run=false).
|
series tags + series_page ordering are preserved.
|
||||||
Irreversible except via DB backup restore."""
|
|
||||||
|
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
|
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"])
|
@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).
|
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).
|
- `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
|
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
|
||||||
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
|
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
|
(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
|
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||||
media = request.args.get("media")
|
media = request.args.get("media")
|
||||||
media_type = media if media in ("image", "video") else None
|
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 = 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
|
platform = request.args.get("platform") or None
|
||||||
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
||||||
no_artist = request.args.get("no_artist") 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
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
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 sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
|
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
|
||||||
from ..services.gallery_service import image_url
|
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, error_dedupe_statements
|
||||||
from ..services.ml.gpu_jobs import GpuJobService
|
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
|
||||||
from ..services.ml.regions import RegionService
|
from ..services.ml.regions import RegionService
|
||||||
|
|
||||||
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
|
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"
|
_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`."""
|
"""Enqueue a job for every image that doesn't already have one for `task`."""
|
||||||
body = await request.get_json(silent=True) or {}
|
body = await request.get_json(silent=True) or {}
|
||||||
task = str(body.get("task") or "ccip")
|
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)
|
r = enqueue_gpu_backfill.delay(task)
|
||||||
return jsonify({"celery_task_id": r.id, "task": task}), 202
|
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 ------------
|
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
|
||||||
|
|
||||||
@gpu_bp.route("/jobs/lease", methods=["POST"])
|
@gpu_bp.route("/jobs/lease", methods=["POST"])
|
||||||
@@ -138,11 +283,12 @@ async def lease():
|
|||||||
# For video/animated: the agent samples at this cadence.
|
# For video/animated: the agent samples at this cadence.
|
||||||
"frame_interval_seconds": ml.video_frame_interval_seconds,
|
"frame_interval_seconds": ml.video_frame_interval_seconds,
|
||||||
"max_frames": ml.video_max_frames,
|
"max_frames": ml.video_max_frames,
|
||||||
# The embedding model the agent must use for concept crops, so
|
# The embedding model the agent must use for concept crops + the
|
||||||
# its region vectors land in the SAME space the heads trained in.
|
# whole-image 'embed' task, so its vectors land in the SAME space
|
||||||
# Server-announced → the agent stays model-agnostic; a swap is a
|
# the heads trained in. Server-announced FROM THE SETTING → the
|
||||||
# server setting + a re-embed migration, never an agent change.
|
# agent stays model-agnostic; an operator swap is a setting + a
|
||||||
"embed_model_name": EMBED_MODEL_NAME,
|
# re-embed, never an agent change.
|
||||||
|
"embed_model_name": ml.embedder_model_name,
|
||||||
"embed_version": ml.embedder_model_version,
|
"embed_version": ml.embedder_model_version,
|
||||||
})
|
})
|
||||||
return jsonify({"jobs": out})
|
return jsonify({"jobs": out})
|
||||||
@@ -188,6 +334,34 @@ async def submit():
|
|||||||
return jsonify({"ok": True, "stored": len(regions)})
|
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"])
|
@gpu_bp.route("/jobs/fail", methods=["POST"])
|
||||||
async def fail():
|
async def fail():
|
||||||
body = await request.get_json(silent=True) or {}
|
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
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
@@ -9,14 +9,9 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
|||||||
|
|
||||||
|
|
||||||
_EDITABLE = (
|
_EDITABLE = (
|
||||||
"suggestion_threshold_character",
|
"cpu_embed_enabled",
|
||||||
"suggestion_threshold_general",
|
|
||||||
"centroid_similarity_threshold",
|
|
||||||
"min_reference_images",
|
|
||||||
"tagger_store_floor",
|
|
||||||
"video_frame_interval_seconds",
|
"video_frame_interval_seconds",
|
||||||
"video_max_frames",
|
"video_max_frames",
|
||||||
"video_min_tag_frames",
|
|
||||||
"head_min_positives",
|
"head_min_positives",
|
||||||
"head_auto_apply_precision",
|
"head_auto_apply_precision",
|
||||||
"head_auto_apply_enabled",
|
"head_auto_apply_enabled",
|
||||||
@@ -24,9 +19,41 @@ _EDITABLE = (
|
|||||||
"ccip_match_threshold",
|
"ccip_match_threshold",
|
||||||
"ccip_auto_apply_enabled",
|
"ccip_auto_apply_enabled",
|
||||||
"ccip_auto_apply_threshold",
|
"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"])
|
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -37,15 +64,9 @@ async def get_settings():
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
"cpu_embed_enabled": s.cpu_embed_enabled,
|
||||||
"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,
|
|
||||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||||
"video_max_frames": s.video_max_frames,
|
"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,
|
"embedder_model_version": s.embedder_model_version,
|
||||||
"head_min_positives": s.head_min_positives,
|
"head_min_positives": s.head_min_positives,
|
||||||
"head_auto_apply_precision": s.head_auto_apply_precision,
|
"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_match_threshold": s.ccip_match_threshold,
|
||||||
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
||||||
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
"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:
|
def _validate(p: dict) -> str | None:
|
||||||
"""Returns an error string if the proposed settings are invalid, else None.
|
"""Returns an error string if the proposed settings are invalid, else None."""
|
||||||
|
# Video embedding (#747).
|
||||||
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).
|
|
||||||
if p["video_frame_interval_seconds"] <= 0:
|
if p["video_frame_interval_seconds"] <= 0:
|
||||||
return "video_frame_interval_seconds must be > 0"
|
return "video_frame_interval_seconds must be > 0"
|
||||||
if p["video_max_frames"] < 1:
|
if p["video_max_frames"] < 1:
|
||||||
return "video_max_frames must be >= 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).
|
# Head training (#114).
|
||||||
if int(p["head_min_positives"]) < 1:
|
if int(p["head_min_positives"]) < 1:
|
||||||
return "head_min_positives must be >= 1"
|
return "head_min_positives must be >= 1"
|
||||||
@@ -125,6 +128,11 @@ def _validate(p: dict) -> str | None:
|
|||||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
return "ccip_match_threshold must be between 0.5 and 0.999"
|
||||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 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"
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -134,11 +142,3 @@ async def trigger_backfill():
|
|||||||
|
|
||||||
r = backfill.delay()
|
r = backfill.delay()
|
||||||
return jsonify({"celery_task_id": r.id}), 202
|
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 quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Tag, TagAllowlist
|
|
||||||
from ..services.ml.allowlist import AllowlistService
|
from ..services.ml.allowlist import AllowlistService
|
||||||
from ..services.ml.suggestions import SuggestionService
|
from ..services.ml.suggestions import SuggestionService
|
||||||
|
|
||||||
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
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"])
|
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||||
async def get_suggestions(image_id: int):
|
async def get_suggestions(image_id: int):
|
||||||
# ?min=<float> overrides the configured per-category thresholds so the typed
|
# ?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
|
return jsonify({"error": "tag_id required"}), 400
|
||||||
tag_id = body["tag_id"]
|
tag_id = body["tag_id"]
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = AllowlistService(session)
|
await AllowlistService(session).accept(image_id, tag_id)
|
||||||
newly_added = await svc.accept(image_id, tag_id)
|
|
||||||
payload = await _accept_payload(session, svc, newly_added, tag_id)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if newly_added:
|
return jsonify({"accepted": True, "tag_id": tag_id})
|
||||||
from ..tasks.ml import apply_allowlist_tags
|
|
||||||
|
|
||||||
apply_allowlist_tags.delay(tag_id=tag_id)
|
|
||||||
return jsonify(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
@suggestions_bp.route(
|
||||||
@@ -104,22 +79,14 @@ async def alias_suggestion(image_id: int):
|
|||||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
||||||
canonical_tag_id = body["canonical_tag_id"]
|
canonical_tag_id = body["canonical_tag_id"]
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = AllowlistService(session)
|
await AllowlistService(session).add_alias_and_accept(
|
||||||
newly_added = await svc.add_alias_and_accept(
|
|
||||||
image_id,
|
image_id,
|
||||||
body["alias_string"],
|
body["alias_string"],
|
||||||
body["alias_category"],
|
body["alias_category"],
|
||||||
canonical_tag_id,
|
canonical_tag_id,
|
||||||
)
|
)
|
||||||
payload = await _accept_payload(
|
|
||||||
session, svc, newly_added, canonical_tag_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if newly_added:
|
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
|
||||||
from ..tasks.ml import apply_allowlist_tags
|
|
||||||
|
|
||||||
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
|
|
||||||
return jsonify(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
@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."""
|
"""Tags API: autocomplete, create, list/add/remove for an image."""
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Tag, TagKind, TagPositiveConfirmation
|
from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
|
||||||
from ..models.tag_allowlist import TagAllowlist
|
from ..models.tag import image_tag
|
||||||
|
from ..models.tag_suggestion_rejection import TagSuggestionRejection
|
||||||
from ..services.bulk_tag_service import BulkTagService
|
from ..services.bulk_tag_service import BulkTagService
|
||||||
from ..services.ml.aliases import AliasService
|
from ..services.ml.aliases import AliasService
|
||||||
from ..services.series_match_service import SeriesMatchService
|
from ..services.series_match_service import SeriesMatchService
|
||||||
@@ -61,6 +62,117 @@ def _parse_bulk_ids(
|
|||||||
return ids, None
|
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"])
|
@tags_bp.route("/tags/autocomplete", methods=["GET"])
|
||||||
async def autocomplete():
|
async def autocomplete():
|
||||||
q = request.args.get("q", "")
|
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
|
status = 404 if "not found" in msg else 400
|
||||||
return jsonify({"error": msg}), status
|
return jsonify({"error": msg}), status
|
||||||
await session.commit()
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"target": {
|
"target": {
|
||||||
|
|||||||
+23
-24
@@ -7,7 +7,7 @@ Queues:
|
|||||||
download — gallery-dl tasks (FC-3)
|
download — gallery-dl tasks (FC-3)
|
||||||
scan — periodic source checks (FC-3) — kept separate so long imports
|
scan — periodic source checks (FC-3) — kept separate so long imports
|
||||||
don't starve the scheduler
|
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
|
default — anything not explicitly routed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.thumbnail",
|
"backend.app.tasks.thumbnail",
|
||||||
"backend.app.tasks.maintenance",
|
"backend.app.tasks.maintenance",
|
||||||
"backend.app.tasks.ml",
|
"backend.app.tasks.ml",
|
||||||
|
"backend.app.tasks.gpu_queue",
|
||||||
"backend.app.tasks.download",
|
"backend.app.tasks.download",
|
||||||
"backend.app.tasks.external",
|
"backend.app.tasks.external",
|
||||||
"backend.app.tasks.backup",
|
"backend.app.tasks.backup",
|
||||||
@@ -41,6 +42,11 @@ def make_celery() -> Celery:
|
|||||||
task_routes={
|
task_routes={
|
||||||
"backend.app.tasks.import_file.*": {"queue": "import"},
|
"backend.app.tasks.import_file.*": {"queue": "import"},
|
||||||
"backend.app.tasks.ml.*": {"queue": "ml"},
|
"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.thumbnail.*": {"queue": "thumbnail"},
|
||||||
"backend.app.tasks.download.*": {"queue": "download"},
|
"backend.app.tasks.download.*": {"queue": "download"},
|
||||||
# External file-host fetches are downloads — same lane (they can run
|
# 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",
|
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||||
"schedule": 86400.0, # daily
|
"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": {
|
"train-heads-nightly": {
|
||||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
"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
|
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
|
||||||
},
|
},
|
||||||
"recover-orphaned-gpu-jobs": {
|
"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
|
"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": {
|
"enqueue-ccip-backfill-hourly": {
|
||||||
"task": "backend.app.tasks.ml.enqueue_gpu_backfill",
|
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
|
||||||
"schedule": 3600.0, # auto-feed new images (+ retry errored) so
|
"schedule": 3600.0, # auto-feed NEW images; errored are
|
||||||
"args": ("ccip",), # the queue keeps moving without the button
|
"args": ("ccip",), # tombstoned — retry is the button only
|
||||||
},
|
},
|
||||||
"enqueue-siglip-backfill-daily": {
|
"enqueue-siglip-backfill-daily": {
|
||||||
"task": "backend.app.tasks.ml.enqueue_gpu_backfill",
|
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
|
||||||
"schedule": 86400.0, # drain the concept-crop back-catalogue +
|
"schedule": 86400.0, # drain the concept-crop back-catalogue
|
||||||
"args": ("siglip",), # retry failed embeds, no button needed
|
"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": {
|
"ccip-auto-apply-daily": {
|
||||||
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
"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",
|
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
|
||||||
"schedule": 300.0,
|
"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": {
|
"recover-stalled-head-training-runs": {
|
||||||
"task": "backend.app.tasks.maintenance.recover_stalled_head_training_runs",
|
"task": "backend.app.tasks.maintenance.recover_stalled_head_training_runs",
|
||||||
"schedule": 300.0,
|
"schedule": 300.0,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from .head_auto_apply_run import HeadAutoApplyRun
|
|||||||
from .head_metric import HeadMetric
|
from .head_metric import HeadMetric
|
||||||
from .head_metrics_snapshot import HeadMetricsSnapshot
|
from .head_metrics_snapshot import HeadMetricsSnapshot
|
||||||
from .head_training_run import HeadTrainingRun
|
from .head_training_run import HeadTrainingRun
|
||||||
from .image_prediction import ImagePrediction
|
|
||||||
from .image_provenance import ImageProvenance
|
from .image_provenance import ImageProvenance
|
||||||
from .image_record import ImageRecord
|
from .image_record import ImageRecord
|
||||||
from .image_region import ImageRegion
|
from .image_region import ImageRegion
|
||||||
@@ -34,11 +33,8 @@ from .subscribestar_failed_media import SubscribeStarFailedMedia
|
|||||||
from .subscribestar_seen_media import SubscribeStarSeenMedia
|
from .subscribestar_seen_media import SubscribeStarSeenMedia
|
||||||
from .tag import Tag, TagKind, image_tag
|
from .tag import Tag, TagKind, image_tag
|
||||||
from .tag_alias import TagAlias
|
from .tag_alias import TagAlias
|
||||||
from .tag_allowlist import TagAllowlist
|
|
||||||
from .tag_eval_run import TagEvalRun
|
|
||||||
from .tag_head import TagHead
|
from .tag_head import TagHead
|
||||||
from .tag_positive_confirmation import TagPositiveConfirmation
|
from .tag_positive_confirmation import TagPositiveConfirmation
|
||||||
from .tag_reference_embedding import TagReferenceEmbedding
|
|
||||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||||
from .task_run import TaskRun
|
from .task_run import TaskRun
|
||||||
|
|
||||||
@@ -60,7 +56,6 @@ __all__ = [
|
|||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
"SeriesSuggestion",
|
"SeriesSuggestion",
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
"ImagePrediction",
|
|
||||||
"ImageProvenance",
|
"ImageProvenance",
|
||||||
"ImageRegion",
|
"ImageRegion",
|
||||||
"Tag",
|
"Tag",
|
||||||
@@ -79,11 +74,8 @@ __all__ = [
|
|||||||
"HeadMetricsSnapshot",
|
"HeadMetricsSnapshot",
|
||||||
"HeadTrainingRun",
|
"HeadTrainingRun",
|
||||||
"TagAlias",
|
"TagAlias",
|
||||||
"TagAllowlist",
|
|
||||||
"TagEvalRun",
|
|
||||||
"TagHead",
|
"TagHead",
|
||||||
"TagPositiveConfirmation",
|
"TagPositiveConfirmation",
|
||||||
"TagReferenceEmbedding",
|
|
||||||
"TagSuggestionRejection",
|
"TagSuggestionRejection",
|
||||||
"TaskRun",
|
"TaskRun",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ pending for another agent).
|
|||||||
|
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -23,6 +32,17 @@ from .base import Base
|
|||||||
class GpuJob(Base):
|
class GpuJob(Base):
|
||||||
__tablename__ = "gpu_job"
|
__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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
image_record_id: Mapped[int] = mapped_column(
|
image_record_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
|
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)
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
|
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
|
||||||
|
|
||||||
Mirrors TagEvalRun so the run SURVIVES navigation and the admin card can show
|
A persisted run row (not transient frontend state) so the run SURVIVES
|
||||||
live + historical status instead of holding it in transient frontend state.
|
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
|
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
|
— a maintenance recovery sweep flips a stalled `running` row to `error`, and the
|
||||||
next run re-trains. State machine: running → ready / error.
|
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_trained: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
n_skipped: 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)
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# Last time the task made progress — the recovery sweep tells a live run from
|
# Last time the task made progress — the recovery sweep tells a live run
|
||||||
# a SIGKILL'd one by this (mirrors TagEvalRun).
|
# from a SIGKILL'd one by this.
|
||||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
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 pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
|
||||||
BigInteger,
|
BigInteger,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
@@ -77,19 +76,13 @@ class ImageRecord(Base):
|
|||||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
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
|
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
|
||||||
# normalized image_prediction table (#768) — the tagger_predictions JSON
|
# embedding dim; siglip_model_version stamps which model produced it (so an
|
||||||
# column was dropped in migration 0046. tagger_model_version stays as the
|
# operator model swap, #1190, can re-embed the stale rows). A different-dim
|
||||||
# "has this been tagged / is it current?" signal the backfill sweep reads.
|
# model would need a column-width migration.
|
||||||
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.
|
|
||||||
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
||||||
siglip_model_version: Mapped[str | None] = mapped_column(String(128), 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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -104,6 +97,16 @@ class ImageRecord(Base):
|
|||||||
effective_date: Mapped[datetime] = mapped_column(
|
effective_date: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ class ImageRegion(Base):
|
|||||||
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
|
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
# 'frame' (a whole video frame → SigLIP bag) | 'face' | 'figure' (→ CCIP
|
# '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)
|
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
# For video/animated media: the source frame's timestamp in SECONDS. NULL for
|
# 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
|
# 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"),)
|
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
suggestion_threshold_character: Mapped[float] = mapped_column(
|
# CPU whole-image embedding (B3, operator 2026-07-02). The ml-worker's ONLY
|
||||||
Float, nullable=False, default=0.70
|
# 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
|
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
|
||||||
# surfaced too many low-confidence picks; 0.70 keeps the rail
|
# a fixed count) so coverage reflects real screen time regardless of length;
|
||||||
# signal-rich while still surfacing more than the original 0.95
|
# cap the total so a long video can't explode into hundreds of embeds. The
|
||||||
# which hid almost everything. Operator-tunable via Settings → ML.
|
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
|
||||||
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_frame_interval_seconds: Mapped[float] = mapped_column(
|
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=4.0
|
Float, nullable=False, default=4.0
|
||||||
)
|
)
|
||||||
video_max_frames: Mapped[int] = mapped_column(
|
video_max_frames: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=64
|
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
|
# Tagging-v2 head training (#114). The head is the suggestion source that
|
||||||
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
||||||
# needs >= head_min_positives labelled images before a head is trained;
|
# 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(
|
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.92
|
Float, nullable=False, default=0.92
|
||||||
)
|
)
|
||||||
tagger_model_version: Mapped[str] = mapped_column(
|
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
||||||
String(128), nullable=False, default="camie-tagger-v2"
|
# existing libraries keep their stored value until the operator re-embeds.
|
||||||
)
|
|
||||||
embedder_model_version: Mapped[str] = mapped_column(
|
embedder_model_version: Mapped[str] = mapped_column(
|
||||||
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
MODEL_ROOT = Path(os.environ.get("ML_MODEL_DIR", "/models"))
|
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_REPO = os.environ.get(
|
||||||
"SIGLIP_HF_REPO", "google/siglip-so400m-patch14-384"
|
"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:
|
def ensure_siglip() -> None:
|
||||||
dest = MODEL_ROOT / "siglip"
|
dest = MODEL_ROOT / "siglip"
|
||||||
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
|
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
|
||||||
@@ -62,7 +33,6 @@ def ensure_siglip() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ensure_camie()
|
|
||||||
ensure_siglip()
|
ensure_siglip()
|
||||||
print("[download_models] Done.")
|
print("[download_models] Done.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Single-color audit: matches images where one color dominates beyond
|
"""Single-color audit: matches images where one color dominates beyond
|
||||||
the threshold (within the given Euclidean RGB tolerance). The first
|
the threshold (within the given Euclidean RGB tolerance). The canonical
|
||||||
canonical implementation — the import-side filter (SkipReason.single_color)
|
predicate for BOTH surfaces: FC-Cleanup's retroactive audit and — since
|
||||||
was never wired; FC-Cleanup's audit module is the source of truth and a
|
2026-07-02 — the import-side filter (Importer._single_color_hit /
|
||||||
future spec can adopt it on the import path too.
|
SkipReason.single_color), so what the audit flags and what the import
|
||||||
|
skips can never disagree.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|||||||
@@ -395,9 +395,8 @@ def delete_images(
|
|||||||
def delete_tag(session: Session, *, tag_id: int) -> dict:
|
def delete_tag(session: Session, *, tag_id: int) -> dict:
|
||||||
"""Simple DELETE FROM tag WHERE id=?.
|
"""Simple DELETE FROM tag WHERE id=?.
|
||||||
|
|
||||||
Postgres cascades the rest (image_tag, tag_alias, tag_allowlist,
|
Postgres cascades the rest (image_tag, tag_alias, tag_suggestion_rejection,
|
||||||
tag_reference_embedding, tag_suggestion_rejection, series_page).
|
series_page). Returns counts BEFORE delete so the caller can surface them.
|
||||||
Returns counts BEFORE delete so the caller can surface them.
|
|
||||||
Raises LookupError if tag_id not found.
|
Raises LookupError if tag_id not found.
|
||||||
"""
|
"""
|
||||||
tag = session.get(Tag, tag_id)
|
tag = session.get(Tag, tag_id)
|
||||||
@@ -719,89 +718,24 @@ def reconcile_duplicate_posts(
|
|||||||
return {"groups": len(groups), "merged": losers_total, "sample": sample}
|
return {"groups": len(groups), "merged": losers_total, "sample": sample}
|
||||||
|
|
||||||
|
|
||||||
# Legacy tags FC no longer uses, in two shapes:
|
# The CONTENT vocabulary. "Reset content tagging" wipes these so the operator
|
||||||
# (1) kinds the tag input never produces — archive/post/artist.
|
# can re-tag from scratch. fandom + series (and series_page ordering) are
|
||||||
# provenance (post grouping) + archive membership are their own
|
# deliberately NOT here — they're kept.
|
||||||
# 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.
|
|
||||||
RESETTABLE_TAG_KINDS = ("general", "character")
|
RESETTABLE_TAG_KINDS = ("general", "character")
|
||||||
|
|
||||||
|
|
||||||
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||||
"""Count (dry_run) or DELETE every general + character tag so the operator
|
"""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
|
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
|
||||||
image's image_prediction rows (untouched) so suggestions
|
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
|
||||||
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
|
applications + metadata. Tag.fandom_id is SET NULL, so deleting character
|
||||||
tag_reference_embedding / tag_suggestion_rejection clears each deleted
|
tags never touches the fandom rows. Irreversible except via DB backup
|
||||||
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
|
restore.
|
||||||
character tags never touches the fandom rows. Irreversible except via DB
|
|
||||||
backup restore.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"by_kind": {"general": N, "character": M},
|
{"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.
|
still an archive on disk, so the cursor is what guarantees forward progress.
|
||||||
"""
|
"""
|
||||||
from ..models import ImportSettings, Post, PostAttachment, Source
|
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 ..tasks.thumbnail import generate_thumbnail
|
||||||
from .archive_extractor import is_archive
|
from .archive_extractor import is_archive
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
@@ -1155,10 +1089,12 @@ def reextract_archive_attachments(
|
|||||||
|
|
||||||
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
||||||
# critical path — a Redis hiccup must not fail the whole re-extract).
|
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||||
|
do_embed = cpu_embed_enabled()
|
||||||
for img_id in enqueue_ids:
|
for img_id in enqueue_ids:
|
||||||
try:
|
try:
|
||||||
generate_thumbnail.delay(img_id)
|
generate_thumbnail.delay(img_id)
|
||||||
tag_and_embed.delay(img_id)
|
if do_embed:
|
||||||
|
embed_image.delay(img_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -326,14 +326,16 @@ class DownloadService:
|
|||||||
# for hours after a download landed. Lazy import to avoid
|
# for hours after a download landed. Lazy import to avoid
|
||||||
# circular-import risk between this service and the
|
# circular-import risk between this service and the
|
||||||
# tasks/* modules that import it.
|
# 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
|
from ..tasks.thumbnail import generate_thumbnail
|
||||||
|
do_embed = cpu_embed_enabled()
|
||||||
ids = list(result.member_image_ids)
|
ids = list(result.member_image_ids)
|
||||||
if result.image_id is not None and result.image_id not in ids:
|
if result.image_id is not None and result.image_id not in ids:
|
||||||
ids.append(result.image_id)
|
ids.append(result.image_id)
|
||||||
for img_id in ids:
|
for img_id in ids:
|
||||||
generate_thumbnail.delay(img_id)
|
generate_thumbnail.delay(img_id)
|
||||||
tag_and_embed.delay(img_id)
|
if do_embed:
|
||||||
|
embed_image.delay(img_id)
|
||||||
elif result.status == "attached":
|
elif result.status == "attached":
|
||||||
# Non-media or extracted archive captured as PostAttachment
|
# Non-media or extracted archive captured as PostAttachment
|
||||||
# (FC-2d-iii). The canonical copy lives in the attachments
|
# (FC-2d-iii). The canonical copy lives in the attachments
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ def _effective_date_col():
|
|||||||
return ImageRecord.effective_date
|
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:
|
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||||
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
|
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
|
||||||
above sees Post.post_date when available. Images without a post
|
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]:
|
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||||
"""Map image_id -> {"name","slug"} via the canonical
|
"""Map image_id -> {"name","slug"} via the canonical
|
||||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||||
@@ -332,7 +425,10 @@ class GalleryService:
|
|||||||
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
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 = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
stmt = _apply_scope(
|
stmt = _apply_scope(
|
||||||
@@ -343,7 +439,7 @@ class GalleryService:
|
|||||||
date_from=date_from, date_to=date_to,
|
date_from=date_from, date_to=date_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
descending = sort != "oldest"
|
descending = sort not in _ASCENDING_SORTS
|
||||||
if cursor:
|
if cursor:
|
||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
# The cursor is just (last eff, last id); the request's sort
|
# 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,
|
untagged: bool = False, no_artist: bool = False,
|
||||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
) -> list[GalleryImage] | None:
|
) -> list[GalleryImage] | None:
|
||||||
"""Visual "more like this": images ranked by cosine distance to
|
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||||
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||||
No ML inference here; the embedding was computed at import.
|
doesn't collapse into one cluster. No ML inference here.
|
||||||
|
|
||||||
Returns None if the source image doesn't exist (→ 404), [] if it has
|
Pure nearest-cosine piles up near-identical images — a reposted banner
|
||||||
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
fills the whole grid, and once you wander into a B&W / comic-panel
|
||||||
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
cluster every neighbour is more of the same with no way back to colour
|
||||||
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
(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:
|
if limit < 1 or limit > 200:
|
||||||
raise ValueError("limit must be between 1 and 200")
|
raise ValueError("limit must be between 1 and 200")
|
||||||
@@ -582,6 +684,11 @@ class GalleryService:
|
|||||||
if src.siglip_embedding is None:
|
if src.siglip_embedding is None:
|
||||||
return []
|
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)
|
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
@@ -597,8 +704,9 @@ class GalleryService:
|
|||||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
date_from=date_from, date_to=date_to,
|
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 = (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])
|
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||||
return _gallery_images(rows, artists)
|
return _gallery_images(rows, artists)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from enum import StrEnum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ from ..utils.sidecar import find_sidecar, parse_sidecar
|
|||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from .archive_extractor import extract_archive, is_archive
|
from .archive_extractor import extract_archive, is_archive
|
||||||
from .attachment_store import AttachmentStore
|
from .attachment_store import AttachmentStore
|
||||||
|
from .audits import single_color
|
||||||
from .link_extract import extract_external_links
|
from .link_extract import extract_external_links
|
||||||
from .thumbnailer import Thumbnailer
|
from .thumbnailer import Thumbnailer
|
||||||
|
|
||||||
@@ -790,6 +791,13 @@ class Importer:
|
|||||||
error=f"{pct:.2%} transparent",
|
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
|
# Artist anchored to the attribution path (folder→artist), resolved
|
||||||
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
|
# 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
|
# 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,
|
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||||
error=f"{pct:.2%} 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:
|
else:
|
||||||
# Best-effort probe for dims + duration so downloaded videos can dedup
|
# Best-effort probe for dims + duration so downloaded videos can dedup
|
||||||
# (#871). LENIENT: unlike _import_media this path does not reject on a
|
# (#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).
|
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||||
if record.primary_post_id == post.id and post.post_date is not None:
|
if record.primary_post_id == post.id and post.post_date is not None:
|
||||||
record.effective_date = post.post_date
|
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()
|
self.session.flush()
|
||||||
|
|
||||||
def _copy_to_library(
|
def _copy_to_library(
|
||||||
@@ -1475,20 +1508,8 @@ class Importer:
|
|||||||
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
||||||
existing.thumbnail_path = None
|
existing.thumbnail_path = None
|
||||||
existing.integrity_status = "unknown"
|
existing.integrity_status = "unknown"
|
||||||
existing.tagger_model_version = None
|
|
||||||
existing.siglip_embedding = None
|
existing.siglip_embedding = None
|
||||||
existing.siglip_model_version = 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.
|
# created_at intentionally preserved; updated_at auto-bumps.
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -1532,6 +1553,33 @@ class Importer:
|
|||||||
# Benign orphan; the DB swap already committed. Don't undo it.
|
# Benign orphan; the DB swap already committed. Don't undo it.
|
||||||
pass
|
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:
|
def _transparency_pct(self, source: Path) -> float:
|
||||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha.
|
"""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
|
"""Suggestion actions: accept applies the canonical tag to an image (which
|
||||||
image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
|
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 sqlalchemy import delete
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from sqlalchemy import and_, delete, distinct, func, or_, select
|
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...models import (
|
from ...models import TagSuggestionRejection
|
||||||
ImagePrediction,
|
|
||||||
MLSettings,
|
|
||||||
Tag,
|
|
||||||
TagAlias,
|
|
||||||
TagAllowlist,
|
|
||||||
TagSuggestionRejection,
|
|
||||||
)
|
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .aliases import AliasService
|
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:
|
class AllowlistService:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -39,21 +23,11 @@ class AllowlistService:
|
|||||||
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
||||||
stmt = insert(image_tag).values(
|
stmt = insert(image_tag).values(
|
||||||
image_record_id=image_id, tag_id=tag_id, source=source
|
image_record_id=image_id, tag_id=tag_id, source=source
|
||||||
)
|
).on_conflict_do_nothing(
|
||||||
stmt = stmt.on_conflict_do_nothing(
|
|
||||||
index_elements=["image_record_id", "tag_id"]
|
index_elements=["image_record_id", "tag_id"]
|
||||||
)
|
)
|
||||||
await self.session.execute(stmt)
|
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):
|
async def _clear_rejection(self, image_id: int, tag_id: int):
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
delete(TagSuggestionRejection)
|
delete(TagSuggestionRejection)
|
||||||
@@ -61,12 +35,11 @@ class AllowlistService:
|
|||||||
.where(TagSuggestionRejection.tag_id == tag_id)
|
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def accept(self, image_id: int, tag_id: int) -> bool:
|
async def accept(self, image_id: int, tag_id: int) -> None:
|
||||||
"""Accept a suggestion. Returns True if the tag was newly added to
|
"""Apply the accepted tag to this image (source='ml_accepted', a head
|
||||||
the allowlist (the API layer enqueues apply_allowlist_tags then)."""
|
training positive) and clear any prior rejection."""
|
||||||
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
||||||
await self._clear_rejection(image_id, tag_id)
|
await self._clear_rejection(image_id, tag_id)
|
||||||
return await self._add_to_allowlist(tag_id)
|
|
||||||
|
|
||||||
async def add_alias_and_accept(
|
async def add_alias_and_accept(
|
||||||
self,
|
self,
|
||||||
@@ -74,17 +47,16 @@ class AllowlistService:
|
|||||||
alias_string: str,
|
alias_string: str,
|
||||||
alias_category: str,
|
alias_category: str,
|
||||||
canonical_tag_id: int,
|
canonical_tag_id: int,
|
||||||
) -> bool:
|
) -> None:
|
||||||
await self.aliases.create(
|
await self.aliases.create(
|
||||||
alias_string, alias_category, canonical_tag_id
|
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:
|
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
||||||
stmt = insert(TagSuggestionRejection).values(
|
stmt = insert(TagSuggestionRejection).values(
|
||||||
image_record_id=image_id, tag_id=tag_id
|
image_record_id=image_id, tag_id=tag_id
|
||||||
)
|
).on_conflict_do_nothing(
|
||||||
stmt = stmt.on_conflict_do_nothing(
|
|
||||||
index_elements=["image_record_id", "tag_id"]
|
index_elements=["image_record_id", "tag_id"]
|
||||||
)
|
)
|
||||||
await self.session.execute(stmt)
|
await self.session.execute(stmt)
|
||||||
@@ -96,118 +68,11 @@ class AllowlistService:
|
|||||||
await self._clear_rejection(image_id, tag_id)
|
await self._clear_rejection(image_id, tag_id)
|
||||||
|
|
||||||
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
||||||
"""Operator removed an applied tag from an image. Remove the
|
"""Operator removed an applied tag from an image. Remove the image_tag
|
||||||
image_tag row AND record a rejection so the allowlist won't
|
row AND record a rejection so head auto-apply won't re-apply it."""
|
||||||
re-apply it on the next maintenance sweep."""
|
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
image_tag.delete()
|
image_tag.delete()
|
||||||
.where(image_tag.c.image_record_id == image_id)
|
.where(image_tag.c.image_record_id == image_id)
|
||||||
.where(image_tag.c.tag_id == tag_id)
|
.where(image_tag.c.tag_id == tag_id)
|
||||||
)
|
)
|
||||||
await self.dismiss(image_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.
|
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
|
||||||
_INTRA_OP_THREADS = 4
|
_INTRA_OP_THREADS = 4
|
||||||
|
|
||||||
MODEL_NAME = os.environ.get(
|
DEFAULT_MODEL_NAME = os.environ.get(
|
||||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
"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(
|
MODEL_VERSION = os.environ.get(
|
||||||
"SIGLIP_MODEL_VERSION", "siglip-so400m-patch14-384"
|
"SIGLIP_MODEL_VERSION", "siglip-so400m-patch14-384"
|
||||||
)
|
)
|
||||||
@@ -29,35 +31,42 @@ _LOCAL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "siglip"
|
|||||||
|
|
||||||
|
|
||||||
class Embedder:
|
class Embedder:
|
||||||
def __init__(self, model_dir: Path | None = None):
|
"""Loads whatever SigLIP-family model it's given by HF NAME. For the default
|
||||||
self._model_dir = model_dir or _LOCAL_DIR
|
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._model = None
|
||||||
self._processor = None
|
self._processor = None
|
||||||
self._torch = 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:
|
def load(self) -> None:
|
||||||
if self._model is not None:
|
if self._model is not None:
|
||||||
return
|
return
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModel, SiglipImageProcessor
|
from transformers import AutoImageProcessor, AutoModel
|
||||||
|
|
||||||
self._torch = torch
|
self._torch = torch
|
||||||
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
|
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
|
||||||
# stays a predictable core consumer on a shared node.
|
# stays a predictable core consumer on a shared node.
|
||||||
torch.set_num_threads(_INTRA_OP_THREADS)
|
torch.set_num_threads(_INTRA_OP_THREADS)
|
||||||
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
# IMAGE inference only — AutoImageProcessor loads just the image side
|
||||||
# loads the full processor including SiglipTokenizer, which requires
|
# (preprocessor_config.json), skipping the SigLIP tokenizer + its
|
||||||
# the sentencepiece library at import time even if we never call it.
|
# sentencepiece dep (operator hit that ImportError 2026-05-25). Works
|
||||||
# SiglipImageProcessor loads ONLY preprocessor_config.json (image
|
# for any SigLIP-family model, keeping the embedder model-agnostic.
|
||||||
# side) and skips the tokenizer config entirely. Operator hit the
|
src = self._source()
|
||||||
# ImportError 2026-05-25 once the ml-worker started actually running
|
self._processor = AutoImageProcessor.from_pretrained(src)
|
||||||
# tag_and_embed; switching to the image-only loader avoids the
|
self._model = AutoModel.from_pretrained(src)
|
||||||
# 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))
|
|
||||||
self._model.eval()
|
self._model.eval()
|
||||||
|
|
||||||
def infer(self, image_path: Path) -> np.ndarray:
|
def infer(self, image_path: Path) -> np.ndarray:
|
||||||
@@ -74,8 +83,12 @@ class Embedder:
|
|||||||
_default_embedder: Embedder | None = None
|
_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
|
global _default_embedder
|
||||||
if _default_embedder is None:
|
name = model_name or DEFAULT_MODEL_NAME
|
||||||
_default_embedder = Embedder()
|
if _default_embedder is None or _default_embedder.model_name != name:
|
||||||
|
_default_embedder = Embedder(model_name=name)
|
||||||
return _default_embedder
|
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 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.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from ...models import GpuJob
|
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
|
DEFAULT_BATCH = 8
|
||||||
MAX_ATTEMPTS = 3
|
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:
|
class GpuJobService:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
@@ -51,25 +153,33 @@ class GpuJobService:
|
|||||||
async def lease(
|
async def lease(
|
||||||
self, token: str, batch_size: int = DEFAULT_BATCH, ttl: int = DEFAULT_LEASE_TTL
|
self, token: str, batch_size: int = DEFAULT_BATCH, ttl: int = DEFAULT_LEASE_TTL
|
||||||
) -> list[GpuJob]:
|
) -> 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)
|
now = datetime.now(UTC)
|
||||||
picked = (
|
|
||||||
await self.session.execute(
|
async def _claim(condition, limit: int) -> list[int]:
|
||||||
select(GpuJob.id)
|
return list(
|
||||||
.where(
|
(
|
||||||
or_(
|
await self.session.execute(
|
||||||
GpuJob.status == "pending",
|
select(GpuJob.id).where(condition)
|
||||||
and_(
|
.order_by(GpuJob.id).limit(limit)
|
||||||
GpuJob.status == "leased",
|
.with_for_update(skip_locked=True)
|
||||||
GpuJob.lease_expires_at < now,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
).scalars().all()
|
||||||
.order_by(GpuJob.id)
|
)
|
||||||
.limit(batch_size)
|
|
||||||
.with_for_update(skip_locked=True)
|
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:
|
if not picked:
|
||||||
return []
|
return []
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
@@ -162,16 +272,11 @@ class GpuJobService:
|
|||||||
|
|
||||||
async def recover_orphaned(self) -> int:
|
async def recover_orphaned(self) -> int:
|
||||||
"""Reset every expired lease back to pending — catches agents that died
|
"""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
|
mid-job (no graceful release) — and convert poison-loopers to 'error'
|
||||||
+ reads honestly even when no worker is actively leasing. Returns rows
|
(see the *_POISON_CAP rationale above). Run on a short beat so the queue
|
||||||
recovered."""
|
recovers + reads honestly even when no worker is actively leasing.
|
||||||
now = datetime.now(UTC)
|
Returns rows recovered to pending (poison conversions are extra)."""
|
||||||
res = await self.session.execute(
|
counts = {}
|
||||||
update(GpuJob)
|
for name, stmt in recover_statements(datetime.now(UTC)).items():
|
||||||
.where(GpuJob.status == "leased", GpuJob.lease_expires_at < now)
|
counts[name] = (await self.session.execute(stmt)).rowcount or 0
|
||||||
.values(
|
return counts["recovered"]
|
||||||
status="pending", lease_token=None, leased_at=None,
|
|
||||||
lease_expires_at=None, updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return res.rowcount or 0
|
|
||||||
|
|||||||
@@ -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).
|
"""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
|
- TRAIN (sync, ml worker — needs scikit-learn): for every general/character tag
|
||||||
with enough labelled positives, fit a logistic-regression head on the FROZEN
|
with enough labelled positives, fit a logistic-regression head on the FROZEN
|
||||||
SigLIP embeddings (positives + negatives = rejections + sampled unlabeled),
|
SigLIP embeddings (positives + negatives = rejections + sampled unlabeled),
|
||||||
derive an honest suggest threshold + earned-auto-apply point from CROSS-
|
derive an honest suggest threshold + earned-auto-apply point from CROSS-
|
||||||
VALIDATED scores, and upsert a TagHead row. Reuses tag_eval's proven data
|
VALIDATED scores, and upsert a TagHead row. Uses the eval-proven data loaders
|
||||||
loaders + metric helpers so production heads match the eval's measured numbers.
|
+ metric helpers (training_data.py) so heads match the measured numbers.
|
||||||
- SCORE (async, API worker — numpy via pgvector, NO scikit-learn): score one
|
- SCORE (async, API worker — numpy via pgvector, NO scikit-learn): score one
|
||||||
image's embedding against all current heads → the suggestions the rail shows,
|
image's embedding against all current heads → the suggestions the rail shows,
|
||||||
REPLACING Camie predictions + per-tag centroids.
|
REPLACING Camie predictions + per-tag centroids.
|
||||||
@@ -37,7 +38,7 @@ from ...models import (
|
|||||||
TagSuggestionRejection,
|
TagSuggestionRejection,
|
||||||
)
|
)
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .tag_eval import (
|
from .training_data import (
|
||||||
_auto_apply_point,
|
_auto_apply_point,
|
||||||
_ids_with_tag,
|
_ids_with_tag,
|
||||||
_l2norm,
|
_l2norm,
|
||||||
@@ -308,25 +309,36 @@ async def score_image(
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
img = await session.get(ImageRecord, image_id)
|
img = await session.get(ImageRecord, image_id)
|
||||||
if img is None or img.siglip_embedding is None:
|
if img is None:
|
||||||
return []
|
return []
|
||||||
settings = await _settings_async(session)
|
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:
|
if heads["W"] is None:
|
||||||
return []
|
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 = (
|
region_vecs = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(ImageRegion.siglip_embedding)
|
select(ImageRegion.siglip_embedding)
|
||||||
.where(ImageRegion.image_record_id == image_id)
|
.where(ImageRegion.image_record_id == image_id)
|
||||||
.where(ImageRegion.siglip_embedding.is_not(None))
|
.where(ImageRegion.siglip_embedding.is_not(None))
|
||||||
.where(ImageRegion.embedding_version == settings.embedder_model_version)
|
.where(ImageRegion.embedding_version == cur_version)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
for (vec,) in region_vecs:
|
for (vec,) in region_vecs:
|
||||||
if vec is not None:
|
if vec is not None:
|
||||||
bag.append(np.asarray(vec, dtype=np.float32))
|
bag.append(np.asarray(vec, dtype=np.float32))
|
||||||
|
if not bag:
|
||||||
|
return []
|
||||||
|
|
||||||
X = np.vstack(bag) # (B, D)
|
X = np.vstack(bag) # (B, D)
|
||||||
norms = np.linalg.norm(X, axis=1, keepdims=True)
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
|
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 .db_helpers import get_or_create
|
||||||
from .tag_query import fandom_join_alias, tag_columns
|
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:
|
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
|
"""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
|
pipeline has ever applied it (manual accept or head auto-apply) — so a
|
||||||
a centroid) — otherwise the proactive apply_allowlist_tags worker
|
re-application or an alias remap resolves the canonical name. Purely-
|
||||||
would silently regenerate it. Purely-manual, ML-unknown tags are
|
manual, ML-unknown tags are deleted outright (no DB bloat)."""
|
||||||
deleted outright (no DB bloat)."""
|
|
||||||
is_machine = await self.session.scalar(
|
is_machine = await self.session.scalar(
|
||||||
select(
|
select(
|
||||||
exists().where(
|
exists().where(
|
||||||
and_(
|
and_(
|
||||||
image_tag.c.tag_id == tag_id,
|
image_tag.c.tag_id == tag_id,
|
||||||
image_tag.c.source.in_(
|
image_tag.c.source.in_(
|
||||||
("ml_auto", "ml_accepted", "auto")
|
("ml_auto", "ml_accepted", "head_auto", "auto")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if is_machine:
|
return bool(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)
|
|
||||||
|
|
||||||
async def rename(self, tag_id: int, new_name: str) -> Tag:
|
async def rename(self, tag_id: int, new_name: str) -> Tag:
|
||||||
"""Rename a tag. Raises TagMergeConflict if the new name collides
|
"""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)
|
merged_count = await self._repoint_image_tags(source_id, target_id)
|
||||||
await self._repoint_rejections(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_aliases(source_id, target_id)
|
||||||
await self._repoint_fandom_children(
|
await self._repoint_fandom_children(
|
||||||
source_id, target_id, source_kind
|
source_id, target_id, source_kind
|
||||||
@@ -639,30 +622,6 @@ class TagService:
|
|||||||
.values(tag_id=tgt)
|
.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:
|
async def _repoint_aliases(self, src: int, tgt: int) -> None:
|
||||||
from ..models.tag_alias import TagAlias
|
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
|
# Thumbnails + ML for any newly-attached images (mirrors the download
|
||||||
# path). Lazy import to dodge a task-module import cycle.
|
# path). Lazy import to dodge a task-module import cycle.
|
||||||
if image_ids:
|
if image_ids:
|
||||||
from .ml import tag_and_embed
|
from .ml import cpu_embed_enabled, embed_image
|
||||||
from .thumbnail import generate_thumbnail
|
from .thumbnail import generate_thumbnail
|
||||||
|
do_embed = cpu_embed_enabled()
|
||||||
for img_id in image_ids:
|
for img_id in image_ids:
|
||||||
generate_thumbnail.delay(img_id)
|
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)}
|
return {"link_id": link_id, "files": len(result.files), "images": len(image_ids)}
|
||||||
except Exception as exc: # never leave a link stuck in 'downloading'
|
except Exception as exc: # never leave a link stuck in 'downloading'
|
||||||
log.exception("external fetch task failed for link %s", link_id)
|
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
|
# Enqueue thumbnail + ML for newly imported AND superseded images
|
||||||
# (a superseded row has cleared ML + no thumbnail).
|
# (a superseded row has cleared ML + no thumbnail).
|
||||||
if result.status in ("imported", "superseded"):
|
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
|
from .thumbnail import generate_thumbnail
|
||||||
|
|
||||||
|
do_embed = cpu_embed_enabled()
|
||||||
ids = list(result.member_image_ids)
|
ids = list(result.member_image_ids)
|
||||||
if result.image_id is not None and result.image_id not in ids:
|
if result.image_id is not None and result.image_id not in ids:
|
||||||
ids.append(result.image_id)
|
ids.append(result.image_id)
|
||||||
for img_id in ids:
|
for img_id in ids:
|
||||||
generate_thumbnail.delay(img_id)
|
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.
|
# If this was the last task in the batch, mark the batch complete.
|
||||||
remaining = session.execute(
|
remaining = session.execute(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from ..models import (
|
|||||||
ImportTask,
|
ImportTask,
|
||||||
LibraryAuditRun,
|
LibraryAuditRun,
|
||||||
Source,
|
Source,
|
||||||
TagEvalRun,
|
|
||||||
TaskRun,
|
TaskRun,
|
||||||
)
|
)
|
||||||
from ..utils.phash import compute_phash
|
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).
|
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
|
||||||
# 2h15m gives a 10-min buffer.
|
# 2h15m gives a 10-min buffer.
|
||||||
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
|
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 (#114) has a 60-min soft limit; flag no-progress past 75.
|
||||||
HEAD_TRAINING_STALL_THRESHOLD_MINUTES = 75
|
HEAD_TRAINING_STALL_THRESHOLD_MINUTES = 75
|
||||||
HEAD_TRAINING_KEEP_RUNS = 20
|
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
|
# task.time_limit + a small buffer. task_name overrides take precedence
|
||||||
# over queue overrides.
|
# 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
|
# import_archive_file: shares the 'import' queue with the fast
|
||||||
# single-file import_media_file, so it needs a task-name override
|
# single-file import_media_file, so it needs a task-name override
|
||||||
# (the import queue itself stays at the 5-min default for single
|
# (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,
|
"download": 30,
|
||||||
# Audit 2026-06-02 — maintenance/scan queues run tasks that
|
# Audit 2026-06-02 — maintenance/scan queues run tasks that
|
||||||
# legitimately exceed the 5-min default (verify_integrity at 70m
|
# legitimately exceed the 5-min default (verify_integrity at 70m
|
||||||
# hard, scan_directory at 70m hard, apply_allowlist_tags /
|
# hard, scan_directory at 70m hard, backfill_phash at 35m hard).
|
||||||
# recompute_centroids / backfill_phash at 35m hard). 75 min lives
|
# 75 min lives above the longest of those and the per-task
|
||||||
# above the longest of those and the per-task overrides below
|
# overrides below cover the outliers (backups, library audit).
|
||||||
# cover the outliers (backups, library audit).
|
|
||||||
"maintenance": 75,
|
"maintenance": 75,
|
||||||
"scan": 75,
|
"scan": 75,
|
||||||
}
|
}
|
||||||
@@ -582,6 +577,28 @@ def verify_integrity() -> int:
|
|||||||
return total
|
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")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_download_events")
|
||||||
def recover_stalled_download_events() -> int:
|
def recover_stalled_download_events() -> int:
|
||||||
"""Recover DownloadEvent rows stuck pending/running past the worker hard kill.
|
"""Recover DownloadEvent rows stuck pending/running past the worker hard kill.
|
||||||
@@ -721,46 +738,6 @@ def recover_stalled_library_audit_runs() -> int:
|
|||||||
return recovered
|
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")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
|
||||||
def recover_stalled_head_training_runs() -> int:
|
def recover_stalled_head_training_runs() -> int:
|
||||||
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
||||||
|
|||||||
+69
-445
@@ -1,20 +1,26 @@
|
|||||||
"""ML Celery tasks: per-image inference, backfill discovery, centroid
|
"""ML Celery tasks: per-image embedding, backfill discovery, head training,
|
||||||
recompute, allowlist auto-apply, model self-heal.
|
model self-heal.
|
||||||
|
|
||||||
All run on the ml-worker (queue 'ml') except recompute_centroids and
|
All run on the ml-worker (queue 'ml'), which under B3 (2026-07-02) is an
|
||||||
apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
|
OPTIONAL container: its only processing role is the CPU whole-image embed
|
||||||
(Celery workers are sync processes), same pattern as FC-2a tasks.
|
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
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from celery.exceptions import SoftTimeLimitExceeded
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
from ..celery_app import celery
|
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
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -27,8 +33,24 @@ def _is_video(path: Path) -> bool:
|
|||||||
return path.suffix.lower() in VIDEO_EXTS
|
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(
|
@celery.task(
|
||||||
name="backend.app.tasks.ml.tag_and_embed",
|
name="backend.app.tasks.ml.embed_image",
|
||||||
bind=True,
|
bind=True,
|
||||||
autoretry_for=(OperationalError, DBAPIError, OSError),
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||||
retry_backoff=5,
|
retry_backoff=5,
|
||||||
@@ -45,20 +67,25 @@ def _is_video(path: Path) -> bool:
|
|||||||
soft_time_limit=900, # 15 min
|
soft_time_limit=900, # 15 min
|
||||||
time_limit=1200, # 20 min hard
|
time_limit=1200, # 20 min hard
|
||||||
)
|
)
|
||||||
def tag_and_embed(self, image_id: int) -> dict:
|
def embed_image(self, image_id: int) -> dict:
|
||||||
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
"""Compute + store one image's whole-image SigLIP embedding — the CPU
|
||||||
then enqueue per-image allowlist application.
|
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 (#747): sample frames at a fixed cadence (ml_settings
|
||||||
video_frame_interval_seconds, capped at video_max_frames), keep a tag only if
|
video_frame_interval_seconds, capped at video_max_frames) and mean-pool the
|
||||||
it appears in >= video_min_tag_frames frames and average its confidence over
|
per-frame SigLIP embeddings — the same shape as the GPU agent's video
|
||||||
those frames (mean-pool, not max — kills one-frame noise); mean-pool the
|
handling. On no-frames returns status='no_frames' (not an error).
|
||||||
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..services.ml.embedder import get_embedder
|
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
|
# Phase + file context, so a timeout/crash names WHICH file and WHERE it
|
||||||
# died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
|
# 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"image_id={image_id} path={record.path} mime={record.mime} "
|
||||||
f"bytes={record.size_bytes} video={is_vid}"
|
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():
|
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}
|
return {"status": "file_missing", "image_id": image_id}
|
||||||
|
|
||||||
phase = "load_models"
|
phase = "load_models"
|
||||||
tagger = get_tagger()
|
embedder = get_embedder(settings.embedder_model_name)
|
||||||
embedder = get_embedder()
|
|
||||||
|
|
||||||
if is_vid:
|
if is_vid:
|
||||||
# Layer-3 isolation: ffprobe (a separate process) validates
|
# Layer-3 isolation: ffprobe (a separate process) validates
|
||||||
# the container before we burn ~20 GPU ops sampling frames
|
# the container before we burn GPU ops sampling frames from it.
|
||||||
# from it. A corrupt video that would crash the frame
|
# A corrupt video that would crash the frame decoder is rejected
|
||||||
# decoder is rejected cleanly here instead of taking down
|
# cleanly here instead of taking down the ml-worker.
|
||||||
# the ml-worker. Operator-flagged 2026-05-28.
|
|
||||||
phase = "video_probe"
|
phase = "video_probe"
|
||||||
from ..utils import safe_probe
|
from ..utils import safe_probe
|
||||||
vprobe = safe_probe.probe_video(src)
|
vprobe = safe_probe.probe_video(src)
|
||||||
if not vprobe.ok:
|
if not vprobe.ok:
|
||||||
log.warning(
|
log.warning(
|
||||||
"tag_and_embed bad video (%s): %s", vprobe.reason, ctx
|
"embed_image bad video (%s): %s", vprobe.reason, ctx
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "bad_video", "image_id": image_id,
|
"status": "bad_video", "image_id": image_id,
|
||||||
"reason": vprobe.reason,
|
"reason": vprobe.reason,
|
||||||
}
|
}
|
||||||
phase = "video_sample_frames"
|
phase = "video_sample_frames"
|
||||||
t0 = time.monotonic()
|
|
||||||
frames = _sample_video_frames(
|
frames = _sample_video_frames(
|
||||||
src,
|
src,
|
||||||
interval=settings.video_frame_interval_seconds,
|
interval=settings.video_frame_interval_seconds,
|
||||||
max_frames=settings.video_max_frames,
|
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:
|
if not frames:
|
||||||
return {"status": "no_frames", "image_id": image_id}
|
return {"status": "no_frames", "image_id": image_id}
|
||||||
phase = "video_infer"
|
phase = "video_embed"
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
preds = _aggregate_video_predictions(
|
# Mean-pool the per-frame SigLIP embeddings into one vector.
|
||||||
[tagger.infer(f, store_floor=settings.tagger_store_floor)
|
|
||||||
for f in frames],
|
|
||||||
min_frames=settings.video_min_tag_frames,
|
|
||||||
)
|
|
||||||
embedding = np.mean(
|
embedding = np.mean(
|
||||||
[embedder.infer(f) for f in frames], axis=0
|
[embedder.infer(f) for f in frames], axis=0
|
||||||
).astype("float32")
|
).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:
|
for f in frames:
|
||||||
f.unlink(missing_ok=True)
|
f.unlink(missing_ok=True)
|
||||||
else:
|
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"
|
phase = "embed"
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
embedding = embedder.infer(src)
|
embedding = embedder.infer(src)
|
||||||
log.info(
|
log.info(
|
||||||
"tag_and_embed embedded in %.1fs: %s",
|
"embed_image embedded in %.1fs: %s",
|
||||||
time.monotonic() - t0, ctx,
|
time.monotonic() - t0, ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
phase = "persist"
|
phase = "persist"
|
||||||
record.tagger_model_version = settings.tagger_model_version
|
|
||||||
record.siglip_embedding = embedding.tolist()
|
record.siglip_embedding = embedding.tolist()
|
||||||
record.siglip_model_version = settings.embedder_model_version
|
record.siglip_model_version = settings.embedder_model_version
|
||||||
session.add(record)
|
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()
|
session.commit()
|
||||||
except SoftTimeLimitExceeded:
|
except SoftTimeLimitExceeded:
|
||||||
log.error(
|
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,
|
_elapsed(), phase, ctx,
|
||||||
)
|
)
|
||||||
# Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in
|
# 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
|
# ORIGINAL so the type is preserved; just make sure it's logged with
|
||||||
# context first.
|
# context first.
|
||||||
log.exception(
|
log.exception(
|
||||||
"tag_and_embed FAILED in phase=%s after %.0fs: %s",
|
"embed_image FAILED in phase=%s after %.0fs: %s",
|
||||||
phase, _elapsed(), ctx,
|
phase, _elapsed(), ctx,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
log.info(
|
log.info("embed_image ok in %.1fs: %s", _elapsed(), ctx)
|
||||||
"tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx
|
return {"status": "ok", "image_id": image_id}
|
||||||
)
|
|
||||||
apply_allowlist_tags.delay(image_id=image_id)
|
|
||||||
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
|
||||||
|
|
||||||
|
|
||||||
def _sample_video_frames(
|
def _sample_video_frames(
|
||||||
@@ -273,318 +251,40 @@ def _sample_video_frames(
|
|||||||
return out
|
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)
|
@celery.task(name="backend.app.tasks.ml.backfill", bind=True)
|
||||||
def backfill(self) -> int:
|
def backfill(self) -> int:
|
||||||
"""Enqueue tag_and_embed for images missing predictions/embeddings for
|
"""Enqueue embed_image (embed-only) for images with no SigLIP embedding.
|
||||||
the current model versions. Keyset pagination by id ASC (restart-safe).
|
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()
|
SessionLocal = _sync_session_factory()
|
||||||
enqueued = 0
|
enqueued = 0
|
||||||
last_id = 0
|
last_id = 0
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
settings = session.execute(
|
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
while True:
|
while True:
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(ImageRecord.id)
|
select(ImageRecord.id)
|
||||||
.where(ImageRecord.id > last_id)
|
.where(ImageRecord.id > last_id)
|
||||||
.where(
|
.where(ImageRecord.siglip_embedding.is_(None))
|
||||||
(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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(ImageRecord.id.asc())
|
.order_by(ImageRecord.id.asc())
|
||||||
.limit(500)
|
.limit(500)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
if not rows:
|
if not rows:
|
||||||
break
|
break
|
||||||
for image_id in rows:
|
for image_id in rows:
|
||||||
tag_and_embed.delay(image_id)
|
embed_image.delay(image_id)
|
||||||
enqueued += 1
|
enqueued += 1
|
||||||
last_id = rows[-1]
|
last_id = rows[-1]
|
||||||
return enqueued
|
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(
|
@celery.task(
|
||||||
name="backend.app.tasks.ml.train_heads",
|
name="backend.app.tasks.ml.train_heads",
|
||||||
bind=True,
|
bind=True,
|
||||||
@@ -740,82 +440,6 @@ def scheduled_apply_head_tags() -> str:
|
|||||||
return "dispatched"
|
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(
|
@celery.task(
|
||||||
name="backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
name="backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||||
soft_time_limit=1800, time_limit=2100,
|
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
|
# create_app is a factory — the `()` tells hypercorn to call it once
|
||||||
# and serve the returned Quart (ASGI) app, rather than treating the
|
# and serve the returned Quart (ASGI) app, rather than treating the
|
||||||
# function itself as the application (which it then mis-invokes as WSGI).
|
# 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 \
|
exec hypercorn \
|
||||||
--bind 0.0.0.0:8080 \
|
--bind 0.0.0.0:8080 \
|
||||||
--workers "${HYPERCORN_WORKERS:-2}" \
|
--workers "${HYPERCORN_WORKERS:-4}" \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
"backend.app:create_app()"
|
"backend.app:create_app()"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
|
|
||||||
Usage: wrap a card's action body in the default slot; pass icon/title/blurb.
|
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
|
`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
|
(e.g. keep a running task's tile expanded). Manual expand/collapse persists per
|
||||||
real <button> with aria-expanded + focus ring.
|
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>
|
<template>
|
||||||
<v-card class="fc-tile" :class="{ 'fc-tile--open': isOpen }">
|
<v-card class="fc-tile" :class="{ 'fc-tile--open': isOpen }">
|
||||||
@@ -53,12 +55,21 @@ const props = defineProps({
|
|||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const local = ref(props.open)
|
// Only MANUAL toggles are saved (keyed by tile title): a forced `open` while a
|
||||||
watch(() => props.open, (v) => { local.value = v })
|
// 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)
|
const isOpen = computed(() => local.value)
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
local.value = !local.value
|
local.value = !local.value
|
||||||
|
try { localStorage.setItem(storeKey, local.value ? '1' : '0') } catch { /* non-fatal */ }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -142,9 +142,13 @@ const tagStore = useTagStore()
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
const router = useRouter()
|
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 = [
|
const SORTS = [
|
||||||
{ title: 'Newest first', value: 'newest' },
|
{ title: 'Newest post date', value: 'posted_new' },
|
||||||
{ title: 'Oldest first', value: 'oldest' },
|
{ title: 'Oldest post date', value: 'posted_old' },
|
||||||
|
{ title: 'Newest added', value: 'newest' },
|
||||||
|
{ title: 'Oldest added', value: 'oldest' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
@@ -175,7 +179,7 @@ const hasActiveFilters = computed(() =>
|
|||||||
store.filter.tag_or.length > 0 ||
|
store.filter.tag_or.length > 0 ||
|
||||||
store.filter.artist_id != null ||
|
store.filter.artist_id != null ||
|
||||||
store.filter.media_type != null ||
|
store.filter.media_type != null ||
|
||||||
store.filter.sort !== 'newest' ||
|
store.filter.sort !== 'posted_new' ||
|
||||||
store.filter.similar_to != null ||
|
store.filter.similar_to != null ||
|
||||||
hasRefineFilters.value
|
hasRefineFilters.value
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ const hasMenu = computed(() =>
|
|||||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
/* Green ✓ / red ✗ verdict pair — same circular language as the eval card
|
/* Green ✓ / red ✗ verdict pair — circular buttons so accept/reject read
|
||||||
(TagEvalCard .fc-act) so accept/reject read identically across surfaces. */
|
identically across surfaces. */
|
||||||
.fc-suggestion__acts {
|
.fc-suggestion__acts {
|
||||||
flex: 0 0 auto; display: flex; gap: 4px;
|
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"
|
icon="mdi-expansion-card"
|
||||||
title="GPU agent (CCIP + crops)"
|
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."
|
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">
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
The agent is a container you run on the machine with the GPU. It
|
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 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>
|
</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
|
<v-btn
|
||||||
class="mt-4" color="accent" variant="tonal" rounded="pill" size="small"
|
class="mt-4" color="accent" variant="tonal" rounded="pill" size="small"
|
||||||
prepend-icon="mdi-account-box-multiple" :loading="backfilling" @click="onBackfill"
|
prepend-icon="mdi-account-box-multiple" :loading="backfilling" @click="onBackfill"
|
||||||
@@ -71,6 +81,16 @@
|
|||||||
images get these automatically; this catches the back-catalogue.
|
images get these automatically; this catches the back-catalogue.
|
||||||
</p>
|
</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 -->
|
<!-- Match strictness -->
|
||||||
<div class="fc-section-h mt-5 mb-1">Character-match strictness</div>
|
<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">
|
<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
|
reversible) — so identity tags keep flowing without review. Stricter than
|
||||||
the suggest cut; 0.92 recommended.
|
the suggest cut; 0.92 recommended.
|
||||||
</p>
|
</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>
|
</MaintenanceTile>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,11 +173,17 @@ const masked = ref(true)
|
|||||||
const rotating = ref(false)
|
const rotating = ref(false)
|
||||||
const backfilling = ref(false)
|
const backfilling = ref(false)
|
||||||
const backfillingSiglip = ref(false)
|
const backfillingSiglip = ref(false)
|
||||||
|
const reprocessing = ref(false)
|
||||||
|
const retrying = ref(false)
|
||||||
const threshold = ref(0.85)
|
const threshold = ref(0.85)
|
||||||
const savingThreshold = ref(false)
|
const savingThreshold = ref(false)
|
||||||
const autoApply = ref(true)
|
const autoApply = ref(true)
|
||||||
const autoThreshold = ref(0.92)
|
const autoThreshold = ref(0.92)
|
||||||
const savingAuto = ref(false)
|
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 })
|
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
|
|
||||||
@@ -157,9 +210,50 @@ onMounted(async () => {
|
|||||||
autoApply.value = ml.settings.ccip_auto_apply_enabled
|
autoApply.value = ml.settings.ccip_auto_apply_enabled
|
||||||
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
|
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 */ }
|
} 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() {
|
async function onSaveAuto() {
|
||||||
savingAuto.value = true
|
savingAuto.value = true
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +333,37 @@ async function onBackfillSiglip() {
|
|||||||
backfillingSiglip.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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
|
<MaintenanceTile
|
||||||
icon="mdi-brain"
|
icon="mdi-brain"
|
||||||
title="Concept heads (the learning suggester)"
|
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."
|
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="headCount > 0 || running"
|
:open="running"
|
||||||
>
|
>
|
||||||
<p class="fc-muted text-body-2 mb-3">
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
A <strong>head</strong> is a tiny classifier trained on the SigLIP
|
A <strong>head</strong> is a tiny classifier trained on the SigLIP
|
||||||
|
|||||||
@@ -90,10 +90,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, watch } from 'vue'
|
import { onMounted, reactive, watch } from 'vue'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
|
||||||
const store = useImportStore()
|
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
|
// 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.
|
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
|
||||||
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
|
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>
|
<template>
|
||||||
<MaintenanceTile
|
<MaintenanceTile
|
||||||
icon="mdi-refresh"
|
icon="mdi-refresh"
|
||||||
title="ML backfill"
|
title="CPU embedding backfill"
|
||||||
blurb="Re-run tagging + embeddings on images missing them."
|
blurb="Whole-image embeddings without a GPU agent — the built-in fallback."
|
||||||
:open="busy"
|
:open="busy"
|
||||||
>
|
>
|
||||||
<p class="text-body-2 mb-3">
|
<p class="text-body-2 mb-3">
|
||||||
Re-run Camie + SigLIP on images missing predictions or embeddings
|
Computes the whole-image SigLIP embedding for anything missing one —
|
||||||
for the current model versions. Safe to re-run.
|
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>
|
</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-icon start>mdi-refresh</v-icon> Run backfill now
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||||
@@ -19,13 +37,40 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
import QueueStatusBar from './QueueStatusBar.vue'
|
import QueueStatusBar from './QueueStatusBar.vue'
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const done = 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() {
|
async function run() {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try { await store.triggerBackfill(); done.value = true }
|
try { await store.triggerBackfill(); done.value = true }
|
||||||
@@ -33,3 +78,7 @@ async function run() {
|
|||||||
finally { busy.value = false }
|
finally { busy.value = false }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,70 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<MaintenanceTile
|
<MaintenanceTile
|
||||||
icon="mdi-tune"
|
icon="mdi-filmstrip"
|
||||||
title="Suggestion thresholds"
|
title="Video embedding"
|
||||||
blurb="Confidence cutoffs that gate auto-suggested tags + video sampling."
|
blurb="How videos are sampled into frames before embedding."
|
||||||
>
|
>
|
||||||
<div v-if="store.settings">
|
<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">
|
<div class="text-caption fc-muted mb-3">
|
||||||
Videos are tagged by sampling frames at a fixed cadence. A tag is kept
|
Videos are embedded by sampling frames at a fixed cadence and mean-pooling
|
||||||
only if it shows up in enough frames (≈ that many × the interval in
|
their SigLIP embeddings. The interval sets the cadence; the cap bounds how
|
||||||
seconds of screen time), which filters one-frame noise without losing
|
many frames a long video samples.
|
||||||
tags that only appear in part of a longer video.
|
|
||||||
</div>
|
</div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" sm="4">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="local.video_frame_interval_seconds"
|
v-model.number="local.video_frame_interval_seconds"
|
||||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" hide-details @change="save"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" sm="4">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="local.video_max_frames"
|
v-model.number="local.video_max_frames"
|
||||||
label="Max frames" type="number" min="1" step="1"
|
label="Max frames" type="number" min="1" step="1"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" hide-details @change="save"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
<div v-else><v-skeleton-loader type="paragraph" /></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'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
const store = useMLStore()
|
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({})
|
const local = reactive({})
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
// Mirror the server invariant: keep the category thresholds at or above the
|
const patch = {
|
||||||
// store floor so a raised floor doesn't leave a threshold stranded below it.
|
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
||||||
const floor = local.tagger_store_floor
|
video_max_frames: local.video_max_frames
|
||||||
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
|
|
||||||
try { await store.patchSettings(patch) }
|
try { await store.patchSettings(patch) }
|
||||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-maint">
|
<div class="fc-maint">
|
||||||
<p class="fc-muted text-body-2 mb-5">
|
<p class="fc-muted text-body-2 mb-5">
|
||||||
One-off backfills, tagging config and storage tools. The ML backfill and
|
Processing, tagging and storage tools, grouped by system. Heads train
|
||||||
centroid recompute also run nightly; the allowlist auto-applies accepted
|
nightly and auto-apply earned tags. Click a tile to open it.
|
||||||
tags. Click a tile to open it.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="fc-section">
|
<section class="fc-section">
|
||||||
<h3 class="fc-section__title">Backfills & reprocessing</h3>
|
<h3 class="fc-section__title">Ingestion & filters</h3>
|
||||||
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
|
<p class="fc-section__hint">
|
||||||
<div class="fc-tile-grid">
|
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 />
|
<MLBackfillCard />
|
||||||
<CentroidRecomputeCard />
|
|
||||||
<ThumbnailBackfillCard />
|
|
||||||
<ArchiveReextractCard />
|
|
||||||
<MissingFileRepairCard />
|
|
||||||
<DbMaintenanceCard />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="fc-section">
|
<section class="fc-section">
|
||||||
<h3 class="fc-section__title">Tagging</h3>
|
<h3 class="fc-section__title">Tagging</h3>
|
||||||
<p class="fc-section__hint">
|
<p class="fc-section__hint">
|
||||||
Suggestion thresholds, the auto-apply allowlist and tag aliases.
|
Suggestion thresholds, trained heads and tag aliases.
|
||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
<MLThresholdSliders />
|
<MLThresholdSliders />
|
||||||
<HeadsCard />
|
<HeadsCard />
|
||||||
<GpuAgentCard />
|
|
||||||
<AllowlistTable />
|
|
||||||
<AliasTable />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -47,18 +67,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
import ImportFiltersForm from './ImportFiltersForm.vue'
|
||||||
import MLBackfillCard from './MLBackfillCard.vue'
|
import MLBackfillCard from './MLBackfillCard.vue'
|
||||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
|
||||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||||
|
import GpuTriageCard from './GpuTriageCard.vue'
|
||||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||||
import HeadsCard from './HeadsCard.vue'
|
import HeadsCard from './HeadsCard.vue'
|
||||||
import GpuAgentCard from './GpuAgentCard.vue'
|
import GpuAgentCard from './GpuAgentCard.vue'
|
||||||
import AllowlistTable from './AllowlistTable.vue'
|
|
||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
import TagEvalCard from './TagEvalCard.vue'
|
|
||||||
import BackupCard from './BackupCard.vue'
|
import BackupCard from './BackupCard.vue'
|
||||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</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 -->
|
<!-- Recent failures pane -->
|
||||||
<v-card class="mb-4">
|
<v-card class="mb-4">
|
||||||
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
|
<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 ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||||
import QueuesTable from './QueuesTable.vue'
|
import QueuesTable from './QueuesTable.vue'
|
||||||
import CardHeading from '../common/CardHeading.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
|
// Click-to-open modal for full error text. Replaces the unusable
|
||||||
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
// :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>
|
</div>
|
||||||
</MaintenanceTile>
|
</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
|
<MaintenanceTile
|
||||||
icon="mdi-format-letter-case"
|
icon="mdi-format-letter-case"
|
||||||
title="Standardize tag casing"
|
title="Standardize tag casing"
|
||||||
@@ -216,26 +119,6 @@ const {
|
|||||||
emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names || [] }),
|
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
|
// Standardize casing. The apply DISPATCHES a self-resuming background task (no
|
||||||
// poll-until-done — that would falsely report complete after the first chunk),
|
// 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
|
// so there's no emptyPreview: leave the projection up; a truthy normResult means
|
||||||
|
|||||||
@@ -52,14 +52,9 @@
|
|||||||
{{ String(store.error) }}
|
{{ String(store.error) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<FailingSourcesCard
|
<!-- The failing-sources rollup moved to the Subscriptions landing tab
|
||||||
:sources="store.failing"
|
(needs-attention strip, 2026-07-02); the maintenance menu above keeps
|
||||||
:retrying-ids="sourcesStore.checkingIds"
|
its bulk-retry via the same shared store action. -->
|
||||||
:retrying-all="retryingAll"
|
|
||||||
@retry="onRetrySource"
|
|
||||||
@retry-all="onRetryAll"
|
|
||||||
@view-logs="onViewFailingLogs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<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 { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||||
import { useSourcesStore } from '../../stores/sources.js'
|
|
||||||
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
||||||
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
||||||
import DownloadStatChips from './DownloadStatChips.vue'
|
import DownloadStatChips from './DownloadStatChips.vue'
|
||||||
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
||||||
import FailingSourcesCard from './FailingSourcesCard.vue'
|
|
||||||
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
||||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useDownloadsStore()
|
const store = useDownloadsStore()
|
||||||
const sourcesStore = useSourcesStore()
|
|
||||||
const filterModel = ref({ ...store.filter })
|
const filterModel = ref({ ...store.filter })
|
||||||
const retryingAll = ref(false)
|
|
||||||
|
|
||||||
// Free-text search (client-side over the loaded events) + a toggle to
|
// Free-text search (client-side over the loaded events) + a toggle to
|
||||||
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
|
// 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
|
// rollup + stats so the operator sees it move. Passes force=true so the
|
||||||
// platform cooldown is bypassed — single-source click is an explicit
|
// platform cooldown is bypassed — single-source click is an explicit
|
||||||
// operator override, useful for rapid auth-fix or fixture testing.
|
// operator override, useful for rapid auth-fix or fixture testing.
|
||||||
async function onRetrySource(source) {
|
// Bulk retry for the maintenance menu — the shared store action keeps the
|
||||||
try {
|
// cooldown semantics + tally shape identical to the needs-attention card on
|
||||||
await sourcesStore.checkNow(source.id, { force: true })
|
// the Subscriptions tab. Toast tallies the three outcomes so the operator
|
||||||
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
|
// can read whether cooldown is the dominant failure mode ("12 deferred
|
||||||
} catch (e) {
|
// (cooldown)" → yes, rate limit is the issue).
|
||||||
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).
|
|
||||||
async function onRetryAll(sources) {
|
async function onRetryAll(sources) {
|
||||||
retryingAll.value = true
|
const t = await store.retryAllFailing(sources)
|
||||||
let ok = 0
|
await store.loadFirst()
|
||||||
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 parts = []
|
const parts = []
|
||||||
if (ok) parts.push(`${ok} queued`)
|
if (t.ok) parts.push(`${t.ok} queued`)
|
||||||
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
|
if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
|
||||||
if (conflict) parts.push(`${conflict} already running`)
|
if (t.conflict) parts.push(`${t.conflict} already running`)
|
||||||
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +234,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
onUnmounted(stopPolling)
|
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
|
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||||
// these as proper query params later if a UX need shows up).
|
// these as proper query params later if a UX need shows up).
|
||||||
@@ -366,22 +334,6 @@ async function openDetail(id) {
|
|||||||
await store.loadOne(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -18,14 +18,6 @@
|
|||||||
subtitle="Mark stranded pending/running events as error (also runs every 5 min)"
|
subtitle="Mark stranded pending/running events as error (also runs every 5 min)"
|
||||||
@click="emit('recover-stalled')"
|
@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-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,7 +25,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep
|
// The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep
|
||||||
// handlers — same toast + refresh logic already used by the failing-sources
|
// handlers — same toast + refresh logic already used by the failing-sources
|
||||||
// RETRY ALL button. We just emit. Import-pipeline maintenance lives in
|
// RETRY ALL button. We just emit. (A permanently-disabled "Export failed
|
||||||
// Settings → Imports (ImportTaskList.vue), not here.
|
// logs" stub sat here until 2026-07-02 — retired; the event list + detail
|
||||||
|
// modal cover forensics.)
|
||||||
const emit = defineEmits(['retry-failed', 'recover-stalled'])
|
const emit = defineEmits(['retry-failed', 'recover-stalled'])
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div>
|
<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" />
|
<ExtensionKeyBar class="mb-4" />
|
||||||
|
|
||||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||||
{{ String(credentialsStore.error) }}
|
{{ String(credentialsStore.error) }}
|
||||||
</v-alert>
|
</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-row>
|
||||||
<v-col
|
<v-col
|
||||||
v-for="p in platformsStore.list"
|
v-for="p in platformsStore.list"
|
||||||
@@ -23,7 +31,8 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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 variant="outlined">
|
||||||
<v-card-text v-if="importStore.settings">
|
<v-card-text v-if="importStore.settings">
|
||||||
<v-row>
|
<v-row>
|
||||||
@@ -52,7 +61,8 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</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 variant="outlined">
|
||||||
<v-card-text v-if="importStore.settings">
|
<v-card-text v-if="importStore.settings">
|
||||||
<div class="fc-help mb-2">
|
<div class="fc-help mb-2">
|
||||||
@@ -98,7 +108,8 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</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 variant="outlined">
|
||||||
<v-card-text v-if="importStore.settings">
|
<v-card-text v-if="importStore.settings">
|
||||||
<v-row>
|
<v-row>
|
||||||
@@ -179,6 +190,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import BrowserExtensionCard from '../settings/BrowserExtensionCard.vue'
|
||||||
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
|
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
|
||||||
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
|
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
|
||||||
import CredentialCard from './CredentialCard.vue'
|
import CredentialCard from './CredentialCard.vue'
|
||||||
@@ -240,6 +252,21 @@ async function saveDownloader() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.fc-help {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
<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">
|
<div class="fc-subs__bar">
|
||||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||||
Add subscription
|
Add subscription
|
||||||
@@ -319,6 +325,8 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useSourcesStore } from '../../stores/sources.js'
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
||||||
|
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
||||||
import SourceRow from './SourceRow.vue'
|
import SourceRow from './SourceRow.vue'
|
||||||
import SourceCard from './SourceCard.vue'
|
import SourceCard from './SourceCard.vue'
|
||||||
import SourceHealthDot from './SourceHealthDot.vue'
|
import SourceHealthDot from './SourceHealthDot.vue'
|
||||||
|
|||||||
@@ -101,12 +101,10 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function purgeLegacyTags(opts = {}) {
|
// Destructive whole-instance reset: deletes ALL general + character tags AND
|
||||||
return _dryRunPost('/api/admin/tags/purge-legacy', opts)
|
// 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.
|
||||||
// Destructive: deletes ALL general + character tags so the operator can
|
|
||||||
// re-tag from scratch via auto-suggest. fandom + series preserved.
|
|
||||||
function resetContentTagging(opts = {}) {
|
function resetContentTagging(opts = {}) {
|
||||||
return _dryRunPost('/api/admin/tags/reset-content', opts)
|
return _dryRunPost('/api/admin/tags/reset-content', opts)
|
||||||
}
|
}
|
||||||
@@ -154,7 +152,6 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
pruneUnusedTags,
|
pruneUnusedTags,
|
||||||
pruneBarePosts,
|
pruneBarePosts,
|
||||||
reconcileDuplicatePosts,
|
reconcileDuplicatePosts,
|
||||||
purgeLegacyTags,
|
|
||||||
resetContentTagging,
|
resetContentTagging,
|
||||||
normalizeTags,
|
normalizeTags,
|
||||||
pollTaskUntilDone,
|
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 { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
import { useSourcesStore } from './sources.js'
|
||||||
|
|
||||||
export const useDownloadsStore = defineStore('downloads', () => {
|
export const useDownloadsStore = defineStore('downloads', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -106,6 +107,44 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return failing.value
|
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() {
|
async function loadActive() {
|
||||||
const [running, pending] = await Promise.all([
|
const [running, pending] = await Promise.all([
|
||||||
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
||||||
@@ -128,5 +167,6 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||||
closeDetail, loadStats,
|
closeDetail, loadStats,
|
||||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
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"
|
// 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.
|
// report). Fall back to the full set only if every neighbour's been seen.
|
||||||
const seen = new Set(breadcrumb.value.map((c) => c.id))
|
const seen = new Set(breadcrumb.value.map((c) => c.id))
|
||||||
let pool = neighbors.value.filter((n) => !seen.has(n.id))
|
const pool = neighbors.value.filter((n) => !seen.has(n.id))
|
||||||
if (!pool.length) pool = neighbors.value
|
const cands = pool.length ? pool : neighbors.value
|
||||||
// neighbors come similarity-sorted (nearest first). Skip the closest slice —
|
// The list is already pHash-deduped + MMR-diversified server-side (it spans
|
||||||
// those near-duplicates are exactly what you get stuck cycling through — and
|
// clusters, not 40 near-dupes), so a plain random pick gives real variance —
|
||||||
// pick from the more-varied remainder, for real variance in the walk.
|
// no need to skip the nearest slice the way the raw nearest-list required.
|
||||||
const skip = pool.length >= 6 ? Math.floor(pool.length / 3) : 0
|
|
||||||
const cands = pool.slice(skip)
|
|
||||||
return cands[Math.floor(Math.random() * cands.length)].id
|
return cands[Math.floor(Math.random() * cands.length)].id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const filter = ref({
|
const filter = ref({
|
||||||
tag_ids: [], artist_id: null, media_type: null,
|
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
|
// #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
|
// "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.
|
// 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.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.artist_id) p.artist_id = filter.value.artist_id
|
||||||
if (filter.value.media_type) p.media = filter.value.media_type
|
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.platform) p.platform = filter.value.platform
|
||||||
if (filter.value.untagged) p.untagged = '1'
|
if (filter.value.untagged) p.untagged = '1'
|
||||||
if (filter.value.no_artist) p.no_artist = '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),
|
tag_exclude: _parseIds(q.tag_not),
|
||||||
artist_id: _toId(q.artist_id),
|
artist_id: _toId(q.artist_id),
|
||||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
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),
|
post_id: _toId(q.post_id),
|
||||||
platform: q.platform || null,
|
platform: q.platform || null,
|
||||||
untagged: _truthy(q.untagged),
|
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.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
|
||||||
if (f.artist_id) q.artist_id = String(f.artist_id)
|
if (f.artist_id) q.artist_id = String(f.artist_id)
|
||||||
if (f.media_type) q.media = f.media_type
|
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.platform) q.platform = f.platform
|
||||||
if (f.untagged) q.untagged = '1'
|
if (f.untagged) q.untagged = '1'
|
||||||
if (f.no_artist) q.no_artist = '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