Compare commits
42
Commits
d45ce5e426
...
ext-1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8300029741 | ||
|
|
f9111c06a7 | ||
|
|
c37a180c3c | ||
|
|
8214afee1e | ||
|
|
306de50f61 | ||
|
|
b5b437ca80 | ||
|
|
57e52433d0 | ||
|
|
ce0dac3524 | ||
|
|
ec66ea5f83 | ||
|
|
e92570a31e | ||
|
|
a2d1ed935d | ||
|
|
05df51b749 | ||
|
|
099e1e664c | ||
|
|
c87f8a1bb3 | ||
|
|
666b3a2ec8 | ||
|
|
9b5ec86222 | ||
|
|
d80a5255ed | ||
|
|
89c83ee5de | ||
|
|
69b5637bd6 | ||
|
|
d3192f1843 | ||
|
|
51749e05db | ||
|
|
5a5694f200 | ||
|
|
50d6c42207 | ||
|
|
bb1a938cc0 | ||
|
|
67c7ca8603 | ||
|
|
fc0293029d | ||
|
|
eed42a260a | ||
|
|
61b14e8f65 | ||
|
|
7d1c701b67 | ||
|
|
447bf73519 | ||
|
|
b638382cd5 | ||
|
|
6104452d2e | ||
|
|
b59828635e | ||
|
|
17903068b4 | ||
|
|
fac5ae6ce5 | ||
|
|
af0d39ed52 | ||
|
|
d9a14e890d | ||
|
|
ad2a5fc5fe | ||
|
|
31d400ab0a | ||
|
|
0da0e47784 | ||
|
|
503c8854bc | ||
|
|
571938781a |
@@ -2,6 +2,7 @@ name: CI
|
|||||||
|
|
||||||
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
||||||
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
||||||
|
# - extension-version: guards the extension publish path (see the job).
|
||||||
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
||||||
# - frontend-build: vitest unit + vite build.
|
# - frontend-build: vitest unit + vite build.
|
||||||
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
||||||
@@ -41,6 +42,121 @@ jobs:
|
|||||||
# catching syntax errors before the image build.
|
# catching syntax errors before the image build.
|
||||||
run: python -m compileall -q agent/fc_agent
|
run: python -m compileall -q agent/fc_agent
|
||||||
|
|
||||||
|
# Guards the extension publish path, which has no self-correcting behavior.
|
||||||
|
#
|
||||||
|
# build.yml's sign-extension job keys its AMO-signing cache purely on the
|
||||||
|
# version string in extension/package.json: if an `ext-<version>` Forgejo
|
||||||
|
# release already carries an XPI, signing is SKIPPED and that old signed XPI
|
||||||
|
# is what build-web bakes into `:latest`. Nothing in that path inspects
|
||||||
|
# whether extension/ actually changed — so a forgotten version bump ships a
|
||||||
|
# stale extension on a fully green build, silently. (AMO can't help: it 409s
|
||||||
|
# on re-signing a version, which is exactly why the cache exists.)
|
||||||
|
#
|
||||||
|
# This job makes that case loud, on the dev push, instead of invisible at
|
||||||
|
# merge-to-main. It is pure git + text work — no deps, no services.
|
||||||
|
extension-version:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history: the check diffs against the push's `before` SHA (or
|
||||||
|
# the PR base), which a depth-1 clone wouldn't contain.
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Extension version guard
|
||||||
|
env:
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
|
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# busybox sh on the act_runner — no bashisms (family rule).
|
||||||
|
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; }
|
||||||
|
PKG=$(ver extension/package.json)
|
||||||
|
MAN=$(ver extension/manifest.json)
|
||||||
|
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; }
|
||||||
|
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
|
||||||
|
|
||||||
|
# (1) Unconditional: the two version strings must agree. `web-ext sign`
|
||||||
|
# reads manifest.json (package.json sits in --ignore-files and isn't
|
||||||
|
# even inside the XPI), so AMO signs MAN and Firefox installs MAN.
|
||||||
|
# build.yml keys its cache, release tag, XPI filename — and therefore
|
||||||
|
# the version /api/extension/manifest reports to the update prompt —
|
||||||
|
# on PKG. Divergence either hard-fails at AMO or ships a mislabelled
|
||||||
|
# XPI whose update prompt lies about what's installed.
|
||||||
|
if [ "$MAN" != "$PKG" ]; then
|
||||||
|
echo "ERROR: extension version mismatch."
|
||||||
|
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs"
|
||||||
|
echo " extension/package.json = $PKG <- what CI caches, names, and reports"
|
||||||
|
echo "Set both to the same value."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (2) If the SHIPPED extension changed, the version must have moved.
|
||||||
|
#
|
||||||
|
# Compare against MAIN, not against the previous push. The publish
|
||||||
|
# decision is made at merge-to-main against whatever ext-<version>
|
||||||
|
# already exists, so "differs from main" is the question that matters.
|
||||||
|
# Diffing against the previous dev push instead would demand a fresh
|
||||||
|
# bump on every iteration — push, tweak the extension again, and CI
|
||||||
|
# would insist on a second bump that buys nothing, inflating the
|
||||||
|
# version for no reason. On a main push there is no "main to compare
|
||||||
|
# to" yet, so fall back to that push's own before-SHA.
|
||||||
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
|
BASE="${BEFORE:-}"
|
||||||
|
else
|
||||||
|
BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "")
|
||||||
|
# PR base is the fallback when main isn't in the clone at all.
|
||||||
|
[ -n "$BASE" ] || BASE="${PR_BASE:-}"
|
||||||
|
fi
|
||||||
|
case "$BASE" in
|
||||||
|
''|0000000000000000000000000000000000000000)
|
||||||
|
echo "No usable base ref (no main in clone / first push) — skipping the bump check."
|
||||||
|
echo "OK: extension version $PKG"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
|
||||||
|
echo "Base commit $BASE not in this clone — skipping the bump check."
|
||||||
|
echo "OK: extension version $PKG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Exclusions mirror --ignore-files in extension/package.json's web-ext
|
||||||
|
# scripts: these files are not packaged into the XPI, so touching them
|
||||||
|
# (e.g. Renovate bumping the web-ext devDep, or editing a spec)
|
||||||
|
# changes nothing shipped and must not demand a version bump.
|
||||||
|
# KEEP IN SYNC with --ignore-files — a file packaged into the XPI but
|
||||||
|
# excluded here is exactly the silent-stale-ship this job exists to
|
||||||
|
# prevent. test/version.spec.js pins the two lists' shared intent.
|
||||||
|
CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ \
|
||||||
|
':(exclude)extension/package.json' \
|
||||||
|
':(exclude)extension/package-lock.json' \
|
||||||
|
':(exclude)extension/README.md' \
|
||||||
|
':(exclude)extension/.gitignore' \
|
||||||
|
':(exclude)extension/vitest.config.js' \
|
||||||
|
':(exclude)extension/test/**')
|
||||||
|
if [ -z "$CHANGED" ]; then
|
||||||
|
echo "No packaged extension files changed since $BASE — nothing to guard."
|
||||||
|
echo "OK: extension version $PKG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Packaged extension files changed since $BASE:"
|
||||||
|
echo "$CHANGED" | sed 's/^/ /'
|
||||||
|
PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
if [ -z "$PKG_OLD" ]; then
|
||||||
|
echo "Could not read the base version — skipping the bump check."
|
||||||
|
echo "OK: extension version $PKG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$PKG_OLD" = "$PKG" ]; then
|
||||||
|
echo "ERROR: packaged extension files changed but the version is still $PKG."
|
||||||
|
echo "build.yml would find the existing ext-$PKG release, skip AMO signing,"
|
||||||
|
echo "and bake the OLD signed XPI into :latest — a green build shipping stale code."
|
||||||
|
echo "Bump the version in BOTH extension/package.json and extension/manifest.json."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: extension version $PKG_OLD -> $PKG"
|
||||||
|
|
||||||
backend-lint-and-test:
|
backend-lint-and-test:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: extension
|
name: extension
|
||||||
# Lint-only workflow. The sign-and-publish dance moved into build.yml's
|
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
|
||||||
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
||||||
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
||||||
# eliminating the prior race between build.yml and a separate extension.yml.
|
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||||
@@ -10,10 +10,15 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'extension/**'
|
- 'extension/**'
|
||||||
- '.forgejo/workflows/extension.yml'
|
- '.forgejo/workflows/extension.yml'
|
||||||
|
# test/version.spec.js asserts ci.yml's extension-version guard never
|
||||||
|
# ignores a file web-ext actually packages, so a ci.yml-only edit can
|
||||||
|
# break this suite and must trigger it.
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'extension/**'
|
- 'extension/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -23,7 +28,13 @@ jobs:
|
|||||||
image: node:24-bookworm-slim
|
image: node:24-bookworm-slim
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install web-ext
|
# Not --no-save: vitest and web-ext are both real devDependencies now,
|
||||||
run: cd extension && npm install --no-save --no-audit --no-fund
|
# and the suite needs vitest resolvable from node_modules.
|
||||||
|
- name: Install dev dependencies
|
||||||
|
run: cd extension && npm install --no-audit --no-fund
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: cd extension && npm run lint
|
run: cd extension && npm run lint
|
||||||
|
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
|
||||||
|
# package version-consistency checks. No browser, no network.
|
||||||
|
- name: Unit tests
|
||||||
|
run: cd extension && npm run test:unit
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ log = logging.getLogger("fc_agent.app")
|
|||||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
# 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
|
# 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.)
|
# 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"
|
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
|
||||||
|
|
||||||
logbuf.install()
|
logbuf.install()
|
||||||
cfg = Config.from_env()
|
cfg = Config.from_env()
|
||||||
@@ -334,9 +334,12 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
|||||||
waited.textContent=s.transient||0
|
waited.textContent=s.transient||0
|
||||||
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
|
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
|
||||||
// as live churn rather than a "broken" headline metric.
|
// as live churn rather than a "broken" headline metric.
|
||||||
|
// '=== false' (not falsy) so a stale page that doesn't send models_loaded shows
|
||||||
|
// nothing; when the idle monitor unloads, the VRAM meter drops alongside this.
|
||||||
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'—')+' · consumers '+(s.consumers!=null?s.consumers:'—')+' · on GPU '+(s.active||0)
|
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'
|
+' · 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):'')
|
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
|
||||||
|
+(s.models_loaded===false?' · GPU models unloaded (idle — reload on next job)':'')
|
||||||
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=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).
|
// 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)
|
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ class Config:
|
|||||||
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
|
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
|
||||||
# all downloaders + video streams (0 = unlimited);
|
# all downloaders + video streams (0 = unlimited);
|
||||||
# tunable live from the agent UI
|
# tunable live from the agent UI
|
||||||
|
idle_unload_seconds: float # after this long with the GPU idle (nothing in
|
||||||
|
# flight, queue empty or Stopped), unload the
|
||||||
|
# SigLIP embedder + YOLO proposers to free their
|
||||||
|
# VRAM; they reload lazily on the next job. A
|
||||||
|
# 24/7 agent otherwise squats on ~5GB doing
|
||||||
|
# nothing. 0 disables (keep models warm forever).
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> Config:
|
def from_env(cls) -> Config:
|
||||||
@@ -87,4 +93,8 @@ class Config:
|
|||||||
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
|
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
|
||||||
# from the agent UI on wired/faster networks.
|
# from the agent UI on wired/faster networks.
|
||||||
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
|
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
|
||||||
|
# 5 min: long enough that a lull between job bursts doesn't thrash the
|
||||||
|
# (few-second) reload, short enough that an agent left running with an
|
||||||
|
# empty queue hands its VRAM back promptly.
|
||||||
|
idle_unload_seconds=float(os.environ.get("IDLE_UNLOAD_SECONDS", "300")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ class YoloProposer:
|
|||||||
))
|
))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Drop the loaded YOLO so its VRAM can be reclaimed; detect() reloads it
|
||||||
|
lazily on the next job. Leaves _ok untouched — a healthy proposer comes
|
||||||
|
back, but one that self-disabled on a fault stays off."""
|
||||||
|
with self._lock:
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
|
||||||
class Proposers:
|
class Proposers:
|
||||||
"""The agent's proposer set, built from config. Each detector is optional —
|
"""The agent's proposer set, built from config. Each detector is optional —
|
||||||
@@ -216,3 +223,11 @@ class Proposers:
|
|||||||
|
|
||||||
def panels(self, image):
|
def panels(self, image):
|
||||||
return self._top(self._panel, image, self.cfg.max_panels)
|
return self._top(self._panel, image, self.cfg.max_panels)
|
||||||
|
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Release every loaded proposer's YOLO (idle VRAM reclaim). The worker
|
||||||
|
also drops its reference to this Proposers and rebuilds a fresh one via
|
||||||
|
_proposers_for on the next job, so this is belt-and-braces."""
|
||||||
|
for p in (self._person, self._anatomy, self._panel):
|
||||||
|
if p is not None:
|
||||||
|
p.unload()
|
||||||
|
|||||||
@@ -75,3 +75,18 @@ class CropEmbedder:
|
|||||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
||||||
arr = pooled.float().cpu().numpy().astype(np.float32)
|
arr = pooled.float().cpu().numpy().astype(np.float32)
|
||||||
return [row.reshape(-1).tolist() for row in arr]
|
return [row.reshape(-1).tolist() for row in arr]
|
||||||
|
|
||||||
|
def unload(self) -> bool:
|
||||||
|
"""Drop the loaded model so its VRAM can be reclaimed — the idle monitor
|
||||||
|
calls this after a spell with no work so an idle agent doesn't squat on
|
||||||
|
the card; the next embed() reloads it lazily (a few seconds). Held under
|
||||||
|
BOTH the load and inference locks so it can never race a concurrent load
|
||||||
|
or an in-flight forward pass. Returns True if a model was actually
|
||||||
|
released (the caller then runs one empty_cache() to hand the freed blocks
|
||||||
|
back to the driver)."""
|
||||||
|
with self._load_lock, self._infer_lock:
|
||||||
|
if self._model is None:
|
||||||
|
return False
|
||||||
|
self._model = None
|
||||||
|
self._processor = None
|
||||||
|
return True
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ MAX_BACKOFF_SECONDS = 60.0
|
|||||||
# up on their own.
|
# up on their own.
|
||||||
IDLE_POLL_MAX_SECONDS = 900.0
|
IDLE_POLL_MAX_SECONDS = 900.0
|
||||||
|
|
||||||
|
# Idle VRAM reclaim (operator 2026-07-17): the SigLIP embedder + YOLO proposers
|
||||||
|
# load lazily and then stay warm for fast job bursts — but a 24/7 agent with an
|
||||||
|
# empty queue would otherwise squat on that VRAM (~5GB on the operator's card)
|
||||||
|
# indefinitely while doing nothing. So a monitor unloads them after
|
||||||
|
# cfg.idle_unload_seconds with the GPU genuinely idle (nothing in flight, buffer
|
||||||
|
# drained); they reload lazily on the next job. This is just how often the
|
||||||
|
# monitor wakes to check — it bounds how soon past the threshold the unload fires.
|
||||||
|
IDLE_UNLOAD_CHECK_INTERVAL = 30.0
|
||||||
|
|
||||||
# A job whose fetch dies transiently this many times IN ONE SESSION stops being
|
# A job whose fetch dies transiently this many times IN ONE SESSION stops being
|
||||||
# handed back and is failed instead. Transient handbacks (release) burn no
|
# handed back and is failed instead. Transient handbacks (release) burn no
|
||||||
# attempts on the server, so a poisoned transfer — an original that stalls the
|
# attempts on the server, so a poisoned transfer — an original that stalls the
|
||||||
@@ -268,6 +277,11 @@ class Worker:
|
|||||||
self._proposers_sig = None # detector-config signature the current
|
self._proposers_sig = None # detector-config signature the current
|
||||||
# proposers were built for (#134)
|
# proposers were built for (#134)
|
||||||
self._proposers_lock = threading.Lock()
|
self._proposers_lock = threading.Lock()
|
||||||
|
# Monotonic time of the last GPU activity (a consumer finishing a job).
|
||||||
|
# The idle monitor unloads the warm models once this goes stale by
|
||||||
|
# cfg.idle_unload_seconds — see _idle_unload_loop.
|
||||||
|
self._last_gpu_activity = time.monotonic()
|
||||||
|
threading.Thread(target=self._idle_unload_loop, daemon=True).start()
|
||||||
|
|
||||||
# --- held-lease bookkeeping --------------------------------------------
|
# --- held-lease bookkeeping --------------------------------------------
|
||||||
def _hold(self, job_ids) -> None:
|
def _hold(self, job_ids) -> None:
|
||||||
@@ -608,6 +622,9 @@ class Worker:
|
|||||||
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
|
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
|
||||||
"bw_capped": self._bw_capped, # autoscaler holding at the cap (UI hint)
|
"bw_capped": self._bw_capped, # autoscaler holding at the cap (UI hint)
|
||||||
"idle": self._idle, # queue empty → poll backed off (UI hint)
|
"idle": self._idle, # queue empty → poll backed off (UI hint)
|
||||||
|
# Whether the GPU models are currently resident (False after an idle
|
||||||
|
# unload freed their VRAM) — a plain bool read, UI hint only.
|
||||||
|
"models_loaded": self._embedder is not None or self._proposers is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
|
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
|
||||||
@@ -788,6 +805,9 @@ class Worker:
|
|||||||
self._bump(processed=1)
|
self._bump(processed=1)
|
||||||
finally:
|
finally:
|
||||||
self._bump(active=-1)
|
self._bump(active=-1)
|
||||||
|
# Mark the GPU busy-until-now so the idle monitor starts its
|
||||||
|
# unload countdown from when work actually stopped, not before.
|
||||||
|
self._last_gpu_activity = time.monotonic()
|
||||||
|
|
||||||
def _ensure_embedder(self, model_name: str):
|
def _ensure_embedder(self, model_name: str):
|
||||||
if self._embedder is not None:
|
if self._embedder is not None:
|
||||||
@@ -845,6 +865,61 @@ class Worker:
|
|||||||
self._proposers_sig = sig
|
self._proposers_sig = sig
|
||||||
return self._proposers
|
return self._proposers
|
||||||
|
|
||||||
|
def _unload_models(self) -> bool:
|
||||||
|
"""Release the GPU-resident models (SigLIP embedder + YOLO proposers) so an
|
||||||
|
idle agent hands their VRAM back instead of squatting on the card. They
|
||||||
|
reload lazily on the next job (_ensure_embedder / _proposers_for) — a
|
||||||
|
few seconds' cost paid only when work actually resumes. Dropping the
|
||||||
|
shared instances under their build locks means a concurrent job either
|
||||||
|
sees the old instance (before) or rebuilds a fresh one (after); the idle
|
||||||
|
monitor only calls this with nothing in flight, so no inference is using
|
||||||
|
them. Returns True if anything was released."""
|
||||||
|
released = False
|
||||||
|
with self._embedder_lock:
|
||||||
|
if self._embedder is not None:
|
||||||
|
self._embedder.unload()
|
||||||
|
self._embedder = None
|
||||||
|
released = True
|
||||||
|
with self._proposers_lock:
|
||||||
|
if self._proposers is not None:
|
||||||
|
self._proposers.unload()
|
||||||
|
self._proposers = None
|
||||||
|
self._proposers_sig = None
|
||||||
|
released = True
|
||||||
|
if released:
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
# torch's caching allocator holds freed blocks; hand them back
|
||||||
|
# to the driver so nvidia-smi actually reflects the drop.
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception: # noqa: BLE001 — torch absent / CPU-only → nothing to free
|
||||||
|
pass
|
||||||
|
return released
|
||||||
|
|
||||||
|
def _idle_unload_loop(self) -> None:
|
||||||
|
"""Unload the warm GPU models after a stretch of inactivity so a 24/7
|
||||||
|
agent with an empty queue doesn't hold ~5GB of VRAM doing nothing. Fires
|
||||||
|
only when nothing is in flight (active == 0 AND the buffer is drained) and
|
||||||
|
no job has completed for cfg.idle_unload_seconds — a window long enough
|
||||||
|
that a brief lull between bursts doesn't thrash reload/unload. Covers BOTH
|
||||||
|
sleep mode (queue empty, pipeline still running) and a full Stop; the
|
||||||
|
models reload lazily on the next job. idle_unload_seconds <= 0 disables it."""
|
||||||
|
idle_after = self.cfg.idle_unload_seconds
|
||||||
|
if idle_after <= 0:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
time.sleep(IDLE_UNLOAD_CHECK_INTERVAL)
|
||||||
|
if self._embedder is None and self._proposers is None:
|
||||||
|
continue # nothing loaded → nothing to free
|
||||||
|
if self._active != 0 or not self._buffer.empty():
|
||||||
|
continue # work in flight → keep them warm
|
||||||
|
if time.monotonic() - self._last_gpu_activity < idle_after:
|
||||||
|
continue # not idle long enough yet
|
||||||
|
if self._unload_models():
|
||||||
|
log.info("idle %.0fs — unloaded GPU models, freed VRAM "
|
||||||
|
"(reload on next job)", idle_after)
|
||||||
|
|
||||||
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
||||||
"""Detect + embed the decoded frames and submit the result. Returns True
|
"""Detect + embed the decoded frames and submit the result. Returns True
|
||||||
when the job was completed (→ count it processed), False otherwise: a
|
when the job was completed (→ count it processed), False otherwise: a
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""title-based WIP auto-tagging (task #1458) — ImportSettings toggle
|
||||||
|
|
||||||
|
ImportSettings gains wip_title_tagging_enabled (ON by default): when a freshly
|
||||||
|
imported post's title explicitly declares work-in-progress ("WIP" / "work in
|
||||||
|
progress"), the importer applies the `wip` system tag to its images. No new
|
||||||
|
table — the tag itself is the seeded `wip` system tag (migration 0075) and the
|
||||||
|
application reuses image_tag with source='wip_title'.
|
||||||
|
|
||||||
|
Revision ID: 0085
|
||||||
|
Revises: 0084
|
||||||
|
Create Date: 2026-07-12
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0085"
|
||||||
|
down_revision: Union[str, None] = "0084"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"wip_title_tagging_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("true"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_settings", "wip_title_tagging_enabled")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""process auto-apply settings + review mode (#1464) — system-tag refactor
|
||||||
|
|
||||||
|
The system-tag behavior refactor gives `wip` / `editor screenshot` (the PROCESS
|
||||||
|
group) their own provisional auto-apply, parallel to the presentation (chrome)
|
||||||
|
sweep. MLSettings gains three knobs: enabled (OFF by default — a new whole-library
|
||||||
|
auto-tagger is opt-in), the flat apply threshold, and the ring-loud conflict
|
||||||
|
threshold. presentation_review gains a `mode` column so one review surface serves
|
||||||
|
both chrome and process flags (existing rows backfill 'chrome'). server_defaults
|
||||||
|
so the existing rows fill cleanly.
|
||||||
|
|
||||||
|
Revision ID: 0086
|
||||||
|
Revises: 0085
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0086"
|
||||||
|
down_revision: Union[str, None] = "0085"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"process_auto_apply_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"process_auto_apply_threshold", sa.Float(), nullable=False,
|
||||||
|
server_default="0.90",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"process_conflict_threshold", sa.Float(), nullable=False,
|
||||||
|
server_default="0.50",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"presentation_review",
|
||||||
|
sa.Column(
|
||||||
|
"mode", sa.String(16), nullable=False,
|
||||||
|
server_default="chrome",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("presentation_review", "mode")
|
||||||
|
op.drop_column("ml_settings", "process_conflict_threshold")
|
||||||
|
op.drop_column("ml_settings", "process_auto_apply_threshold")
|
||||||
|
op.drop_column("ml_settings", "process_auto_apply_enabled")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""soft WIP title tier toggle (#1474) — ImportSettings.wip_soft_title_tagging_enabled
|
||||||
|
|
||||||
|
The soft tier also tags sketch/doodle/scribble titles, but with a provisional source
|
||||||
|
that never trains the head. OFF by default (a lower-precision tier is opt-in).
|
||||||
|
server_default so the existing singleton row (id=1) fills cleanly.
|
||||||
|
|
||||||
|
Revision ID: 0087
|
||||||
|
Revises: 0086
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0087"
|
||||||
|
down_revision: Union[str, None] = "0086"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"wip_soft_title_tagging_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
|
||||||
@@ -148,6 +148,17 @@ async def similar():
|
|||||||
# Explore passes exclude_wip=1 to also drop work-in-progress from the
|
# Explore passes exclude_wip=1 to also drop work-in-progress from the
|
||||||
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
|
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
|
||||||
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
|
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
|
||||||
|
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
|
||||||
|
# distance bands so the walk can escape a dense cluster. exclude_ids = the
|
||||||
|
# breadcrumb, so already-walked images aren't re-served as neighbours.
|
||||||
|
try:
|
||||||
|
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
|
||||||
|
except ValueError:
|
||||||
|
reach = 0.0
|
||||||
|
exclude_ids = [
|
||||||
|
int(x) for x in request.args.get("exclude_ids", "").split(",")
|
||||||
|
if x.strip().isdigit()
|
||||||
|
] or None
|
||||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||||
@@ -158,7 +169,8 @@ async def similar():
|
|||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
images = await svc.similar(
|
images = await svc.similar(
|
||||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
|
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
|
||||||
|
reach=reach, exclude_ids=exclude_ids, **scope)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
if images is None:
|
if images is None:
|
||||||
@@ -236,8 +248,10 @@ async def jump():
|
|||||||
# content", surfaced in the gallery's Show-hidden review strip. -----------
|
# content", surfaced in the gallery's Show-hidden review strip. -----------
|
||||||
@gallery_bp.route("/hidden-review", methods=["GET"])
|
@gallery_bp.route("/hidden-review", methods=["GET"])
|
||||||
async def hidden_review():
|
async def hidden_review():
|
||||||
"""Unresolved presentation auto-hide flags, most-concerning first (highest
|
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
|
||||||
content score) — for the gallery's Hidden-view review strip."""
|
most-concerning first (highest content score) — for the review strip. `mode`
|
||||||
|
tells the client whether the flagged tag hid the image ('chrome') or left it
|
||||||
|
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
|
||||||
ptag = aliased(Tag)
|
ptag = aliased(Tag)
|
||||||
ctag = aliased(Tag)
|
ctag = aliased(Tag)
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -247,6 +261,7 @@ async def hidden_review():
|
|||||||
PresentationReview.tag_id,
|
PresentationReview.tag_id,
|
||||||
PresentationReview.conflict_tag_id,
|
PresentationReview.conflict_tag_id,
|
||||||
PresentationReview.conflict_score,
|
PresentationReview.conflict_score,
|
||||||
|
PresentationReview.mode,
|
||||||
ImageRecord.path, ImageRecord.thumbnail_path,
|
ImageRecord.path, ImageRecord.thumbnail_path,
|
||||||
ImageRecord.sha256, ImageRecord.mime,
|
ImageRecord.sha256, ImageRecord.mime,
|
||||||
ptag.name.label("tag_name"),
|
ptag.name.label("tag_name"),
|
||||||
@@ -266,6 +281,7 @@ async def hidden_review():
|
|||||||
"conflict_tag_id": r.conflict_tag_id,
|
"conflict_tag_id": r.conflict_tag_id,
|
||||||
"conflict_name": r.conflict_name,
|
"conflict_name": r.conflict_name,
|
||||||
"conflict_score": r.conflict_score,
|
"conflict_score": r.conflict_score,
|
||||||
|
"mode": r.mode,
|
||||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||||
"image_url": image_url(r.path),
|
"image_url": image_url(r.path),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,9 +256,7 @@ async def lease():
|
|||||||
if not await _agent_authed(session):
|
if not await _agent_authed(session):
|
||||||
return jsonify({"error": "unauthorized"}), 401
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
|
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
|
||||||
ml = (
|
ml = await MLSettings.load(session)
|
||||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
|
||||||
).scalar_one()
|
|
||||||
# image rows for url/mime in one shot
|
# image rows for url/mime in one shot
|
||||||
ids = [j.image_record_id for j in jobs]
|
ids = [j.image_record_id for j in jobs]
|
||||||
imgs = {
|
imgs = {
|
||||||
|
|||||||
+24
-38
@@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import MLSettings
|
from ..models import MLSettings
|
||||||
|
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
|
||||||
|
|
||||||
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
||||||
|
|
||||||
@@ -42,6 +43,9 @@ _EDITABLE = (
|
|||||||
"presentation_auto_apply_enabled",
|
"presentation_auto_apply_enabled",
|
||||||
"presentation_auto_apply_threshold",
|
"presentation_auto_apply_threshold",
|
||||||
"presentation_conflict_threshold",
|
"presentation_conflict_threshold",
|
||||||
|
"process_auto_apply_enabled",
|
||||||
|
"process_auto_apply_threshold",
|
||||||
|
"process_conflict_threshold",
|
||||||
"embedder_model_name",
|
"embedder_model_name",
|
||||||
"embedder_model_version",
|
"embedder_model_version",
|
||||||
*_DETECTOR_FIELDS,
|
*_DETECTOR_FIELDS,
|
||||||
@@ -80,45 +84,21 @@ async def embedder_models():
|
|||||||
|
|
||||||
@ml_admin_bp.route("/settings", methods=["GET"])
|
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
s = (
|
s = await MLSettings.load(session)
|
||||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
|
||||||
).scalar_one()
|
# can never be silently absent from GET — the split that historically dropped
|
||||||
return jsonify(
|
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
|
||||||
{
|
return jsonify({f: getattr(s, f) for f in _EDITABLE})
|
||||||
"cpu_embed_enabled": s.cpu_embed_enabled,
|
|
||||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
|
||||||
"video_max_frames": s.video_max_frames,
|
|
||||||
"embedder_model_version": s.embedder_model_version,
|
|
||||||
"head_min_positives": s.head_min_positives,
|
|
||||||
"head_auto_apply_precision": s.head_auto_apply_precision,
|
|
||||||
"head_auto_apply_enabled": s.head_auto_apply_enabled,
|
|
||||||
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
|
|
||||||
"ccip_match_threshold": s.ccip_match_threshold,
|
|
||||||
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
|
||||||
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
|
||||||
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
|
|
||||||
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
|
|
||||||
"presentation_conflict_threshold": s.presentation_conflict_threshold,
|
|
||||||
"embedder_model_name": s.embedder_model_name,
|
|
||||||
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
||||||
async def patch_settings():
|
async def patch_settings():
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
return jsonify({"error": "body must be an object"}), 400
|
return jsonify({"error": "body must be an object"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
s = (
|
s = await MLSettings.load(session)
|
||||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
# Merge the patch over current values, then validate the result as a
|
# Merge the patch over current values, then validate the result as a
|
||||||
# whole — the store-floor invariant couples three fields, so they
|
# whole — the store-floor invariant couples three fields, so they
|
||||||
@@ -148,20 +128,26 @@ def _validate(p: dict) -> str | None:
|
|||||||
# Head training (#114).
|
# Head training (#114).
|
||||||
if int(p["head_min_positives"]) < 1:
|
if int(p["head_min_positives"]) < 1:
|
||||||
return "head_min_positives must be >= 1"
|
return "head_min_positives must be >= 1"
|
||||||
if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
|
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||||
return "head_auto_apply_precision must be between 0.5 and 0.999"
|
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||||
if int(p["head_auto_apply_min_positives"]) < 1:
|
if int(p["head_auto_apply_min_positives"]) < 1:
|
||||||
return "head_auto_apply_min_positives must be >= 1"
|
return "head_auto_apply_min_positives must be >= 1"
|
||||||
if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
|
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
|
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||||
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
|
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||||
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
|
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
|
||||||
# consequential); the conflict cut is a plain probability [0,1].
|
# consequential); the conflict cut is a plain probability [0,1].
|
||||||
if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
|
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||||
return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
|
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||||
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
|
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
|
||||||
return "presentation_conflict_threshold must be between 0 and 1"
|
return "presentation_conflict_threshold must be between 0 and 1"
|
||||||
|
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
|
||||||
|
# low-harm (excludes-from-training + a review flag), but keep the same bar.
|
||||||
|
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||||
|
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||||
|
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
|
||||||
|
return "process_conflict_threshold must be between 0 and 1"
|
||||||
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
||||||
# different embedding space — the operator must re-embed + retrain after.
|
# different embedding space — the operator must re-embed + retrain after.
|
||||||
for key in ("embedder_model_name", "embedder_model_version"):
|
for key in ("embedder_model_name", "embedder_model_version"):
|
||||||
|
|||||||
+29
-26
@@ -48,6 +48,8 @@ _EDITABLE_FIELDS = (
|
|||||||
"interpreter_base_url",
|
"interpreter_base_url",
|
||||||
"translation_target_lang",
|
"translation_target_lang",
|
||||||
"translation_min_confidence",
|
"translation_min_confidence",
|
||||||
|
"wip_title_tagging_enabled",
|
||||||
|
"wip_soft_title_tagging_enabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||||
@@ -64,32 +66,9 @@ _EXTDL_TOGGLE_FIELDS = (
|
|||||||
async def get_import_settings():
|
async def get_import_settings():
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
row = await ImportSettings.load(session)
|
row = await ImportSettings.load(session)
|
||||||
return jsonify({
|
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
|
||||||
"min_width": row.min_width,
|
# can't be silently absent from GET.
|
||||||
"min_height": row.min_height,
|
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
|
||||||
"skip_transparent": row.skip_transparent,
|
|
||||||
"transparency_threshold": row.transparency_threshold,
|
|
||||||
"skip_single_color": row.skip_single_color,
|
|
||||||
"single_color_threshold": row.single_color_threshold,
|
|
||||||
"single_color_tolerance": row.single_color_tolerance,
|
|
||||||
"phash_threshold": row.phash_threshold,
|
|
||||||
"download_rate_limit_seconds": row.download_rate_limit_seconds,
|
|
||||||
"download_validate_files": row.download_validate_files,
|
|
||||||
"download_schedule_default_seconds": row.download_schedule_default_seconds,
|
|
||||||
"download_event_retention_days": row.download_event_retention_days,
|
|
||||||
"download_failure_warning_threshold": row.download_failure_warning_threshold,
|
|
||||||
"series_suggest_enabled": row.series_suggest_enabled,
|
|
||||||
"series_suggest_threshold": row.series_suggest_threshold,
|
|
||||||
"extdl_mega_enabled": row.extdl_mega_enabled,
|
|
||||||
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
|
|
||||||
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
|
||||||
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
|
||||||
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
|
||||||
"translation_enabled": row.translation_enabled,
|
|
||||||
"interpreter_base_url": row.interpreter_base_url,
|
|
||||||
"translation_target_lang": row.translation_target_lang,
|
|
||||||
"translation_min_confidence": row.translation_min_confidence,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/settings/import", methods=["PATCH"])
|
@settings_bp.route("/settings/import", methods=["PATCH"])
|
||||||
@@ -171,6 +150,18 @@ async def update_import_settings():
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{"error": "series_suggest_threshold must be a number in [0, 1]"}
|
{"error": "series_suggest_threshold must be a number in [0, 1]"}
|
||||||
), 400
|
), 400
|
||||||
|
if "wip_title_tagging_enabled" in body and not isinstance(
|
||||||
|
body["wip_title_tagging_enabled"], bool
|
||||||
|
):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "wip_title_tagging_enabled must be a boolean"}
|
||||||
|
), 400
|
||||||
|
if "wip_soft_title_tagging_enabled" in body and not isinstance(
|
||||||
|
body["wip_soft_title_tagging_enabled"], bool
|
||||||
|
):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
|
||||||
|
), 400
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
row = await ImportSettings.load(session)
|
row = await ImportSettings.load(session)
|
||||||
@@ -182,6 +173,18 @@ async def update_import_settings():
|
|||||||
return await get_import_settings()
|
return await get_import_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/wip-title/scan", methods=["POST"])
|
||||||
|
async def wip_title_scan():
|
||||||
|
"""Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
|
||||||
|
apply the `wip` system tag to EXISTING posts whose title declares
|
||||||
|
work-in-progress. New imports are tagged live by the importer; this catches
|
||||||
|
the existing library. Returns the Celery task id (202)."""
|
||||||
|
from ..tasks.maintenance import backfill_wip_title_tags
|
||||||
|
|
||||||
|
r = backfill_wip_title_tags.delay()
|
||||||
|
return jsonify({"celery_task_id": r.id}), 202
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/system/stats", methods=["GET"])
|
@settings_bp.route("/system/stats", methods=["GET"])
|
||||||
async def system_stats():
|
async def system_stats():
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
|||||||
@@ -171,9 +171,19 @@ def make_celery() -> Celery:
|
|||||||
},
|
},
|
||||||
"presentation-auto-apply-daily": {
|
"presentation-auto-apply-daily": {
|
||||||
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
||||||
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
|
"schedule": 86400.0, # auto-hide banner chrome (#141);
|
||||||
# no-op unless presentation_auto_apply_enabled
|
# no-op unless presentation_auto_apply_enabled
|
||||||
},
|
},
|
||||||
|
"process-auto-apply-daily": {
|
||||||
|
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||||
|
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
|
||||||
|
# no-op unless process_auto_apply_enabled (opt-in)
|
||||||
|
},
|
||||||
|
"soft-wip-conflict-audit-daily": {
|
||||||
|
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
|
||||||
|
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
|
||||||
|
# for review (#1474); no-op with no content heads
|
||||||
|
},
|
||||||
"prune-presentation-reviews-daily": {
|
"prune-presentation-reviews-daily": {
|
||||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ class ImportSettings(Base):
|
|||||||
Float, nullable=False, default=0.9, server_default="0.9",
|
Float, nullable=False, default=0.9, server_default="0.9",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
|
||||||
|
# TITLE explicitly declares work-in-progress ("WIP" / "work in progress"),
|
||||||
|
# the importer applies the `wip` system tag to its images — the artist's own
|
||||||
|
# label, used to keep unfinished pieces out of the Explore/gallery browse. ON
|
||||||
|
# by default (rule 26 — the feature works out of the box). Gates only the
|
||||||
|
# LIVE import hook; the existing catalogue is caught by the operator-triggered
|
||||||
|
# "Scan existing posts" backfill (which runs regardless of this flag).
|
||||||
|
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
|
||||||
|
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
|
||||||
|
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
|
||||||
|
# precision tier is opt-in (the ring-loud audit surfaces false positives).
|
||||||
|
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def load(cls, session) -> ImportSettings:
|
async def load(cls, session) -> ImportSettings:
|
||||||
"""The singleton settings row (id=1), via an async session."""
|
"""The singleton settings row (id=1), via an async session."""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
|||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
func,
|
func,
|
||||||
|
select,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -85,12 +86,14 @@ class MLSettings(Base):
|
|||||||
Float, nullable=False, default=0.95
|
Float, nullable=False, default=0.95
|
||||||
)
|
)
|
||||||
# -- Presentation chrome auto-hide (#141) -------------------------------
|
# -- Presentation chrome auto-hide (#141) -------------------------------
|
||||||
# banner / editor screenshot auto-apply on the sweep with their OWN flat
|
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
||||||
# threshold (decoupled from content-head graduation). Hiding is consequential
|
# with its OWN flat threshold (decoupled from content-head graduation) and is
|
||||||
# so it runs HIGH. `wip` is never auto-applied. When an image would be
|
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
|
||||||
# auto-hidden but ALSO scores >= presentation_conflict_threshold on a content
|
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
|
||||||
# head, it's still hidden but flagged for review (PresentationReview) instead
|
# on a content head, it's still hidden but flagged for review
|
||||||
# of buried silently. ON by default (opt-out); every auto-tag is reversible.
|
# (PresentationReview, mode='chrome') instead of buried silently. ON by default
|
||||||
|
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
|
||||||
|
# screenshot` are no longer chrome — they went to the PROCESS path below.
|
||||||
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=True
|
Boolean, nullable=False, default=True
|
||||||
)
|
)
|
||||||
@@ -100,6 +103,26 @@ class MLSettings(Base):
|
|||||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.50
|
Float, nullable=False, default=0.50
|
||||||
)
|
)
|
||||||
|
# -- Process auto-apply (#1464) ----------------------------------------
|
||||||
|
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
|
||||||
|
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
|
||||||
|
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
|
||||||
|
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
|
||||||
|
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
|
||||||
|
# it learns only from title (`wip_title`) + manual labels, which breaks the
|
||||||
|
# runaway loop. When a process tag would be applied but the image ALSO scores
|
||||||
|
# >= process_conflict_threshold on a content head, it's flagged for review
|
||||||
|
# (PresentationReview, mode='process') rather than silently marked. OFF by
|
||||||
|
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
|
||||||
|
process_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
process_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.90
|
||||||
|
)
|
||||||
|
process_conflict_threshold: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.50
|
||||||
|
)
|
||||||
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
||||||
# existing libraries keep their stored value until the operator re-embeds.
|
# existing libraries keep their stored value until the operator re-embeds.
|
||||||
embedder_model_version: Mapped[str] = mapped_column(
|
embedder_model_version: Mapped[str] = mapped_column(
|
||||||
@@ -190,3 +213,14 @@ class MLSettings(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def load(cls, session) -> MLSettings:
|
||||||
|
"""The singleton settings row (id=1), via an async session. Mirrors
|
||||||
|
ImportSettings.load — the shared singleton-loader pattern."""
|
||||||
|
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_sync(cls, session) -> MLSettings:
|
||||||
|
"""The singleton settings row (id=1), via a sync session."""
|
||||||
|
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
|
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
|
||||||
real content, flagged for operator review (milestone 141).
|
like real content, flagged for operator review (milestone 141 + #1464).
|
||||||
|
|
||||||
When the auto-apply sweep hides an image as chrome (banner / editor screenshot)
|
When a sweep applies a system tag but the image ALSO scores highly on a content
|
||||||
but the image ALSO scores highly on a content head, it still hides it but records
|
head, it still applies the tag but records this row so a review strip can surface
|
||||||
this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>")
|
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
|
||||||
for a keep-hidden / un-hide decision. Resolved rows are pruned by retention.
|
image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor
|
||||||
|
screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows
|
||||||
|
are pruned by retention.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Float, ForeignKey, func
|
from sqlalchemy import DateTime, Float, ForeignKey, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -31,6 +33,12 @@ class PresentationReview(Base):
|
|||||||
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
|
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
# Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process'
|
||||||
|
# (wip / editor screenshot, shown). Drives which review strip surfaces it and
|
||||||
|
# what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'.
|
||||||
|
mode: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="chrome", server_default="chrome"
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,14 +43,19 @@ class TagKind(StrEnum):
|
|||||||
# to keep historic tag rows queryable.
|
# to keep historic tag rows queryable.
|
||||||
|
|
||||||
|
|
||||||
# The seeded system tags (migration 0075). PRESENTATION tags additionally
|
# The seeded system tags (migration 0075). Two behavior groups (#1464):
|
||||||
# hide from whole-image similarity results — they cluster on UI chrome, not
|
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
|
||||||
# content. `wip` is real art: only the training pipelines exclude it.
|
# gallery + from similarity; auto-applied via the sweep's chrome mode.
|
||||||
|
# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots
|
||||||
|
# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore
|
||||||
|
# rabbit-hole; auto-applied via the sweep's process mode (provisional source,
|
||||||
|
# ring-loud review guard).
|
||||||
|
# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene,
|
||||||
|
# keyed on is_system); a system tag's OWN head trains on them — that's what makes
|
||||||
|
# auto-flagging work.
|
||||||
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
||||||
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
CHROME_SYSTEM_TAGS = ("banner",)
|
||||||
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
|
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
|
||||||
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
|
|
||||||
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
|
|
||||||
WIP_SYSTEM_TAG = "wip"
|
WIP_SYSTEM_TAG = "wip"
|
||||||
|
|
||||||
image_tag = Table(
|
image_tag = Table(
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
|||||||
# reviewers catch drift.
|
# reviewers catch drift.
|
||||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
("patreon", re.compile(
|
("patreon", re.compile(
|
||||||
|
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
|
||||||
|
# (the "creator workspace" URL served once subscribed, see
|
||||||
|
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
|
||||||
|
# creator's inner page still derives the slug. Nav pages stay excluded.
|
||||||
r"^https?://(?:www\.)?patreon\.com/"
|
r"^https?://(?:www\.)?patreon\.com/"
|
||||||
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
r"(?:cw/|c/)?"
|
||||||
r"(?P<slug>[^/?#]+)/?$",
|
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||||
|
r"(?P<slug>[^/?#]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)),
|
)),
|
||||||
("subscribestar", re.compile(
|
("subscribestar", re.compile(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..models import (
|
|||||||
Tag,
|
Tag,
|
||||||
TagPositiveConfirmation,
|
TagPositiveConfirmation,
|
||||||
)
|
)
|
||||||
from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
|
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||||
from .pagination import decode_cursor, encode_cursor
|
from .pagination import decode_cursor, encode_cursor
|
||||||
from .tag_query import (
|
from .tag_query import (
|
||||||
fandom_join_alias,
|
fandom_join_alias,
|
||||||
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
|||||||
return [kept[i] for i in order]
|
return [kept[i] for i in order]
|
||||||
|
|
||||||
|
|
||||||
|
def _reach_sample(rows, limit, reach):
|
||||||
|
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
|
||||||
|
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
|
||||||
|
MMR — the Explore "reach" dial (#1476).
|
||||||
|
|
||||||
|
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
|
||||||
|
pool), evenly strided from rank 0 so the nearest are still represented. In a
|
||||||
|
dense signature the nearest ranks are near-identical, so reaching farther is the
|
||||||
|
only way to hand MMR genuinely different content — MMR alone can't escape a pool
|
||||||
|
that's already all-near. reach<=0 or a small pool passes through unchanged."""
|
||||||
|
n = len(rows)
|
||||||
|
want = max(limit * 8, 100)
|
||||||
|
if reach <= 0 or n <= want:
|
||||||
|
return rows
|
||||||
|
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
|
||||||
|
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
|
||||||
|
return [rows[i] for i in idx]
|
||||||
|
|
||||||
|
|
||||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||||
"""Map image_id -> {"name","slug"} via the canonical
|
"""Map image_id -> {"name","slug"} via the canonical
|
||||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||||
@@ -419,16 +438,17 @@ class GalleryService:
|
|||||||
async def _hidden_tag_ids(
|
async def _hidden_tag_ids(
|
||||||
self, include_hidden, tag_ids, tag_or_groups,
|
self, include_hidden, tag_ids, tag_or_groups,
|
||||||
) -> list[int] | None:
|
) -> list[int] | None:
|
||||||
"""Presentation-chrome tag ids to implicitly exclude from a gallery query,
|
"""Chrome (banner) tag ids to implicitly exclude from a gallery query, or
|
||||||
or None. None when the caller asked to include hidden, when the operator
|
None. None when the caller asked to include hidden, when the operator is
|
||||||
is explicitly filtering FOR a presentation tag (they clearly want to see
|
explicitly filtering FOR a chrome tag (they clearly want to see it), or when
|
||||||
it), or when no presentation tags exist. (milestone 141)"""
|
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
|
||||||
|
— shown — so only `banner` hides here.)"""
|
||||||
if include_hidden:
|
if include_hidden:
|
||||||
return None
|
return None
|
||||||
rows = await self.session.execute(
|
rows = await self.session.execute(
|
||||||
select(Tag.id).where(
|
select(Tag.id).where(
|
||||||
Tag.is_system.is_(True),
|
Tag.is_system.is_(True),
|
||||||
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
|
Tag.name.in_(CHROME_SYSTEM_TAGS),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pres = [r[0] for r in rows]
|
pres = [r[0] for r in rows]
|
||||||
@@ -716,6 +736,7 @@ class GalleryService:
|
|||||||
untagged: bool = False, no_artist: bool = False,
|
untagged: bool = False, no_artist: bool = False,
|
||||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
exclude_wip: bool = False,
|
exclude_wip: bool = False,
|
||||||
|
reach: float = 0.0, exclude_ids: list[int] | None = None,
|
||||||
) -> list[GalleryImage] | None:
|
) -> list[GalleryImage] | None:
|
||||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||||
@@ -744,20 +765,27 @@ class GalleryService:
|
|||||||
# wide pool there's nothing but the near-dupes to choose from. Widened
|
# wide pool there's nothing but the near-dupes to choose from. Widened
|
||||||
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
||||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||||
pool_n = min(400, max(limit * 8, 100))
|
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
|
||||||
|
# nearest few hundred are all near-identical, so far-enough candidates only
|
||||||
|
# exist deeper in the ranked pool. _reach_sample then strides across them.
|
||||||
|
if reach > 0:
|
||||||
|
pool_n = min(1000, max(limit * 25, 100))
|
||||||
|
else:
|
||||||
|
pool_n = min(400, max(limit * 8, 100))
|
||||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
# Presentation images (banner / editor-screenshot system tags, #128)
|
# Chrome (banner, #128) clusters on UI rather than content, so near any one
|
||||||
# cluster on UI chrome rather than content, so near any one of them
|
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor
|
||||||
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
# itself may be a banner). PROCESS art (wip / editor screenshot) stays
|
||||||
# itself may be a banner. `wip` stays surfaced here by default (real art;
|
# surfaced here by default (real content; only the training pipelines exclude
|
||||||
# only the training pipelines exclude it), but the Explore rabbit-hole
|
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
|
||||||
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
|
# process group so a browse doesn't keep surfacing work-in-progress
|
||||||
excluded_system_tags = PRESENTATION_SYSTEM_TAGS
|
# (operator, 2026-07-08; #1464 — editor now rides with wip here).
|
||||||
|
excluded_system_tags = CHROME_SYSTEM_TAGS
|
||||||
if exclude_wip:
|
if exclude_wip:
|
||||||
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
|
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
|
||||||
presentation = (
|
presentation = (
|
||||||
select(image_tag.c.image_record_id)
|
select(image_tag.c.image_record_id)
|
||||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||||
@@ -771,6 +799,10 @@ class GalleryService:
|
|||||||
ImageRecord.id != image_id,
|
ImageRecord.id != image_id,
|
||||||
ImageRecord.id.not_in(presentation),
|
ImageRecord.id.not_in(presentation),
|
||||||
)
|
)
|
||||||
|
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
|
||||||
|
# walked images aren't re-served as neighbours — → can't loop you back in.
|
||||||
|
if exclude_ids:
|
||||||
|
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
|
||||||
stmt = _apply_scope(
|
stmt = _apply_scope(
|
||||||
stmt, tag_ids=tag_ids, post_id=None,
|
stmt, tag_ids=tag_ids, post_id=None,
|
||||||
artist_id=artist_id, media_type=media_type,
|
artist_id=artist_id, media_type=media_type,
|
||||||
@@ -780,6 +812,10 @@ class GalleryService:
|
|||||||
)
|
)
|
||||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
# Explore reach: stride across an outward-growing distance span so the pool
|
||||||
|
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
|
||||||
|
if reach > 0:
|
||||||
|
rows = _reach_sample(rows, limit, reach)
|
||||||
rows = _diversify_similar(src, rows, limit)
|
rows = _diversify_similar(src, rows, limit)
|
||||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||||
return _gallery_images(rows, artists)
|
return _gallery_images(rows, artists)
|
||||||
|
|||||||
@@ -47,9 +47,21 @@ from .attachment_store import AttachmentStore
|
|||||||
from .audits import single_color
|
from .audits import single_color
|
||||||
from .link_extract import extract_external_links
|
from .link_extract import extract_external_links
|
||||||
from .thumbnailer import Thumbnailer
|
from .thumbnailer import Thumbnailer
|
||||||
|
from .wip_title import (
|
||||||
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
|
WIP_TITLE_SOURCE,
|
||||||
|
apply_wip_image_tags,
|
||||||
|
matches_soft_wip_title,
|
||||||
|
matches_wip_title,
|
||||||
|
resolve_wip_tag_id,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sentinel for the lazily-resolved wip tag id (distinguishes "not resolved yet"
|
||||||
|
# from a genuine None = tag absent, so absence is cached and not re-queried).
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
class SkipReason(StrEnum):
|
class SkipReason(StrEnum):
|
||||||
too_small = "too_small"
|
too_small = "too_small"
|
||||||
@@ -183,6 +195,10 @@ class Importer:
|
|||||||
# invalidated mid-Importer (Importer instances are per-task /
|
# invalidated mid-Importer (Importer instances are per-task /
|
||||||
# per-archive-import so cross-instance staleness is harmless).
|
# per-archive-import so cross-instance staleness is harmless).
|
||||||
self._phash_candidates: list[tuple] | None = None
|
self._phash_candidates: list[tuple] | None = None
|
||||||
|
# Lazily-resolved `wip` system tag id for title-based WIP auto-tagging
|
||||||
|
# (task #1458). Sentinel _UNSET so a genuine None (tag absent) is cached
|
||||||
|
# and not re-queried per media. Importer is per-task, so this can't stale.
|
||||||
|
self._wip_tag_id: int | None = _UNSET
|
||||||
|
|
||||||
def _phash_candidates_cache(self) -> list[tuple]:
|
def _phash_candidates_cache(self) -> list[tuple]:
|
||||||
"""Cached `(phash, width, height, id)` rows from image_record.
|
"""Cached `(phash, width, height, id)` rows from image_record.
|
||||||
@@ -933,6 +949,10 @@ class Importer:
|
|||||||
# Thumbnail is queued separately by the calling task; the importer
|
# Thumbnail is queued separately by the calling task; the importer
|
||||||
# does not generate thumbnails inline so the import queue stays moving.
|
# does not generate thumbnails inline so the import queue stays moving.
|
||||||
|
|
||||||
|
# Title-based WIP auto-tag (task #1458): fresh import only, after the
|
||||||
|
# sidecar has linked the post so record.primary_post_id / its title exist.
|
||||||
|
self._maybe_apply_wip_title(record)
|
||||||
|
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="imported", image_id=record.id)
|
return ImportResult(status="imported", image_id=record.id)
|
||||||
|
|
||||||
@@ -976,6 +996,47 @@ class Importer:
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="refreshed", image_id=existing.id)
|
return ImportResult(status="refreshed", image_id=existing.id)
|
||||||
|
|
||||||
|
def _maybe_apply_wip_title(self, record: ImageRecord) -> None:
|
||||||
|
"""Auto-apply the `wip` system tag to a FRESHLY-imported image when its
|
||||||
|
primary post's TITLE explicitly declares work-in-progress (task #1458 —
|
||||||
|
the artist's own "WIP" / "work in progress" label).
|
||||||
|
|
||||||
|
Called ONLY from the two new-record paths (never deep-scan / supersede),
|
||||||
|
so a manually-removed WIP tag is never re-applied by a routine re-scan —
|
||||||
|
removal sticks. The existing catalogue is covered separately by the
|
||||||
|
operator-triggered backfill sweep. Gated by the settings toggle, and
|
||||||
|
best-effort: any failure is logged, never allowed to fail the import."""
|
||||||
|
hard_on = self.settings.wip_title_tagging_enabled
|
||||||
|
soft_on = self.settings.wip_soft_title_tagging_enabled
|
||||||
|
if not (hard_on or soft_on):
|
||||||
|
return
|
||||||
|
if record.primary_post_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
title = self.session.execute(
|
||||||
|
select(Post.post_title).where(Post.id == record.primary_post_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it
|
||||||
|
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
|
||||||
|
# that never trains (source wip_title_soft).
|
||||||
|
if hard_on and matches_wip_title(title):
|
||||||
|
source = WIP_TITLE_SOURCE
|
||||||
|
elif soft_on and matches_soft_wip_title(title):
|
||||||
|
source = WIP_TITLE_SOFT_SOURCE
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
if self._wip_tag_id is _UNSET:
|
||||||
|
self._wip_tag_id = resolve_wip_tag_id(self.session)
|
||||||
|
if self._wip_tag_id is None:
|
||||||
|
return
|
||||||
|
apply_wip_image_tags(
|
||||||
|
self.session, [record.id], self._wip_tag_id, source=source
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
|
||||||
|
log.warning(
|
||||||
|
"wip-title auto-tag failed for image %s: %s", record.id, exc
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_post_fields(self, post: Post, sd) -> None:
|
def _apply_post_fields(self, post: Post, sd) -> None:
|
||||||
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE
|
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE
|
||||||
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
|
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
|
||||||
@@ -1253,6 +1314,10 @@ class Importer:
|
|||||||
# per-post Source row.
|
# per-post Source row.
|
||||||
self._apply_sidecar(record, path, artist, explicit_source=source)
|
self._apply_sidecar(record, path, artist, explicit_source=source)
|
||||||
|
|
||||||
|
# Title-based WIP auto-tag (task #1458): fresh import only, see the
|
||||||
|
# matching call in _import_media.
|
||||||
|
self._maybe_apply_wip_title(record)
|
||||||
|
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="imported", image_id=record.id)
|
return ImportResult(status="imported", image_id=record.id)
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,7 @@ def refresh_character_prototypes(
|
|||||||
"""Incrementally refresh the prototype store. `full=True` rebuilds every
|
"""Incrementally refresh the prototype store. `full=True` rebuilds every
|
||||||
character regardless of the gate/fingerprints (nightly reconcile). Returns
|
character regardless of the gate/fingerprints (nightly reconcile). Returns
|
||||||
{skipped, rebuilt, removed}; commits."""
|
{skipped, rebuilt, removed}; commits."""
|
||||||
settings = session.execute(
|
settings = MLSettings.load_sync(session)
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
sig = _global_signature(session)
|
sig = _global_signature(session)
|
||||||
if not full and settings.ccip_ref_signature == sig:
|
if not full and settings.ccip_ref_signature == sig:
|
||||||
return {"skipped": True, "rebuilt": 0, "removed": 0}
|
return {"skipped": True, "rebuilt": 0, "removed": 0}
|
||||||
@@ -204,9 +202,7 @@ def retract_auto_applied_ccip(session: Session) -> int:
|
|||||||
n_retracted."""
|
n_retracted."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
settings = session.execute(
|
settings = MLSettings.load_sync(session)
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
if not settings.ccip_auto_apply_enabled:
|
if not settings.ccip_auto_apply_enabled:
|
||||||
return 0
|
return 0
|
||||||
thr = float(settings.ccip_auto_apply_threshold)
|
thr = float(settings.ccip_auto_apply_threshold)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from datetime import UTC, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import delete, exists, func, select
|
from sqlalchemy import delete, exists, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -39,9 +40,10 @@ from ...models import (
|
|||||||
TagPositiveConfirmation,
|
TagPositiveConfirmation,
|
||||||
TagSuggestionRejection,
|
TagSuggestionRejection,
|
||||||
)
|
)
|
||||||
from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
|
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||||
from .training_data import (
|
from .training_data import (
|
||||||
_AUTO_SOURCES,
|
_AUTO_SOURCES,
|
||||||
|
_applied_or_rejected,
|
||||||
_auto_apply_point,
|
_auto_apply_point,
|
||||||
_hygiene_excluded_ids,
|
_hygiene_excluded_ids,
|
||||||
_ids_with_tag,
|
_ids_with_tag,
|
||||||
@@ -61,6 +63,14 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise
|
|||||||
_UNLABELED_POOL = 4000
|
_UNLABELED_POOL = 4000
|
||||||
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
|
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
|
||||||
|
|
||||||
|
# Auto-apply / match confidence operating range. Every graduated auto-apply or
|
||||||
|
# CCIP-match threshold the operator can set lives in this band, and the head
|
||||||
|
# precision target is clamped to it: below 0.5 "auto-apply" is meaningless, and
|
||||||
|
# 1.0 is unachievable so 0.999 is the ceiling. One source shared by the service
|
||||||
|
# clamp (_normalize_params) and the API validator (ml_admin._validate).
|
||||||
|
AUTO_APPLY_THRESHOLD_MIN = 0.5
|
||||||
|
AUTO_APPLY_THRESHOLD_MAX = 0.999
|
||||||
|
|
||||||
# Only these tag kinds get heads (the surfaced suggestion categories).
|
# Only these tag kinds get heads (the surfaced suggestion categories).
|
||||||
_HEAD_KINDS = (TagKind.general, TagKind.character)
|
_HEAD_KINDS = (TagKind.general, TagKind.character)
|
||||||
# tag.kind -> the suggestion category the rail groups under.
|
# tag.kind -> the suggestion category the rail groups under.
|
||||||
@@ -78,6 +88,38 @@ _CATEGORY = {TagKind.general: "general", TagKind.character: "character"}
|
|||||||
_SYSTEM_TAG_SUGGEST_FLOOR = 0.65
|
_SYSTEM_TAG_SUGGEST_FLOOR = 0.65
|
||||||
|
|
||||||
|
|
||||||
|
def _sigmoid(z, np):
|
||||||
|
"""Logistic sigmoid 1/(1+e^-z): the head score→probability transform. One home
|
||||||
|
for what was inlined at every scoring site (suggest, both sweeps, retract)."""
|
||||||
|
return 1.0 / (1.0 + np.exp(-z))
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict_scores(Xn, Wc, bc, np):
|
||||||
|
"""The presentation conflict signal (#141): per row, the MAX content-head
|
||||||
|
probability and WHICH head produced it. Shared by the system-tag sweep's guard-2
|
||||||
|
and the soft-wip audit — both ask "does this ALSO look like real content?"."""
|
||||||
|
cprobs = _sigmoid(Xn @ Wc.T + bc, np)
|
||||||
|
return cprobs.max(axis=1), cprobs.argmax(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_presentation_review(
|
||||||
|
session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode,
|
||||||
|
):
|
||||||
|
"""Single-source the ring-loud PresentationReview row shape so the two writers
|
||||||
|
(system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` —
|
||||||
|
they share the (image_record_id, tag_id) composite PK, so a divergent `mode`
|
||||||
|
would be a silent first-writer-wins bug."""
|
||||||
|
session.execute(
|
||||||
|
pg_insert(PresentationReview)
|
||||||
|
.values(
|
||||||
|
image_record_id=image_record_id, tag_id=tag_id,
|
||||||
|
conflict_tag_id=conflict_tag_id, conflict_score=conflict_score,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HeadTrainingAlreadyRunning(Exception):
|
class HeadTrainingAlreadyRunning(Exception):
|
||||||
"""Raised by start_head_training_run when a run is already in flight."""
|
"""Raised by start_head_training_run when a run is already in flight."""
|
||||||
|
|
||||||
@@ -103,9 +145,7 @@ def start_head_training_run(session: Session, params: dict[str, Any]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _settings(session: Session) -> MLSettings:
|
def _settings(session: Session) -> MLSettings:
|
||||||
return session.execute(
|
return MLSettings.load_sync(session)
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
|
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
@@ -124,7 +164,7 @@ def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[s
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
cv_folds = DEFAULT_CV_FOLDS
|
cv_folds = DEFAULT_CV_FOLDS
|
||||||
try:
|
try:
|
||||||
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999)
|
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), AUTO_APPLY_THRESHOLD_MIN), AUTO_APPLY_THRESHOLD_MAX)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
precision_target = s.head_auto_apply_precision
|
precision_target = s.head_auto_apply_precision
|
||||||
return {
|
return {
|
||||||
@@ -536,7 +576,7 @@ async def score_image(
|
|||||||
norms[norms == 0] = 1.0
|
norms[norms == 0] = 1.0
|
||||||
Xn = X / norms
|
Xn = X / norms
|
||||||
Z = Xn @ heads["W"].T + heads["b"] # (B, H)
|
Z = Xn @ heads["W"].T + heads["b"] # (B, H)
|
||||||
probs_bag = 1.0 / (1.0 + np.exp(-Z)) # (B, H)
|
probs_bag = _sigmoid(Z, np) # (B, H)
|
||||||
probs = probs_bag.max(axis=0) # (H,) best over the bag
|
probs = probs_bag.max(axis=0) # (H,) best over the bag
|
||||||
# ARGMAX beside the max: WHICH bag row won each head → the region that grounds
|
# ARGMAX beside the max: WHICH bag row won each head → the region that grounds
|
||||||
# the tag (bag_meta[win]); None when the whole-image vector won (#1206).
|
# the tag (bag_meta[win]); None when the whole-image vector won (#1206).
|
||||||
@@ -614,9 +654,7 @@ async def ground_applied_tag(
|
|||||||
|
|
||||||
|
|
||||||
async def _settings_async(session: AsyncSession) -> MLSettings:
|
async def _settings_async(session: AsyncSession) -> MLSettings:
|
||||||
return (
|
return await MLSettings.load(session)
|
||||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Earned auto-apply (sync, ml worker) ---------------------------------
|
# --- Earned auto-apply (sync, ml worker) ---------------------------------
|
||||||
@@ -687,7 +725,6 @@ def auto_apply_sweep(
|
|||||||
embeddings in chunks; commits per chunk on a real run. Returns
|
embeddings in chunks; commits per chunk on a real run. Returns
|
||||||
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
|
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
|
|
||||||
settings = _settings(session)
|
settings = _settings(session)
|
||||||
rows = _auto_apply_heads(
|
rows = _auto_apply_heads(
|
||||||
@@ -704,18 +741,7 @@ def auto_apply_sweep(
|
|||||||
names = [r.name for r in rows]
|
names = [r.name for r in rows]
|
||||||
|
|
||||||
# Skip images that already carry, or have rejected, each tag.
|
# Skip images that already carry, or have rejected, each tag.
|
||||||
skip = {tid: set() for tid in tag_ids}
|
skip = _applied_or_rejected(session, tag_ids)
|
||||||
for tid in tag_ids:
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
|
||||||
):
|
|
||||||
skip[tid].add(iid)
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
select(TagSuggestionRejection.image_record_id).where(
|
|
||||||
TagSuggestionRejection.tag_id == tid
|
|
||||||
)
|
|
||||||
):
|
|
||||||
skip[tid].add(iid)
|
|
||||||
|
|
||||||
applied = [0] * len(rows)
|
applied = [0] * len(rows)
|
||||||
scanned = 0
|
scanned = 0
|
||||||
@@ -729,7 +755,7 @@ def auto_apply_sweep(
|
|||||||
if not cids:
|
if not cids:
|
||||||
continue
|
continue
|
||||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H)
|
probs = _sigmoid(Xn @ W.T + b, np) # (N, H)
|
||||||
scanned += len(cids)
|
scanned += len(cids)
|
||||||
for h in range(len(rows)):
|
for h in range(len(rows)):
|
||||||
tid = tag_ids[h]
|
tid = tag_ids[h]
|
||||||
@@ -759,18 +785,42 @@ def auto_apply_sweep(
|
|||||||
|
|
||||||
|
|
||||||
_PRESENTATION_SOURCE = "presentation_auto"
|
_PRESENTATION_SOURCE = "presentation_auto"
|
||||||
|
_PROCESS_SOURCE = "process_auto"
|
||||||
|
|
||||||
|
# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply
|
||||||
|
# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review
|
||||||
|
# guard — and differ ONLY in which tags, which settings knobs, and which
|
||||||
|
# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process'
|
||||||
|
# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of
|
||||||
|
# the tag's group membership, not of this sweep).
|
||||||
|
_SWEEP_MODES = {
|
||||||
|
"chrome": {
|
||||||
|
"names": CHROME_SYSTEM_TAGS,
|
||||||
|
"enabled": "presentation_auto_apply_enabled",
|
||||||
|
"threshold": "presentation_auto_apply_threshold",
|
||||||
|
"conflict": "presentation_conflict_threshold",
|
||||||
|
"source": _PRESENTATION_SOURCE,
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"names": PROCESS_SYSTEM_TAGS,
|
||||||
|
"enabled": "process_auto_apply_enabled",
|
||||||
|
"threshold": "process_auto_apply_threshold",
|
||||||
|
"conflict": "process_conflict_threshold",
|
||||||
|
"source": _PROCESS_SOURCE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _presentation_heads(session: Session, embedding_version: str):
|
def _system_tag_heads(session: Session, embedding_version: str, names):
|
||||||
"""Trained heads for the presentation chrome tags (banner / editor screenshot).
|
"""Trained heads for a system-tag group (chrome banner / process wip+editor).
|
||||||
They fire at the FLAT presentation threshold regardless of graduation — a head
|
They fire at the group's FLAT threshold regardless of graduation — a head
|
||||||
exists once the operator has labelled enough chrome (head_min_positives)."""
|
exists once the operator has labelled enough (head_min_positives)."""
|
||||||
return session.execute(
|
return session.execute(
|
||||||
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
|
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
|
||||||
.join(Tag, Tag.id == TagHead.tag_id)
|
.join(Tag, Tag.id == TagHead.tag_id)
|
||||||
.where(TagHead.embedding_version == embedding_version)
|
.where(TagHead.embedding_version == embedding_version)
|
||||||
.where(Tag.is_system.is_(True))
|
.where(Tag.is_system.is_(True))
|
||||||
.where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS))
|
.where(Tag.name.in_(names))
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
@@ -802,27 +852,32 @@ def _valued_image_ids(session: Session) -> set[int]:
|
|||||||
return {r[0] for r in rows}
|
return {r[0] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict:
|
def system_tag_auto_apply_sweep(
|
||||||
"""Auto-hide presentation chrome (banner / editor screenshot) at the FLAT
|
session: Session, *, mode: str, dry_run: bool = False
|
||||||
presentation threshold (#141) — NOT the per-head graduated threshold. Two
|
) -> dict:
|
||||||
guards keep it safe: (1) never hide an image carrying a human/confirmed content
|
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
|
||||||
tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold
|
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
|
||||||
on a content head, still hide it but flag it (PresentationReview) so the Hidden
|
VISIBLE — the ONLY difference is the tag group's gallery membership, not this
|
||||||
view surfaces "also looks like <X>" for review. No-op unless
|
sweep. Two guards keep it safe: (1) never touch an image carrying a
|
||||||
presentation_auto_apply_enabled. numpy-only (no sklearn). Returns
|
human/confirmed content tag; (2) if the image ALSO scores >= the conflict
|
||||||
{n_applied, n_flagged, concepts}."""
|
threshold on a content head, still apply but flag it (PresentationReview,
|
||||||
|
mode=<mode>) so the review strip surfaces "also looks like <X>". The source is
|
||||||
|
PROVISIONAL so the head never trains on its own output. No-op unless the mode's
|
||||||
|
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
|
||||||
|
concepts}."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
|
|
||||||
|
cfg = _SWEEP_MODES[mode]
|
||||||
settings = _settings(session)
|
settings = _settings(session)
|
||||||
if not dry_run and not settings.presentation_auto_apply_enabled:
|
if not dry_run and not getattr(settings, cfg["enabled"]):
|
||||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||||
ver = settings.embedder_model_version
|
ver = settings.embedder_model_version
|
||||||
pres = _presentation_heads(session, ver)
|
pres = _system_tag_heads(session, ver, cfg["names"])
|
||||||
if not pres:
|
if not pres:
|
||||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||||
thr = float(settings.presentation_auto_apply_threshold)
|
thr = float(getattr(settings, cfg["threshold"]))
|
||||||
conflict_thr = float(settings.presentation_conflict_threshold)
|
conflict_thr = float(getattr(settings, cfg["conflict"]))
|
||||||
|
source = cfg["source"]
|
||||||
|
|
||||||
Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres])
|
Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres])
|
||||||
bp = np.asarray([r.bias for r in pres], dtype=np.float32)
|
bp = np.asarray([r.bias for r in pres], dtype=np.float32)
|
||||||
@@ -839,18 +894,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
|||||||
valued = _valued_image_ids(session)
|
valued = _valued_image_ids(session)
|
||||||
|
|
||||||
# Skip images that already carry, or have rejected, each presentation tag.
|
# Skip images that already carry, or have rejected, each presentation tag.
|
||||||
skip = {tid: set() for tid in pres_tag_ids}
|
skip = _applied_or_rejected(session, pres_tag_ids)
|
||||||
for tid in pres_tag_ids:
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
|
||||||
):
|
|
||||||
skip[tid].add(iid)
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
select(TagSuggestionRejection.image_record_id).where(
|
|
||||||
TagSuggestionRejection.tag_id == tid
|
|
||||||
)
|
|
||||||
):
|
|
||||||
skip[tid].add(iid)
|
|
||||||
|
|
||||||
applied = [0] * len(pres)
|
applied = [0] * len(pres)
|
||||||
n_flagged = 0
|
n_flagged = 0
|
||||||
@@ -865,11 +909,9 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
|||||||
if not cids:
|
if not cids:
|
||||||
continue
|
continue
|
||||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P)
|
probs = _sigmoid(Xn @ Wp.T + bp, np) # (N, P)
|
||||||
if Wc is not None:
|
if Wc is not None:
|
||||||
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
|
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,)
|
||||||
max_c = cprobs.max(axis=1)
|
|
||||||
arg_c = cprobs.argmax(axis=1)
|
|
||||||
scanned += len(cids)
|
scanned += len(cids)
|
||||||
for p in range(len(pres)):
|
for p in range(len(pres)):
|
||||||
tid = pres_tag_ids[p]
|
tid = pres_tag_ids[p]
|
||||||
@@ -884,22 +926,22 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
|||||||
pg_insert(image_tag)
|
pg_insert(image_tag)
|
||||||
.values(
|
.values(
|
||||||
image_record_id=iid, tag_id=tid,
|
image_record_id=iid, tag_id=tid,
|
||||||
source=_PRESENTATION_SOURCE,
|
source=source,
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing()
|
.on_conflict_do_nothing()
|
||||||
)
|
)
|
||||||
# Guard 2: also looks like content → hide but flag for review.
|
# Guard 2: also looks like real content → still apply, but flag it
|
||||||
|
# for the review strip instead of silently marking (chrome hides,
|
||||||
|
# process stays visible — either way the operator gets a heads-up).
|
||||||
if Wc is not None and float(max_c[idx]) >= conflict_thr:
|
if Wc is not None and float(max_c[idx]) >= conflict_thr:
|
||||||
n_flagged += 1
|
n_flagged += 1
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
session.execute(
|
_insert_presentation_review(
|
||||||
pg_insert(PresentationReview)
|
session,
|
||||||
.values(
|
image_record_id=iid, tag_id=tid,
|
||||||
image_record_id=iid, tag_id=tid,
|
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
conflict_score=float(max_c[idx]),
|
||||||
conflict_score=float(max_c[idx]),
|
mode=mode,
|
||||||
)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
)
|
)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -914,6 +956,68 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
|
||||||
|
"""Ring-loud audit for the SOFT WIP-title cohort (#1474). Images auto-tagged
|
||||||
|
`wip` from a low-precision sketch/doodle title (source='wip_title_soft') that ALSO
|
||||||
|
score >= the process conflict threshold on a content head are probably FINISHED
|
||||||
|
art mis-tagged as process — flag them (PresentationReview, mode='process') so the
|
||||||
|
review strip surfaces them ("also looks like <X>", Keep tag / Remove tag). Does
|
||||||
|
NOT remove the tag; the operator decides. No-op when there are no content heads.
|
||||||
|
numpy-only. Returns {n_scanned, n_flagged}."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id
|
||||||
|
|
||||||
|
settings = _settings(session)
|
||||||
|
ver = settings.embedder_model_version
|
||||||
|
conflict_thr = float(settings.process_conflict_threshold)
|
||||||
|
conf = _conflict_heads(session, ver)
|
||||||
|
wip_id = resolve_wip_tag_id(session)
|
||||||
|
if not conf or wip_id is None:
|
||||||
|
return {"n_scanned": 0, "n_flagged": 0}
|
||||||
|
Wc = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in conf])
|
||||||
|
bc = np.asarray([r.bias for r in conf], dtype=np.float32)
|
||||||
|
conf_tag_ids = [r.tag_id for r in conf]
|
||||||
|
|
||||||
|
soft_ids = [iid for (iid,) in session.execute(
|
||||||
|
select(image_tag.c.image_record_id)
|
||||||
|
.where(image_tag.c.tag_id == wip_id)
|
||||||
|
.where(image_tag.c.source == WIP_TITLE_SOFT_SOURCE)
|
||||||
|
)]
|
||||||
|
# Skip images already flagged for this tag (idempotent re-runs).
|
||||||
|
flagged = {iid for (iid,) in session.execute(
|
||||||
|
select(PresentationReview.image_record_id)
|
||||||
|
.where(PresentationReview.tag_id == wip_id)
|
||||||
|
)}
|
||||||
|
soft_ids = [i for i in soft_ids if i not in flagged]
|
||||||
|
|
||||||
|
n_flagged = 0
|
||||||
|
scanned = 0
|
||||||
|
for start in range(0, len(soft_ids), _AUTO_APPLY_CHUNK):
|
||||||
|
chunk = soft_ids[start:start + _AUTO_APPLY_CHUNK]
|
||||||
|
emb = _load_embeddings(session, chunk)
|
||||||
|
cids = [i for i in chunk if i in emb]
|
||||||
|
if not cids:
|
||||||
|
continue
|
||||||
|
scanned += len(cids)
|
||||||
|
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||||
|
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np)
|
||||||
|
for k in range(len(cids)):
|
||||||
|
if float(max_c[k]) >= conflict_thr:
|
||||||
|
n_flagged += 1
|
||||||
|
if not dry_run:
|
||||||
|
_insert_presentation_review(
|
||||||
|
session,
|
||||||
|
image_record_id=cids[k], tag_id=wip_id,
|
||||||
|
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
|
||||||
|
conflict_score=float(max_c[k]),
|
||||||
|
mode="process",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
session.commit()
|
||||||
|
return {"n_scanned": scanned, "n_flagged": n_flagged}
|
||||||
|
|
||||||
|
|
||||||
def retract_auto_applied_heads(session: Session) -> int:
|
def retract_auto_applied_heads(session: Session) -> int:
|
||||||
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto'
|
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto'
|
||||||
tag against its CURRENT head and REMOVE the ones now BELOW the head's
|
tag against its CURRENT head and REMOVE the ones now BELOW the head's
|
||||||
@@ -961,7 +1065,7 @@ def retract_auto_applied_heads(session: Session) -> int:
|
|||||||
continue
|
continue
|
||||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||||
w = np.asarray(weights, dtype=np.float32)
|
w = np.asarray(weights, dtype=np.float32)
|
||||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias))))
|
probs = _sigmoid(Xn @ w + float(bias), np)
|
||||||
below = [cids[k] for k in np.where(probs < float(thr))[0]]
|
below = [cids[k] for k in np.where(probs < float(thr))[0]]
|
||||||
for iid in below:
|
for iid in below:
|
||||||
session.execute(
|
session.execute(
|
||||||
|
|||||||
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
|
|||||||
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
||||||
# auto-applied predictions out of training is what makes them "soft" — a misfire
|
# auto-applied predictions out of training is what makes them "soft" — a misfire
|
||||||
# can't reinforce itself, so the retraction sweep can actually drop it.
|
# can't reinforce itself, so the retraction sweep can actually drop it.
|
||||||
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
|
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
|
||||||
|
# ALSO provisional — the head must learn only from title (`wip_title`) + manual
|
||||||
|
# labels, never its own auto-applied output, or it would runaway (operator 2026-07-12).
|
||||||
|
# `wip_title_soft` (#1474): the soft title tier (sketch/doodle) is LOW-precision, so
|
||||||
|
# it's provisional too — a finished piece titled "sketch" must not train the wip head.
|
||||||
|
_AUTO_SOURCES = (
|
||||||
|
"head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto",
|
||||||
|
"wip_title_soft",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _hygiene_excluded_ids(session: Session) -> set[int]:
|
def _hygiene_excluded_ids(session: Session) -> set[int]:
|
||||||
@@ -86,6 +94,24 @@ def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _applied_or_rejected(session: Session, tag_ids) -> dict[int, set[int]]:
|
||||||
|
"""Per-tag skip set for the auto-apply sweeps: every image that ALREADY carries
|
||||||
|
the tag (ANY source — not just training positives) OR has rejected it. A sweep
|
||||||
|
never re-applies to these. Shared by auto_apply_sweep + system_tag_auto_apply_sweep
|
||||||
|
(heads.py) and scheduled_ccip_auto_apply (tasks/ml.py). Callers mutate the returned
|
||||||
|
sets in-place to also dedupe within a single run."""
|
||||||
|
skip: dict[int, set[int]] = {}
|
||||||
|
for tid in tag_ids:
|
||||||
|
ids = {
|
||||||
|
r[0] for r in session.execute(
|
||||||
|
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
ids.update(_rejected_ids(session, tid))
|
||||||
|
skip[tid] = ids
|
||||||
|
return skip
|
||||||
|
|
||||||
|
|
||||||
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
||||||
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||||
sparse, so an untagged image is almost always a true negative."""
|
sparse, so an untagged image is almost always a true negative."""
|
||||||
|
|||||||
@@ -91,48 +91,46 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None:
|
||||||
|
"""The first `data` object from Patreon's campaigns API filtered by vanity
|
||||||
|
(`?filter[vanity]=<vanity>&fields[campaign]=name`), or None on any failure
|
||||||
|
(network / non-200 / non-JSON / empty). The single request shape shared by
|
||||||
|
_lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the
|
||||||
|
display name)."""
|
||||||
jar = _load_cookie_jar(cookies_path)
|
jar = _load_cookie_jar(cookies_path)
|
||||||
headers = {
|
|
||||||
"User-Agent": _USER_AGENT,
|
|
||||||
"Accept": "application/vnd.api+json",
|
|
||||||
}
|
|
||||||
params = {
|
|
||||||
"filter[vanity]": vanity,
|
|
||||||
"fields[campaign]": "name",
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
_CAMPAIGNS_URL,
|
_CAMPAIGNS_URL,
|
||||||
params=params,
|
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||||
headers=headers,
|
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||||
cookies=jar,
|
cookies=jar,
|
||||||
timeout=_TIMEOUT_SECONDS,
|
timeout=_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
|
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Patreon campaigns API returned HTTP %d for vanity=%s",
|
"Patreon campaigns API returned HTTP %d for vanity=%s",
|
||||||
resp.status_code, vanity,
|
resp.status_code, vanity,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = resp.json()
|
payload = resp.json()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
|
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
|
||||||
return None
|
return None
|
||||||
|
data = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||||
|
return None
|
||||||
|
return data[0]
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
|
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
||||||
|
first = _campaigns_api_first(vanity, cookies_path)
|
||||||
|
if first is None:
|
||||||
return None
|
return None
|
||||||
data = payload.get("data")
|
campaign_id = first.get("id")
|
||||||
if not isinstance(data, list) or not data:
|
|
||||||
return None
|
|
||||||
first = data[0] if isinstance(data[0], dict) else None
|
|
||||||
campaign_id = first.get("id") if first else None
|
|
||||||
if not isinstance(campaign_id, str) or not campaign_id:
|
if not isinstance(campaign_id, str) or not campaign_id:
|
||||||
return None
|
return None
|
||||||
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
|
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
|
||||||
@@ -144,24 +142,10 @@ def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
|
|||||||
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None
|
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None
|
||||||
on any failure — the caller falls back to the vanity handle. Sync: call from
|
on any failure — the caller falls back to the vanity handle. Sync: call from
|
||||||
an executor."""
|
an executor."""
|
||||||
jar = _load_cookie_jar(cookies_path)
|
first = _campaigns_api_first(vanity, cookies_path)
|
||||||
try:
|
if first is None:
|
||||||
resp = requests.get(
|
|
||||||
_CAMPAIGNS_URL,
|
|
||||||
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
|
||||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
|
||||||
cookies=jar,
|
|
||||||
timeout=_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return None
|
|
||||||
data = resp.json().get("data")
|
|
||||||
except (requests.RequestException, ValueError) as exc:
|
|
||||||
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
|
|
||||||
return None
|
return None
|
||||||
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
name = (first.get("attributes") or {}).get("name")
|
||||||
return None
|
|
||||||
name = (data[0].get("attributes") or {}).get("name")
|
|
||||||
return name.strip() if isinstance(name, str) and name.strip() else None
|
return name.strip() if isinstance(name, str) and name.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Title-based WIP auto-tagging (task #1458).
|
||||||
|
|
||||||
|
Deterministic heuristic: when a post's TITLE explicitly declares work-in-progress
|
||||||
|
(the artist's own "WIP" / "work in progress" label), the ``wip`` system tag is
|
||||||
|
applied to that post's images — a cheap, high-precision complement to the
|
||||||
|
image-based ML ``wip`` head. WIP images are excluded from the Explore/gallery
|
||||||
|
browse (see gallery_service ``excluded_system_tags``), so honouring the artist's
|
||||||
|
own label keeps unfinished pieces out of the main browse right at import.
|
||||||
|
|
||||||
|
Precision over recall — a false WIP tag HIDES a finished post — so matching is
|
||||||
|
token-anchored: ``swipe`` / ``wiped`` / ``wiping`` never trip it (a letter on the
|
||||||
|
boundary blocks the match).
|
||||||
|
|
||||||
|
Sync-only: both consumers (the importer and the backfill Celery task) run on a
|
||||||
|
sync Session. Application is idempotent-additive (ON CONFLICT DO NOTHING) and
|
||||||
|
stamps a distinct ``image_tag.source`` so a later pass can tell where a wip tag
|
||||||
|
came from — the "manual" / "head_auto" / "ccip_auto" / "ml_accepted" provenance
|
||||||
|
family gains one member.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag
|
||||||
|
|
||||||
|
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other
|
||||||
|
# apply sources so provenance stays legible and a future undo can target only these.
|
||||||
|
# HARD tier ("WIP"/"work in progress") is high-precision → trains the wip head.
|
||||||
|
WIP_TITLE_SOURCE = "wip_title"
|
||||||
|
# SOFT tier (sketch/doodle/scribble, #1474) is LOWER-precision — a finished "sketch"
|
||||||
|
# is often not WIP. This source is PROVISIONAL (in training_data._AUTO_SOURCES) so it
|
||||||
|
# NEVER trains the wip head; a soft-tagged image that also looks like real content is
|
||||||
|
# surfaced by the ring-loud audit for review.
|
||||||
|
WIP_TITLE_SOFT_SOURCE = "wip_title_soft"
|
||||||
|
|
||||||
|
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
|
||||||
|
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
|
||||||
|
# make this precision-first: `s|wip|e`, `|wip|ed`, `|wip|ing` all have a letter
|
||||||
|
# abutting the token, so they're rejected. A trailing digit is allowed so
|
||||||
|
# "WIP2" (= WIP part 2) still matches.
|
||||||
|
_WIP_RE = re.compile(
|
||||||
|
r"(?<![A-Za-z])(?:w\.?i\.?p\.?|work[\s_-]+in[\s_-]+progress)(?![A-Za-z])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft tier: sketch / doodle / scribble (+ plurals), letter-boundary anchored so
|
||||||
|
# "sketchbook" / "kadoodle" don't trip it. Deliberately conservative — recall is
|
||||||
|
# secondary because the soft source doesn't train the head and the ring-loud audit
|
||||||
|
# catches false positives.
|
||||||
|
_SOFT_WIP_RE = re.compile(
|
||||||
|
r"(?<![A-Za-z])(?:sketch|sketches|doodle|doodles|scribble|scribbles)(?![A-Za-z])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Coarse SQL prefilters for the backfill sweep — narrow the post scan to rows that
|
||||||
|
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
|
||||||
|
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
|
||||||
|
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
|
||||||
|
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
|
||||||
|
|
||||||
|
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
|
||||||
|
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
|
||||||
|
_INSERT_CHUNK = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def matches_wip_title(title: str | None) -> bool:
|
||||||
|
"""True when a post title explicitly marks it work-in-progress (HARD tier)."""
|
||||||
|
if not title:
|
||||||
|
return False
|
||||||
|
return _WIP_RE.search(title) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def matches_soft_wip_title(title: str | None) -> bool:
|
||||||
|
"""True when a title carries a SOFT WIP cue (sketch/doodle/scribble, #1474)."""
|
||||||
|
if not title:
|
||||||
|
return False
|
||||||
|
return _SOFT_WIP_RE.search(title) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_wip_tag_id(session: Session) -> int | None:
|
||||||
|
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
|
||||||
|
return session.execute(
|
||||||
|
select(Tag.id).where(Tag.name == WIP_SYSTEM_TAG, Tag.is_system.is_(True))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_wip_image_tags(
|
||||||
|
session: Session, image_ids, tag_id: int, *, source: str = WIP_TITLE_SOURCE
|
||||||
|
) -> int:
|
||||||
|
"""Attach ``tag_id`` (stamped with ``source``) to each image id, idempotently —
|
||||||
|
never disturbs an existing tag or its source. Returns the number of image_tag
|
||||||
|
rows newly inserted. Does NOT commit.
|
||||||
|
|
||||||
|
The insert count is computed from a pre-SELECT of already-tagged ids rather
|
||||||
|
than the statement's ``rowcount``: psycopg reports -1 for a multi-row
|
||||||
|
ON CONFLICT DO NOTHING insert (it runs via an executemany path), so rowcount
|
||||||
|
is unusable here. The SELECT is accurate within this single transaction (no
|
||||||
|
concurrent writer touches these (image, wip) rows); ON CONFLICT DO NOTHING
|
||||||
|
stays as a race-safety belt so a rare concurrent insert can't error."""
|
||||||
|
ids = list({int(i) for i in image_ids})
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
inserted = 0
|
||||||
|
for start in range(0, len(ids), _INSERT_CHUNK):
|
||||||
|
chunk = ids[start:start + _INSERT_CHUNK]
|
||||||
|
already = set(session.execute(
|
||||||
|
select(image_tag.c.image_record_id)
|
||||||
|
.where(image_tag.c.tag_id == tag_id)
|
||||||
|
.where(image_tag.c.image_record_id.in_(chunk))
|
||||||
|
).scalars())
|
||||||
|
to_insert = [iid for iid in chunk if iid not in already]
|
||||||
|
if not to_insert:
|
||||||
|
continue
|
||||||
|
session.execute(
|
||||||
|
pg_insert(image_tag)
|
||||||
|
.values([
|
||||||
|
{"image_record_id": iid, "tag_id": tag_id, "source": source}
|
||||||
|
for iid in to_insert
|
||||||
|
])
|
||||||
|
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||||
|
)
|
||||||
|
inserted += len(to_insert)
|
||||||
|
return inserted
|
||||||
@@ -76,6 +76,8 @@ DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
|
|||||||
OLD_TASK_DAYS = 7
|
OLD_TASK_DAYS = 7
|
||||||
PHASH_PAGE = 500
|
PHASH_PAGE = 500
|
||||||
VERIFY_PAGE = 200
|
VERIFY_PAGE = 200
|
||||||
|
# Title-based WIP backfill (task #1458): posts scanned per keyset page.
|
||||||
|
WIP_BACKFILL_PAGE = 500
|
||||||
FFPROBE_TIMEOUT_SECONDS = 10
|
FFPROBE_TIMEOUT_SECONDS = 10
|
||||||
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
||||||
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
@@ -774,89 +776,62 @@ def recover_stalled_library_audit_runs() -> int:
|
|||||||
return recovered
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_stalled_runs(model, *, stall_minutes: int, keep_runs: int, label: str) -> int:
|
||||||
|
"""Shared recovery + retention sweep for the head run-tracking tables
|
||||||
|
(HeadTrainingRun / HeadAutoApplyRun, which share the
|
||||||
|
status/last_progress_at/started_at/finished_at/error/id columns): flip 'running'
|
||||||
|
rows with no progress past `stall_minutes` to 'error', then prune to the last
|
||||||
|
`keep_runs` (rule 89). Returns the number recovered. NOTE the two other recover
|
||||||
|
tasks are deliberately NOT folded in — library-audit has no prune tail and
|
||||||
|
backup uses a single started_at cutoff."""
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cutoff = now - timedelta(minutes=stall_minutes)
|
||||||
|
with SessionLocal() as session:
|
||||||
|
result = session.execute(
|
||||||
|
update(model)
|
||||||
|
.where(model.status == "running")
|
||||||
|
.where(func.coalesce(model.last_progress_at, model.started_at) < cutoff)
|
||||||
|
.values(
|
||||||
|
status="error", finished_at=now,
|
||||||
|
error=f"stranded by recovery sweep (no progress for {stall_minutes} min)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keep = session.execute(
|
||||||
|
select(model.id).order_by(model.id.desc()).limit(keep_runs)
|
||||||
|
).scalars().all()
|
||||||
|
if keep:
|
||||||
|
session.execute(delete(model).where(model.id.not_in(keep)))
|
||||||
|
session.commit()
|
||||||
|
recovered = result.rowcount or 0
|
||||||
|
if recovered:
|
||||||
|
log.info("%s: recovered %d rows", label, recovered)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
|
||||||
def recover_stalled_head_training_runs() -> int:
|
def recover_stalled_head_training_runs() -> int:
|
||||||
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
||||||
'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention,
|
'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention,
|
||||||
rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
|
rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
|
||||||
SessionLocal = _sync_session_factory()
|
return _recover_stalled_runs(
|
||||||
now = datetime.now(UTC)
|
HeadTrainingRun,
|
||||||
cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES)
|
stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES,
|
||||||
with SessionLocal() as session:
|
keep_runs=HEAD_TRAINING_KEEP_RUNS,
|
||||||
result = session.execute(
|
label="recover_stalled_head_training_runs",
|
||||||
update(HeadTrainingRun)
|
)
|
||||||
.where(HeadTrainingRun.status == "running")
|
|
||||||
.where(
|
|
||||||
func.coalesce(
|
|
||||||
HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at
|
|
||||||
)
|
|
||||||
< cutoff
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
status="error", finished_at=now,
|
|
||||||
error=(
|
|
||||||
f"stranded by recovery sweep (no progress for "
|
|
||||||
f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
keep = session.execute(
|
|
||||||
select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc())
|
|
||||||
.limit(HEAD_TRAINING_KEEP_RUNS)
|
|
||||||
).scalars().all()
|
|
||||||
if keep:
|
|
||||||
session.execute(
|
|
||||||
delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep))
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
recovered = result.rowcount or 0
|
|
||||||
if recovered:
|
|
||||||
log.info(
|
|
||||||
"recover_stalled_head_training_runs: recovered %d rows", recovered
|
|
||||||
)
|
|
||||||
return recovered
|
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
|
||||||
def recover_stalled_head_auto_apply_runs() -> int:
|
def recover_stalled_head_auto_apply_runs() -> int:
|
||||||
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
|
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
|
||||||
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
|
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
|
||||||
SessionLocal = _sync_session_factory()
|
return _recover_stalled_runs(
|
||||||
now = datetime.now(UTC)
|
HeadAutoApplyRun,
|
||||||
cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES)
|
stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES,
|
||||||
with SessionLocal() as session:
|
keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS,
|
||||||
result = session.execute(
|
label="recover_stalled_head_auto_apply_runs",
|
||||||
update(HeadAutoApplyRun)
|
)
|
||||||
.where(HeadAutoApplyRun.status == "running")
|
|
||||||
.where(
|
|
||||||
func.coalesce(
|
|
||||||
HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at
|
|
||||||
)
|
|
||||||
< cutoff
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
status="error", finished_at=now,
|
|
||||||
error=(
|
|
||||||
f"stranded by recovery sweep (no progress for "
|
|
||||||
f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
keep = session.execute(
|
|
||||||
select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc())
|
|
||||||
.limit(HEAD_AUTO_APPLY_KEEP_RUNS)
|
|
||||||
).scalars().all()
|
|
||||||
if keep:
|
|
||||||
session.execute(
|
|
||||||
delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep))
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
recovered = result.rowcount or 0
|
|
||||||
if recovered:
|
|
||||||
log.info(
|
|
||||||
"recover_stalled_head_auto_apply_runs: recovered %d rows", recovered
|
|
||||||
)
|
|
||||||
return recovered
|
|
||||||
|
|
||||||
|
|
||||||
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
|
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
|
||||||
@@ -1045,6 +1020,96 @@ def cleanup_old_download_events() -> int:
|
|||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
|
||||||
|
"""One keyset-paginated pass over posts whose title matches a WIP tier, applying
|
||||||
|
`tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers
|
||||||
|
(#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
|
||||||
|
`matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row
|
||||||
|
count newly applied."""
|
||||||
|
from ..models import Post
|
||||||
|
from ..models.image_provenance import ImageProvenance
|
||||||
|
from ..services.wip_title import apply_wip_image_tags
|
||||||
|
|
||||||
|
applied = 0
|
||||||
|
last_id = 0
|
||||||
|
while True:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Post.id, Post.post_title)
|
||||||
|
.where(Post.id > last_id)
|
||||||
|
.where(Post.post_title.is_not(None))
|
||||||
|
.where(or_(*[Post.post_title.ilike(p) for p in prefilter]))
|
||||||
|
.order_by(Post.id.asc())
|
||||||
|
.limit(WIP_BACKFILL_PAGE)
|
||||||
|
).all()
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
last_id = rows[-1][0]
|
||||||
|
match_ids = [pid for pid, title in rows if matcher(title)]
|
||||||
|
if match_ids:
|
||||||
|
image_ids = session.execute(
|
||||||
|
select(ImageProvenance.image_record_id)
|
||||||
|
.where(ImageProvenance.post_id.in_(match_ids))
|
||||||
|
).scalars().all()
|
||||||
|
applied += apply_wip_image_tags(session, image_ids, tag_id, source=source)
|
||||||
|
session.commit()
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
|
||||||
|
# Coarse-prefiltered scan over posts; the candidate set is small on a typical
|
||||||
|
# library, but bound it like the other full-library sweeps.
|
||||||
|
soft_time_limit=1800, time_limit=2100,
|
||||||
|
)
|
||||||
|
def backfill_wip_title_tags() -> int:
|
||||||
|
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
|
||||||
|
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
|
||||||
|
#1474 soft tier). New imports are tagged live by the importer; this covers the
|
||||||
|
existing library.
|
||||||
|
|
||||||
|
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
|
||||||
|
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
|
||||||
|
wip_soft_title_tagging_enabled, AFTER hard so a title matching both keeps the
|
||||||
|
trained hard tag (ON CONFLICT DO NOTHING). Keyset-paginated, restart-safe.
|
||||||
|
|
||||||
|
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
|
||||||
|
posts and silently undo a manual WIP removal, so it stays an explicit operator
|
||||||
|
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
|
||||||
|
"""
|
||||||
|
from ..models import ImportSettings
|
||||||
|
from ..services.wip_title import (
|
||||||
|
SOFT_WIP_TITLE_SQL_PREFILTER,
|
||||||
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
|
WIP_TITLE_SOURCE,
|
||||||
|
WIP_TITLE_SQL_PREFILTER,
|
||||||
|
matches_soft_wip_title,
|
||||||
|
matches_wip_title,
|
||||||
|
resolve_wip_tag_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
tag_id = resolve_wip_tag_id(session)
|
||||||
|
if tag_id is None:
|
||||||
|
log.warning(
|
||||||
|
"backfill_wip_title_tags: no `wip` system tag present; nothing to do"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
settings = ImportSettings.load_sync(session)
|
||||||
|
applied = _backfill_wip_tier(
|
||||||
|
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
|
||||||
|
WIP_TITLE_SOURCE,
|
||||||
|
)
|
||||||
|
if settings.wip_soft_title_tagging_enabled:
|
||||||
|
applied += _backfill_wip_tier(
|
||||||
|
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
|
||||||
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
|
)
|
||||||
|
if applied:
|
||||||
|
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
|
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
|
||||||
def vacuum_analyze() -> dict:
|
def vacuum_analyze() -> dict:
|
||||||
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
|
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
|
||||||
|
|||||||
+52
-36
@@ -105,9 +105,7 @@ def embed_image(self, image_id: int) -> dict:
|
|||||||
record = session.get(ImageRecord, image_id)
|
record = session.get(ImageRecord, image_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return {"status": "missing", "image_id": image_id}
|
return {"status": "missing", "image_id": image_id}
|
||||||
settings = session.execute(
|
settings = MLSettings.load_sync(session)
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
src = Path(record.path)
|
src = Path(record.path)
|
||||||
is_vid = _is_video(src)
|
is_vid = _is_video(src)
|
||||||
@@ -488,15 +486,10 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection
|
from ..models import ImageRegion, MLSettings, Tag, TagKind
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
from ..services.ml.ccip import _FIGURE_KINDS
|
||||||
fig = ("face", "figure")
|
from ..services.ml.training_data import _applied_or_rejected, _l2norm
|
||||||
|
|
||||||
def _l2(m):
|
|
||||||
n = np.linalg.norm(m, axis=1, keepdims=True)
|
|
||||||
n[n == 0] = 1.0
|
|
||||||
return m / n
|
|
||||||
|
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
@@ -521,7 +514,7 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
)
|
)
|
||||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||||
.where(Tag.kind == TagKind.character)
|
.where(Tag.kind == TagKind.character)
|
||||||
.where(ImageRegion.kind.in_(fig))
|
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
|
||||||
.where(ImageRegion.ccip_embedding.is_not(None))
|
.where(ImageRegion.ccip_embedding.is_not(None))
|
||||||
.where(ImageRegion.image_record_id.in_(single))
|
.where(ImageRegion.image_record_id.in_(single))
|
||||||
).all()
|
).all()
|
||||||
@@ -532,29 +525,16 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
for tid, vec in ref_rows:
|
for tid, vec in ref_rows:
|
||||||
by_char.setdefault(tid, []).append(vec)
|
by_char.setdefault(tid, []).append(vec)
|
||||||
ref_tags = list(by_char)
|
ref_tags = list(by_char)
|
||||||
mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags]
|
mats = [_l2norm(np.asarray(by_char[t], dtype=np.float32), np) for t in ref_tags]
|
||||||
allref = np.vstack(mats) # (total, 768)
|
allref = np.vstack(mats) # (total, 768)
|
||||||
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
|
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
|
||||||
|
|
||||||
# Per character: images that already carry OR rejected the tag — skip.
|
# Per character: images that already carry OR rejected the tag — skip.
|
||||||
skip = {t: set() for t in ref_tags}
|
skip = _applied_or_rejected(session, ref_tags)
|
||||||
for t in ref_tags:
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
sa_select(image_tag.c.image_record_id).where(
|
|
||||||
image_tag.c.tag_id == t
|
|
||||||
)
|
|
||||||
):
|
|
||||||
skip[t].add(iid)
|
|
||||||
for (iid,) in session.execute(
|
|
||||||
sa_select(TagSuggestionRejection.image_record_id).where(
|
|
||||||
TagSuggestionRejection.tag_id == t
|
|
||||||
)
|
|
||||||
):
|
|
||||||
skip[t].add(iid)
|
|
||||||
|
|
||||||
img_ids = list(session.execute(
|
img_ids = list(session.execute(
|
||||||
sa_select(ImageRegion.image_record_id)
|
sa_select(ImageRegion.image_record_id)
|
||||||
.where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None))
|
.where(ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None))
|
||||||
.distinct()
|
.distinct()
|
||||||
).scalars())
|
).scalars())
|
||||||
|
|
||||||
@@ -566,7 +546,7 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
|
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
|
||||||
.where(
|
.where(
|
||||||
ImageRegion.image_record_id.in_(chunk),
|
ImageRegion.image_record_id.in_(chunk),
|
||||||
ImageRegion.kind.in_(fig),
|
ImageRegion.kind.in_(_FIGURE_KINDS),
|
||||||
ImageRegion.ccip_embedding.is_not(None),
|
ImageRegion.ccip_embedding.is_not(None),
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
@@ -574,7 +554,7 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
for iid, vec in rows:
|
for iid, vec in rows:
|
||||||
by_img.setdefault(iid, []).append(vec)
|
by_img.setdefault(iid, []).append(vec)
|
||||||
for iid, vecs in by_img.items():
|
for iid, vecs in by_img.items():
|
||||||
q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768)
|
q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768)
|
||||||
colmax = (q @ allref.T).max(axis=0) # (total,)
|
colmax = (q @ allref.T).max(axis=0) # (total,)
|
||||||
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
|
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
|
||||||
for ci in np.where(charmax >= thr)[0]:
|
for ci in np.where(charmax >= thr)[0]:
|
||||||
@@ -599,18 +579,54 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
soft_time_limit=1800, time_limit=2100,
|
soft_time_limit=1800, time_limit=2100,
|
||||||
)
|
)
|
||||||
def scheduled_presentation_auto_apply() -> str:
|
def scheduled_presentation_auto_apply() -> str:
|
||||||
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily
|
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
|
||||||
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
|
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images
|
||||||
— already-hidden images are skipped — so an interrupted run simply re-runs next
|
are skipped — so an interrupted run simply re-runs next cycle (that IS the
|
||||||
cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
|
recovery). Wall-clock bounded by the task time limits."""
|
||||||
from ..services.ml.heads import presentation_auto_apply_sweep
|
from ..services.ml.heads import system_tag_auto_apply_sweep
|
||||||
|
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
result = presentation_auto_apply_sweep(session)
|
result = system_tag_auto_apply_sweep(session, mode="chrome")
|
||||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||||
|
soft_time_limit=1800, time_limit=2100,
|
||||||
|
)
|
||||||
|
def scheduled_process_auto_apply() -> str:
|
||||||
|
"""Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily
|
||||||
|
passive sweep (#1464) — provisional source, ring-loud review guard, image stays
|
||||||
|
VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent —
|
||||||
|
already-tagged/rejected images are skipped — so an interrupted run just re-runs
|
||||||
|
next cycle (the recovery). Wall-clock bounded by the task time limits."""
|
||||||
|
from ..services.ml.heads import system_tag_auto_apply_sweep
|
||||||
|
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
result = system_tag_auto_apply_sweep(session, mode="process")
|
||||||
|
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
|
||||||
|
soft_time_limit=1800, time_limit=2100,
|
||||||
|
)
|
||||||
|
def scheduled_soft_wip_conflict_audit() -> str:
|
||||||
|
"""Ring-loud audit over the SOFT WIP-title cohort (#1474) — flag sketch/doodle
|
||||||
|
auto-tags that ALSO look like real content for review. No-op when there are no
|
||||||
|
content heads; idempotent (already-flagged images skipped). Runs regardless of
|
||||||
|
the process-sweep toggle, since soft-title tags come from the importer, not that
|
||||||
|
sweep. Wall-clock bounded by the task time limits."""
|
||||||
|
from ..services.ml.heads import soft_wip_conflict_audit
|
||||||
|
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
result = soft_wip_conflict_audit(session)
|
||||||
|
return f"scanned={result['n_scanned']} flagged={result['n_flagged']}"
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
|
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
|
||||||
def prune_presentation_reviews() -> str:
|
def prune_presentation_reviews() -> str:
|
||||||
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
||||||
|
|||||||
+21
-3
@@ -11,12 +11,22 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
|||||||
- python 3.14
|
- python 3.14
|
||||||
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
|
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
|
||||||
- node (frontend job: `npm install` + vitest + vite build)
|
- node (frontend job: `npm install` + vitest + vite build)
|
||||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Forgejo registry push)
|
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Fabled-Git registry push)
|
||||||
|
|
||||||
|
## Secondary runtime image
|
||||||
|
|
||||||
|
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
|
||||||
|
|
||||||
|
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
|
||||||
|
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
|
||||||
|
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
|
||||||
|
per `docs/process.md`'s "add deps to the image when used by >1 project".
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
|
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
|
||||||
- `npm install --no-audit --no-fund` — in `frontend-build` job
|
- `npm install --no-audit --no-fund` — in `frontend-build` job
|
||||||
|
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@@ -26,12 +36,20 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
|||||||
"add deps to image when used by >1 project" rule: FC alone is one Python
|
"add deps to image when used by >1 project" rule: FC alone is one Python
|
||||||
project, so the deps live in `requirements.txt` and install per-job.
|
project, so the deps live in `requirements.txt` and install per-job.
|
||||||
Reconsider when a second Fabled-family Python backend lands.
|
Reconsider when a second Fabled-family Python backend lands.
|
||||||
- Integration uses Forgejo Actions `services:` + socket-discovered bridge IPs
|
- Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs
|
||||||
because `act_runner` (swarm-runner v0.6+) puts services on the default
|
because `act_runner` (swarm-runner v0.6+) puts services on the default
|
||||||
bridge with no embedded DNS. The pattern is documented in the rulebook's
|
bridge with no embedded DNS. The pattern is documented in the rulebook's
|
||||||
`forgejo.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
`fabled-git.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
||||||
example.
|
example.
|
||||||
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
|
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
|
||||||
memory bans `npm install` locally). Using `npm install` rather than
|
memory bans `npm install` locally). Using `npm install` rather than
|
||||||
`npm ci` until a lockfile lands.
|
`npm ci` until a lockfile lands.
|
||||||
- No `imagemagick` / `pandoc` per-job installs needed.
|
- No `imagemagick` / `pandoc` per-job installs needed.
|
||||||
|
- `extension/`'s vitest specs load `lib/*.js` by evaluating the real file as a
|
||||||
|
classic script (`test/helpers/loadLib.js`) rather than adding `module.exports`
|
||||||
|
shims to production code — the libs ship as `background.scripts`, not ES
|
||||||
|
modules, so the specs exercise exactly the bytes packaged into the XPI.
|
||||||
|
- Extension test files are excluded from the XPI via `--ignore-files` in
|
||||||
|
`extension/package.json`, and the same paths are excluded from `ci.yml`'s
|
||||||
|
`extension-version` guard. Those two lists must agree — `test/version.spec.js`
|
||||||
|
asserts the guard never ignores a file web-ext actually packages.
|
||||||
|
|||||||
@@ -31,6 +31,68 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
|||||||
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
||||||
ensureInitialized().catch(e => console.error('init failed:', e));
|
ensureInitialized().catch(e => console.error('init failed:', e));
|
||||||
|
|
||||||
|
// ---- Extension self-update check (#1489) ----
|
||||||
|
// Installed per-instance from the operator's FC host, so Firefox's static
|
||||||
|
// update_url can't apply (each instance has a different host). Instead ask the
|
||||||
|
// configured backend for the latest published version and nudge the operator to
|
||||||
|
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
||||||
|
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
||||||
|
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
|
||||||
|
|
||||||
|
function versionIsNewer(candidate, current) {
|
||||||
|
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
||||||
|
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
|
||||||
|
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdateInfo() {
|
||||||
|
await ensureInitialized();
|
||||||
|
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await api.getExtensionManifest();
|
||||||
|
} catch (e) {
|
||||||
|
return { updateAvailable: false, error: e.message };
|
||||||
|
}
|
||||||
|
const currentVersion = browser.runtime.getManifest().version;
|
||||||
|
const latestVersion = info && info.version ? info.version : null;
|
||||||
|
// latest_url is served from the web root, not the JSON API.
|
||||||
|
const base = api.webRoot();
|
||||||
|
return {
|
||||||
|
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUpdateBadge() {
|
||||||
|
let r;
|
||||||
|
try { r = await checkForUpdateInfo(); } catch { return; }
|
||||||
|
try {
|
||||||
|
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
||||||
|
if (r.updateAvailable) {
|
||||||
|
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
||||||
|
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
|
||||||
|
} else {
|
||||||
|
await browser.action.setTitle({ title: 'FabledCurator' });
|
||||||
|
}
|
||||||
|
} catch { /* action API unavailable — non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily proactive check (needs the "alarms" permission). create() is idempotent
|
||||||
|
// by name, so re-running it on each event-page load is safe.
|
||||||
|
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
|
||||||
|
browser.alarms.onAlarm.addListener((alarm) => {
|
||||||
|
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
|
||||||
|
});
|
||||||
|
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
|
||||||
|
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
|
||||||
|
|
||||||
// ---- Discord token capture via webRequest ----
|
// ---- Discord token capture via webRequest ----
|
||||||
|
|
||||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||||
@@ -148,6 +210,21 @@ browser.webRequest.onBeforeRedirect.addListener(
|
|||||||
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
|
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extract → verify → upload one cookie-auth platform. Returns a structured
|
||||||
|
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
|
||||||
|
// their own response + skip semantics. Verifies the captured cookies are
|
||||||
|
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
|
||||||
|
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
|
||||||
|
// through to upload.
|
||||||
|
async function exportPlatformCookies(key) {
|
||||||
|
const cookies = await extractCookiesForPlatform(key);
|
||||||
|
if (cookies.length === 0) return { status: 'empty' };
|
||||||
|
const v = await verifyCookiesForPlatform(key);
|
||||||
|
if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
|
||||||
|
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||||
|
return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Message router ----
|
// ---- Message router ----
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (msg) => {
|
browser.runtime.onMessage.addListener(async (msg) => {
|
||||||
@@ -192,22 +269,14 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
if (!platform) return { error: `Unknown platform: ${key}` };
|
if (!platform) return { error: `Unknown platform: ${key}` };
|
||||||
try {
|
try {
|
||||||
if (platform.authType === 'cookies') {
|
if (platform.authType === 'cookies') {
|
||||||
const cookies = await extractCookiesForPlatform(key);
|
const r = await exportPlatformCookies(key);
|
||||||
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
|
if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
|
||||||
// Verify the captured cookies are actually live BEFORE
|
if (r.status === 'stale') {
|
||||||
// uploading. Skips upload on confirmed-stale sessions so we
|
|
||||||
// don't overwrite FC-side credentials with garbage. Platforms
|
|
||||||
// without a verify config (verify.ok === null) fall through
|
|
||||||
// to upload as before.
|
|
||||||
const v = await verifyCookiesForPlatform(key);
|
|
||||||
if (v.ok === false) {
|
|
||||||
return {
|
return {
|
||||||
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
|
error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const data = toNetscapeFormat(cookies);
|
return { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||||
await api.uploadCredentials(key, 'cookies', data);
|
|
||||||
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
|
||||||
}
|
}
|
||||||
if (key === 'discord') {
|
if (key === 'discord') {
|
||||||
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||||
@@ -235,18 +304,10 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const cookies = await extractCookiesForPlatform(key);
|
const r = await exportPlatformCookies(key);
|
||||||
if (cookies.length === 0) {
|
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
|
||||||
results[key] = { skipped: true, reason: 'no cookies' };
|
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
|
||||||
continue;
|
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||||
}
|
|
||||||
const v = await verifyCookiesForPlatform(key);
|
|
||||||
if (v.ok === false) {
|
|
||||||
results[key] = { error: `verify failed: ${v.reason}` };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
|
||||||
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results[key] = { error: e.message };
|
results[key] = { error: e.message };
|
||||||
}
|
}
|
||||||
@@ -283,11 +344,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'OPEN_ARTIST_PAGE': {
|
case 'OPEN_ARTIST_PAGE': {
|
||||||
// apiUrl is configured with the /api suffix (see
|
// The SPA artist route (/artist/:slug) is served from the web root, not
|
||||||
// options/options.html placeholder); the SPA artist route is
|
// the JSON API — see api.webRoot().
|
||||||
// /artist/:slug, served from the same origin. Strip /api so the
|
const base = api.webRoot();
|
||||||
// browser-level URL hits the Vue router, not the JSON API.
|
|
||||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
|
||||||
const slug = encodeURIComponent(msg.slug || '');
|
const slug = encodeURIComponent(msg.slug || '');
|
||||||
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
||||||
try {
|
try {
|
||||||
@@ -298,6 +357,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'CHECK_UPDATE':
|
||||||
|
return await checkForUpdateInfo();
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { error: `Unknown message type: ${msg.type}` };
|
return { error: `Unknown message type: ${msg.type}` };
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -11,7 +11,10 @@ class FabledCuratorAPI {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||||
this.baseUrl = cfg.apiUrl || null;
|
// Normalize on READ, not just on save: configs stored before the options
|
||||||
|
// page started normalizing are missing the `/api` suffix, and this heals
|
||||||
|
// them without the operator having to reopen Settings.
|
||||||
|
this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null;
|
||||||
this.apiKey = cfg.apiKey || null;
|
this.apiKey = cfg.apiKey || null;
|
||||||
return this.isConfigured();
|
return this.isConfigured();
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,13 @@ class FabledCuratorAPI {
|
|||||||
} catch {
|
} catch {
|
||||||
message = `HTTP ${response.status}: ${response.statusText}`;
|
message = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
}
|
}
|
||||||
|
// 404/405 from FC almost always means the request never reached the JSON
|
||||||
|
// API — it fell through to the SPA catch-all, which serves HTML on GET
|
||||||
|
// and rejects everything else. Say so, rather than making the operator
|
||||||
|
// decode "Method Not Allowed" on an endpoint that plainly allows POST.
|
||||||
|
if (response.status === 404 || response.status === 405) {
|
||||||
|
message += ` — ${url} isn't the FC API. Check the FC URL in settings.`;
|
||||||
|
}
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
err.status = response.status;
|
err.status = response.status;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -89,6 +99,18 @@ class FabledCuratorAPI {
|
|||||||
const qs = new URLSearchParams({ url }).toString();
|
const qs = new URLSearchParams({ url }).toString();
|
||||||
return this.request('GET', `/extension/probe?${qs}`);
|
return this.request('GET', `/extension/probe?${qs}`);
|
||||||
}
|
}
|
||||||
|
// Latest published extension version on this instance — drives the in-app
|
||||||
|
// update prompt. Public endpoint (no key needed, but request() sends it
|
||||||
|
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
|
||||||
|
getExtensionManifest() {
|
||||||
|
return this.request('GET', '/extension/manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The web/SPA root: where the Vue router (artist pages) and the served XPI
|
||||||
|
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||||
|
webRoot() {
|
||||||
|
return webRootFromApiUrl(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Connection test = the cheapest read with auth.
|
// Connection test = the cheapest read with auth.
|
||||||
testConnection() {
|
testConnection() {
|
||||||
|
|||||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
|||||||
* script to decide whether to show the floating "Add as source" button.
|
* script to decide whether to show the floating "Add as source" button.
|
||||||
*/
|
*/
|
||||||
const PLATFORM_ARTIST_PATTERNS = {
|
const PLATFORM_ARTIST_PATTERNS = {
|
||||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
// Patreon serves the same creator under three URL shapes (see backend
|
||||||
|
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
|
||||||
|
// `cw/` "creator workspace" — the last is the URL you land on once you're
|
||||||
|
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
|
||||||
|
// drop the single-segment end-anchor so a creator's inner page
|
||||||
|
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
|
||||||
|
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
|
||||||
|
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
|
||||||
|
// vanished once subscribed because the old pattern only matched the bare root).
|
||||||
|
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Canonical FC endpoint derivation, shared by the background client and the
|
||||||
|
* options page so a URL entered either way behaves identically.
|
||||||
|
*
|
||||||
|
* FC serves two things on one origin: the JSON API under `/api`, and the Vue
|
||||||
|
* SPA from the root. `api.js` builds requests as `${baseUrl}/credentials`, so
|
||||||
|
* the stored base URL has to carry the `/api` suffix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept what an operator would naturally type — the instance root
|
||||||
|
* (`http://curator.example.com`) or the API root (`.../api`) — and return the
|
||||||
|
* API root either way.
|
||||||
|
*
|
||||||
|
* Worth normalizing rather than validating: a root-form URL doesn't fail
|
||||||
|
* loudly, it lands on the SPA catch-all, which answers `GET /credentials` with
|
||||||
|
* 200 HTML and rejects `POST /credentials` with 405. The operator sees a
|
||||||
|
* working Test Connection and a broken export.
|
||||||
|
*/
|
||||||
|
function normalizeApiUrl(raw) {
|
||||||
|
const trimmed = (raw || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SPA root — where the Vue router (artist pages) and the served XPI live,
|
||||||
|
* NOT the JSON API. Accepts either input form, same as normalizeApiUrl.
|
||||||
|
*/
|
||||||
|
function webRootFromApiUrl(raw) {
|
||||||
|
return normalizeApiUrl(raw).replace(/\/api$/i, '');
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"name": "FabledCurator",
|
||||||
"version": "1.0.7",
|
"version": "1.0.10",
|
||||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"tabs",
|
"tabs",
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"webRequestBlocking"
|
"webRequestBlocking",
|
||||||
|
"alarms"
|
||||||
],
|
],
|
||||||
|
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"]
|
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/url.js", "lib/api.js", "background/background.js"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
|
|||||||
@@ -21,9 +21,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<h1>FabledCurator extension</h1>
|
<h1>FabledCurator extension</h1>
|
||||||
|
|
||||||
<label for="api-url">FC base URL</label>
|
<label for="api-url">FC instance URL</label>
|
||||||
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
|
<input id="api-url" type="url" placeholder="http://curator.example.com" />
|
||||||
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
|
<div class="hint">
|
||||||
|
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
|
||||||
|
Find it on FC → Settings → Maintenance → Browser extension.
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="api-key">Extension API key</label>
|
<label for="api-key">Extension API key</label>
|
||||||
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
|
|
||||||
<div id="status" class="status" style="display:none;"></div>
|
<div id="status" class="status" style="display:none;"></div>
|
||||||
|
|
||||||
|
<script src="../lib/url.js"></script>
|
||||||
<script src="options.js"></script>
|
<script src="options.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||||
const apiKey = document.getElementById('api-key').value.trim();
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiUrl || !apiKey) {
|
||||||
showStatus('Both fields are required.', 'err');
|
showStatus('Both fields are required.', 'err');
|
||||||
@@ -16,11 +16,14 @@ async function save() {
|
|||||||
}
|
}
|
||||||
await browser.storage.local.set({ apiUrl, apiKey });
|
await browser.storage.local.set({ apiUrl, apiKey });
|
||||||
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
||||||
showStatus('Saved.', 'ok');
|
// Show what was actually stored — the operator may have typed the instance
|
||||||
|
// root and it was normalized to the API root.
|
||||||
|
document.getElementById('api-url').value = apiUrl;
|
||||||
|
showStatus(`Saved — using ${apiUrl}`, 'ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||||
const apiKey = document.getElementById('api-key').value.trim();
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiUrl || !apiKey) {
|
||||||
showStatus('Fill both fields first.', 'err');
|
showStatus('Fill both fields first.', 'err');
|
||||||
@@ -31,8 +34,23 @@ async function test() {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'X-Extension-Key': apiKey },
|
headers: { 'X-Extension-Key': apiKey },
|
||||||
});
|
});
|
||||||
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
|
if (!r.ok) {
|
||||||
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of
|
||||||
|
// the JSON API, the catch-all route returns 200 with an HTML document —
|
||||||
|
// which used to report "Connected" on a config that could not POST at all.
|
||||||
|
const contentType = r.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('json')) {
|
||||||
|
showStatus(
|
||||||
|
`${apiUrl} answered with ${contentType || 'no content-type'}, not JSON `
|
||||||
|
+ '— that looks like the FC web UI rather than its API.',
|
||||||
|
'err',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.7",
|
"version": "1.0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore",
|
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\"",
|
||||||
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --firefox=firefox",
|
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --firefox=firefox",
|
||||||
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --overwrite-dest",
|
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --overwrite-dest",
|
||||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET",
|
||||||
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"web-ext": "^8.0.0"
|
"vitest": "^4.0.0",
|
||||||
|
"web-ext": "^10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ body {
|
|||||||
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||||
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
||||||
.btn.link:hover { color: var(--accent); }
|
.btn.link:hover { color: var(--accent); }
|
||||||
|
.btn.small { padding: 6px 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* In-app update prompt (accent-tinted so it reads as an actionable notice). */
|
||||||
|
.update-banner {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin: 10px 10px 0; padding: 10px 12px;
|
||||||
|
background: rgba(244, 186, 122, 0.12);
|
||||||
|
border: 1px solid rgba(244, 186, 122, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
#update-text { flex: 1; font-size: 13px; }
|
||||||
|
|
||||||
.source-row .play {
|
.source-row .play {
|
||||||
background: none; border: none; color: var(--on-surface-variant);
|
background: none; border: none; color: var(--on-surface-variant);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="main-content" class="main hidden">
|
<section id="main-content" class="main hidden">
|
||||||
|
<div id="update-banner" class="update-banner hidden">
|
||||||
|
<span id="update-text"></span>
|
||||||
|
<button id="update-btn" class="btn primary small">Update</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button class="tab active" data-tab="platforms">Platforms</button>
|
<button class="tab active" data-tab="platforms">Platforms</button>
|
||||||
<button class="tab" data-tab="sources">Sources</button>
|
<button class="tab" data-tab="sources">Sources</button>
|
||||||
|
|||||||
+33
-12
@@ -2,6 +2,15 @@ document.addEventListener('DOMContentLoaded', init);
|
|||||||
|
|
||||||
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
|
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
// A centered muted note div — the loading / empty state shared by the platform
|
||||||
|
// and sources lists.
|
||||||
|
function mutedNote(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||||
|
d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||||
@@ -14,6 +23,7 @@ async function init() {
|
|||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
showPlatformsLoading();
|
showPlatformsLoading();
|
||||||
testConnectionIfNeeded();
|
testConnectionIfNeeded();
|
||||||
|
checkForUpdate();
|
||||||
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSetupRequired();
|
showSetupRequired();
|
||||||
@@ -37,10 +47,7 @@ function showSetupRequired() {
|
|||||||
function showPlatformsLoading() {
|
function showPlatformsLoading() {
|
||||||
const c = document.getElementById('platforms-list');
|
const c = document.getElementById('platforms-list');
|
||||||
c.textContent = '';
|
c.textContent = '';
|
||||||
const d = document.createElement('div');
|
c.appendChild(mutedNote('Loading platforms…'));
|
||||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
|
||||||
d.textContent = 'Loading platforms…';
|
|
||||||
c.appendChild(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testConnectionIfNeeded() {
|
async function testConnectionIfNeeded() {
|
||||||
@@ -63,6 +70,26 @@ function updateConnectionDot(connected) {
|
|||||||
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nudge to reinstall when the configured instance publishes a newer signed XPI
|
||||||
|
// (the extension is self-hosted, so there's no Firefox auto-update). Never
|
||||||
|
// blocks the popup — a failed check just leaves the banner hidden.
|
||||||
|
async function checkForUpdate() {
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
|
||||||
|
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateBanner(r) {
|
||||||
|
document.getElementById('update-text').textContent =
|
||||||
|
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
|
||||||
|
// Opening the signed XPI triggers Firefox's native install prompt.
|
||||||
|
document.getElementById('update-btn').addEventListener('click', () => {
|
||||||
|
browser.tabs.create({ url: r.xpiUrl });
|
||||||
|
});
|
||||||
|
document.getElementById('update-banner').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPlatformStatus() {
|
async function loadPlatformStatus() {
|
||||||
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||||
const c = document.getElementById('platforms-list');
|
const c = document.getElementById('platforms-list');
|
||||||
@@ -162,10 +189,7 @@ async function exportAllCookies() {
|
|||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
const c = document.getElementById('sources-list');
|
const c = document.getElementById('sources-list');
|
||||||
c.textContent = '';
|
c.textContent = '';
|
||||||
const d = document.createElement('div');
|
c.appendChild(mutedNote('Loading sources…'));
|
||||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
|
||||||
d.textContent = 'Loading sources…';
|
|
||||||
c.appendChild(d);
|
|
||||||
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
||||||
c.textContent = '';
|
c.textContent = '';
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
@@ -176,10 +200,7 @@ async function loadSources() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!r.sources || r.sources.length === 0) {
|
if (!r.sources || r.sources.length === 0) {
|
||||||
const empty = document.createElement('div');
|
c.appendChild(mutedNote('No sources yet.'));
|
||||||
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
|
||||||
empty.textContent = 'No sources yet.';
|
|
||||||
c.appendChild(empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const LIB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an extension lib and hand back the globals it declares.
|
||||||
|
*
|
||||||
|
* The files under lib/ are CLASSIC scripts, not ES modules: manifest.json
|
||||||
|
* lists them in `background.scripts` and options.html pulls them in with a
|
||||||
|
* plain <script> tag, so they declare bare functions into a shared scope and
|
||||||
|
* export nothing. Rather than bolt a `module.exports` shim onto production
|
||||||
|
* code that would never run in the browser, evaluate the real file the same
|
||||||
|
* way the browser does — as a script body — and pick the declarations back out.
|
||||||
|
*
|
||||||
|
* This means the specs exercise the exact bytes that get packaged into the
|
||||||
|
* XPI. Only usable for libs that touch no browser APIs at load time
|
||||||
|
* (url.js, platforms.js); cookies.js and api.js reference `browser.*` and
|
||||||
|
* would need stubbing, which is why they aren't loaded this way.
|
||||||
|
*
|
||||||
|
* @param {string} filename e.g. 'url.js'
|
||||||
|
* @param {string[]} names declarations to return, e.g. ['normalizeApiUrl']
|
||||||
|
*/
|
||||||
|
export function loadLib(filename, names) {
|
||||||
|
const source = readFileSync(path.join(LIB_DIR, filename), 'utf8')
|
||||||
|
const factory = new Function(`${source}\nreturn { ${names.join(', ')} }`)
|
||||||
|
return factory()
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { loadLib } from './helpers/loadLib.js'
|
||||||
|
|
||||||
|
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
|
||||||
|
'platforms.js',
|
||||||
|
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('getPlatformFromUrl', () => {
|
||||||
|
it('identifies each platform from a domain URL', () => {
|
||||||
|
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||||
|
expect(getPlatformFromUrl('https://subscribestar.adult/someone')).toBe('subscribestar')
|
||||||
|
expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry')
|
||||||
|
expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord')
|
||||||
|
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv')
|
||||||
|
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe('deviantart')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts http as well as https, with or without www', () => {
|
||||||
|
expect(getPlatformFromUrl('http://patreon.com/Atole')).toBe('patreon')
|
||||||
|
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unrelated hosts', () => {
|
||||||
|
expect(getPlatformFromUrl('https://example.com/patreon.com')).toBe(null)
|
||||||
|
expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null)
|
||||||
|
expect(getPlatformFromUrl('')).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isArtistPage', () => {
|
||||||
|
// Regression cases from issue #1485: the Add-to-FC button vanished once the
|
||||||
|
// operator SUBSCRIBED to a creator, because Patreon serves subscribed users
|
||||||
|
// the /cw/ ("creator workspace") URL and the pattern only matched the bare
|
||||||
|
// root. All three creator URL shapes must match, plus inner pages — the
|
||||||
|
// button matters most exactly when you're subscribed.
|
||||||
|
it('matches all three Patreon creator URL shapes', () => {
|
||||||
|
expect(isArtistPage('https://www.patreon.com/Atole', 'patreon')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.patreon.com/c/Atole', 'patreon')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.patreon.com/cw/Atole', 'patreon')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches Patreon creator inner pages', () => {
|
||||||
|
expect(isArtistPage('https://www.patreon.com/cw/Atole/posts', 'patreon')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.patreon.com/Atole/membership', 'patreon')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes Patreon navigation pages that are not creators', () => {
|
||||||
|
for (const nav of ['home', 'search', 'messages', 'notifications', 'library', 'settings']) {
|
||||||
|
expect(isArtistPage(`https://www.patreon.com/${nav}`, 'patreon')).toBe(false)
|
||||||
|
expect(isArtistPage(`https://www.patreon.com/${nav}/anything`, 'patreon')).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches SubscribeStar creator roots on both TLDs but not feed pages', () => {
|
||||||
|
expect(isArtistPage('https://subscribestar.adult/someone', 'subscribestar')).toBe(true)
|
||||||
|
expect(isArtistPage('https://subscribestar.com/someone', 'subscribestar')).toBe(true)
|
||||||
|
expect(isArtistPage('https://subscribestar.adult/feed', 'subscribestar')).toBe(false)
|
||||||
|
expect(isArtistPage('https://subscribestar.adult/messages', 'subscribestar')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches Hentai Foundry user pages only', () => {
|
||||||
|
expect(isArtistPage('https://www.hentai-foundry.com/user/someone', 'hentaifoundry')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.hentai-foundry.com/pictures/popular', 'hentaifoundry')).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches Pixiv numeric user pages, with or without the /en/ prefix', () => {
|
||||||
|
expect(isArtistPage('https://www.pixiv.net/users/12345', 'pixiv')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.pixiv.net/en/users/12345', 'pixiv')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes DeviantArt navigation roots', () => {
|
||||||
|
expect(isArtistPage('https://www.deviantart.com/someone', 'deviantart')).toBe(true)
|
||||||
|
expect(isArtistPage('https://www.deviantart.com/home', 'deviantart')).toBe(false)
|
||||||
|
expect(isArtistPage('https://www.deviantart.com/watch', 'deviantart')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for a platform with no artist pattern (discord)', () => {
|
||||||
|
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for an unknown platform key', () => {
|
||||||
|
expect(isArtistPage('https://www.patreon.com/Atole', 'nope')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('platform table integrity', () => {
|
||||||
|
it('gives every artist pattern a corresponding platform entry', () => {
|
||||||
|
// A pattern keyed to a platform that no longer exists is dead code that
|
||||||
|
// silently never fires; the reverse (a platform with no pattern) is the
|
||||||
|
// legitimate discord case, so only this direction is an error.
|
||||||
|
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
||||||
|
expect(Object.keys(PLATFORMS)).toContain(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every platform the fields the popup renders', () => {
|
||||||
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||||
|
expect(platform.name, `${key}.name`).toBeTruthy()
|
||||||
|
expect(platform.color, `${key}.color`).toMatch(/^#[0-9A-Fa-f]{6}$/)
|
||||||
|
expect(['cookies', 'token'], `${key}.authType`).toContain(platform.authType)
|
||||||
|
expect(platform.urlPattern, `${key}.urlPattern`).toBeInstanceOf(RegExp)
|
||||||
|
expect(Array.isArray(platform.domains), `${key}.domains`).toBe(true)
|
||||||
|
expect(platform.domains.length, `${key}.domains`).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every artist URL matched by its own platform pattern too', () => {
|
||||||
|
// isArtistPage is only ever consulted after getPlatformFromUrl resolves a
|
||||||
|
// key, so an artist pattern matching a URL its platform's urlPattern
|
||||||
|
// rejects would be unreachable.
|
||||||
|
const samples = {
|
||||||
|
patreon: 'https://www.patreon.com/cw/Atole',
|
||||||
|
subscribestar: 'https://subscribestar.adult/someone',
|
||||||
|
hentaifoundry: 'https://www.hentai-foundry.com/user/someone',
|
||||||
|
deviantart: 'https://www.deviantart.com/someone',
|
||||||
|
pixiv: 'https://www.pixiv.net/en/users/12345'
|
||||||
|
}
|
||||||
|
for (const [key, url] of Object.entries(samples)) {
|
||||||
|
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
|
||||||
|
expect(getPlatformFromUrl(url), `${key} urlPattern`).toBe(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { loadLib } from './helpers/loadLib.js'
|
||||||
|
|
||||||
|
const { normalizeApiUrl, webRootFromApiUrl } = loadLib('url.js', [
|
||||||
|
'normalizeApiUrl',
|
||||||
|
'webRootFromApiUrl'
|
||||||
|
])
|
||||||
|
|
||||||
|
describe('normalizeApiUrl', () => {
|
||||||
|
// The bug this exists for (issue #2393): the instance root was accepted and
|
||||||
|
// stored verbatim, so every request went to /credentials instead of
|
||||||
|
// /api/credentials. That path is a Vue router route, so the SPA catch-all
|
||||||
|
// answered GET with 200 HTML and rejected POST with 405 — which read as a
|
||||||
|
// backend bug rather than a URL one.
|
||||||
|
it('appends /api to an instance root', () => {
|
||||||
|
expect(normalizeApiUrl('http://curator.traefik.internal')).toBe(
|
||||||
|
'http://curator.traefik.internal/api'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an API root alone rather than doubling the suffix', () => {
|
||||||
|
expect(normalizeApiUrl('http://curator.traefik.internal/api')).toBe(
|
||||||
|
'http://curator.traefik.internal/api'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const once = normalizeApiUrl('http://curator.example.com')
|
||||||
|
expect(normalizeApiUrl(once)).toBe(once)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips trailing slashes before deciding', () => {
|
||||||
|
expect(normalizeApiUrl('http://curator.example.com/')).toBe('http://curator.example.com/api')
|
||||||
|
expect(normalizeApiUrl('http://curator.example.com///')).toBe('http://curator.example.com/api')
|
||||||
|
expect(normalizeApiUrl('http://curator.example.com/api/')).toBe('http://curator.example.com/api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims surrounding whitespace (paste artifacts)', () => {
|
||||||
|
expect(normalizeApiUrl(' http://curator.example.com ')).toBe(
|
||||||
|
'http://curator.example.com/api'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches the /api suffix case-insensitively', () => {
|
||||||
|
expect(normalizeApiUrl('http://curator.example.com/API')).toBe('http://curator.example.com/API')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for empty/nullish input, never a bare "/api"', () => {
|
||||||
|
// isConfigured() gates on truthiness, so a bogus '/api' here would read as
|
||||||
|
// "configured" and produce a request against the options page's own origin.
|
||||||
|
expect(normalizeApiUrl('')).toBe('')
|
||||||
|
expect(normalizeApiUrl(' ')).toBe('')
|
||||||
|
expect(normalizeApiUrl(null)).toBe('')
|
||||||
|
expect(normalizeApiUrl(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat a path merely containing "api" as the suffix', () => {
|
||||||
|
expect(normalizeApiUrl('http://curator.example.com/apiary')).toBe(
|
||||||
|
'http://curator.example.com/apiary/api'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a subpath deployment', () => {
|
||||||
|
expect(normalizeApiUrl('http://host.internal/curator')).toBe('http://host.internal/curator/api')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('webRootFromApiUrl', () => {
|
||||||
|
// The SPA root, where the Vue router and the served XPI live. Used by
|
||||||
|
// OPEN_ARTIST_PAGE and the self-update check — NOT the JSON API.
|
||||||
|
it('strips the /api suffix', () => {
|
||||||
|
expect(webRootFromApiUrl('http://curator.example.com/api')).toBe('http://curator.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an instance root unchanged', () => {
|
||||||
|
expect(webRootFromApiUrl('http://curator.example.com')).toBe('http://curator.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('agrees with normalizeApiUrl in both directions', () => {
|
||||||
|
for (const input of ['http://curator.example.com', 'http://curator.example.com/api']) {
|
||||||
|
expect(normalizeApiUrl(webRootFromApiUrl(input))).toBe(normalizeApiUrl(input))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a subpath deployment', () => {
|
||||||
|
expect(webRootFromApiUrl('http://host.internal/curator/api')).toBe('http://host.internal/curator')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for empty/nullish input', () => {
|
||||||
|
expect(webRootFromApiUrl('')).toBe('')
|
||||||
|
expect(webRootFromApiUrl(null)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
|
||||||
|
|
||||||
|
describe('extension version consistency', () => {
|
||||||
|
// Duplicates check (1) of ci.yml's extension-version job, deliberately.
|
||||||
|
// That job is the gate that can't be bypassed; this spec is the one that
|
||||||
|
// fails in a second on the developer's own CI lane with a readable diff.
|
||||||
|
// The two version strings feed different systems and nothing else reconciles
|
||||||
|
// them:
|
||||||
|
// manifest.json -> what `web-ext sign` signs, so what Firefox installs
|
||||||
|
// (package.json is in --ignore-files, not in the XPI)
|
||||||
|
// package.json -> build.yml's AMO cache key, the ext-<version> release
|
||||||
|
// tag, the XPI filename, and therefore the version
|
||||||
|
// /api/extension/manifest reports to the update prompt
|
||||||
|
it('keeps manifest.json and package.json in lockstep', () => {
|
||||||
|
const manifest = read('manifest.json')
|
||||||
|
const pkg = read('package.json')
|
||||||
|
expect(manifest.version).toBe(pkg.version)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a plain dotted numeric version AMO will accept', () => {
|
||||||
|
// AMO rejects exotic version strings, and build.yml embeds this value in a
|
||||||
|
// release tag and a filename — so anything needing escaping breaks the
|
||||||
|
// publish path rather than the extension.
|
||||||
|
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declares manifest v3', () => {
|
||||||
|
expect(read('manifest.json').manifest_version).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never lets the CI guard ignore a file that actually ships', () => {
|
||||||
|
// ci.yml's extension-version job skips its bump check for paths it deems
|
||||||
|
// non-shipping. If it excludes something web-ext DOES package, a real
|
||||||
|
// change to shipped code passes the guard unnoticed — precisely the
|
||||||
|
// silent-stale-ship the guard exists to stop. The reverse drift (guard
|
||||||
|
// stricter than web-ext) only costs a needless bump, so it isn't asserted.
|
||||||
|
const ci = readFileSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows', 'ci.yml'), 'utf8')
|
||||||
|
const lint = read('package.json').scripts.lint
|
||||||
|
const after = lint.split('--ignore-files')[1] ?? ''
|
||||||
|
const ignored = new Set(
|
||||||
|
after
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((tok) => tok && !tok.startsWith('--'))
|
||||||
|
.map((tok) => tok.replace(/^["']|["']$/g, ''))
|
||||||
|
)
|
||||||
|
expect(ignored.size, 'parsed --ignore-files from the lint script').toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const guarded = [...ci.matchAll(/:\(exclude\)extension\/(\S+?)'/g)].map((m) => m[1])
|
||||||
|
expect(guarded.length, 'parsed :(exclude) entries from ci.yml').toBeGreaterThan(0)
|
||||||
|
for (const entry of guarded) {
|
||||||
|
expect(ignored, `ci.yml excludes "${entry}" but web-ext packages it`).toContain(entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists every background script that exists, in dependency order', () => {
|
||||||
|
// url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
|
||||||
|
// init()-time, and these are classic scripts sharing one scope, so a
|
||||||
|
// reordering here is a runtime ReferenceError with no build-time signal.
|
||||||
|
const scripts = read('manifest.json').background.scripts
|
||||||
|
for (const rel of scripts) {
|
||||||
|
expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
|
||||||
|
}
|
||||||
|
expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// Mirrors frontend/vitest.config.js, minus the Vue plugin — the extension has
|
||||||
|
// no SFCs and mounts nothing. Pure-logic specs only, so `node` is enough; the
|
||||||
|
// libs under test are deliberately the ones with no browser-API surface (see
|
||||||
|
// test/helpers/loadLib.js).
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['test/**/*.spec.js'],
|
||||||
|
passWithNoTests: true
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,16 +13,16 @@
|
|||||||
"test:unit": "vitest run"
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^5.0.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^3.0.0",
|
||||||
"vuetify": "^3.5.0",
|
"vuetify": "^4.0.0",
|
||||||
"@mdi/font": "^7.4.0"
|
"@mdi/font": "^7.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-vuetify": "^2.0.0",
|
"vite-plugin-vuetify": "^2.1.0",
|
||||||
"sass": "^1.71.0",
|
"sass": "^1.71.0",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^4.0.0",
|
||||||
"@vue/test-utils": "^2.4.0",
|
"@vue/test-utils": "^2.4.0",
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ const route = useRoute()
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-content {
|
.fc-content {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
/* Push initial viewport content below the sticky TopNav. Without
|
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
|
||||||
this, some views' first rows / form fields / table headers can
|
own space in the v-app flex column — content flows directly below it. The
|
||||||
end up obscured by the navbar (depending on parent overflow
|
old 64px padding-top was a leftover from a FIXED navbar and double-counted
|
||||||
context interacting with position: sticky). Scrolled-down content
|
that space, leaving a large empty band at the top of EVERY view (and pushing
|
||||||
still slides under the nav — the gradient-fade design is intact. */
|
the full-height calc(100vh - 64px) views down so they overflowed). Removed
|
||||||
padding-top: 64px;
|
2026-07-13. Scrolled content still slides under the sticky nav as before. */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
v-model="show" :color="color" location="bottom right" timeout="4000"
|
v-model="show" :color="color" location="bottom right" timeout="4000"
|
||||||
multi-line elevation="4"
|
min-height="68" elevation="4"
|
||||||
>
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="fc-topnav">
|
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
||||||
<div class="fc-nav-left">
|
<div class="fc-nav-left">
|
||||||
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
||||||
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
||||||
@@ -64,13 +64,39 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import router, { FRONT_DOOR } from '../router.js'
|
import router, { FRONT_DOOR } from '../router.js'
|
||||||
import { useSystemStore } from '../stores/system.js'
|
import { useSystemStore } from '../stores/system.js'
|
||||||
import PipelineStatusChip from './PipelineStatusChip.vue'
|
import PipelineStatusChip from './PipelineStatusChip.vue'
|
||||||
|
|
||||||
const system = useSystemStore()
|
const system = useSystemStore()
|
||||||
onMounted(() => system.refreshHealth())
|
|
||||||
|
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
|
||||||
|
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
|
||||||
|
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
|
||||||
|
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
|
||||||
|
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
|
||||||
|
const navEl = ref(null)
|
||||||
|
let navRO = null
|
||||||
|
onMounted(() => {
|
||||||
|
system.refreshHealth()
|
||||||
|
if (navEl.value && 'ResizeObserver' in window) {
|
||||||
|
navRO = new ResizeObserver(() => {
|
||||||
|
const h = navEl.value?.offsetHeight
|
||||||
|
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
|
||||||
|
})
|
||||||
|
navRO.observe(navEl.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { navRO?.disconnect() })
|
||||||
|
|
||||||
|
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
|
||||||
|
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
|
||||||
|
// its bottom — it hands off at the shared seam alpha so the sub-header can
|
||||||
|
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
|
||||||
|
const route = useRoute()
|
||||||
|
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
|
||||||
|
|
||||||
// Every route with a meta.title is a nav entry. Order by meta.navOrder —
|
// Every route with a meta.title is a nav entry. Order by meta.navOrder —
|
||||||
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
|
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
|
||||||
@@ -119,16 +145,35 @@ const health = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
/* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
|
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 →
|
||||||
|
0.84) through the top half, then eases to transparent over the bottom
|
||||||
|
quarter so it tails off softly instead of a straight line to a hard edge
|
||||||
|
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
|
||||||
|
sub-header continuation. */
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(20, 23, 26, 0.92) 0%,
|
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||||
rgba(20, 23, 26, 0.65) 60%,
|
rgba(var(--fc-chrome-rgb), 0.84) 50%,
|
||||||
rgba(20, 23, 26, 0) 100%
|
rgba(var(--fc-chrome-rgb), 0.55) 75%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
|
||||||
|
stops fading at the shared seam alpha instead of going fully transparent — the
|
||||||
|
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
|
||||||
|
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
|
||||||
|
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
|
||||||
|
global :root in app.css (custom props inherit into scoped styles). */
|
||||||
|
.fc-topnav.fc-topnav--chrome {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.84) 60%,
|
||||||
|
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.fc-brand {
|
.fc-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
filter, applied retroactively to the existing library.
|
filter, applied retroactively to the existing library.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-row dense>
|
<v-row density="compact">
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="minW" label="Min width (px)" type="number"
|
v-model.number="minW" label="Min width (px)" type="number"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
cadence as the transparency audit.
|
cadence as the transparency audit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-row dense>
|
<v-row density="compact">
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="threshold" label="Threshold (0–1)"
|
v-model.number="threshold" label="Threshold (0–1)"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!--
|
||||||
|
Canonical settings number field (DRY pass #161): a compact numeric v-text-field
|
||||||
|
with a built-in clamp to [min,max] on commit. Hand-rolled identically across the
|
||||||
|
ML settings cards (HeadsCard x6, CropProposersCard, VideoEmbeddingCard).
|
||||||
|
|
||||||
|
The clamp is the point: the cards previously sent Number(raw) straight to the
|
||||||
|
API, so an out-of-range value bounced off the API's 400 validator (only
|
||||||
|
TranslationCard clamped). This is now the single home for that clamp.
|
||||||
|
|
||||||
|
Binds `modelValue` (v-model) and emits `change` on blur/enter AFTER clamping, so
|
||||||
|
the parent's save reads the already-clamped value — same as the prior
|
||||||
|
`v-model.number` + `@change=save` pattern.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<v-text-field
|
||||||
|
:model-value="modelValue"
|
||||||
|
:label="label"
|
||||||
|
type="number"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
:step="step"
|
||||||
|
:disabled="disabled"
|
||||||
|
:density="density" hide-details
|
||||||
|
:style="{ maxWidth }"
|
||||||
|
@update:model-value="v => emit('update:modelValue', v)"
|
||||||
|
@change="onCommit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: [Number, String], default: null },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
min: { type: [Number, String], default: null },
|
||||||
|
max: { type: [Number, String], default: null },
|
||||||
|
step: { type: [Number, String], default: 1 },
|
||||||
|
maxWidth: { type: String, default: '200px' },
|
||||||
|
density: { type: String, default: 'compact' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
function onCommit() {
|
||||||
|
// On blur/enter: coerce to a number and clamp to [min,max] so an out-of-range
|
||||||
|
// value never reaches the API. props.modelValue reflects the latest keystroke
|
||||||
|
// (kept in sync by the passthrough above); re-emit the clamped number, then let
|
||||||
|
// the parent persist.
|
||||||
|
let n = Number(props.modelValue)
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
if (props.min !== null && props.min !== '') n = Math.max(Number(props.min), n)
|
||||||
|
if (props.max !== null && props.max !== '') n = Math.min(Number(props.max), n)
|
||||||
|
if (n !== Number(props.modelValue)) emit('update:modelValue', n)
|
||||||
|
}
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Canonical settings toggle row (DRY pass #161): an accent icon + an uppercase
|
||||||
|
.fc-section-h label + a right-aligned switch. Hand-rolled identically in the
|
||||||
|
ML settings cards (HeadsCard x3, CropProposersCard, MLBackfillCard).
|
||||||
|
|
||||||
|
Two-way binds `modelValue` (so the parent switch state stays optimistic) AND
|
||||||
|
emits `change` with the new boolean, so the parent can persist + revert on
|
||||||
|
failure — matching the prior `v-model` + `@update:model-value=handler` pattern.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||||
|
<v-icon v-if="icon" size="18" :color="iconColor">{{ icon }}</v-icon>
|
||||||
|
<span class="fc-section-h">{{ label }}</span>
|
||||||
|
<v-switch
|
||||||
|
:model-value="modelValue"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="disabled"
|
||||||
|
hide-details density="compact" color="success" class="ml-auto"
|
||||||
|
@update:model-value="onSwitch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
// Icon tint. Default accent; pass null for the theme default (e.g. when a row
|
||||||
|
// is off). null (not undefined) so the default doesn't override it.
|
||||||
|
iconColor: { type: String, default: 'accent' },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
function onSwitch(v) {
|
||||||
|
const b = !!v
|
||||||
|
emit('update:modelValue', b)
|
||||||
|
emit('change', b)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
@update:model-value="onPick"
|
@update:model-value="onPick"
|
||||||
>
|
>
|
||||||
<template #item="{ props: itemProps, item }">
|
<template #item="{ props: itemProps, internalItem }">
|
||||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
{{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
|
{{ internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind }}
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-filterbar-wrap">
|
<div class="fc-filterbar-wrap fc-chrome-continues">
|
||||||
<div class="fc-filterbar">
|
<div class="fc-filterbar">
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
@update:model-value="onPick"
|
@update:model-value="onPick"
|
||||||
>
|
>
|
||||||
<template #item="{ props: itemProps, item }">
|
<template #item="{ props: itemProps, internalItem }">
|
||||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
|
<v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon>
|
||||||
</template>
|
</template>
|
||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
{{ item.raw.kind === 'artist' ? 'artist'
|
{{ internalItem.raw.kind === 'artist' ? 'artist'
|
||||||
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
|
: (internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind) }}
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
|
|||||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||||
.fc-filterbar-wrap {
|
.fc-filterbar-wrap {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
||||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||||
and a gap shows through when scrolled to the top. */
|
and a gap shows through when scrolled to the top. */
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
|
||||||
the two read as one continuous piece of chrome — images scroll visibly
|
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
|
||||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
rather than re-darkening, so the nav + bar read as one gradient (operator
|
||||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
2026-07-13). This block only owns the sticky positioning now. */
|
||||||
the nav's transparent edge) separates them when scrolled to the very top,
|
|
||||||
while under-scroll they frost as one. */
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(20, 23, 26, 0.92) 0%,
|
|
||||||
rgba(20, 23, 26, 0.65) 60%,
|
|
||||||
rgba(20, 23, 26, 0) 100%
|
|
||||||
);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
-webkit-backdrop-filter: blur(2px);
|
|
||||||
}
|
}
|
||||||
.fc-filterbar {
|
.fc-filterbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
|
|||||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||||
background-color: rgba(20, 23, 26, 0.72);
|
background-color: rgba(20, 23, 26, 0.72);
|
||||||
}
|
}
|
||||||
|
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
|
||||||
|
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
|
||||||
|
SEGMENT individually, so the rounded ends collided at the joins — the shapes
|
||||||
|
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
|
||||||
|
segments (over the pill utility's !important) and clip the group to a single
|
||||||
|
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
|
||||||
|
only — no height change, so the bar height and nav offset are untouched. */
|
||||||
|
.fc-filterbar-wrap :deep(.v-btn-toggle) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Auto-hidden chrome that ALSO looked like real content — surfaced PROACTIVELY
|
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
|
||||||
atop the gallery whenever there's something to review (NOT gated on the
|
like real content — surfaced PROACTIVELY atop the gallery whenever there's
|
||||||
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
|
something to review (NOT gated on the Show-hidden toggle, so misfires can't
|
||||||
with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
|
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
|
||||||
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
|
Renders nothing when there's nothing to review. -->
|
||||||
|
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
|
||||||
<div class="fc-review__head">
|
<div class="fc-review__head">
|
||||||
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
|
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
|
||||||
<span class="fc-review__title">
|
<span class="fc-review__title">
|
||||||
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
|
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
|
||||||
may be real content — review before they stay hidden
|
may be real content — review
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-review__cards">
|
<div class="fc-review__cards">
|
||||||
@@ -26,16 +27,16 @@
|
|||||||
>
|
>
|
||||||
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
|
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
|
<div class="fc-review-card__tag">{{ tagLine(it) }}</div>
|
||||||
<div class="fc-review-card__acts">
|
<div class="fc-review-card__acts">
|
||||||
<button
|
<button
|
||||||
type="button" class="fc-review-btn fc-review-btn--keep"
|
type="button" class="fc-review-btn fc-review-btn--keep"
|
||||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
|
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
|
||||||
>Keep hidden</button>
|
>{{ keepLabel(it) }}</button>
|
||||||
<button
|
<button
|
||||||
type="button" class="fc-review-btn fc-review-btn--unhide"
|
type="button" class="fc-review-btn fc-review-btn--unhide"
|
||||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
||||||
>Un-hide</button>
|
>{{ removeLabel(it) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +55,11 @@ const items = ref([])
|
|||||||
const busy = ref([])
|
const busy = ref([])
|
||||||
|
|
||||||
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
|
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
|
||||||
|
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
|
||||||
|
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
|
||||||
|
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
|
||||||
|
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
|
||||||
|
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// Fetched unconditionally on mount — the strip prompts for pending misfires
|
// Fetched unconditionally on mount — the strip prompts for pending misfires
|
||||||
@@ -71,7 +77,8 @@ async function resolve(it, action) {
|
|||||||
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
|
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
|
||||||
items.value = items.value.filter((x) => keyOf(x) !== k)
|
items.value = items.value.filter((x) => keyOf(x) !== k)
|
||||||
if (action === 'unhide') {
|
if (action === 'unhide') {
|
||||||
toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden'
|
||||||
|
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -15,28 +15,23 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
||||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
<SettingToggleRow
|
||||||
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
|
v-model="p.on" :loading="busy" :icon="p.icon"
|
||||||
<span class="fc-section-h">{{ p.label }}</span>
|
:icon-color="p.on ? 'accent' : null" :label="p.label"
|
||||||
<v-switch
|
@change="v => saveToggle(p, v)"
|
||||||
v-model="p.on" :loading="busy" hide-details density="compact"
|
/>
|
||||||
color="success" class="ml-auto"
|
|
||||||
@update:model-value="v => saveToggle(p, v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
||||||
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="p.weights" label="Weights" density="compact" hide-details
|
v-model="p.weights" label="Weights" density="compact" hide-details
|
||||||
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
||||||
placeholder="name | URL | hf_repo::file"
|
placeholder="name | URL | hf_repo::file"
|
||||||
@change="save({ [`detector_${p.key}_weights`]: p.weights })"
|
@change="saveField({ [`detector_${p.key}_weights`]: p.weights })"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="p.conf" label="Confidence" type="number"
|
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
|
||||||
min="0" max="1" step="0.05" density="compact" hide-details
|
max-width="140px" :disabled="busy || !p.on"
|
||||||
style="max-width: 140px;" :disabled="busy || !p.on"
|
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||||
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,12 +43,12 @@
|
|||||||
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-for="c in caps" :key="c.key"
|
v-for="c in caps" :key="c.key"
|
||||||
v-model.number="c.val" :label="c.label" type="number"
|
v-model="c.val" :label="c.label"
|
||||||
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
|
:min="c.min" :max="c.max" :step="c.step || 1"
|
||||||
hide-details style="max-width: 165px;" :disabled="busy"
|
max-width="165px" :disabled="busy"
|
||||||
@change="save({ [c.key]: Number(c.val) })"
|
@change="saveField({ [c.key]: Number(c.val) })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,14 +56,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||||
|
import SettingToggleRow from '../common/SettingToggleRow.vue'
|
||||||
|
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
|
||||||
const mlSettings = useMLStore()
|
const mlSettings = useMLStore()
|
||||||
const busy = ref(false)
|
const { busy, save } = useSettingSave(mlSettings.patchSettings)
|
||||||
const proposers = ref([])
|
const proposers = ref([])
|
||||||
const caps = ref([])
|
const caps = ref([])
|
||||||
|
|
||||||
@@ -111,31 +108,20 @@ onMounted(async () => {
|
|||||||
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save(patch, revert) {
|
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
|
||||||
busy.value = true
|
// already clamped numeric values to their [min,max] before this fires.
|
||||||
try {
|
function saveField(patch) {
|
||||||
await mlSettings.patchSettings(patch)
|
save(patch, { successMessage: 'Saved' })
|
||||||
toast({ text: 'Saved', type: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
if (revert) revert()
|
|
||||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveToggle (p, v) {
|
async function saveToggle(p, v) {
|
||||||
// Revert the switch on failure so it never lies about the persisted state.
|
// Revert the switch on failure so it never lies about the persisted state.
|
||||||
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
|
const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' })
|
||||||
|
if (!ok) p.on = !v
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-section-h {
|
|
||||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
.fc-proposer {
|
.fc-proposer {
|
||||||
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ async function onCommit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-code {
|
.fc-code {
|
||||||
background: rgb(var(--v-theme-surface-light));
|
background: rgb(var(--v-theme-surface-light));
|
||||||
border-radius: 4px; padding: 2px 8px;
|
border-radius: 4px; padding: 2px 8px;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
<p v-else class="text-caption mt-3" style="opacity: 0.6;">
|
<p v-else class="text-caption mt-3 fc-muted">
|
||||||
No table statistics yet.
|
No table statistics yet.
|
||||||
</p>
|
</p>
|
||||||
</MaintenanceTile>
|
</MaintenanceTile>
|
||||||
|
|||||||
@@ -72,6 +72,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-cells { display: flex; gap: 28px; }
|
.fc-cells { display: flex; gap: 28px; }
|
||||||
.fc-cell__n {
|
.fc-cell__n {
|
||||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||||
@@ -105,6 +104,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
|||||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
|
||||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -367,11 +367,6 @@ async function onReprocess() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-section-h {
|
|
||||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
.fc-token {
|
.fc-token {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
|
background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
|
||||||
@@ -390,6 +385,4 @@ async function onReprocess() {
|
|||||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
|
||||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -155,11 +155,6 @@ async function onRecover(it) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-section-h {
|
|
||||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
.fc-queue { display: flex; gap: 24px; }
|
.fc-queue { display: flex; gap: 24px; }
|
||||||
.fc-q__n {
|
.fc-q__n {
|
||||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||||
@@ -169,8 +164,6 @@ async function onRecover(it) {
|
|||||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
|
||||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
|
||||||
.fc-defect {
|
.fc-defect {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
||||||
|
|||||||
@@ -95,14 +95,10 @@
|
|||||||
|
|
||||||
<!-- Earned auto-apply -->
|
<!-- Earned auto-apply -->
|
||||||
<div class="fc-auto mt-6">
|
<div class="fc-auto mt-6">
|
||||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
<SettingToggleRow
|
||||||
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
|
v-model="autoEnabled" :loading="settingBusy"
|
||||||
<span class="fc-section-h">Auto-apply</span>
|
icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto"
|
||||||
<v-switch
|
/>
|
||||||
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
|
|
||||||
color="success" class="ml-auto" @update:model-value="onToggleAuto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="fc-muted text-body-2 mb-3">
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
Graduated heads (⚡, with ≥ {{ autoMinPosInput }} examples) apply their tag
|
Graduated heads (⚡, with ≥ {{ autoMinPosInput }} examples) apply their tag
|
||||||
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
|
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
|
||||||
@@ -111,17 +107,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="d-flex mb-3" style="gap: 12px;">
|
<div class="d-flex mb-3" style="gap: 12px;">
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="autoPrecisionInput" label="Precision target"
|
v-model="autoPrecisionInput" label="Precision target"
|
||||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
|
||||||
@change="onSaveSettings"
|
@change="onSaveSettings"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="autoMinPosInput" label="Min examples to fire"
|
v-model="autoMinPosInput" label="Min examples to fire"
|
||||||
type="number" min="1" density="compact" hide-details
|
:min="1" :disabled="settingBusy" @change="onSaveSettings"
|
||||||
style="max-width: 200px;" :disabled="settingBusy"
|
|
||||||
@change="onSaveSettings"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,40 +154,65 @@
|
|||||||
|
|
||||||
<!-- Presentation chrome auto-hide (#141) -->
|
<!-- Presentation chrome auto-hide (#141) -->
|
||||||
<div class="fc-auto mt-6">
|
<div class="fc-auto mt-6">
|
||||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
<SettingToggleRow
|
||||||
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
|
v-model="presentationEnabled" :loading="settingBusy"
|
||||||
<span class="fc-section-h">Hide presentation chrome</span>
|
icon="mdi-image-off-outline" label="Hide presentation chrome"
|
||||||
<v-switch
|
@change="onTogglePresentation"
|
||||||
v-model="presentationEnabled" :loading="settingBusy" hide-details
|
/>
|
||||||
density="compact" color="success" class="ml-auto"
|
|
||||||
@update:model-value="onTogglePresentation"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="fc-muted text-body-2 mb-3">
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
Auto-hide banners and editor screenshots from the gallery once a head has
|
Auto-hide <code>banner</code> chrome from the gallery once a head has
|
||||||
learned them (≥ {{ minPositives }} examples) and clears
|
learned it (≥ {{ minPositives }} examples) and clears
|
||||||
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
|
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
|
||||||
<code>wip</code> is never auto-hidden. If a hidden image also looks like
|
(<code>wip</code> and <code>editor screenshot</code> are handled by the
|
||||||
real content (≥ {{ Math.round((presentationConflictInput || 0) * 100) }}%
|
process auto-tagger below.) If a hidden image also looks like real content
|
||||||
on a content tag), it's flagged in the Hidden view instead of buried.
|
(≥ {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
|
||||||
Every auto-hide is reversible.
|
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex mb-3" style="gap: 12px;">
|
<div class="d-flex mb-3" style="gap: 12px;">
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="presentationThresholdInput" label="Hide confidence"
|
v-model="presentationThresholdInput" label="Hide confidence"
|
||||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
|
||||||
@change="onSavePresentation"
|
@change="onSavePresentation"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="presentationConflictInput" label="Flag if content ≥"
|
v-model="presentationConflictInput" label="Flag if content ≥"
|
||||||
type="number" min="0" max="1" step="0.05" density="compact"
|
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
|
||||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
|
||||||
@change="onSavePresentation"
|
@change="onSavePresentation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
|
||||||
|
<div class="fc-auto mt-6">
|
||||||
|
<SettingToggleRow
|
||||||
|
v-model="processEnabled" :loading="settingBusy"
|
||||||
|
icon="mdi-progress-wrench" label="Auto-tag work-in-progress"
|
||||||
|
@change="onToggleProcess"
|
||||||
|
/>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
|
||||||
|
once a head has learned them (≥ {{ minPositives }} examples) and clears
|
||||||
|
{{ Math.round((processThresholdInput || 0) * 100) }}% confidence. These stay
|
||||||
|
<strong>visible</strong> in the gallery — the tag just keeps them out of
|
||||||
|
training and the Explore rabbit-hole. Off by default. If a tagged image also
|
||||||
|
looks like real content (≥ {{ Math.round((processConflictInput || 0) * 100) }}%
|
||||||
|
on a content tag), it's flagged for review. Learns only from your titles +
|
||||||
|
manual tags, never its own guesses — so it can't run away. Every tag reversible.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex mb-3" style="gap: 12px;">
|
||||||
|
<SettingNumberField
|
||||||
|
v-model="processThresholdInput" label="Tag confidence"
|
||||||
|
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||||
|
@change="onSaveProcess"
|
||||||
|
/>
|
||||||
|
<SettingNumberField
|
||||||
|
v-model="processConflictInput" label="Flag if content ≥"
|
||||||
|
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
|
||||||
|
@change="onSaveProcess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Performance / tuning -->
|
<!-- Performance / tuning -->
|
||||||
<div v-if="metricsConcepts.length" class="mt-5">
|
<div v-if="metricsConcepts.length" class="mt-5">
|
||||||
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
||||||
@@ -219,7 +237,7 @@
|
|||||||
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
|
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
|
||||||
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
|
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
|
||||||
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
|
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
|
||||||
{{ ratePct(c.misfire_rate) }}
|
{{ pct(c.misfire_rate) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
|
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -235,6 +253,9 @@ import { toast } from '../../utils/toast.js'
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||||
|
import SettingToggleRow from '../common/SettingToggleRow.vue'
|
||||||
|
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||||
import { useHeadsStore } from '../../stores/heads.js'
|
import { useHeadsStore } from '../../stores/heads.js'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
|
||||||
@@ -248,7 +269,9 @@ let pollTimer = null
|
|||||||
const autoEnabled = ref(false)
|
const autoEnabled = ref(false)
|
||||||
const autoPrecisionInput = ref(0.97)
|
const autoPrecisionInput = ref(0.97)
|
||||||
const autoMinPosInput = ref(30)
|
const autoMinPosInput = ref(30)
|
||||||
const settingBusy = ref(false)
|
// Shared settings-save flow (busy + toast + revert); `settingBusy` gates the
|
||||||
|
// toggles/fields, `save` returns ok/false for the optimistic-switch revert.
|
||||||
|
const { busy: settingBusy, save } = useSettingSave(mlSettings.patchSettings)
|
||||||
const autoBusy = ref(false)
|
const autoBusy = ref(false)
|
||||||
const autoStatus = ref(null)
|
const autoStatus = ref(null)
|
||||||
const metricsData = ref(null)
|
const metricsData = ref(null)
|
||||||
@@ -258,6 +281,9 @@ let autoTimer = null
|
|||||||
const presentationEnabled = ref(true)
|
const presentationEnabled = ref(true)
|
||||||
const presentationThresholdInput = ref(0.90)
|
const presentationThresholdInput = ref(0.90)
|
||||||
const presentationConflictInput = ref(0.50)
|
const presentationConflictInput = ref(0.50)
|
||||||
|
const processEnabled = ref(false)
|
||||||
|
const processThresholdInput = ref(0.90)
|
||||||
|
const processConflictInput = ref(0.50)
|
||||||
|
|
||||||
const autoRunning = computed(() => autoStatus.value?.running_id != null)
|
const autoRunning = computed(() => autoStatus.value?.running_id != null)
|
||||||
const lastSweep = computed(() =>
|
const lastSweep = computed(() =>
|
||||||
@@ -292,6 +318,9 @@ onMounted(async () => {
|
|||||||
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
|
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
|
||||||
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
|
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
|
||||||
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
|
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
|
||||||
|
processEnabled.value = s.process_auto_apply_enabled ?? false
|
||||||
|
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
|
||||||
|
processConflictInput.value = s.process_conflict_threshold ?? 0.50
|
||||||
} catch { /* non-fatal */ }
|
} catch { /* non-fatal */ }
|
||||||
await refresh()
|
await refresh()
|
||||||
if (running.value) startPoll()
|
if (running.value) startPoll()
|
||||||
@@ -352,55 +381,39 @@ function startAutoPoll() {
|
|||||||
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
|
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
|
||||||
|
|
||||||
async function onToggleAuto(val) {
|
async function onToggleAuto(val) {
|
||||||
settingBusy.value = true
|
const ok = await save({ head_auto_apply_enabled: !!val },
|
||||||
try {
|
{ successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' })
|
||||||
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
|
if (!ok) autoEnabled.value = !val // revert the switch
|
||||||
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
autoEnabled.value = !val // revert the switch
|
|
||||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
|
||||||
} finally {
|
|
||||||
settingBusy.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async function onSaveSettings() {
|
async function onSaveSettings() {
|
||||||
settingBusy.value = true
|
await save({
|
||||||
try {
|
head_auto_apply_precision: Number(autoPrecisionInput.value),
|
||||||
await mlSettings.patchSettings({
|
head_auto_apply_min_positives: Number(autoMinPosInput.value),
|
||||||
head_auto_apply_precision: Number(autoPrecisionInput.value),
|
})
|
||||||
head_auto_apply_min_positives: Number(autoMinPosInput.value),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
|
||||||
} finally {
|
|
||||||
settingBusy.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTogglePresentation(val) {
|
async function onTogglePresentation(val) {
|
||||||
settingBusy.value = true
|
const ok = await save({ presentation_auto_apply_enabled: !!val },
|
||||||
try {
|
{ successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' })
|
||||||
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
|
if (!ok) presentationEnabled.value = !val // revert the switch
|
||||||
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
presentationEnabled.value = !val // revert the switch
|
|
||||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
|
||||||
} finally {
|
|
||||||
settingBusy.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async function onSavePresentation() {
|
async function onSavePresentation() {
|
||||||
settingBusy.value = true
|
await save({
|
||||||
try {
|
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
|
||||||
await mlSettings.patchSettings({
|
presentation_conflict_threshold: Number(presentationConflictInput.value),
|
||||||
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
|
})
|
||||||
presentation_conflict_threshold: Number(presentationConflictInput.value),
|
}
|
||||||
})
|
|
||||||
} catch (e) {
|
async function onToggleProcess(val) {
|
||||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
const ok = await save({ process_auto_apply_enabled: !!val },
|
||||||
} finally {
|
{ successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
|
||||||
settingBusy.value = false
|
if (!ok) processEnabled.value = !val // revert the switch
|
||||||
}
|
}
|
||||||
|
async function onSaveProcess() {
|
||||||
|
await save({
|
||||||
|
process_auto_apply_threshold: Number(processThresholdInput.value),
|
||||||
|
process_conflict_threshold: Number(processConflictInput.value),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
function onPreview() { startSweep(true) }
|
function onPreview() { startSweep(true) }
|
||||||
function onApplyNow() { startSweep(false) }
|
function onApplyNow() { startSweep(false) }
|
||||||
@@ -426,7 +439,6 @@ function sweepConcepts(run) {
|
|||||||
.sort((a, b) => b.applied - a.applied)
|
.sort((a, b) => b.applied - a.applied)
|
||||||
}
|
}
|
||||||
function sweepTotal(run) { return run?.n_applied ?? 0 }
|
function sweepTotal(run) { return run?.n_applied ?? 0 }
|
||||||
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
|
|
||||||
function rateClass(x) {
|
function rateClass(x) {
|
||||||
if (x == null) return ''
|
if (x == null) return ''
|
||||||
if (x <= 0.03) return 'fc-good'
|
if (x <= 0.03) return 'fc-good'
|
||||||
@@ -457,12 +469,6 @@ function relTime(iso) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
|
|
||||||
.fc-section-h {
|
|
||||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
.fc-auto {
|
.fc-auto {
|
||||||
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
|
||||||
}
|
}
|
||||||
@@ -519,7 +525,5 @@ function relTime(iso) {
|
|||||||
background: rgb(var(--v-theme-surface-light));
|
background: rgb(var(--v-theme-surface-light));
|
||||||
padding: 1px 6px; border-radius: 999px;
|
padding: 1px 6px; border-radius: 999px;
|
||||||
}
|
}
|
||||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
|
||||||
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
|
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
|
||||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
being dropped as duplicates;</strong> raise it to collapse more
|
being dropped as duplicates;</strong> raise it to collapse more
|
||||||
look-alikes. Applies to new imports.
|
look-alikes. Applies to new imports.
|
||||||
</div>
|
</div>
|
||||||
<v-row align="center" no-gutters>
|
<v-row no-gutters class="align-center">
|
||||||
<v-col cols="12" sm="9">
|
<v-col cols="12" sm="9">
|
||||||
<v-slider
|
<v-slider
|
||||||
v-model="local.phash_threshold"
|
v-model="local.phash_threshold"
|
||||||
@@ -79,6 +79,46 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<!-- Title-based WIP auto-tagging (task #1458). The switch gates the LIVE
|
||||||
|
import hook; the button runs the one-off back-catalogue scan (an
|
||||||
|
explicit action — it is deliberately not a scheduled sweep so it can't
|
||||||
|
silently re-apply a WIP tag you removed by hand). -->
|
||||||
|
<div class="fc-wip">
|
||||||
|
<div class="fc-wip__title">WIP auto-tagging</div>
|
||||||
|
<v-switch
|
||||||
|
v-model="local.wip_title_tagging_enabled"
|
||||||
|
label="Tag work-in-progress from post titles"
|
||||||
|
density="compact" hide-details color="primary" @change="save"
|
||||||
|
/>
|
||||||
|
<div class="fc-help mb-3">
|
||||||
|
When a post's title says <strong>“WIP”</strong> or
|
||||||
|
<strong>“work in progress”</strong>, new imports get the
|
||||||
|
<code>wip</code> tag automatically — keeping unfinished pieces out of
|
||||||
|
the Explore browse. Applies to new imports; run the scan below to catch
|
||||||
|
posts already in your library.
|
||||||
|
</div>
|
||||||
|
<v-switch
|
||||||
|
v-model="local.wip_soft_title_tagging_enabled"
|
||||||
|
label="Also tag “sketch” / “doodle” titles (lower precision)"
|
||||||
|
density="compact" hide-details color="primary" @change="save"
|
||||||
|
/>
|
||||||
|
<div class="fc-help mb-3">
|
||||||
|
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
|
||||||
|
<code>scribble</code>). These stay <strong>visible</strong> and never train
|
||||||
|
the tagging model — a daily audit flags any that actually look like finished
|
||||||
|
art for review. Off by default.
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
variant="tonal" color="primary" size="small"
|
||||||
|
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
|
||||||
|
@click="store.scanWipTitles()"
|
||||||
|
>
|
||||||
|
Scan existing posts for WIP titles
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||||
{{ store.settingsError }}
|
{{ store.settingsError }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -109,6 +149,8 @@ const local = reactive({
|
|||||||
skip_transparent: false, transparency_threshold: 0.9,
|
skip_transparent: false, transparency_threshold: 0.9,
|
||||||
skip_single_color: false, single_color_threshold: 0.95,
|
skip_single_color: false, single_color_threshold: 0.95,
|
||||||
phash_threshold: 10,
|
phash_threshold: 10,
|
||||||
|
wip_title_tagging_enabled: true,
|
||||||
|
wip_soft_title_tagging_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
@@ -124,11 +166,18 @@ async function save() {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.fc-phash__title {
|
.fc-phash__title,
|
||||||
|
.fc-wip__title {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
}
|
}
|
||||||
|
.fc-wip code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
|
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
|
||||||
.fc-phash__slider { margin-bottom: 18px; }
|
.fc-phash__slider { margin-bottom: 18px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,13 +39,14 @@
|
|||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
import QueueStatusBar from './QueueStatusBar.vue'
|
import QueueStatusBar from './QueueStatusBar.vue'
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
|
const { busy: saving, save } = useSettingSave(store.patchSettings)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const done = ref(false)
|
const done = ref(false)
|
||||||
const enabled = ref(true)
|
const enabled = ref(true)
|
||||||
const saving = ref(false)
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await store.loadSettings()
|
await store.loadSettings()
|
||||||
@@ -55,21 +56,12 @@ onMounted(async () => {
|
|||||||
} catch { /* non-fatal */ }
|
} catch { /* non-fatal */ }
|
||||||
})
|
})
|
||||||
async function onToggle() {
|
async function onToggle() {
|
||||||
saving.value = true
|
const ok = await save({ cpu_embed_enabled: enabled.value }, {
|
||||||
try {
|
successMessage: enabled.value
|
||||||
await store.patchSettings({ cpu_embed_enabled: enabled.value })
|
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
||||||
toast({
|
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
|
||||||
text: enabled.value
|
})
|
||||||
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
if (!ok) enabled.value = !enabled.value
|
||||||
: '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
|
||||||
@@ -80,5 +72,4 @@ async function run() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
the CPU fallback.
|
the CPU fallback.
|
||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
|
<VideoEmbeddingCard />
|
||||||
<GpuAgentCard />
|
<GpuAgentCard />
|
||||||
<GpuTriageCard />
|
<GpuTriageCard />
|
||||||
<MLBackfillCard />
|
<MLBackfillCard />
|
||||||
@@ -36,7 +37,6 @@
|
|||||||
Suggestion thresholds, trained heads and tag aliases.
|
Suggestion thresholds, trained heads and tag aliases.
|
||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
<MLThresholdSliders />
|
|
||||||
<CropProposersCard />
|
<CropProposersCard />
|
||||||
<HeadsCard />
|
<HeadsCard />
|
||||||
<AliasTable />
|
<AliasTable />
|
||||||
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
|||||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||||
import GpuTriageCard from './GpuTriageCard.vue'
|
import GpuTriageCard from './GpuTriageCard.vue'
|
||||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
|
||||||
import CropProposersCard from './CropProposersCard.vue'
|
import CropProposersCard from './CropProposersCard.vue'
|
||||||
import HeadsCard from './HeadsCard.vue'
|
import HeadsCard from './HeadsCard.vue'
|
||||||
import GpuAgentCard from './GpuAgentCard.vue'
|
import GpuAgentCard from './GpuAgentCard.vue'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-row dense>
|
<v-row density="compact">
|
||||||
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2">
|
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2">
|
||||||
<v-card class="fc-stat">
|
<v-card class="fc-stat">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
|||||||
+18
-16
@@ -12,17 +12,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" sm="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="local.video_frame_interval_seconds"
|
v-model="local.video_frame_interval_seconds"
|
||||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
label="Frame interval (s)" :min="0.5" :step="0.5"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" max-width="none" @change="onSave"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" sm="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<SettingNumberField
|
||||||
v-model.number="local.video_max_frames"
|
v-model="local.video_max_frames"
|
||||||
label="Max frames" type="number" min="1" step="1"
|
label="Max frames" :min="1" :step="1"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" max-width="none" @change="onSave"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -32,21 +32,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||||
|
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||||
|
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
|
const { save } = useSettingSave(store.patchSettings)
|
||||||
const local = reactive({})
|
const local = reactive({})
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|
||||||
async function save() {
|
// SettingNumberField clamps interval to ≥0.5 and max-frames to ≥1 before this
|
||||||
const patch = {
|
// fires, so an out-of-range value never reaches the API.
|
||||||
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
function onSave() {
|
||||||
video_max_frames: local.video_max_frames
|
save({
|
||||||
}
|
video_frame_interval_seconds: Number(local.video_frame_interval_seconds),
|
||||||
try { await store.patchSettings(patch) }
|
video_max_frames: Number(local.video_max_frames),
|
||||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { toast } from '../utils/toast.js'
|
||||||
|
|
||||||
|
// The shared "persist a settings patch" flow for the ML settings cards. Flips a
|
||||||
|
// busy flag, calls the store's patch (which rethrows on failure), toasts
|
||||||
|
// success/error, and returns true/false so a toggle handler can revert its
|
||||||
|
// optimistic switch on failure. Centralises the try/catch/toast the cards each
|
||||||
|
// hand-rolled (HeadsCard x6, CropProposersCard, MLBackfillCard) — and where the
|
||||||
|
// threshold-clamp drifted; the clamp now lives in <SettingNumberField>.
|
||||||
|
//
|
||||||
|
// Pass the store's patch fn, e.g. useSettingSave(ml.patchSettings).
|
||||||
|
export function useSettingSave(patchFn) {
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
// opts.successMessage — toast on success (toggles announce their new state;
|
||||||
|
// silent field-saves omit it). opts.errorPrefix — the failure toast prefix
|
||||||
|
// ("Could not save" default; toggles used "Could not update").
|
||||||
|
async function save(patch, { successMessage = '', errorPrefix = 'Could not save' } = {}) {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await patchFn(patch)
|
||||||
|
if (successMessage) toast({ text: successMessage, type: 'success' })
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `${errorPrefix}: ${e.message}`, type: 'error' })
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { busy, save }
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ const routes = [
|
|||||||
|
|
||||||
// FC-2: image backbone
|
// FC-2: image backbone
|
||||||
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
|
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
|
||||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
|
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } },
|
||||||
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours
|
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours
|
||||||
// (left) while tagging the focused image (center viewer + modal-parity tag
|
// (left) while tagging the focused image (center viewer + modal-parity tag
|
||||||
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random
|
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random
|
||||||
@@ -29,11 +29,11 @@ const routes = [
|
|||||||
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
||||||
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
||||||
// standalone paths redirect into the matching tab (below).
|
// standalone paths redirect into the matching tab (below).
|
||||||
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
|
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } },
|
||||||
// Artist detail — no meta.title (reached by clicking an artist, not nav).
|
// Artist detail — no meta.title (reached by clicking an artist, not nav).
|
||||||
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
|
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
|
||||||
// Series browse — a nav entry (meta.title).
|
// Series browse — a nav entry (meta.title).
|
||||||
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
|
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } },
|
||||||
// Series management — no meta.title (reached from a series card/tag).
|
// Series management — no meta.title (reached from a series card/tag).
|
||||||
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
|
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
|
||||||
// Series reader — immersive (no top nav, no meta.title).
|
// Series reader — immersive (no top nav, no meta.title).
|
||||||
@@ -41,10 +41,10 @@ const routes = [
|
|||||||
|
|
||||||
// FC-3: subscription backbone — purely management (sources/downloads),
|
// FC-3: subscription backbone — purely management (sources/downloads),
|
||||||
// distinct from the Browse hub.
|
// distinct from the Browse hub.
|
||||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
|
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } },
|
||||||
|
|
||||||
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
|
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
|
||||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } },
|
||||||
|
|
||||||
// The old standalone paths now redirect into the Browse hub, preserving any
|
// The old standalone paths now redirect into the Browse hub, preserving any
|
||||||
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
|
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
const cursor = ref(-1)
|
const cursor = ref(-1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
|
||||||
|
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
|
||||||
|
// mid-far escape routes so the walk diversifies without hitting "Random image".
|
||||||
|
const reach = ref(0.4)
|
||||||
|
|
||||||
const inflight = useInflightToken()
|
const inflight = useInflightToken()
|
||||||
|
|
||||||
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
const body = await api.get('/api/gallery/similar', {
|
const body = await api.get('/api/gallery/similar', {
|
||||||
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
|
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
|
||||||
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
|
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
|
||||||
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
|
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve
|
||||||
|
// an already-walked image, so the walk keeps moving instead of getting stuck.
|
||||||
|
params: {
|
||||||
|
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
|
||||||
|
reach: reach.value,
|
||||||
|
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!t.isCurrent()) return
|
if (!t.isCurrent()) return
|
||||||
neighbors.value = body.images || []
|
neighbors.value = body.images || []
|
||||||
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Change how far the walk reaches and re-fetch the current anchor's neighbours
|
||||||
|
// with the new setting (the anchor + trail are unchanged — only the grid varies).
|
||||||
|
function setReach (v) {
|
||||||
|
reach.value = Math.max(0, Math.min(1, Number(v)))
|
||||||
|
const id = anchor.value?.id
|
||||||
|
if (id != null) anchorOn(id)
|
||||||
|
}
|
||||||
|
|
||||||
// --- TagPanel "host" surface ---------------------------------------------
|
// --- TagPanel "host" surface ---------------------------------------------
|
||||||
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
||||||
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
||||||
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
function close () {}
|
function close () {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
|
||||||
anchorOn, reset, backTarget, forwardTarget,
|
anchorOn, reset, backTarget, forwardTarget, setReach,
|
||||||
// host surface
|
// host surface
|
||||||
current, currentImageId,
|
current, currentImageId,
|
||||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
const settings = ref(null)
|
const settings = ref(null)
|
||||||
const settingsLoading = ref(false)
|
const settingsLoading = ref(false)
|
||||||
const settingsError = ref(null)
|
const settingsError = ref(null)
|
||||||
|
const wipScanBusy = ref(false)
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
settingsLoading.value = true
|
settingsLoading.value = true
|
||||||
@@ -40,8 +41,25 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enqueue the back-catalogue WIP-title scan (task #1458). New imports are
|
||||||
|
// tagged live; this catches posts already in the library. Fire-and-forget —
|
||||||
|
// the sweep runs on the maintenance worker and its run shows in Activity.
|
||||||
|
async function scanWipTitles() {
|
||||||
|
wipScanBusy.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.post('/api/settings/wip-title/scan')
|
||||||
|
toast({ text: 'Scanning existing posts for WIP titles…', type: 'success' })
|
||||||
|
return r
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `WIP scan failed: ${e.message}`, type: 'error' })
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
wipScanBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings, settingsLoading, settingsError,
|
settings, settingsLoading, settingsError, wipScanBusy,
|
||||||
loadSettings, patchSettings,
|
loadSettings, patchSettings, scanWipTitles,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,3 +39,91 @@
|
|||||||
deliberately not used here. `.fc-muted` is a custom class Vuetify never
|
deliberately not used here. `.fc-muted` is a custom class Vuetify never
|
||||||
emits, so no specificity/reorder fight — no !important needed. */
|
emits, so no specificity/reorder fight — no !important needed. */
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
|
||||||
|
/* Section sub-heading in settings cards (DRY pass #161): was redefined
|
||||||
|
identically in 4 cards, and TranslationCard used the class with NO local def
|
||||||
|
so its section headers rendered unstyled. Now one global utility. */
|
||||||
|
.fc-section-h {
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error,
|
||||||
|
consolidated from the GPU / heads cards. fc-ok is intentionally NOT global —
|
||||||
|
it means on-surface in HeadsCard but success in QueuesTable. */
|
||||||
|
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||||
|
.fc-weak { color: rgb(var(--v-theme-error)); }
|
||||||
|
|
||||||
|
/* Vuetify 4 dropped its global CSS reset (normalisation moved into each
|
||||||
|
component). FC's layouts assumed the reset zeroed margins on text elements, so
|
||||||
|
restore just that — the "minimal reset" from the v4 upgrade guide — inside
|
||||||
|
Vuetify's own reset layer, which is low precedence so component + app styles
|
||||||
|
still win over it. Batch-4 Vuetify 3→4 (#1449). */
|
||||||
|
@layer vuetify-core.reset {
|
||||||
|
ul, ol, figure, details, summary { padding: 0; margin: 0; }
|
||||||
|
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active-tab indicator (operator-flagged 2026-07-13 in the Vuetify-4 review): v4's
|
||||||
|
MD3 v-tab "slider" underline renders wider than the tab and floats below it. The
|
||||||
|
active tab's TEXT is already accent-coloured (color="accent"), so drop the slider
|
||||||
|
and mark the active tab with a subtle accent fill + rounded top — a clean,
|
||||||
|
unambiguous highlight app-wide (Subscriptions / Browse / Settings / Series). */
|
||||||
|
.v-tab__slider { display: none !important; }
|
||||||
|
.v-tab[aria-selected="true"],
|
||||||
|
.v-tab.v-tab--selected {
|
||||||
|
background: rgb(var(--v-theme-accent) / 0.12);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Continuous chrome fade (operator-asked 2026-07-13: "group the sub-nav as
|
||||||
|
part of the nav and use a single gradient in them"). ------------------------
|
||||||
|
The TopNav and any sticky sub-header pinned directly beneath it (Gallery's
|
||||||
|
filter bar, the Browse/Series/Settings/Subscriptions tabs bars) used to each
|
||||||
|
paint their OWN dark-to-transparent gradient (or a solid band), so the fade
|
||||||
|
read as happening TWICE — dark, fade out, then dark again. Instead the two
|
||||||
|
share ONE obsidian fade: the nav paints the TOP half (opaque → the seam
|
||||||
|
alpha) and the sub-header paints the CONTINUATION (seam alpha → transparent)
|
||||||
|
over its own height. Both reference --fc-chrome-seam, so the alphas meet
|
||||||
|
exactly at the 64px boundary — no re-darkening, no doubling, one gradient.
|
||||||
|
|
||||||
|
--fc-chrome-seam is the single knob: raise it for a heavier sub-header (more
|
||||||
|
legible tabs/controls over scrolling content), lower it for a lighter fade. */
|
||||||
|
:root {
|
||||||
|
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
|
||||||
|
/* Alpha where the nav hands off to the sub-header — also the "hold" level of
|
||||||
|
the fade. The chrome stays fairly opaque (0.92 → this) through the bulk of
|
||||||
|
its height, then drops to transparent in a small eased section at the very
|
||||||
|
bottom (see the multi-stop gradients), so it reads as a slow falloff that
|
||||||
|
tails off softly rather than a straight line to a hard edge (operator
|
||||||
|
2026-07-13). Raise for heavier/more-legible chrome, lower for a lighter fade. */
|
||||||
|
--fc-chrome-seam: 0.68;
|
||||||
|
/* Actual TopNav height, measured live (ResizeObserver in TopNav.vue) and used
|
||||||
|
by full-height workspaces (Explore/Subscriptions: calc(100vh - var)) and by
|
||||||
|
every sticky sub-header pinned beneath the nav (top: var). This was a
|
||||||
|
hardcoded 64px in ~6 places; Vuetify 4's MD3 sizing made the real nav a
|
||||||
|
different height, so the Explore workspace overflowed and its breadcrumb
|
||||||
|
tucked under the nav (#1481). This fallback is only used pre-measure. */
|
||||||
|
--fc-nav-h: 64px;
|
||||||
|
}
|
||||||
|
/* Applied to a sticky sub-header so it continues the nav's fade instead of
|
||||||
|
restarting it. Percentage stops so the fade always spans the element's height
|
||||||
|
(survives the filter bar's expanding refine panel). The blur keeps tabs and
|
||||||
|
controls legible as the fill thins toward transparent — the solid-surface
|
||||||
|
bars it replaces had none, so it must live here. */
|
||||||
|
.fc-chrome-continues {
|
||||||
|
/* Continues the nav's fade: HOLDS near the seam alpha through the first ~55%
|
||||||
|
(subtle), then eases down to transparent over the last ~45% with an
|
||||||
|
intermediate stop so the tail is soft — no hard line at the bottom edge
|
||||||
|
(operator 2026-07-13). Percentage stops keep the shape spanning the
|
||||||
|
element's height (survives the filter bar's expanding refine panel). */
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.60) 55%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.28) 82%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
switcher and the search stay reachable no matter how far you scroll.
|
switcher and the search stay reachable no matter how far you scroll.
|
||||||
Background uses the theme surface token so content scrolls cleanly
|
Background uses the theme surface token so content scrolls cleanly
|
||||||
under it (matches SettingsView's sticky tabs). -->
|
under it (matches SettingsView's sticky tabs). -->
|
||||||
<div class="fc-browse__head">
|
<div class="fc-browse__head fc-chrome-continues">
|
||||||
<v-container fluid class="py-0">
|
<v-container fluid class="py-0">
|
||||||
<!-- Tabs and search share one row: the axis switcher on the left, the
|
<!-- Tabs and search share one row: the axis switcher on the left, the
|
||||||
search field + active-scope chips on the right (operator-asked
|
search field + active-scope chips on the right (operator-asked
|
||||||
@@ -154,9 +154,10 @@ function clearFilter(key) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-browse__head {
|
.fc-browse__head {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px; /* directly under AppShell's 64px sticky TopNav */
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
background: rgb(var(--v-theme-surface));
|
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
||||||
|
gradient instead of a solid surface band (operator 2026-07-13). */
|
||||||
}
|
}
|
||||||
.fc-browse__bar {
|
.fc-browse__bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -31,6 +31,20 @@
|
|||||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||||
</button>
|
</button>
|
||||||
<div class="fc-ex__trail-actions">
|
<div class="fc-ex__trail-actions">
|
||||||
|
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
|
||||||
|
Raise it to break out of a dense signature without hitting Random. -->
|
||||||
|
<div
|
||||||
|
class="fc-ex__reach"
|
||||||
|
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
|
||||||
|
>
|
||||||
|
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
|
||||||
|
<v-slider
|
||||||
|
:model-value="store.reach" @end="store.setReach"
|
||||||
|
:min="0" :max="1" :step="0.2" hide-details density="compact"
|
||||||
|
color="accent" class="fc-ex__reach-slider"
|
||||||
|
/>
|
||||||
|
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
|
||||||
|
</div>
|
||||||
<!-- Active retrain right where you tag: fold the +/- you just gave
|
<!-- Active retrain right where you tag: fold the +/- you just gave
|
||||||
into the heads without a trip to Settings (the nightly beat is the
|
into the heads without a trip to Settings (the nightly beat is the
|
||||||
passive cadence). -->
|
passive cadence). -->
|
||||||
@@ -153,6 +167,12 @@ const modal = useModalStore()
|
|||||||
|
|
||||||
const anchorId = computed(() => route.params.imageId || null)
|
const anchorId = computed(() => route.params.imageId || null)
|
||||||
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
||||||
|
const reachLabel = computed(() => {
|
||||||
|
const r = store.reach
|
||||||
|
if (r <= 0.15) return 'Near'
|
||||||
|
if (r <= 0.55) return 'Varied'
|
||||||
|
return 'Far'
|
||||||
|
})
|
||||||
|
|
||||||
// #1206: hovering a suggestion in the rail highlights the crop it came from on
|
// #1206: hovering a suggestion in the rail highlights the crop it came from on
|
||||||
// the anchor image (same provide/inject as the modal viewer).
|
// the anchor image (same provide/inject as the modal viewer).
|
||||||
@@ -264,12 +284,14 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
|
|
||||||
/* Full-height workspace under the sticky top nav. */
|
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
|
||||||
|
measured height (set by TopNav) — a hardcoded 64px here overflowed the
|
||||||
|
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
|
||||||
|
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
|
||||||
.fc-ex {
|
.fc-ex {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +318,14 @@ onUnmounted(() => {
|
|||||||
.fc-ex__trail-actions {
|
.fc-ex__trail-actions {
|
||||||
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
.fc-ex__reach {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.fc-ex__reach-slider { width: 96px; }
|
||||||
|
.fc-ex__reach-label {
|
||||||
|
font-size: 12px; min-width: 44px; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
/* The three panes fill the remaining height; each scrolls on its own.
|
/* The three panes fill the remaining height; each scrolls on its own.
|
||||||
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
|
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
|
||||||
search/sort stay reachable on a long grid. The controls can't sit
|
search/sort stay reachable on a long grid. The controls can't sit
|
||||||
inside v-window (it clips sticky children), so they're hoisted here. -->
|
inside v-window (it clips sticky children), so they're hoisted here. -->
|
||||||
<div class="fc-series__head">
|
<div class="fc-series__head fc-chrome-continues">
|
||||||
<v-tabs v-model="tab" density="compact">
|
<v-tabs v-model="tab" density="compact">
|
||||||
<v-tab value="browse">Browse</v-tab>
|
<v-tab value="browse">Browse</v-tab>
|
||||||
<v-tab value="suggestions">
|
<v-tab value="suggestions">
|
||||||
@@ -294,12 +294,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
|
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
|
||||||
content scrolls cleanly beneath it. Surface bg matches SettingsView. */
|
content scrolls cleanly beneath it. Background is the shared
|
||||||
|
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
|
||||||
|
band (operator 2026-07-13). */
|
||||||
.fc-series__head {
|
.fc-series__head {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
background: rgb(var(--v-theme-surface));
|
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
.fc-series-browse__controls {
|
.fc-series-browse__controls {
|
||||||
|
|||||||
@@ -7,13 +7,11 @@
|
|||||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||||
tab strip lives directly under it. Background uses the theme surface
|
tab strip lives directly under it. The .fc-chrome-continues fade
|
||||||
token so it visually merges with the page rather than the
|
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
||||||
translucent v-tabs default. -->
|
|
||||||
<v-tabs
|
<v-tabs
|
||||||
v-model="tab" color="accent" class="mb-4"
|
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
||||||
style="position: sticky; top: 64px; z-index: 4;
|
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
|
||||||
background: rgb(var(--v-theme-surface));"
|
|
||||||
>
|
>
|
||||||
<v-tab value="overview">Overview</v-tab>
|
<v-tab value="overview">Overview</v-tab>
|
||||||
<v-tab value="activity">Activity</v-tab>
|
<v-tab value="activity">Activity</v-tab>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
align-tabs="start"
|
align-tabs="start"
|
||||||
color="accent"
|
color="accent"
|
||||||
density="compact"
|
density="compact"
|
||||||
class="fc-subs-tabs"
|
class="fc-subs-tabs fc-chrome-continues"
|
||||||
>
|
>
|
||||||
<v-tab value="subscriptions">
|
<v-tab value="subscriptions">
|
||||||
<v-icon start>mdi-account-multiple-check</v-icon>
|
<v-icon start>mdi-account-multiple-check</v-icon>
|
||||||
@@ -55,15 +55,18 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
|
|||||||
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
||||||
put while ONLY the tab content scrolls — previously the whole view
|
put while ONLY the tab content scrolls — previously the whole view
|
||||||
scrolled instead of just the subscription list (operator-flagged
|
scrolled instead of just the subscription list (operator-flagged
|
||||||
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
|
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.fc-subs-tabs {
|
.fc-subs-tabs {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
/* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting
|
||||||
|
the .fc-chrome-continues fade read as one gradient with it (operator
|
||||||
|
2026-07-13). The fade replaces the old border-bottom separator. */
|
||||||
|
margin-top: -8px;
|
||||||
}
|
}
|
||||||
.fc-subs-window {
|
.fc-subs-window {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -349,7 +349,6 @@ async function onDeleteTagConfirm() {
|
|||||||
.fc-tags__sentinel {
|
.fc-tags__sentinel {
|
||||||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||||||
}
|
}
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
||||||
.fc-merge-preview {
|
.fc-merge-preview {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
|
||||||
|
parser that gates the browser extension's "Add as source" button and pulls
|
||||||
|
the creator slug on probe/add.
|
||||||
|
|
||||||
|
Regression cover for #1485: Patreon serves the same creator under three URL
|
||||||
|
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
|
||||||
|
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
|
||||||
|
because the pattern only matched the bare root and excluded `c/`.
|
||||||
|
|
||||||
|
_derive is pure URL parsing (no DB / no async), so a session-less instance is
|
||||||
|
fine to exercise directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.extension_service import (
|
||||||
|
ExtensionService,
|
||||||
|
InvalidUrlError,
|
||||||
|
UnknownPlatformError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_svc = ExtensionService(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url, slug",
|
||||||
|
[
|
||||||
|
# All three Patreon creator prefixes resolve to the same vanity slug.
|
||||||
|
("https://www.patreon.com/Atole", "Atole"),
|
||||||
|
("https://www.patreon.com/c/Atole", "Atole"),
|
||||||
|
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
|
||||||
|
# A creator's inner page still derives the slug (trailing sub-path).
|
||||||
|
("https://www.patreon.com/cw/Atole/posts", "Atole"),
|
||||||
|
("https://www.patreon.com/Atole/membership", "Atole"),
|
||||||
|
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_derive_patreon_creator_urls(url, slug):
|
||||||
|
platform, got = _svc._derive(url)
|
||||||
|
assert platform == "patreon"
|
||||||
|
assert got == slug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
# Patreon's own nav pages must never read as a creator slug.
|
||||||
|
"https://www.patreon.com/home",
|
||||||
|
"https://www.patreon.com/settings",
|
||||||
|
"https://www.patreon.com/search",
|
||||||
|
"https://www.patreon.com/messages",
|
||||||
|
"https://www.patreon.com/library",
|
||||||
|
"https://www.patreon.com/notifications",
|
||||||
|
"https://www.patreon.com/posts/12345", # post permalink
|
||||||
|
"https://www.patreon.com/settings/billing", # nav sub-page
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_derive_patreon_nav_pages_rejected(url):
|
||||||
|
with pytest.raises(UnknownPlatformError):
|
||||||
|
_svc._derive(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_rejects_missing_scheme():
|
||||||
|
with pytest.raises(InvalidUrlError):
|
||||||
|
_svc._derive("patreon.com/Atole")
|
||||||
@@ -82,24 +82,28 @@ async def _system_tag(db, name):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scroll_hides_presentation_chrome_by_default(db):
|
async def test_scroll_hides_presentation_chrome_by_default(db):
|
||||||
# banner / editor screenshot (presentation system tags) are hidden from the
|
# banner (chrome) is hidden from the default gallery; wip AND editor screenshot
|
||||||
# default gallery; wip (also a system tag) is NOT — it's real, in-progress
|
# (the PROCESS system tags) are NOT — they're real art / process shots that stay
|
||||||
# art (milestone 141). Seeded system tags survive the harness TRUNCATE.
|
# visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE.
|
||||||
imgs = await _seed_images(db, 3, sha_prefix="p")
|
imgs = await _seed_images(db, 4, sha_prefix="p")
|
||||||
banner = await _system_tag(db, "banner")
|
banner = await _system_tag(db, "banner")
|
||||||
wip = await _system_tag(db, "wip")
|
wip = await _system_tag(db, "wip")
|
||||||
|
editor = await _system_tag(db, "editor screenshot")
|
||||||
await db.execute(image_tag.insert().values(
|
await db.execute(image_tag.insert().values(
|
||||||
image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
|
image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
|
||||||
await db.execute(image_tag.insert().values(
|
await db.execute(image_tag.insert().values(
|
||||||
image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
|
image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
|
||||||
|
await db.execute(image_tag.insert().values(
|
||||||
|
image_record_id=imgs[3].id, tag_id=editor.id, source="manual"))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
svc = GalleryService(db)
|
svc = GalleryService(db)
|
||||||
|
|
||||||
# Default: the banner image is hidden; the wip image + the plain image stay.
|
# Default: only the banner image is hidden; wip + editor + plain all stay.
|
||||||
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
|
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
|
||||||
assert imgs[0].id not in default_ids # banner hidden
|
assert imgs[0].id not in default_ids # banner hidden
|
||||||
assert imgs[1].id in default_ids # wip visible
|
assert imgs[1].id in default_ids # wip visible
|
||||||
assert imgs[2].id in default_ids # plain visible
|
assert imgs[2].id in default_ids # plain visible
|
||||||
|
assert imgs[3].id in default_ids # editor screenshot visible (#1464)
|
||||||
|
|
||||||
# include_hidden surfaces the banner image (the Hidden view).
|
# include_hidden surfaces the banner image (the Hidden view).
|
||||||
shown = {i.id for i in (
|
shown = {i.id for i in (
|
||||||
|
|||||||
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
|
|||||||
assert len(res) == 2
|
assert len(res) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_similar_exclude_ids_drops_walked(db):
|
||||||
|
"""Explore passes its breadcrumb as exclude_ids so already-walked images
|
||||||
|
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
|
||||||
|
→ the sampler passes through)."""
|
||||||
|
src = await _img(db, 1, _vec(1, 0))
|
||||||
|
walked = await _img(db, 2, _vec(1, 0.05))
|
||||||
|
fresh = await _img(db, 3, _vec(1, 0.3))
|
||||||
|
svc = GalleryService(db)
|
||||||
|
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
|
||||||
|
ids = {i.id for i in res}
|
||||||
|
assert walked.id not in ids
|
||||||
|
assert fresh.id in ids
|
||||||
|
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
|
||||||
|
assert fresh.id in {i.id for i in res_reach}
|
||||||
|
|
||||||
|
|
||||||
# --- API ---
|
# --- API ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Shared ML helpers extracted in the DRY pass (milestone #161). These pin the
|
||||||
|
single sources the auto-apply sweeps now trust, so a future edit can't silently
|
||||||
|
drift them: `_applied_or_rejected` is the skip-set used by auto_apply_sweep,
|
||||||
|
system_tag_auto_apply_sweep (heads.py) and scheduled_ccip_auto_apply (tasks/ml.py);
|
||||||
|
`_sigmoid` is the head score→prob transform used at every scoring site."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.models import ImageRecord, Tag, TagKind, TagSuggestionRejection
|
||||||
|
from backend.app.models.tag import image_tag
|
||||||
|
from backend.app.services.ml.training_data import _applied_or_rejected
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigmoid_matches_naive_form():
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.app.services.ml.heads import _sigmoid
|
||||||
|
|
||||||
|
z = np.array([-3.0, -0.5, 0.0, 1.5, 12.0], dtype=np.float32)
|
||||||
|
assert np.allclose(_sigmoid(z, np), 1.0 / (1.0 + np.exp(-z)))
|
||||||
|
assert float(_sigmoid(np.array([0.0]), np)[0]) == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_applied_or_rejected_unions_applied_any_source_and_rejected(db_sync):
|
||||||
|
a = Tag(name="dry-helper-a", kind=TagKind.general)
|
||||||
|
b = Tag(name="dry-helper-b", kind=TagKind.general)
|
||||||
|
db_sync.add_all([a, b])
|
||||||
|
db_sync.flush()
|
||||||
|
|
||||||
|
imgs = []
|
||||||
|
for i in range(5):
|
||||||
|
img = ImageRecord(
|
||||||
|
path=f"/images/dryhelp{i}.jpg", sha256=f"{i:064d}", size_bytes=1,
|
||||||
|
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
|
||||||
|
integrity_status="unknown", siglip_embedding=[0.0] * 1152,
|
||||||
|
)
|
||||||
|
db_sync.add(img)
|
||||||
|
imgs.append(img)
|
||||||
|
db_sync.flush()
|
||||||
|
|
||||||
|
# tag a: applied manually (img0), applied by an AUTO source (img1), rejected (img2).
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=imgs[0].id, tag_id=a.id, source="manual"))
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=imgs[1].id, tag_id=a.id, source="head_auto"))
|
||||||
|
db_sync.add(TagSuggestionRejection(image_record_id=imgs[2].id, tag_id=a.id))
|
||||||
|
# tag b: applied to img3 only.
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=imgs[3].id, tag_id=b.id, source="manual"))
|
||||||
|
db_sync.flush()
|
||||||
|
|
||||||
|
skip = _applied_or_rejected(db_sync, [a.id, b.id])
|
||||||
|
|
||||||
|
# Applied-under-ANY-source (manual + head_auto) ∪ rejected, kept per-tag; the
|
||||||
|
# untouched image (img4) appears under neither tag.
|
||||||
|
assert skip[a.id] == {imgs[0].id, imgs[1].id, imgs[2].id}
|
||||||
|
assert skip[b.id] == {imgs[3].id}
|
||||||
|
assert imgs[4].id not in skip[a.id]
|
||||||
|
assert imgs[4].id not in skip[b.id]
|
||||||
@@ -15,7 +15,7 @@ from backend.app.models import (
|
|||||||
from backend.app.models.tag import image_tag
|
from backend.app.models.tag import image_tag
|
||||||
from backend.app.services.ml.heads import (
|
from backend.app.services.ml.heads import (
|
||||||
auto_apply_sweep,
|
auto_apply_sweep,
|
||||||
presentation_auto_apply_sweep,
|
system_tag_auto_apply_sweep,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
@@ -70,7 +70,7 @@ def test_presentation_sweep_hides_chrome(db_sync):
|
|||||||
_head(db_sync, banner.id, 0, weight=3.0)
|
_head(db_sync, banner.id, 0, weight=3.0)
|
||||||
img = _img(db_sync, "a" * 64, _emb(0))
|
img = _img(db_sync, "a" * 64, _emb(0))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
res = presentation_auto_apply_sweep(db_sync)
|
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||||
assert res["n_applied"] == 1
|
assert res["n_applied"] == 1
|
||||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ def test_presentation_sweep_hard_skips_valued_image(db_sync):
|
|||||||
db_sync.execute(image_tag.insert().values(
|
db_sync.execute(image_tag.insert().values(
|
||||||
image_record_id=img.id, tag_id=content.id, source="manual"))
|
image_record_id=img.id, tag_id=content.id, source="manual"))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
res = presentation_auto_apply_sweep(db_sync)
|
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||||
assert res["n_applied"] == 0
|
assert res["n_applied"] == 0
|
||||||
assert _source(db_sync, img.id, banner.id) is None
|
assert _source(db_sync, img.id, banner.id) is None
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def test_presentation_sweep_flags_conflict(db_sync):
|
|||||||
_head(db_sync, content.id, 0, weight=1.0) # content head also fires
|
_head(db_sync, content.id, 0, weight=1.0) # content head also fires
|
||||||
img = _img(db_sync, "c" * 64, _emb(0))
|
img = _img(db_sync, "c" * 64, _emb(0))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
res = presentation_auto_apply_sweep(db_sync)
|
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||||
assert res["n_applied"] == 1
|
assert res["n_applied"] == 1
|
||||||
assert res["n_flagged"] == 1
|
assert res["n_flagged"] == 1
|
||||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||||
@@ -123,7 +123,7 @@ def test_presentation_sweep_disabled_is_noop(db_sync):
|
|||||||
_head(db_sync, banner.id, 0, weight=3.0)
|
_head(db_sync, banner.id, 0, weight=3.0)
|
||||||
img = _img(db_sync, "d" * 64, _emb(0))
|
img = _img(db_sync, "d" * 64, _emb(0))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
res = presentation_auto_apply_sweep(db_sync)
|
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||||
assert res["n_applied"] == 0
|
assert res["n_applied"] == 0
|
||||||
assert _source(db_sync, img.id, banner.id) is None
|
assert _source(db_sync, img.id, banner.id) is None
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def test_presentation_sweep_ignores_wip(db_sync):
|
|||||||
_head(db_sync, wip.id, 0, weight=3.0)
|
_head(db_sync, wip.id, 0, weight=3.0)
|
||||||
img = _img(db_sync, "e" * 64, _emb(0))
|
img = _img(db_sync, "e" * 64, _emb(0))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
res = presentation_auto_apply_sweep(db_sync)
|
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||||
assert res["n_applied"] == 0
|
assert res["n_applied"] == 0
|
||||||
assert _source(db_sync, img.id, wip.id) is None
|
assert _source(db_sync, img.id, wip.id) is None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Process-group auto-apply sweep (#1464): wip / editor screenshot auto-tag at a
|
||||||
|
flat threshold with a PROVISIONAL source (`process_auto`) so the head never trains
|
||||||
|
on its own output, and stay VISIBLE (unlike chrome). Mirrors the chrome guards.
|
||||||
|
numpy-only (no sklearn), tested directly via the sync session."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import (
|
||||||
|
ImageRecord,
|
||||||
|
MLSettings,
|
||||||
|
PresentationReview,
|
||||||
|
Tag,
|
||||||
|
TagHead,
|
||||||
|
TagKind,
|
||||||
|
)
|
||||||
|
from backend.app.models.tag import image_tag
|
||||||
|
from backend.app.services.ml.heads import system_tag_auto_apply_sweep
|
||||||
|
from backend.app.services.ml.training_data import _ids_with_tag
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def _emb(slot: int) -> list[float]:
|
||||||
|
v = [0.0] * 1152
|
||||||
|
v[slot] = 3.0
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _img(db, sha: str, emb) -> ImageRecord:
|
||||||
|
img = ImageRecord(
|
||||||
|
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
|
||||||
|
width=1, height=1, origin="imported_filesystem",
|
||||||
|
integrity_status="unknown", siglip_embedding=emb,
|
||||||
|
)
|
||||||
|
db.add(img)
|
||||||
|
db.flush()
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _head(db, tag_id: int, slot: int, *, weight=1.0):
|
||||||
|
s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
|
||||||
|
w = [0.0] * 1152
|
||||||
|
w[slot] = weight
|
||||||
|
db.add(TagHead(
|
||||||
|
tag_id=tag_id, embedding_version=s.embedder_model_version,
|
||||||
|
weights=w, bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.5,
|
||||||
|
n_pos=60, n_neg=90, ap=0.9, precision_cv=0.98, recall=0.7,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _system_tag(db, name):
|
||||||
|
return db.execute(
|
||||||
|
select(Tag).where(Tag.is_system.is_(True), Tag.name == name)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_process(db):
|
||||||
|
# process auto-apply is opt-in (default False) — turn it on for these tests.
|
||||||
|
db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one().process_auto_apply_enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
def _source(db, image_id, tag_id):
|
||||||
|
return db.execute(
|
||||||
|
select(image_tag.c.source)
|
||||||
|
.where(image_tag.c.image_record_id == image_id)
|
||||||
|
.where(image_tag.c.tag_id == tag_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_sweep_applies_wip_and_editor(db_sync):
|
||||||
|
_enable_process(db_sync)
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
editor = _system_tag(db_sync, "editor screenshot")
|
||||||
|
_head(db_sync, wip.id, 0, weight=3.0)
|
||||||
|
_head(db_sync, editor.id, 1, weight=3.0)
|
||||||
|
w_img = _img(db_sync, "a" * 64, _emb(0))
|
||||||
|
e_img = _img(db_sync, "b" * 64, _emb(1))
|
||||||
|
db_sync.commit()
|
||||||
|
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||||
|
assert res["n_applied"] == 2
|
||||||
|
assert _source(db_sync, w_img.id, wip.id) == "process_auto"
|
||||||
|
assert _source(db_sync, e_img.id, editor.id) == "process_auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_sweep_disabled_by_default_is_noop(db_sync):
|
||||||
|
# process_auto_apply_enabled defaults False (opt-in) — no enable = no-op.
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
_head(db_sync, wip.id, 0, weight=3.0)
|
||||||
|
img = _img(db_sync, "c" * 64, _emb(0))
|
||||||
|
db_sync.commit()
|
||||||
|
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||||
|
assert res["n_applied"] == 0
|
||||||
|
assert _source(db_sync, img.id, wip.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_sweep_skips_valued_image(db_sync):
|
||||||
|
# Guard 1: never auto-apply to an image the operator already content-tagged.
|
||||||
|
_enable_process(db_sync)
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
_head(db_sync, wip.id, 0, weight=3.0)
|
||||||
|
content = Tag(name="mychar", kind=TagKind.character)
|
||||||
|
db_sync.add(content)
|
||||||
|
db_sync.flush()
|
||||||
|
img = _img(db_sync, "d" * 64, _emb(0))
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=img.id, tag_id=content.id, source="manual"))
|
||||||
|
db_sync.commit()
|
||||||
|
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||||
|
assert res["n_applied"] == 0
|
||||||
|
assert _source(db_sync, img.id, wip.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_sweep_flags_conflict_with_process_mode(db_sync):
|
||||||
|
# Guard 2: also scores high on a content head → still applied, but flagged
|
||||||
|
# for review with mode='process' (the ring-loud guard).
|
||||||
|
_enable_process(db_sync)
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
_head(db_sync, wip.id, 0, weight=3.0)
|
||||||
|
content = Tag(name="looksreal", kind=TagKind.general)
|
||||||
|
db_sync.add(content)
|
||||||
|
db_sync.flush()
|
||||||
|
_head(db_sync, content.id, 0, weight=1.0)
|
||||||
|
img = _img(db_sync, "e" * 64, _emb(0))
|
||||||
|
db_sync.commit()
|
||||||
|
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||||
|
assert res["n_applied"] == 1
|
||||||
|
assert res["n_flagged"] == 1
|
||||||
|
flag = db_sync.execute(
|
||||||
|
select(PresentationReview).where(
|
||||||
|
PresentationReview.image_record_id == img.id,
|
||||||
|
PresentationReview.tag_id == wip.id,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert flag.mode == "process"
|
||||||
|
assert flag.conflict_tag_id == content.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_auto_source_never_trains_head(db_sync):
|
||||||
|
# The runaway break: provisional wip tags (process sweep 'process_auto', soft
|
||||||
|
# title 'wip_title_soft') are NOT training positives; a HARD title-heuristic /
|
||||||
|
# manual one IS. So the head learns only from trusted labels, never its own
|
||||||
|
# output or the low-precision sketch/doodle tier (#1464 + #1474).
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
auto_img = _img(db_sync, "f" * 64, _emb(0))
|
||||||
|
soft_img = _img(db_sync, "9" * 64, _emb(2))
|
||||||
|
title_img = _img(db_sync, "0" * 64, _emb(1))
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=auto_img.id, tag_id=wip.id, source="process_auto"))
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=soft_img.id, tag_id=wip.id, source="wip_title_soft"))
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
|
||||||
|
db_sync.commit()
|
||||||
|
positives = set(_ids_with_tag(db_sync, wip.id))
|
||||||
|
assert title_img.id in positives # trusted HARD label trains the head
|
||||||
|
assert auto_img.id not in positives # its own auto-applied output does NOT
|
||||||
|
assert soft_img.id not in positives # low-precision soft tier does NOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_wip_conflict_audit_flags_ring_loud(db_sync):
|
||||||
|
# A soft-tagged image (sketch/doodle title) that ALSO scores high on a content
|
||||||
|
# head is probably finished art mis-tagged — flagged for review; a quiet one is not.
|
||||||
|
from backend.app.services.ml.heads import soft_wip_conflict_audit
|
||||||
|
|
||||||
|
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
|
||||||
|
s.process_conflict_threshold = 0.6
|
||||||
|
wip = _system_tag(db_sync, "wip")
|
||||||
|
content = Tag(name="looksreal", kind=TagKind.general)
|
||||||
|
db_sync.add(content)
|
||||||
|
db_sync.flush()
|
||||||
|
_head(db_sync, content.id, 0, weight=1.0) # sigmoid(1)=0.73 > 0.6 conflict
|
||||||
|
ring = _img(db_sync, "1" * 64, _emb(0)) # scores on the content head
|
||||||
|
quiet = _img(db_sync, "2" * 64, _emb(5)) # orthogonal → 0.5 < 0.6
|
||||||
|
for img in (ring, quiet):
|
||||||
|
db_sync.execute(image_tag.insert().values(
|
||||||
|
image_record_id=img.id, tag_id=wip.id, source="wip_title_soft"))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
res = soft_wip_conflict_audit(db_sync)
|
||||||
|
assert res["n_flagged"] == 1
|
||||||
|
flag = db_sync.execute(
|
||||||
|
select(PresentationReview).where(PresentationReview.image_record_id == ring.id)
|
||||||
|
).scalar_one()
|
||||||
|
assert flag.mode == "process"
|
||||||
|
assert flag.conflict_tag_id == content.id
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(PresentationReview).where(PresentationReview.image_record_id == quiet.id)
|
||||||
|
).scalar_one_or_none() is None
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Explore reach sampler (#1476) — pure unit tests for `_reach_sample`, which picks
|
||||||
|
a distance-rank spread so the neighbour pool handed to MMR spans near→mid-far and
|
||||||
|
the walk can escape a dense cluster. No DB. `_reach_sample` only indexes rows, so
|
||||||
|
plain ints stand in for the (ImageRecord, ...) tuples."""
|
||||||
|
from backend.app.services.gallery_service import _reach_sample
|
||||||
|
|
||||||
|
|
||||||
|
def test_reach_zero_or_negative_passes_through():
|
||||||
|
rows = list(range(1000))
|
||||||
|
assert _reach_sample(rows, 40, 0.0) is rows
|
||||||
|
assert _reach_sample(rows, 40, -1.0) is rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_pool_passes_through():
|
||||||
|
# n <= want (limit*8 = 320) → nothing to reach into.
|
||||||
|
rows = list(range(50))
|
||||||
|
assert _reach_sample(rows, 40, 1.0) is rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_higher_reach_reaches_deeper_ranks():
|
||||||
|
rows = list(range(1000))
|
||||||
|
near = _reach_sample(rows, 40, 0.2)
|
||||||
|
far = _reach_sample(rows, 40, 1.0)
|
||||||
|
# Both keep the nearest rank (stride starts at 0) so you can still tag the cluster.
|
||||||
|
assert near[0] == 0
|
||||||
|
assert far[0] == 0
|
||||||
|
# But higher reach samples genuinely farther ranks.
|
||||||
|
assert max(far) > max(near)
|
||||||
|
assert max(far) >= 900 # reach=1 spans (almost) the whole pool
|
||||||
|
assert max(near) <= 550 # reach=0.2 stays in the near half
|
||||||
|
# Never runs past the pool.
|
||||||
|
assert max(far) <= len(rows) - 1
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""recover_stalled_head_training_runs + recover_stalled_head_auto_apply_runs share
|
||||||
|
one helper (_recover_stalled_runs, DRY pass #161). These pin BOTH wrappers so the
|
||||||
|
shared source stays correct: a 'running' row with no progress past the stall
|
||||||
|
threshold flips to 'error'; a fresh 'running' row is left alone."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import HeadAutoApplyRun, HeadTrainingRun
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_stalled_head_training_runs_flips_stalled_keeps_fresh(db_sync):
|
||||||
|
from backend.app.tasks.maintenance import recover_stalled_head_training_runs
|
||||||
|
|
||||||
|
stale = HeadTrainingRun(
|
||||||
|
params={}, status="running",
|
||||||
|
last_progress_at=datetime.now(UTC) - timedelta(days=1),
|
||||||
|
)
|
||||||
|
fresh = HeadTrainingRun(
|
||||||
|
params={}, status="running", last_progress_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db_sync.add_all([stale, fresh])
|
||||||
|
db_sync.commit()
|
||||||
|
stale_id, fresh_id = stale.id, fresh.id
|
||||||
|
|
||||||
|
assert recover_stalled_head_training_runs.apply().get() == 1
|
||||||
|
|
||||||
|
db_sync.expire_all()
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(HeadTrainingRun.status).where(HeadTrainingRun.id == stale_id)
|
||||||
|
).scalar_one() == "error"
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(HeadTrainingRun.status).where(HeadTrainingRun.id == fresh_id)
|
||||||
|
).scalar_one() == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_stalled_head_auto_apply_runs_flips_stalled_keeps_fresh(db_sync):
|
||||||
|
from backend.app.tasks.maintenance import recover_stalled_head_auto_apply_runs
|
||||||
|
|
||||||
|
stale = HeadAutoApplyRun(
|
||||||
|
dry_run=False, params={}, status="running",
|
||||||
|
last_progress_at=datetime.now(UTC) - timedelta(days=1),
|
||||||
|
)
|
||||||
|
fresh = HeadAutoApplyRun(
|
||||||
|
dry_run=False, params={}, status="running",
|
||||||
|
last_progress_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db_sync.add_all([stale, fresh])
|
||||||
|
db_sync.commit()
|
||||||
|
stale_id, fresh_id = stale.id, fresh.id
|
||||||
|
|
||||||
|
assert recover_stalled_head_auto_apply_runs.apply().get() == 1
|
||||||
|
|
||||||
|
db_sync.expire_all()
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == stale_id)
|
||||||
|
).scalar_one() == "error"
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == fresh_id)
|
||||||
|
).scalar_one() == "running"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Title-based WIP matcher (task #1458) — pure unit tests for
|
||||||
|
``matches_wip_title``, the precision-first heuristic that decides whether a post
|
||||||
|
title explicitly declares work-in-progress. No DB, no network.
|
||||||
|
|
||||||
|
Precision matters more than recall here: a false positive applies the ``wip``
|
||||||
|
system tag, which HIDES a finished post from the Explore browse — so the
|
||||||
|
negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.wip_title import matches_soft_wip_title, matches_wip_title
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", [
|
||||||
|
"WIP",
|
||||||
|
"wip",
|
||||||
|
"WiP",
|
||||||
|
"Nami Heroines - WIP Part 1", # the real-world example from the gate work
|
||||||
|
"sketch (WIP)",
|
||||||
|
"[WIP] new piece",
|
||||||
|
"WIP: colour test",
|
||||||
|
"cool art WIP2", # trailing digit = "WIP part 2", still WIP
|
||||||
|
"W.I.P.",
|
||||||
|
"W.I.P",
|
||||||
|
"work in progress",
|
||||||
|
"Work In Progress",
|
||||||
|
"commission work-in-progress",
|
||||||
|
"big_project_work_in_progress",
|
||||||
|
"final touches, wip",
|
||||||
|
])
|
||||||
|
def test_matches_positive(title):
|
||||||
|
assert matches_wip_title(title) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", [
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
"a quick swipe left", # 'wip' inside swipe — must NOT match
|
||||||
|
"she wiped the counter", # wiped
|
||||||
|
"wiping down the desk", # wiping
|
||||||
|
"unwiped surface",
|
||||||
|
"progressive rock cover", # 'progress' inside progressive, no 'work in'
|
||||||
|
"workin on it", # not the full phrase
|
||||||
|
"finished at last",
|
||||||
|
"Kawips diner", # 'wip' mid-word
|
||||||
|
"swipright",
|
||||||
|
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
|
||||||
|
])
|
||||||
|
def test_matches_negative(title):
|
||||||
|
assert matches_wip_title(title) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", [
|
||||||
|
"quick sketch",
|
||||||
|
"Nami sketch",
|
||||||
|
"morning doodle",
|
||||||
|
"some doodles",
|
||||||
|
"sketches from today",
|
||||||
|
"a little scribble",
|
||||||
|
"SKETCH",
|
||||||
|
])
|
||||||
|
def test_soft_matches_positive(title):
|
||||||
|
assert matches_soft_wip_title(title) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", [
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
"sketchbook tour", # 'sketch' inside sketchbook — must NOT match
|
||||||
|
"kadoodle mascot", # 'doodle' mid-word
|
||||||
|
"the final piece",
|
||||||
|
"WIP", # a HARD cue is not a SOFT cue
|
||||||
|
"prescribed colours", # 'scrib' inside prescribed — must NOT match
|
||||||
|
])
|
||||||
|
def test_soft_matches_negative(title):
|
||||||
|
assert matches_soft_wip_title(title) is False
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Title-based WIP auto-tagging (task #1458) — integration tests for the apply
|
||||||
|
helpers and the operator-triggered backfill sweep against a real DB. The
|
||||||
|
importer's live hook is a thin call to these same tested pieces (matcher +
|
||||||
|
apply), so the DB-facing behaviour is covered here.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.celery_app import celery
|
||||||
|
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
|
||||||
|
from backend.app.models.tag import image_tag
|
||||||
|
from backend.app.services.wip_title import (
|
||||||
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
|
apply_wip_image_tags,
|
||||||
|
resolve_wip_tag_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_N = 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def eager():
|
||||||
|
celery.conf.task_always_eager = True
|
||||||
|
yield
|
||||||
|
celery.conf.task_always_eager = False
|
||||||
|
|
||||||
|
|
||||||
|
def _img(db_sync):
|
||||||
|
global _N
|
||||||
|
_N += 1
|
||||||
|
rec = ImageRecord(
|
||||||
|
path=f"/images/w/{_N}.jpg", sha256=f"w{_N:063d}",
|
||||||
|
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||||
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
|
)
|
||||||
|
db_sync.add(rec)
|
||||||
|
db_sync.flush()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _post(db_sync, *, title, slug, ext):
|
||||||
|
a = Artist(name=slug.upper(), slug=slug)
|
||||||
|
db_sync.add(a)
|
||||||
|
db_sync.flush()
|
||||||
|
s = Source(artist_id=a.id, platform="patreon", url=f"https://patreon.test/{slug}")
|
||||||
|
db_sync.add(s)
|
||||||
|
db_sync.flush()
|
||||||
|
p = Post(source_id=s.id, artist_id=a.id, external_post_id=ext, post_title=title)
|
||||||
|
db_sync.add(p)
|
||||||
|
db_sync.flush()
|
||||||
|
return s, p
|
||||||
|
|
||||||
|
|
||||||
|
def _link(db_sync, rec, post, source):
|
||||||
|
db_sync.add(ImageProvenance(
|
||||||
|
image_record_id=rec.id, post_id=post.id, source_id=source.id,
|
||||||
|
))
|
||||||
|
db_sync.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _wip_source(db_sync, image_id, tag_id):
|
||||||
|
return db_sync.execute(
|
||||||
|
select(image_tag.c.source).where(
|
||||||
|
image_tag.c.image_record_id == image_id,
|
||||||
|
image_tag.c.tag_id == tag_id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_wip_tag_id_present(db_sync):
|
||||||
|
# The `wip` system tag is seeded by migration 0075 and restored between tests.
|
||||||
|
assert resolve_wip_tag_id(db_sync) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_is_idempotent_and_stamps_source(db_sync):
|
||||||
|
tag_id = resolve_wip_tag_id(db_sync)
|
||||||
|
rec = _img(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert apply_wip_image_tags(db_sync, [rec.id], tag_id) == 1
|
||||||
|
# Second apply is a no-op (ON CONFLICT DO NOTHING) — never disturbs the row.
|
||||||
|
assert apply_wip_image_tags(db_sync, [rec.id], tag_id) == 0
|
||||||
|
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_tags_only_wip_titled_posts(db_sync):
|
||||||
|
from backend.app.tasks.maintenance import backfill_wip_title_tags
|
||||||
|
|
||||||
|
tag_id = resolve_wip_tag_id(db_sync)
|
||||||
|
wip_rec = _img(db_sync)
|
||||||
|
plain_rec = _img(db_sync)
|
||||||
|
swipe_rec = _img(db_sync)
|
||||||
|
s1, wip_post = _post(db_sync, title="Cool piece - WIP Part 1", slug="wipy", ext="1")
|
||||||
|
s2, plain_post = _post(db_sync, title="Finished commission", slug="doney", ext="2")
|
||||||
|
s3, swipe_post = _post(db_sync, title="a quick swipe", slug="swipey", ext="3")
|
||||||
|
_link(db_sync, wip_rec, wip_post, s1)
|
||||||
|
_link(db_sync, plain_rec, plain_post, s2)
|
||||||
|
_link(db_sync, swipe_rec, swipe_post, s3) # 'swipe' must NOT trip the matcher
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert backfill_wip_title_tags.apply().get() == 1
|
||||||
|
|
||||||
|
assert _wip_source(db_sync, wip_rec.id, tag_id) == "wip_title"
|
||||||
|
assert _wip_source(db_sync, plain_rec.id, tag_id) is None
|
||||||
|
assert _wip_source(db_sync, swipe_rec.id, tag_id) is None
|
||||||
|
|
||||||
|
# Idempotent: a second sweep finds the tag already present and applies nothing.
|
||||||
|
assert backfill_wip_title_tags.apply().get() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_soft_source_stamps_wip_title_soft(db_sync):
|
||||||
|
# The soft tier (#1474) stamps a distinct provisional source.
|
||||||
|
tag_id = resolve_wip_tag_id(db_sync)
|
||||||
|
rec = _img(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
assert apply_wip_image_tags(
|
||||||
|
db_sync, [rec.id], tag_id, source=WIP_TITLE_SOFT_SOURCE
|
||||||
|
) == 1
|
||||||
|
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title_soft"
|
||||||
Reference in New Issue
Block a user