Compare commits
61 Commits
ba3fd2a118
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ec66ea5f83 | |||
| e92570a31e | |||
| a2d1ed935d | |||
| 05df51b749 | |||
| 099e1e664c | |||
| c87f8a1bb3 | |||
| 666b3a2ec8 | |||
| d80a5255ed | |||
| 69b5637bd6 | |||
| 51749e05db | |||
| 50d6c42207 | |||
| 67c7ca8603 | |||
| eed42a260a | |||
| 61b14e8f65 | |||
| 447bf73519 | |||
| 6104452d2e | |||
| b59828635e | |||
| fac5ae6ce5 | |||
| af0d39ed52 | |||
| d9a14e890d | |||
| ad2a5fc5fe | |||
| 0da0e47784 | |||
| 503c8854bc | |||
| 571938781a | |||
| 0cf3a02797 | |||
| 6ab495292a | |||
| c98db303d0 | |||
| 2d0fca8729 | |||
| a2858892e9 | |||
| 7f5e0603de | |||
| 49f6765326 | |||
| b23b19bf58 | |||
| c17a6e6c40 | |||
| 64053a5f57 | |||
| b6c5638eab | |||
| 466ee898ab | |||
| e00deee451 | |||
| 87d3198f89 | |||
| 5010de6178 | |||
| 3063af6a74 | |||
| ba2de60439 | |||
| ff9846ce64 | |||
| 279b0560d9 | |||
| af09bf58af | |||
| aea2701c28 | |||
| a444cf82d1 | |||
| 6ab7fd5c7f | |||
| 7ddf94220d | |||
| ffdbdbaf07 | |||
| a017771621 | |||
| 25e555cab6 | |||
| 3c7ab44e74 | |||
| 06f98acf3e | |||
| 4371ddb7e7 | |||
| 40cc11be5b | |||
| 0b78264d62 | |||
| 9eae636047 | |||
| f8105046dc | |||
| d631ed023c | |||
| c64261593d | |||
| 1f6d94f51d |
@@ -9,10 +9,15 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
# pull_request trigger intentionally absent — with branches: [dev, main]
|
||||
# above, every PR commit already fires CI via the push event on dev. Adding
|
||||
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
|
||||
# (single-operator Forgejo repo) so push coverage is complete.
|
||||
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches
|
||||
# never push to dev/main, so the push trigger above gives them NO pre-merge
|
||||
# CI — a bump could only be validated after it was already merged. This
|
||||
# pull_request trigger (base `dev` only) validates Renovate PRs before merge.
|
||||
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
|
||||
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
|
||||
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
|
||||
@@ -92,10 +97,10 @@ jobs:
|
||||
# If we want strict lockfile-based reproducibility later, commit a
|
||||
# package-lock.json and flip this back to `npm ci`.
|
||||
- run: npm install --no-audit --no-fund
|
||||
# `npm run check` (vue-tsc --noEmit) skipped: the frontend is pure JS
|
||||
# with no .ts files and no JSDoc annotations, so vue-tsc has nothing
|
||||
# to type-check. Re-enable once we add a tsconfig.json and either
|
||||
# convert to TS or add JSDoc.
|
||||
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
|
||||
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
|
||||
# script were dropped 2026-07-11 rather than bumped to v3. If we add
|
||||
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
|
||||
- run: npm run test:unit
|
||||
- run: npm run build
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
image: node:24-bookworm-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install web-ext
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
# No package-lock.json is tracked yet (we don't run npm locally per
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM python:3.14-slim
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""translation strictness setting + per-post translation override (milestone 155)
|
||||
|
||||
ImportSettings gains ``translation_min_confidence`` (the latin-script acceptance
|
||||
floor, now operator-tunable in the UI; default 0.9 — stricter than the old
|
||||
hardcoded 0.8, since Interpreter confidently mis-detects short ASCII English at
|
||||
~0.86). Post gains ``translation_override`` — a sticky per-post choice of
|
||||
auto / force / original so the operator can force a skipped translation on, or
|
||||
knock a wrongly-translated one back to the original, and have it survive a
|
||||
Re-translate-all.
|
||||
|
||||
Revision ID: 0084
|
||||
Revises: 0083
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0084"
|
||||
down_revision: Union[str, None] = "0083"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"translation_min_confidence", sa.Float(), nullable=False,
|
||||
server_default=sa.text("0.9"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"post",
|
||||
sa.Column(
|
||||
"translation_override", sa.String(16), nullable=False,
|
||||
server_default="auto",
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_post_translation_override",
|
||||
"post",
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_post_translation_override", "post", type_="check")
|
||||
op.drop_column("post", "translation_override")
|
||||
op.drop_column("import_settings", "translation_min_confidence")
|
||||
@@ -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")
|
||||
@@ -145,6 +145,20 @@ async def similar():
|
||||
filters, _sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "similar_to query param required"}), 400
|
||||
# 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).
|
||||
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.
|
||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||
@@ -154,7 +168,9 @@ async def similar():
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
|
||||
images = await svc.similar(
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
|
||||
reach=reach, exclude_ids=exclude_ids, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
@@ -232,8 +248,10 @@ async def jump():
|
||||
# content", surfaced in the gallery's Show-hidden review strip. -----------
|
||||
@gallery_bp.route("/hidden-review", methods=["GET"])
|
||||
async def hidden_review():
|
||||
"""Unresolved presentation auto-hide flags, most-concerning first (highest
|
||||
content score) — for the gallery's Hidden-view review strip."""
|
||||
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
|
||||
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)
|
||||
ctag = aliased(Tag)
|
||||
async with get_session() as session:
|
||||
@@ -243,6 +261,7 @@ async def hidden_review():
|
||||
PresentationReview.tag_id,
|
||||
PresentationReview.conflict_tag_id,
|
||||
PresentationReview.conflict_score,
|
||||
PresentationReview.mode,
|
||||
ImageRecord.path, ImageRecord.thumbnail_path,
|
||||
ImageRecord.sha256, ImageRecord.mime,
|
||||
ptag.name.label("tag_name"),
|
||||
@@ -262,6 +281,7 @@ async def hidden_review():
|
||||
"conflict_tag_id": r.conflict_tag_id,
|
||||
"conflict_name": r.conflict_name,
|
||||
"conflict_score": r.conflict_score,
|
||||
"mode": r.mode,
|
||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||
"image_url": image_url(r.path),
|
||||
}
|
||||
|
||||
@@ -256,9 +256,7 @@ async def lease():
|
||||
if not await _agent_authed(session):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
|
||||
ml = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
ml = await MLSettings.load(session)
|
||||
# image rows for url/mime in one shot
|
||||
ids = [j.image_record_id for j in jobs]
|
||||
imgs = {
|
||||
|
||||
+24
-38
@@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
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")
|
||||
|
||||
@@ -42,6 +43,9 @@ _EDITABLE = (
|
||||
"presentation_auto_apply_enabled",
|
||||
"presentation_auto_apply_threshold",
|
||||
"presentation_conflict_threshold",
|
||||
"process_auto_apply_enabled",
|
||||
"process_auto_apply_threshold",
|
||||
"process_conflict_threshold",
|
||||
"embedder_model_name",
|
||||
"embedder_model_version",
|
||||
*_DETECTOR_FIELDS,
|
||||
@@ -80,45 +84,21 @@ async def embedder_models():
|
||||
|
||||
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||
async def get_settings():
|
||||
from sqlalchemy import select
|
||||
|
||||
async with get_session() as session:
|
||||
s = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
return jsonify(
|
||||
{
|
||||
"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},
|
||||
}
|
||||
)
|
||||
s = await MLSettings.load(session)
|
||||
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
|
||||
# can never be silently absent from GET — the split that historically dropped
|
||||
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
|
||||
return jsonify({f: getattr(s, f) for f in _EDITABLE})
|
||||
|
||||
|
||||
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
||||
async def patch_settings():
|
||||
from sqlalchemy import select
|
||||
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "body must be an object"}), 400
|
||||
async with get_session() as session:
|
||||
s = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
s = await MLSettings.load(session)
|
||||
|
||||
# Merge the patch over current values, then validate the result as a
|
||||
# whole — the store-floor invariant couples three fields, so they
|
||||
@@ -148,20 +128,26 @@ def _validate(p: dict) -> str | None:
|
||||
# Head training (#114).
|
||||
if int(p["head_min_positives"]) < 1:
|
||||
return "head_min_positives must be >= 1"
|
||||
if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
|
||||
return "head_auto_apply_precision must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
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:
|
||||
return "head_auto_apply_min_positives must be >= 1"
|
||||
if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
|
||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
|
||||
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
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
|
||||
# consequential); the conflict cut is a plain probability [0,1].
|
||||
if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
|
||||
return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
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):
|
||||
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
|
||||
# different embedding space — the operator must re-embed + retrain after.
|
||||
for key in ("embedder_model_name", "embedder_model_version"):
|
||||
|
||||
@@ -3,12 +3,28 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import ImportSettings, Post
|
||||
from ..services import interpreter_client as ic
|
||||
from ..services.post_feed_service import PostFeedService
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ..utils.text import html_to_plain
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
|
||||
|
||||
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
|
||||
|
||||
|
||||
def _queue_for_sweep(post: Post) -> None:
|
||||
"""Mark a post untranslated (all translation columns NULL) so the periodic
|
||||
sweep re-runs it under its new override — used when Interpreter is down and we
|
||||
can't translate inline."""
|
||||
post.post_title_translated = None
|
||||
post.description_translated = None
|
||||
post.translated_source_lang = None
|
||||
post.translation_engine_version = None
|
||||
post.translated_at = None
|
||||
|
||||
|
||||
@posts_bp.route("", methods=["GET"])
|
||||
async def list_posts():
|
||||
@@ -82,3 +98,70 @@ async def get_post(post_id: int):
|
||||
if item is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
@posts_bp.route("/<int:post_id>/translation-override", methods=["POST"])
|
||||
async def set_translation_override(post_id: int):
|
||||
"""Sticky per-post translation override (milestone 155). Body:
|
||||
``{"override": "auto" | "force" | "original"}``.
|
||||
|
||||
'original' keeps the original (clears any stored translation now — no
|
||||
Interpreter needed). 'force'/'auto' translate the post immediately if the
|
||||
service is up (force bypasses the acceptance floor; auto re-runs the gate);
|
||||
if it's down we save the flag and mark the post untranslated so the next sweep
|
||||
applies it. The override persists, so the sweep + Re-translate-all keep
|
||||
honoring it. Returns the updated translation fields + an ``applied`` status."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
override = body.get("override")
|
||||
if override not in _TRANSLATION_OVERRIDES:
|
||||
return _bad(
|
||||
"invalid_override",
|
||||
detail=f"override must be one of {list(_TRANSLATION_OVERRIDES)}",
|
||||
)
|
||||
|
||||
# Lazy import (mirrors settings.py) so the API module doesn't pull the celery
|
||||
# task graph at import time.
|
||||
from ..tasks.translation import _store_translation, _translate_field
|
||||
|
||||
async with get_session() as session:
|
||||
post = await session.get(Post, post_id)
|
||||
if post is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
post.translation_override = override
|
||||
cfg = await ImportSettings.load(session)
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
|
||||
if override == "original":
|
||||
_store_translation(post, (None, None, None), (None, None, None), target)
|
||||
applied = "cleared"
|
||||
else:
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
if cfg.translation_enabled and base_url and ic.health(base_url):
|
||||
force = override == "force"
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
mc = cfg.translation_min_confidence
|
||||
try:
|
||||
title_res = _translate_field(title, base_url, target, mc, force=force)
|
||||
desc_res = _translate_field(desc, base_url, target, mc, force=force)
|
||||
except ic.InterpreterUnavailable:
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
else:
|
||||
_store_translation(post, title_res, desc_res, target)
|
||||
applied = "translated"
|
||||
else:
|
||||
# Disabled / no URL / unhealthy → let the sweep apply it later.
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
|
||||
await session.commit()
|
||||
return jsonify({
|
||||
"id": post.id,
|
||||
"translation_override": post.translation_override,
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": post.description_translated,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
"applied": applied,
|
||||
})
|
||||
|
||||
+142
-27
@@ -16,6 +16,7 @@ from ..models import (
|
||||
ImportTask,
|
||||
Post,
|
||||
Tag,
|
||||
TaskRun,
|
||||
)
|
||||
from ..services import interpreter_client as ic
|
||||
|
||||
@@ -46,6 +47,9 @@ _EDITABLE_FIELDS = (
|
||||
"translation_enabled",
|
||||
"interpreter_base_url",
|
||||
"translation_target_lang",
|
||||
"translation_min_confidence",
|
||||
"wip_title_tagging_enabled",
|
||||
"wip_soft_title_tagging_enabled",
|
||||
)
|
||||
|
||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||
@@ -62,31 +66,9 @@ _EXTDL_TOGGLE_FIELDS = (
|
||||
async def get_import_settings():
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
return jsonify({
|
||||
"min_width": row.min_width,
|
||||
"min_height": row.min_height,
|
||||
"skip_transparent": row.skip_transparent,
|
||||
"transparency_threshold": row.transparency_threshold,
|
||||
"skip_single_color": row.skip_single_color,
|
||||
"single_color_threshold": row.single_color_threshold,
|
||||
"single_color_tolerance": row.single_color_tolerance,
|
||||
"phash_threshold": row.phash_threshold,
|
||||
"download_rate_limit_seconds": row.download_rate_limit_seconds,
|
||||
"download_validate_files": row.download_validate_files,
|
||||
"download_schedule_default_seconds": row.download_schedule_default_seconds,
|
||||
"download_event_retention_days": row.download_event_retention_days,
|
||||
"download_failure_warning_threshold": row.download_failure_warning_threshold,
|
||||
"series_suggest_enabled": row.series_suggest_enabled,
|
||||
"series_suggest_threshold": row.series_suggest_threshold,
|
||||
"extdl_mega_enabled": row.extdl_mega_enabled,
|
||||
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
|
||||
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
||||
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
||||
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
||||
"translation_enabled": row.translation_enabled,
|
||||
"interpreter_base_url": row.interpreter_base_url,
|
||||
"translation_target_lang": row.translation_target_lang,
|
||||
})
|
||||
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
|
||||
# can't be silently absent from GET.
|
||||
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/import", methods=["PATCH"])
|
||||
@@ -154,12 +136,32 @@ async def update_import_settings():
|
||||
for key in ("interpreter_base_url", "translation_target_lang"):
|
||||
if key in body and not isinstance(body[key], str):
|
||||
return jsonify({"error": f"{key} must be a string"}), 400
|
||||
# Acceptance floor (milestone 155): latin-script translations below this
|
||||
# Interpreter confidence are kept as the original.
|
||||
if "translation_min_confidence" in body:
|
||||
v = body["translation_min_confidence"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
return jsonify(
|
||||
{"error": "translation_min_confidence must be a number in [0, 1]"}
|
||||
), 400
|
||||
if "series_suggest_threshold" in body:
|
||||
v = body["series_suggest_threshold"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
return jsonify(
|
||||
{"error": "series_suggest_threshold must be a number in [0, 1]"}
|
||||
), 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:
|
||||
row = await ImportSettings.load(session)
|
||||
@@ -171,6 +173,18 @@ async def update_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"])
|
||||
async def system_stats():
|
||||
async with get_session() as session:
|
||||
@@ -306,6 +320,10 @@ async def translation_status():
|
||||
"""For the Settings card: is it on, is a URL set, is the service reachable,
|
||||
and how many posts still await translation. Health runs the sync client in a
|
||||
thread so the event loop isn't blocked."""
|
||||
translation_tasks = (
|
||||
"backend.app.tasks.translation.translate_posts",
|
||||
"backend.app.tasks.translation.retranslate_posts",
|
||||
)
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
untranslated = (await session.execute(
|
||||
@@ -315,6 +333,21 @@ async def translation_status():
|
||||
Post.post_title.is_not(None), Post.description.is_not(None),
|
||||
))
|
||||
)).scalar_one()
|
||||
# Live progress: is a sweep running now, and what did the last one do?
|
||||
# (run-until-done re-enqueues itself, so `active` stays true across a
|
||||
# bulk re-translate; `last_run` surfaces a completed run's outcome.)
|
||||
active = (await session.execute(
|
||||
select(func.count(TaskRun.id))
|
||||
.where(TaskRun.task_name.in_(translation_tasks))
|
||||
.where(TaskRun.status == "running")
|
||||
)).scalar_one()
|
||||
last = (await session.execute(
|
||||
select(TaskRun.task_name, TaskRun.status, TaskRun.finished_at)
|
||||
.where(TaskRun.task_name.in_(translation_tasks))
|
||||
.where(TaskRun.finished_at.is_not(None))
|
||||
.order_by(TaskRun.finished_at.desc())
|
||||
.limit(1)
|
||||
)).first()
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
|
||||
return jsonify({
|
||||
@@ -322,6 +355,12 @@ async def translation_status():
|
||||
"base_url_set": bool(base_url),
|
||||
"healthy": healthy,
|
||||
"untranslated_count": int(untranslated),
|
||||
"active": int(active) > 0,
|
||||
"last_run": {
|
||||
"task": last[0].rsplit(".", 1)[-1],
|
||||
"status": last[1],
|
||||
"finished_at": last[2].isoformat() if last[2] else None,
|
||||
} if last else None,
|
||||
})
|
||||
|
||||
|
||||
@@ -338,9 +377,49 @@ async def translation_test():
|
||||
return jsonify({"healthy": healthy})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/probe", methods=["POST"])
|
||||
async def translation_probe():
|
||||
"""Diagnostic for the Settings 'Test translation' box: translate a pasted
|
||||
snippet WITHOUT saving anything, returning what Interpreter *detected*
|
||||
(language + confidence) alongside the result. Lets the operator see why a
|
||||
given string was (mis-)detected — e.g. a short English title flagged as
|
||||
another language — so a detection guard can be tuned from real numbers.
|
||||
Read-only: no post is touched. Uses the currently-saved base URL + target."""
|
||||
body = await request.get_json(silent=True)
|
||||
body = body if isinstance(body, dict) else {}
|
||||
text = (body.get("text") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "provide text to translate"}), 400
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
if not base_url:
|
||||
return jsonify({"error": "no Interpreter base URL is set"}), 400
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
try:
|
||||
res = await asyncio.to_thread(
|
||||
ic.translate, [text], base_url=base_url, target=target,
|
||||
)
|
||||
except ic.InterpreterUnavailable as e:
|
||||
return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
|
||||
except ic.InterpreterBadRequest as e:
|
||||
return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
|
||||
translations = res.get("translations") or []
|
||||
return jsonify({
|
||||
"target": target,
|
||||
"detected_lang": res.get("detected_lang"),
|
||||
"detected_confidence": res.get("detected_confidence"),
|
||||
"engine": res.get("engine"),
|
||||
"engine_version": res.get("engine_version"),
|
||||
"translated": translations[0] if translations else None,
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/run", methods=["POST"])
|
||||
async def translation_run():
|
||||
"""Enqueue the translate sweep now (the Settings 'Translate now' button)."""
|
||||
"""Enqueue the translate sweep now (the Settings 'Translate now' button).
|
||||
Runs in drain mode — run-until-done — so one press chases the whole
|
||||
untranslated backlog to zero rather than a single 300-post chunk."""
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
|
||||
@@ -349,5 +428,41 @@ async def translation_run():
|
||||
), 400
|
||||
from ..tasks.translation import translate_posts
|
||||
|
||||
r = translate_posts.delay()
|
||||
r = translate_posts.delay(drain=True)
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/retranslate", methods=["POST"])
|
||||
async def translation_retranslate():
|
||||
"""Re-translate stored translations after a model change (m146). Body:
|
||||
``{"artist_id": <int>}`` aims at one artist; ``{"all": true}`` re-runs every
|
||||
artist. ``all`` must be explicit so an empty/typo body can't wipe everything.
|
||||
Clears the scoped translations and enqueues the run-until-done retranslate
|
||||
sweep (the Interpreter cache re-translates on a changed model, is cache-fast
|
||||
otherwise). Same enabled + base-URL guard as 'Translate now'."""
|
||||
body = await request.get_json(silent=True)
|
||||
body = body if isinstance(body, dict) else {}
|
||||
artist_id = body.get("artist_id")
|
||||
do_all = bool(body.get("all"))
|
||||
if artist_id is None and not do_all:
|
||||
return jsonify(
|
||||
{"error": "provide artist_id, or all=true to re-translate everything"}
|
||||
), 400
|
||||
if artist_id is not None:
|
||||
try:
|
||||
artist_id = int(artist_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "artist_id must be an integer"}), 400
|
||||
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
|
||||
return jsonify(
|
||||
{"error": "translation is disabled or no base URL is set"}
|
||||
), 400
|
||||
from ..tasks.translation import retranslate_posts
|
||||
|
||||
# artist_id wins when both are sent; otherwise all=true → None (every artist).
|
||||
artist_ids = [artist_id] if artist_id is not None else None
|
||||
r = retranslate_posts.delay(artist_ids=artist_ids)
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
@@ -11,10 +11,11 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
||||
|
||||
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||
async def get_suggestions(image_id: int):
|
||||
# ?min=<float> overrides the configured per-category thresholds so the typed
|
||||
# tag-input dropdown can surface EVERY stored prediction (min=0), including
|
||||
# low-confidence actions/features, in canonical formatting. Omitted → the
|
||||
# curated above-threshold list the Suggestions panel uses.
|
||||
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
|
||||
# rail sends min=0 in its single per-image fetch to get EVERY head (each row
|
||||
# still carries above_threshold vs its natural cut), then derives the panel
|
||||
# (above_threshold) and the typed dropdown (all, filtered by text) client-side
|
||||
# — no second request. Omitted → only above-threshold rows.
|
||||
override = None
|
||||
raw_min = request.args.get("min")
|
||||
if raw_min is not None:
|
||||
@@ -36,12 +37,11 @@ async def get_suggestions(image_id: int):
|
||||
"category": s.category,
|
||||
"score": round(s.score, 4),
|
||||
"source": s.source,
|
||||
"creates_new_tag": s.creates_new_tag,
|
||||
# raw model key (alias is stored under this) + whether an
|
||||
# operator alias produced this suggestion — drive the
|
||||
# modal's "Treat as alias"/"Remove alias" affordances.
|
||||
"raw_name": s.raw_name,
|
||||
"via_alias": s.via_alias,
|
||||
# whether the score cleared the head's own suggest cut.
|
||||
# The single min=0 fetch returns every head; the panel
|
||||
# shows above_threshold, the typed dropdown shows all and
|
||||
# annotates each match with its score.
|
||||
"above_threshold": s.above_threshold,
|
||||
# operator dismissed this tag for this image — surfaced
|
||||
# (not dropped) so the rail can show it rejected + offer
|
||||
# one-click un-reject.
|
||||
@@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int):
|
||||
return jsonify({"accepted": True, "tag_id": tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
|
||||
)
|
||||
async def alias_suggestion(image_id: int):
|
||||
body = await request.get_json()
|
||||
required = {"alias_string", "alias_category", "canonical_tag_id"}
|
||||
if not body or not required.issubset(body):
|
||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
||||
canonical_tag_id = body["canonical_tag_id"]
|
||||
async with get_session() as session:
|
||||
await AllowlistService(session).add_alias_and_accept(
|
||||
image_id,
|
||||
body["alias_string"],
|
||||
body["alias_category"],
|
||||
canonical_tag_id,
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
|
||||
)
|
||||
|
||||
@@ -70,6 +70,14 @@ def make_celery() -> Celery:
|
||||
},
|
||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||
task_acks_late=True,
|
||||
# Deploy graceful-shutdown safety: with acks_late, a task killed because
|
||||
# it outran the container's stop-grace window (SIGKILL) is re-queued
|
||||
# rather than silently lost. Safe because our long tasks are idempotent +
|
||||
# chunked (translation per-post commit, downloads terminal-status, audits
|
||||
# chunk) and the 5-min recovery sweeps re-drive anything left non-terminal
|
||||
# — a re-run resumes cleanly and never corrupts. No redeliver-loop risk:
|
||||
# heavy GPU work is tombstoned via gpu_queue, not run inline in a worker.
|
||||
task_reject_on_worker_lost=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
# Broker resilience (2026-06-24): a swarm overlay-network blip after a
|
||||
# redeploy left Redis healthy but transiently unreachable, and a worker
|
||||
@@ -107,6 +115,11 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||
"schedule": 86400.0, # daily
|
||||
},
|
||||
"cleanup-orphaned-temp-files": {
|
||||
"task": "backend.app.tasks.maintenance.cleanup_orphaned_temp_files",
|
||||
"schedule": 86400.0, # daily — sweep .part/.partial left by a
|
||||
# download/import killed mid-write (graceful-shutdown fallout)
|
||||
},
|
||||
"train-heads-nightly": {
|
||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||
@@ -158,16 +171,30 @@ def make_celery() -> Celery:
|
||||
},
|
||||
"presentation-auto-apply-daily": {
|
||||
"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
|
||||
},
|
||||
"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": {
|
||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||
},
|
||||
"translate-posts-daily": {
|
||||
"translate-posts-8h": {
|
||||
"task": "backend.app.tasks.translation.translate_posts",
|
||||
"schedule": 86400.0, # no-op unless translation configured + healthy
|
||||
"schedule": 28800.0, # every 8h: steady-state cadence for the
|
||||
# trickle of newly-imported posts (no-op unless translation
|
||||
# configured + healthy). One bounded 300-chunk per fire — the
|
||||
# one-time backlog drains via the "Translate now" button
|
||||
# (drain=True, run-until-done), not this sweep.
|
||||
},
|
||||
"snapshot-head-metrics-daily": {
|
||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||
|
||||
@@ -106,6 +106,33 @@ class ImportSettings(Base):
|
||||
translation_target_lang: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="en", server_default="en",
|
||||
)
|
||||
# The latin-script acceptance floor for the translation gate: a translation
|
||||
# whose Interpreter-reported confidence is below this is kept as the original
|
||||
# (operator-tunable, milestone 155). Default 0.9 — stricter than the old
|
||||
# hardcoded 0.8, because Interpreter confidently mis-detects short ASCII
|
||||
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
|
||||
# trusted regardless (script-detected). Per-post overrides handle the misses.
|
||||
translation_min_confidence: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.9, server_default="0.9",
|
||||
)
|
||||
|
||||
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
|
||||
# TITLE explicitly declares work-in-progress ("WIP" / "work in progress"),
|
||||
# the importer applies the `wip` system tag to its images — the artist's own
|
||||
# label, used to keep unfinished pieces out of the Explore/gallery browse. ON
|
||||
# by default (rule 26 — the feature works out of the box). Gates only the
|
||||
# LIVE import hook; the existing catalogue is caught by the operator-triggered
|
||||
# "Scan existing posts" backfill (which runs regardless of this flag).
|
||||
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true",
|
||||
)
|
||||
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
|
||||
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
|
||||
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
|
||||
# precision tier is opt-in (the ring-loud audit surfaces false positives).
|
||||
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, session) -> ImportSettings:
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -85,12 +86,14 @@ class MLSettings(Base):
|
||||
Float, nullable=False, default=0.95
|
||||
)
|
||||
# -- Presentation chrome auto-hide (#141) -------------------------------
|
||||
# banner / editor screenshot auto-apply on the sweep with their OWN flat
|
||||
# threshold (decoupled from content-head graduation). Hiding is consequential
|
||||
# so it runs HIGH. `wip` is never auto-applied. When an image would be
|
||||
# auto-hidden but ALSO scores >= presentation_conflict_threshold on a content
|
||||
# head, it's still hidden but flagged for review (PresentationReview) instead
|
||||
# of buried silently. ON by default (opt-out); every auto-tag is reversible.
|
||||
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
||||
# with its OWN flat threshold (decoupled from content-head graduation) and is
|
||||
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
|
||||
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
|
||||
# on a content head, it's still hidden but flagged for review
|
||||
# (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(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
@@ -100,6 +103,26 @@ class MLSettings(Base):
|
||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
||||
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);
|
||||
# existing libraries keep their stored value until the operator re-embeds.
|
||||
embedder_model_version: Mapped[str] = mapped_column(
|
||||
@@ -190,3 +213,14 @@ class MLSettings(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
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()
|
||||
|
||||
@@ -8,7 +8,17 @@ artist-filter queries don't depend on the Source detour).
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -23,6 +33,10 @@ class Post(Base):
|
||||
# (created in alembic 0030) covers that case via
|
||||
# (artist_id, external_post_id).
|
||||
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
||||
CheckConstraint(
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
name="ck_post_translation_override",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -64,6 +78,16 @@ class Post(Base):
|
||||
translated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Sticky per-post override of the translation decision (milestone 155):
|
||||
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
|
||||
# translation even below the confidence floor (rescue a skipped legit-foreign
|
||||
# title); 'original' = never translate, keep the original (kill a confidently
|
||||
# mis-flagged one the floor can't catch). The sweep reads this on every run,
|
||||
# and re-translate leaves 'original' posts alone, so the choice survives a
|
||||
# Re-translate-all.
|
||||
translation_override: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="auto", server_default="auto",
|
||||
)
|
||||
|
||||
downloaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
|
||||
real content, flagged for operator review (milestone 141).
|
||||
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
|
||||
like real content, flagged for operator review (milestone 141 + #1464).
|
||||
|
||||
When the auto-apply sweep hides an image as chrome (banner / editor screenshot)
|
||||
but the image ALSO scores highly on a content head, it still hides it but records
|
||||
this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>")
|
||||
for a keep-hidden / un-hide decision. Resolved rows are pruned by retention.
|
||||
When a sweep applies a system tag but the image ALSO scores highly on a content
|
||||
head, it still applies the tag but records this row so a review strip can surface
|
||||
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
|
||||
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 sqlalchemy import DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -31,6 +33,12 @@ class PresentationReview(Base):
|
||||
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
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(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -43,11 +43,20 @@ class TagKind(StrEnum):
|
||||
# to keep historic tag rows queryable.
|
||||
|
||||
|
||||
# The seeded system tags (migration 0075). PRESENTATION tags additionally
|
||||
# hide from whole-image similarity results — they cluster on UI chrome, not
|
||||
# content. `wip` is real art: only the training pipelines exclude it.
|
||||
# The seeded system tags (migration 0075). Two behavior groups (#1464):
|
||||
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
|
||||
# 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")
|
||||
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
||||
CHROME_SYSTEM_TAGS = ("banner",)
|
||||
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
|
||||
WIP_SYSTEM_TAG = "wip"
|
||||
|
||||
image_tag = Table(
|
||||
"image_tag",
|
||||
|
||||
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
||||
# reviewers catch drift.
|
||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("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"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
||||
r"(?P<slug>[^/?#]+)/?$",
|
||||
r"(?:cw/|c/)?"
|
||||
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||
r"(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("subscribestar", re.compile(
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..models import (
|
||||
Tag,
|
||||
TagPositiveConfirmation,
|
||||
)
|
||||
from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
|
||||
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from .pagination import decode_cursor, encode_cursor
|
||||
from .tag_query import (
|
||||
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]
|
||||
|
||||
|
||||
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]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -419,16 +438,17 @@ class GalleryService:
|
||||
async def _hidden_tag_ids(
|
||||
self, include_hidden, tag_ids, tag_or_groups,
|
||||
) -> list[int] | None:
|
||||
"""Presentation-chrome tag ids to implicitly exclude from a gallery query,
|
||||
or None. None when the caller asked to include hidden, when the operator
|
||||
is explicitly filtering FOR a presentation tag (they clearly want to see
|
||||
it), or when no presentation tags exist. (milestone 141)"""
|
||||
"""Chrome (banner) tag ids to implicitly exclude from a gallery query, or
|
||||
None. None when the caller asked to include hidden, when the operator is
|
||||
explicitly filtering FOR a chrome tag (they clearly want to see it), or when
|
||||
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
|
||||
— shown — so only `banner` hides here.)"""
|
||||
if include_hidden:
|
||||
return None
|
||||
rows = await self.session.execute(
|
||||
select(Tag.id).where(
|
||||
Tag.is_system.is_(True),
|
||||
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
|
||||
Tag.name.in_(CHROME_SYSTEM_TAGS),
|
||||
)
|
||||
)
|
||||
pres = [r[0] for r in rows]
|
||||
@@ -715,6 +735,8 @@ class GalleryService:
|
||||
platform: str | None = None,
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
exclude_wip: bool = False,
|
||||
reach: float = 0.0, exclude_ids: list[int] | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||
@@ -743,22 +765,33 @@ class GalleryService:
|
||||
# wide pool there's nothing but the near-dupes to choose from. Widened
|
||||
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
# 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)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
# Presentation images (banner / editor-screenshot system tags, #128)
|
||||
# cluster on UI chrome rather than content, so near any one of them
|
||||
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
||||
# itself may be a banner, and `wip` stays surfaced (real art; only
|
||||
# the training pipelines exclude it).
|
||||
# Chrome (banner, #128) clusters on UI rather than content, so near any one
|
||||
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor
|
||||
# itself may be a banner). PROCESS art (wip / editor screenshot) stays
|
||||
# surfaced here by default (real content; only the training pipelines exclude
|
||||
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
|
||||
# process group so a browse doesn't keep surfacing work-in-progress
|
||||
# (operator, 2026-07-08; #1464 — editor now rides with wip here).
|
||||
excluded_system_tags = CHROME_SYSTEM_TAGS
|
||||
if exclude_wip:
|
||||
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
|
||||
presentation = (
|
||||
select(image_tag.c.image_record_id)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(
|
||||
Tag.is_system.is_(True),
|
||||
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
|
||||
Tag.name.in_(excluded_system_tags),
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(
|
||||
@@ -766,6 +799,10 @@ class GalleryService:
|
||||
ImageRecord.id != image_id,
|
||||
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, tag_ids=tag_ids, post_id=None,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
@@ -775,6 +812,10 @@ class GalleryService:
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||
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)
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
@@ -47,9 +47,21 @@ from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
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__)
|
||||
|
||||
# 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):
|
||||
too_small = "too_small"
|
||||
@@ -183,6 +195,10 @@ class Importer:
|
||||
# invalidated mid-Importer (Importer instances are per-task /
|
||||
# per-archive-import so cross-instance staleness is harmless).
|
||||
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]:
|
||||
"""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
|
||||
# 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()
|
||||
return ImportResult(status="imported", image_id=record.id)
|
||||
|
||||
@@ -976,6 +996,47 @@ class Importer:
|
||||
self.session.commit()
|
||||
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:
|
||||
"""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)
|
||||
@@ -1253,6 +1314,10 @@ class Importer:
|
||||
# per-post Source row.
|
||||
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()
|
||||
return ImportResult(status="imported", image_id=record.id)
|
||||
|
||||
|
||||
@@ -8,12 +8,24 @@ behind a reverse proxy). Full API contract: Scribe note #1347.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
class InterpreterUnavailable(Exception):
|
||||
"""The translation engine is down / unreachable (HTTP 503 or a connection
|
||||
error) — retry later, don't drop the item."""
|
||||
"""The translation engine is down / unreachable / draining — a connection
|
||||
error, HTTP 429, or a 5xx (commonly 502/503/504 through a reverse proxy while
|
||||
the service restarts). Retry later, don't drop the item. ``retry_after`` holds
|
||||
the server's Retry-After hint in seconds when it sent one, so the caller can
|
||||
back off exactly as long as it's asked to."""
|
||||
|
||||
def __init__(self, message: str, *, retry_after: float | None = None):
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class InterpreterBadRequest(Exception):
|
||||
@@ -24,13 +36,52 @@ def _url(base_url: str, path: str) -> str:
|
||||
return f"{base_url.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _parse_retry_after(resp) -> float | None:
|
||||
"""Parse a Retry-After header (RFC 7231: delta-seconds or an HTTP-date) into
|
||||
non-negative seconds, or None if absent/unparseable — so a gracefully-
|
||||
draining Interpreter can tell Curator exactly how long to wait before it
|
||||
tries again."""
|
||||
raw = (resp.headers.get("Retry-After") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, float(int(raw))) # delta-seconds form
|
||||
except ValueError:
|
||||
pass
|
||||
try: # HTTP-date form
|
||||
when = parsedate_to_datetime(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if when is None:
|
||||
return None
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=UTC)
|
||||
return max(0.0, (when - datetime.now(UTC)).total_seconds())
|
||||
|
||||
|
||||
# A shared session pools the keep-alive connection across the per-post sweep
|
||||
# calls, and retries CONNECT failures only (connect=2, short backoff) — smoothing
|
||||
# the instant a reverse proxy reloads. Status codes are deliberately NOT retried
|
||||
# (status=0, raise_on_status=False): translate() maps 429/5xx → InterpreterUnavailable
|
||||
# itself, and letting urllib3 retry a draining 503 would defeat the Retry-After
|
||||
# backoff we honour upstream.
|
||||
_retry = Retry(
|
||||
total=None, connect=2, read=0, redirect=0, status=0,
|
||||
backoff_factor=0.3, raise_on_status=False,
|
||||
)
|
||||
session = requests.Session()
|
||||
_adapter = HTTPAdapter(max_retries=_retry)
|
||||
session.mount("http://", _adapter)
|
||||
session.mount("https://", _adapter)
|
||||
|
||||
|
||||
def health(base_url: str, *, timeout: float = 5.0) -> bool:
|
||||
"""True iff the Interpreter LLM engine is up. Any error (unset URL, network,
|
||||
non-200, engine down) → False, so the sweep just no-ops rather than raising."""
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
r = requests.get(_url(base_url, "/v1/health"), timeout=timeout)
|
||||
r = session.get(_url(base_url, "/v1/health"), timeout=timeout)
|
||||
except requests.RequestException:
|
||||
return False
|
||||
if r.status_code != 200:
|
||||
@@ -45,22 +96,25 @@ def translate(
|
||||
) -> dict:
|
||||
"""Translate a batch. Returns::
|
||||
|
||||
{"translations": [str, ...], # SAME order & length as `texts`
|
||||
"detected_lang": str | None, # aggregate: describes the FIRST item
|
||||
{"translations": [str, ...], # SAME order & length as `texts`
|
||||
"detected_lang": str | None, # aggregate: describes the FIRST item
|
||||
"detected_confidence": float | None, # detector's confidence, if given
|
||||
"engine": str | None,
|
||||
"engine_version": str | None}
|
||||
|
||||
Interpreter batch metadata is aggregate (first item only) — fine for one
|
||||
post's ``[title, description]`` batch since they share a language. Passthrough
|
||||
items (already target-language / emoji-only) come back UNCHANGED in their
|
||||
slot. Raises InterpreterUnavailable on 503 / connection error (retry later),
|
||||
InterpreterBadRequest on 400. (Scribe note #1347.)
|
||||
slot. Raises InterpreterUnavailable on a connection error / 429 / 5xx (retry
|
||||
later, honouring Retry-After), InterpreterBadRequest on 400. (Scribe note
|
||||
#1347.)
|
||||
"""
|
||||
if not texts:
|
||||
return {"translations": [], "detected_lang": None,
|
||||
"detected_confidence": None,
|
||||
"engine": None, "engine_version": None}
|
||||
try:
|
||||
r = requests.post(
|
||||
r = session.post(
|
||||
_url(base_url, "/v1/translate"),
|
||||
json={"q": list(texts), "source": source,
|
||||
"target": target, "engine": "auto"},
|
||||
@@ -68,10 +122,18 @@ def translate(
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise InterpreterUnavailable(str(e)) from e
|
||||
if r.status_code == 503:
|
||||
raise InterpreterUnavailable("interpreter engine unavailable")
|
||||
if r.status_code == 400:
|
||||
raise InterpreterBadRequest((r.json() or {}).get("error", "bad request"))
|
||||
# A gracefully-draining service — often behind a reverse proxy — returns 429
|
||||
# or a 5xx gateway error (502/503/504), not always a clean 503. Treat every
|
||||
# one as "unavailable, retry later" and honour any Retry-After it sends, so a
|
||||
# rolling Interpreter restart interrupts the sweep cleanly (instead of raising
|
||||
# an opaque HTTPError) and resumes exactly when the service says it's ready.
|
||||
if r.status_code == 429 or r.status_code >= 500:
|
||||
raise InterpreterUnavailable(
|
||||
f"interpreter unavailable (HTTP {r.status_code})",
|
||||
retry_after=_parse_retry_after(r),
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json() or {}
|
||||
out = body.get("translatedText")
|
||||
@@ -80,9 +142,11 @@ def translate(
|
||||
if not isinstance(out, list):
|
||||
out = [out]
|
||||
interp = body.get("interpreter") or {}
|
||||
detected = body.get("detectedLanguage") or {}
|
||||
return {
|
||||
"translations": out,
|
||||
"detected_lang": (body.get("detectedLanguage") or {}).get("language"),
|
||||
"detected_lang": detected.get("language"),
|
||||
"detected_confidence": detected.get("confidence"),
|
||||
"engine": interp.get("engine"),
|
||||
"engine_version": interp.get("engine_version"),
|
||||
}
|
||||
|
||||
@@ -12,13 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import TagSuggestionRejection
|
||||
from ...models.tag import image_tag
|
||||
from .aliases import AliasService
|
||||
|
||||
|
||||
class AllowlistService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
self.aliases = AliasService(session)
|
||||
|
||||
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
||||
stmt = insert(image_tag).values(
|
||||
@@ -41,18 +39,6 @@ class AllowlistService:
|
||||
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
||||
await self._clear_rejection(image_id, tag_id)
|
||||
|
||||
async def add_alias_and_accept(
|
||||
self,
|
||||
image_id: int,
|
||||
alias_string: str,
|
||||
alias_category: str,
|
||||
canonical_tag_id: int,
|
||||
) -> None:
|
||||
await self.aliases.create(
|
||||
alias_string, alias_category, canonical_tag_id
|
||||
)
|
||||
await self.accept(image_id, canonical_tag_id)
|
||||
|
||||
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
||||
stmt = insert(TagSuggestionRejection).values(
|
||||
image_record_id=image_id, tag_id=tag_id
|
||||
|
||||
@@ -150,9 +150,7 @@ def refresh_character_prototypes(
|
||||
"""Incrementally refresh the prototype store. `full=True` rebuilds every
|
||||
character regardless of the gate/fingerprints (nightly reconcile). Returns
|
||||
{skipped, rebuilt, removed}; commits."""
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
sig = _global_signature(session)
|
||||
if not full and settings.ccip_ref_signature == sig:
|
||||
return {"skipped": True, "rebuilt": 0, "removed": 0}
|
||||
@@ -204,9 +202,7 @@ def retract_auto_applied_ccip(session: Session) -> int:
|
||||
n_retracted."""
|
||||
import numpy as np
|
||||
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
if not settings.ccip_auto_apply_enabled:
|
||||
return 0
|
||||
thr = float(settings.ccip_auto_apply_threshold)
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, exists, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -39,9 +40,10 @@ from ...models import (
|
||||
TagPositiveConfirmation,
|
||||
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 (
|
||||
_AUTO_SOURCES,
|
||||
_applied_or_rejected,
|
||||
_auto_apply_point,
|
||||
_hygiene_excluded_ids,
|
||||
_ids_with_tag,
|
||||
@@ -61,6 +63,14 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise
|
||||
_UNLABELED_POOL = 4000
|
||||
_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).
|
||||
_HEAD_KINDS = (TagKind.general, TagKind.character)
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
"""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:
|
||||
return session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
return MLSettings.load_sync(session)
|
||||
|
||||
|
||||
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):
|
||||
cv_folds = DEFAULT_CV_FOLDS
|
||||
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):
|
||||
precision_target = s.head_auto_apply_precision
|
||||
return {
|
||||
@@ -500,13 +540,19 @@ async def score_image(
|
||||
session: AsyncSession, image_id: int, threshold_override: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Suggestions for one image from the trained heads: [{tag_id, name,
|
||||
category, score}], ranked. A concept surfaces when its score clears the
|
||||
head's own suggest_threshold — or, when threshold_override is given (the
|
||||
typed-dropdown "show everything" mode), that flat floor instead (0 → every
|
||||
head). System-tag heads (wip/banner/editor) instead use a flat
|
||||
_SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection
|
||||
(still overridden by threshold_override). Empty if the image has no
|
||||
embedding or no heads exist yet.
|
||||
category, score, above_threshold, grounding}], ranked. A concept is INCLUDED
|
||||
when its score clears the head's own suggest_threshold — or, when
|
||||
threshold_override is given (the typed-dropdown "show everything" mode), that
|
||||
flat floor instead (0 → every head). System-tag heads (wip/banner/editor)
|
||||
instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface
|
||||
for rejection (still overridden by threshold_override). Empty if the image has
|
||||
no embedding or no heads exist yet.
|
||||
|
||||
``above_threshold`` is reported SEPARATELY from inclusion: it's always whether
|
||||
the score cleared the head's NATURAL cut (suggest_threshold, or the system
|
||||
floor), regardless of any override. So the single min=0 fetch returns every
|
||||
head, and the caller can split panel (above_threshold) from dropdown (all)
|
||||
without a second request.
|
||||
|
||||
MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image
|
||||
vector PLUS every concept-region crop the agent embedded (same model
|
||||
@@ -530,28 +576,32 @@ async def score_image(
|
||||
norms[norms == 0] = 1.0
|
||||
Xn = X / norms
|
||||
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
|
||||
# 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).
|
||||
winners = probs_bag.argmax(axis=0) # (H,)
|
||||
out = []
|
||||
for i, p in enumerate(probs):
|
||||
if threshold_override is not None:
|
||||
cut = threshold_override
|
||||
elif heads["meta"][i]["is_system"]:
|
||||
# System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR)
|
||||
# so their false positives show up for the operator to reject.
|
||||
cut = _SYSTEM_TAG_SUGGEST_FLOOR
|
||||
else:
|
||||
cut = heads["thr"][i]
|
||||
m = heads["meta"][i]
|
||||
# The head's NATURAL suggest cut — system tags use the flat floor (see
|
||||
# _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the
|
||||
# operator to reject; content heads use their own precision-tuned
|
||||
# threshold. This is what "above threshold" means (drives the panel).
|
||||
natural = (
|
||||
_SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i])
|
||||
)
|
||||
# INCLUSION is looser under threshold_override (dropdown show-all,
|
||||
# override=0): every head comes back so a low-confidence concept can still
|
||||
# be typed + picked, each carrying its own above_threshold flag.
|
||||
cut = threshold_override if threshold_override is not None else natural
|
||||
if p >= cut:
|
||||
m = heads["meta"][i]
|
||||
out.append({
|
||||
"tag_id": m["tag_id"],
|
||||
"name": m["name"],
|
||||
"category": m["category"],
|
||||
"score": float(p),
|
||||
"above_threshold": bool(p >= natural),
|
||||
"grounding": bag_meta[int(winners[i])],
|
||||
})
|
||||
out.sort(key=lambda d: d["score"], reverse=True)
|
||||
@@ -604,9 +654,7 @@ async def ground_applied_tag(
|
||||
|
||||
|
||||
async def _settings_async(session: AsyncSession) -> MLSettings:
|
||||
return (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
return await MLSettings.load(session)
|
||||
|
||||
|
||||
# --- Earned auto-apply (sync, ml worker) ---------------------------------
|
||||
@@ -677,7 +725,6 @@ def auto_apply_sweep(
|
||||
embeddings in chunks; commits per chunk on a real run. Returns
|
||||
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
settings = _settings(session)
|
||||
rows = _auto_apply_heads(
|
||||
@@ -694,18 +741,7 @@ def auto_apply_sweep(
|
||||
names = [r.name for r in rows]
|
||||
|
||||
# Skip images that already carry, or have rejected, each tag.
|
||||
skip = {tid: set() for tid in tag_ids}
|
||||
for tid in tag_ids:
|
||||
for (iid,) in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == tid
|
||||
)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
skip = _applied_or_rejected(session, tag_ids)
|
||||
|
||||
applied = [0] * len(rows)
|
||||
scanned = 0
|
||||
@@ -719,7 +755,7 @@ def auto_apply_sweep(
|
||||
if not cids:
|
||||
continue
|
||||
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)
|
||||
for h in range(len(rows)):
|
||||
tid = tag_ids[h]
|
||||
@@ -749,18 +785,42 @@ def auto_apply_sweep(
|
||||
|
||||
|
||||
_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):
|
||||
"""Trained heads for the presentation chrome tags (banner / editor screenshot).
|
||||
They fire at the FLAT presentation threshold regardless of graduation — a head
|
||||
exists once the operator has labelled enough chrome (head_min_positives)."""
|
||||
def _system_tag_heads(session: Session, embedding_version: str, names):
|
||||
"""Trained heads for a system-tag group (chrome banner / process wip+editor).
|
||||
They fire at the group's FLAT threshold regardless of graduation — a head
|
||||
exists once the operator has labelled enough (head_min_positives)."""
|
||||
return session.execute(
|
||||
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
|
||||
.join(Tag, Tag.id == TagHead.tag_id)
|
||||
.where(TagHead.embedding_version == embedding_version)
|
||||
.where(Tag.is_system.is_(True))
|
||||
.where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS))
|
||||
.where(Tag.name.in_(names))
|
||||
).all()
|
||||
|
||||
|
||||
@@ -792,27 +852,32 @@ def _valued_image_ids(session: Session) -> set[int]:
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict:
|
||||
"""Auto-hide presentation chrome (banner / editor screenshot) at the FLAT
|
||||
presentation threshold (#141) — NOT the per-head graduated threshold. Two
|
||||
guards keep it safe: (1) never hide an image carrying a human/confirmed content
|
||||
tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold
|
||||
on a content head, still hide it but flag it (PresentationReview) so the Hidden
|
||||
view surfaces "also looks like <X>" for review. No-op unless
|
||||
presentation_auto_apply_enabled. numpy-only (no sklearn). Returns
|
||||
{n_applied, n_flagged, concepts}."""
|
||||
def system_tag_auto_apply_sweep(
|
||||
session: Session, *, mode: str, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
|
||||
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
|
||||
VISIBLE — the ONLY difference is the tag group's gallery membership, not this
|
||||
sweep. Two guards keep it safe: (1) never touch an image carrying a
|
||||
human/confirmed content tag; (2) if the image ALSO scores >= the conflict
|
||||
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
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
cfg = _SWEEP_MODES[mode]
|
||||
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": []}
|
||||
ver = settings.embedder_model_version
|
||||
pres = _presentation_heads(session, ver)
|
||||
pres = _system_tag_heads(session, ver, cfg["names"])
|
||||
if not pres:
|
||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||
thr = float(settings.presentation_auto_apply_threshold)
|
||||
conflict_thr = float(settings.presentation_conflict_threshold)
|
||||
thr = float(getattr(settings, cfg["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])
|
||||
bp = np.asarray([r.bias for r in pres], dtype=np.float32)
|
||||
@@ -829,18 +894,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
valued = _valued_image_ids(session)
|
||||
|
||||
# Skip images that already carry, or have rejected, each presentation tag.
|
||||
skip = {tid: set() for tid in pres_tag_ids}
|
||||
for tid in pres_tag_ids:
|
||||
for (iid,) in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == tid
|
||||
)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
skip = _applied_or_rejected(session, pres_tag_ids)
|
||||
|
||||
applied = [0] * len(pres)
|
||||
n_flagged = 0
|
||||
@@ -855,11 +909,9 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
if not cids:
|
||||
continue
|
||||
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:
|
||||
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
|
||||
max_c = cprobs.max(axis=1)
|
||||
arg_c = cprobs.argmax(axis=1)
|
||||
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,)
|
||||
scanned += len(cids)
|
||||
for p in range(len(pres)):
|
||||
tid = pres_tag_ids[p]
|
||||
@@ -874,22 +926,22 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
pg_insert(image_tag)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
source=_PRESENTATION_SOURCE,
|
||||
source=source,
|
||||
)
|
||||
.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:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(PresentationReview)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
_insert_presentation_review(
|
||||
session,
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
mode=mode,
|
||||
)
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
@@ -904,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:
|
||||
"""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
|
||||
@@ -951,7 +1065,7 @@ def retract_auto_applied_heads(session: Session) -> int:
|
||||
continue
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
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]]
|
||||
for iid in below:
|
||||
session.execute(
|
||||
|
||||
@@ -22,22 +22,20 @@ from .heads import score_image
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Suggestion:
|
||||
# canonical_tag_id is None when this is a raw Camie tag with no alias and
|
||||
# no existing Tag row — accepting it will create the tag.
|
||||
canonical_tag_id: int | None
|
||||
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
|
||||
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
|
||||
# cases are gone — a suggestion always maps to a real tag id.
|
||||
canonical_tag_id: int
|
||||
display_name: str
|
||||
category: str
|
||||
score: float
|
||||
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
|
||||
creates_new_tag: bool
|
||||
# raw_name = the booru model vocab key behind this suggestion. It's the key
|
||||
# an alias MUST be stored under (resolution looks up the raw key), so the
|
||||
# modal needs it to author an alias correctly. None for centroid-only hits
|
||||
# (no underlying prediction → nothing to alias).
|
||||
raw_name: str | None = None
|
||||
# via_alias = this suggestion was surfaced because an operator alias remapped
|
||||
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
|
||||
via_alias: bool = False
|
||||
# above_threshold = the score cleared the head's own suggest cut (or the
|
||||
# system floor). The Suggestions PANEL shows only these; the typed-tag
|
||||
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each
|
||||
# matching row with its score, so a low-confidence concept can still be typed
|
||||
# and picked. CCIP character matches are always above their match threshold.
|
||||
above_threshold: bool
|
||||
# rejected = the operator dismissed this tag for this image (a stored
|
||||
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
||||
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
||||
@@ -108,6 +106,7 @@ class SuggestionService:
|
||||
for h in hits:
|
||||
merged[(h["category"], h["tag_id"])] = {
|
||||
"name": h["name"], "score": h["score"], "source": "head",
|
||||
"above_threshold": h["above_threshold"],
|
||||
"grounding": h.get("grounding"),
|
||||
}
|
||||
for c in ccip_hits:
|
||||
@@ -116,12 +115,16 @@ class SuggestionService:
|
||||
if ex is not None:
|
||||
ex["source"] = "both"
|
||||
ex["score"] = max(ex["score"], c["score"])
|
||||
# CCIP only returns matches above its own threshold, so a CCIP
|
||||
# corroboration always makes the merged suggestion above-threshold.
|
||||
ex["above_threshold"] = True
|
||||
# Keep the head's localized crop if it had one; else fall back to
|
||||
# the CCIP figure so a corroborated character still grounds (#1206).
|
||||
ex["grounding"] = ex.get("grounding") or c.get("grounding")
|
||||
else:
|
||||
merged[key] = {
|
||||
"name": c["name"], "score": c["score"], "source": "ccip",
|
||||
"above_threshold": True,
|
||||
"grounding": c.get("grounding"),
|
||||
}
|
||||
|
||||
@@ -136,7 +139,7 @@ class SuggestionService:
|
||||
category=cat,
|
||||
score=m["score"],
|
||||
source=m["source"],
|
||||
creates_new_tag=False,
|
||||
above_threshold=m["above_threshold"],
|
||||
rejected=tag_id in rejected,
|
||||
grounding=m.get("grounding"),
|
||||
)
|
||||
@@ -157,8 +160,7 @@ class SuggestionService:
|
||||
was suggested for (or already applied to) >= threshold fraction of
|
||||
the selection AND was acceptable on >= 1 image. Confidence is the
|
||||
mean over images where it was suggested. Aggregated by
|
||||
canonical_tag_id; creates-new (no canonical id) suggestions are
|
||||
skipped (bulk Accept applies by tag id)."""
|
||||
canonical_tag_id (every suggestion is a canonical tag now)."""
|
||||
if not image_ids:
|
||||
return {}
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
@@ -169,8 +171,6 @@ class SuggestionService:
|
||||
sl = await self.for_image(image_id)
|
||||
for category, items in sl.by_category.items():
|
||||
for s in items:
|
||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
||||
continue
|
||||
# for_image keeps rejected tags (flagged) for the rail;
|
||||
# bulk consensus must still ignore them — a tag dismissed on
|
||||
# an image isn't a suggestion for that image.
|
||||
|
||||
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
|
||||
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
||||
# 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.
|
||||
_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]:
|
||||
@@ -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]:
|
||||
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||
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)
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "application/vnd.api+json",
|
||||
}
|
||||
params = {
|
||||
"filter[vanity]": vanity,
|
||||
"fields[campaign]": "name",
|
||||
}
|
||||
try:
|
||||
resp = requests.get(
|
||||
_CAMPAIGNS_URL,
|
||||
params=params,
|
||||
headers=headers,
|
||||
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
|
||||
return None
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.warning(
|
||||
"Patreon campaigns API returned HTTP %d for vanity=%s",
|
||||
resp.status_code, vanity,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
|
||||
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
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
first = data[0] if isinstance(data[0], dict) else None
|
||||
campaign_id = first.get("id") if first else None
|
||||
campaign_id = first.get("id")
|
||||
if not isinstance(campaign_id, str) or not campaign_id:
|
||||
return None
|
||||
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
|
||||
on any failure — the caller falls back to the vanity handle. Sync: call from
|
||||
an executor."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
try:
|
||||
resp = requests.get(
|
||||
_CAMPAIGNS_URL,
|
||||
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json().get("data")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
|
||||
first = _campaigns_api_first(vanity, cookies_path)
|
||||
if first is None:
|
||||
return None
|
||||
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||
return None
|
||||
name = (data[0].get("attributes") or {}).get("name")
|
||||
name = (first.get("attributes") or {}).get("name")
|
||||
return name.strip() if isinstance(name, str) and name.strip() else None
|
||||
|
||||
|
||||
|
||||
@@ -397,6 +397,8 @@ class PostFeedService:
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": desc_trans_short,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
# Sticky per-post translation choice (auto/force/original, #155).
|
||||
"translation_override": post.translation_override,
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
"source": (
|
||||
{"id": source.id, "platform": source.platform}
|
||||
|
||||
@@ -35,6 +35,7 @@ def _post_dict(p: Post) -> dict:
|
||||
"title_translated": p.post_title_translated,
|
||||
"description_translated": p.description_translated,
|
||||
"translated_source_lang": p.translated_source_lang,
|
||||
"translation_override": p.translation_override,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,9 +76,19 @@ DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
|
||||
OLD_TASK_DAYS = 7
|
||||
PHASH_PAGE = 500
|
||||
VERIFY_PAGE = 200
|
||||
# Title-based WIP backfill (task #1458): posts scanned per keyset page.
|
||||
WIP_BACKFILL_PAGE = 500
|
||||
FFPROBE_TIMEOUT_SECONDS = 10
|
||||
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
||||
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||
# Orphaned staging files: downloads/imports stage into <name>.part / <name>.partial
|
||||
# then os.replace() into place (importer / external_fetch / native_ingest_common /
|
||||
# attachment_store), so a kill mid-write leaves a discardable temp, never a corrupt
|
||||
# final. cleanup_orphaned_temp_files sweeps ones left behind; the min-age guard
|
||||
# keeps it from deleting an in-flight download's staging file mid-write.
|
||||
IMAGES_ROOT = Path("/images")
|
||||
TEMP_STAGING_SUFFIXES = (".part", ".partial")
|
||||
ORPHAN_TEMP_MIN_AGE_HOURS = 6
|
||||
|
||||
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
|
||||
# > the entity's longest legitimate runtime (its task's time_limit + a
|
||||
@@ -339,6 +349,34 @@ def cleanup_old_tasks() -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.cleanup_orphaned_temp_files")
|
||||
def cleanup_orphaned_temp_files() -> int:
|
||||
"""Delete orphaned .part/.partial staging files under the images root, left by
|
||||
a download/import killed mid-write. Only removes files older than
|
||||
ORPHAN_TEMP_MIN_AGE_HOURS so an in-flight download's staging file is never
|
||||
pulled out from under it. Returns the count removed."""
|
||||
if not IMAGES_ROOT.is_dir():
|
||||
return 0
|
||||
cutoff = datetime.now(UTC).timestamp() - ORPHAN_TEMP_MIN_AGE_HOURS * 3600
|
||||
removed = 0
|
||||
for path in IMAGES_ROOT.rglob("*"):
|
||||
if path.suffix not in TEMP_STAGING_SUFFIXES or not path.is_file():
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_mtime >= cutoff:
|
||||
continue # too fresh — may be an active download
|
||||
path.unlink()
|
||||
removed += 1
|
||||
except OSError as exc:
|
||||
log.warning("cleanup_orphaned_temp_files: %s: %s", path, exc)
|
||||
if removed:
|
||||
log.info(
|
||||
"cleanup_orphaned_temp_files: removed %d orphaned staging file(s)",
|
||||
removed,
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_task_runs")
|
||||
def recover_stalled_task_runs() -> int:
|
||||
"""Flip task_run rows stuck in 'running' past their queue-specific
|
||||
@@ -738,89 +776,62 @@ def recover_stalled_library_audit_runs() -> int:
|
||||
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")
|
||||
def recover_stalled_head_training_runs() -> int:
|
||||
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
||||
'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."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(HeadTrainingRun)
|
||||
.where(HeadTrainingRun.status == "running")
|
||||
.where(
|
||||
func.coalesce(
|
||||
HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at
|
||||
)
|
||||
< cutoff
|
||||
)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=(
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)"
|
||||
),
|
||||
)
|
||||
)
|
||||
keep = session.execute(
|
||||
select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc())
|
||||
.limit(HEAD_TRAINING_KEEP_RUNS)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(
|
||||
delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep))
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_head_training_runs: recovered %d rows", recovered
|
||||
)
|
||||
return recovered
|
||||
return _recover_stalled_runs(
|
||||
HeadTrainingRun,
|
||||
stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES,
|
||||
keep_runs=HEAD_TRAINING_KEEP_RUNS,
|
||||
label="recover_stalled_head_training_runs",
|
||||
)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
|
||||
def recover_stalled_head_auto_apply_runs() -> int:
|
||||
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
|
||||
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(HeadAutoApplyRun)
|
||||
.where(HeadAutoApplyRun.status == "running")
|
||||
.where(
|
||||
func.coalesce(
|
||||
HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at
|
||||
)
|
||||
< cutoff
|
||||
)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=(
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)"
|
||||
),
|
||||
)
|
||||
)
|
||||
keep = session.execute(
|
||||
select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc())
|
||||
.limit(HEAD_AUTO_APPLY_KEEP_RUNS)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(
|
||||
delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep))
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_head_auto_apply_runs: recovered %d rows", recovered
|
||||
)
|
||||
return recovered
|
||||
return _recover_stalled_runs(
|
||||
HeadAutoApplyRun,
|
||||
stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES,
|
||||
keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS,
|
||||
label="recover_stalled_head_auto_apply_runs",
|
||||
)
|
||||
|
||||
|
||||
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
|
||||
@@ -1009,6 +1020,96 @@ def cleanup_old_download_events() -> int:
|
||||
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")
|
||||
def vacuum_analyze() -> dict:
|
||||
"""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)
|
||||
if record is None:
|
||||
return {"status": "missing", "image_id": image_id}
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
|
||||
src = Path(record.path)
|
||||
is_vid = _is_video(src)
|
||||
@@ -488,15 +486,10 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
from sqlalchemy import select as sa_select
|
||||
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
|
||||
|
||||
fig = ("face", "figure")
|
||||
|
||||
def _l2(m):
|
||||
n = np.linalg.norm(m, axis=1, keepdims=True)
|
||||
n[n == 0] = 1.0
|
||||
return m / n
|
||||
from ..services.ml.ccip import _FIGURE_KINDS
|
||||
from ..services.ml.training_data import _applied_or_rejected, _l2norm
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
@@ -521,7 +514,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.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.image_record_id.in_(single))
|
||||
).all()
|
||||
@@ -532,29 +525,16 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
for tid, vec in ref_rows:
|
||||
by_char.setdefault(tid, []).append(vec)
|
||||
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)
|
||||
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.
|
||||
skip = {t: set() for t in ref_tags}
|
||||
for t in ref_tags:
|
||||
for (iid,) in session.execute(
|
||||
sa_select(image_tag.c.image_record_id).where(
|
||||
image_tag.c.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
sa_select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
skip = _applied_or_rejected(session, ref_tags)
|
||||
|
||||
img_ids = list(session.execute(
|
||||
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()
|
||||
).scalars())
|
||||
|
||||
@@ -566,7 +546,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
|
||||
.where(
|
||||
ImageRegion.image_record_id.in_(chunk),
|
||||
ImageRegion.kind.in_(fig),
|
||||
ImageRegion.kind.in_(_FIGURE_KINDS),
|
||||
ImageRegion.ccip_embedding.is_not(None),
|
||||
)
|
||||
).all()
|
||||
@@ -574,7 +554,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
for iid, vec in rows:
|
||||
by_img.setdefault(iid, []).append(vec)
|
||||
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,)
|
||||
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
|
||||
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,
|
||||
)
|
||||
def scheduled_presentation_auto_apply() -> str:
|
||||
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily
|
||||
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
|
||||
— already-hidden images are skipped — so an interrupted run simply re-runs next
|
||||
cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import presentation_auto_apply_sweep
|
||||
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
|
||||
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images
|
||||
are skipped — so an interrupted run simply re-runs next cycle (that IS 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 = presentation_auto_apply_sweep(session)
|
||||
result = system_tag_auto_apply_sweep(session, mode="chrome")
|
||||
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")
|
||||
def prune_presentation_reviews() -> str:
|
||||
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"""Post-text translation Celery task (milestone 143).
|
||||
"""Post-text translation Celery tasks (milestone 143 + re-translate m146).
|
||||
|
||||
Backfills non-English post title/description to the target language via the
|
||||
self-hosted Interpreter LAN service, storing the result + engine_version so
|
||||
viewing is instant and re-runs are cache-fast. Sync (Celery workers are sync),
|
||||
same pattern as the other backfill sweeps. No-op unless translation is enabled,
|
||||
a base URL is set, and the service is healthy.
|
||||
|
||||
Curator does NO language detection of its own: each field's translation is
|
||||
accepted or rejected purely on Interpreter's reported engine + detected language
|
||||
+ confidence (see ``_accept``) — a short English title Interpreter mis-labels as
|
||||
a European language scores below the latin-script floor and is kept as-is.
|
||||
|
||||
Two entry points:
|
||||
- ``translate_posts`` — the periodic untranslated sweep (every 8h;
|
||||
translated_source_lang IS NULL only), one bounded chunk per fire. The Settings
|
||||
"Translate now" button calls it with ``drain=True`` to chase the tail
|
||||
run-until-done and clear the whole backlog in a single press.
|
||||
- ``retranslate_posts`` — after a model change, resets the stored translation
|
||||
columns for a scoped set of posts (all, or a given set of artists) so the
|
||||
untranslated selection re-runs them, then chases the tail until drained
|
||||
(run-until-done). The Interpreter cache keys on engine_version: a changed
|
||||
model genuinely re-translates, an unchanged one is ~1ms cache-fast.
|
||||
"""
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import ImportSettings, Post
|
||||
@@ -24,78 +40,359 @@ log = logging.getLogger(__name__)
|
||||
# limit; the rest resumes next cycle (idempotent — only untranslated posts are
|
||||
# picked, so an interrupted run just re-runs = rule-89 recovery).
|
||||
_MAX_POSTS_PER_RUN = 300
|
||||
# Short gap between run-until-done chunks so a big re-translate finishes on its
|
||||
# own without monopolising the maintenance_long worker in one uninterrupted run.
|
||||
_RETRANSLATE_COUNTDOWN = 5
|
||||
# When Interpreter drains mid-chunk (graceful restart), resume the bulk
|
||||
# re-translate after the service's Retry-After hint if it sent one, else this
|
||||
# default; capped so a bogus/huge hint can't park the work longer than the daily
|
||||
# beat's own safety net would.
|
||||
_INTERRUPT_BACKOFF = 60
|
||||
_INTERRUPT_BACKOFF_MAX = 900
|
||||
|
||||
# --- Translation acceptance gate (Scribe rule 133; contract note #1347) -------
|
||||
# Curator does NO language detection of its own — it accepts or rejects a
|
||||
# translation purely on Interpreter's reported detection. CJK is script-detected
|
||||
# (kana/hangul/kanji) and reliably high-confidence, so ja/ko/zh are trusted
|
||||
# outright (pure-kanji Japanese can even come back labelled zh ~0.75, still
|
||||
# correctly translated). Latin-script detection is a real statistical
|
||||
# probability, so a short English title Interpreter mis-labels as a European
|
||||
# language scores low; require it to clear this floor, else keep the original.
|
||||
# Calibrated 2026-07-09 against fresh (uncached) probes once Interpreter returned
|
||||
# real langdetect confidence: genuine German detected at 1.0, while a correctly-
|
||||
# detected but ambiguous latin string dipped to 0.86 — so a single constant can't win.
|
||||
# The floor is now operator-tunable (ImportSettings.translation_min_confidence,
|
||||
# milestone 155) with a stricter 0.90 default; per-post overrides
|
||||
# (Post.translation_override) rescue the false negatives it skips. Genuine German
|
||||
# and mis-flagged short English both land ~0.86, and single-word mis-flags sit at
|
||||
# a confident 1.0 no floor catches. Real langdetect fixed some cases at the
|
||||
# source (some short English titles now detect as English → passthrough), so this
|
||||
# floor is a safety net, not the primary fix. Re-tune via the "Test translation"
|
||||
# box (send fresh text — cache hits report 1.0).
|
||||
_CJK_LANGS = frozenset({"ja", "ko", "zh"})
|
||||
_DEFAULT_MIN_LATIN_CONFIDENCE = 0.90
|
||||
|
||||
|
||||
def _interrupt_backoff(retry_after) -> int:
|
||||
"""Seconds to wait before resuming after an Interpreter interruption: the
|
||||
server's Retry-After hint (capped), else the default."""
|
||||
if retry_after and retry_after > 0:
|
||||
return int(min(retry_after, _INTERRUPT_BACKOFF_MAX))
|
||||
return _INTERRUPT_BACKOFF
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.translation.translate_posts",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def translate_posts() -> str:
|
||||
def translate_posts(drain: bool = False) -> str:
|
||||
"""Translate untranslated non-English post title/description (default target
|
||||
en) via Interpreter. Per-post [title, description] batch → per-post detected
|
||||
language. Passthrough / already-target posts are marked handled (source ==
|
||||
target) with no stored translation. 503 or a connection error interrupts the
|
||||
run (retry next cycle); 400 stops it (fix config); posts done so far stay
|
||||
committed. No-op unless configured + healthy. Returns a summary string."""
|
||||
committed. No-op unless configured + healthy. Returns a summary string.
|
||||
|
||||
``drain`` (the Settings "Translate now" button) chases the tail
|
||||
run-until-done: on a clean chunk with work still remaining it re-enqueues
|
||||
itself until the untranslated backlog is zero, like ``retranslate_posts``. The
|
||||
periodic beat leaves it False → one bounded chunk per fire (the 8h cadence
|
||||
drives the rest, enough for the trickle of newly-imported posts)."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
cfg = ImportSettings.load_sync(session)
|
||||
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
|
||||
return "disabled"
|
||||
base_url = cfg.interpreter_base_url.strip()
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
if not ic.health(base_url):
|
||||
return "interpreter unavailable"
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target, min_confidence = ready
|
||||
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
|
||||
status, translated, retry_after = _translate_batch(
|
||||
session, posts, base_url, target, min_confidence,
|
||||
)
|
||||
# Drain mode (manual "Translate now"): chase the tail until the backlog is
|
||||
# zero, so one press clears the whole pile instead of one 300-chunk.
|
||||
# Termination is guaranteed — every handled post leaves the untranslated
|
||||
# set, so the remaining count strictly decreases each chunk.
|
||||
if status == "ok" and drain:
|
||||
remaining = _count_untranslated(session, None)
|
||||
if remaining:
|
||||
translate_posts.apply_async(
|
||||
(), {"drain": True}, countdown=_RETRANSLATE_COUNTDOWN,
|
||||
)
|
||||
log.info(
|
||||
"translate_posts: draining — %d remaining → re-enqueued",
|
||||
remaining,
|
||||
)
|
||||
# Interpreter drained mid-chunk (graceful restart): don't make a manual
|
||||
# "Translate now" wait a whole beat cycle — re-enqueue after its
|
||||
# Retry-After hint (or the default backoff), preserving `drain` so a bulk
|
||||
# drain resumes cleanly. Self-terminating: once the service is down the
|
||||
# health gate returns "interpreter unavailable" early.
|
||||
elif status == "interrupted" and _count_untranslated(session, None):
|
||||
delay = _interrupt_backoff(retry_after)
|
||||
translate_posts.apply_async((), {"drain": drain}, countdown=delay)
|
||||
log.info(
|
||||
"translate_posts: interrupted (service draining) → retry in %ss",
|
||||
delay,
|
||||
)
|
||||
return _summary(status, translated, len(posts))
|
||||
|
||||
posts = session.execute(
|
||||
select(Post)
|
||||
.where(Post.translated_source_lang.is_(None))
|
||||
.where(or_(
|
||||
Post.post_title.is_not(None), Post.description.is_not(None),
|
||||
))
|
||||
.limit(_MAX_POSTS_PER_RUN)
|
||||
).scalars().all()
|
||||
|
||||
translated = 0
|
||||
@celery.task(
|
||||
name="backend.app.tasks.translation.retranslate_posts",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
|
||||
"""Re-translate posts after a model change. On the first call resets the
|
||||
stored translation columns to NULL for the scoped posts — all artists when
|
||||
``artist_ids`` is falsy, else ``WHERE artist_id IN artist_ids`` — so the
|
||||
normal untranslated selection re-runs them through Interpreter. Then handles
|
||||
one bounded chunk and re-enqueues itself until the scoped backlog is drained
|
||||
(run-until-done; ``_reset_done`` guards against wiping fresh work on the
|
||||
follow-up chunks). No reset happens unless the service is configured +
|
||||
healthy, so translations are never cleared when they can't be rebuilt."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target, min_confidence = ready
|
||||
|
||||
if not _reset_done:
|
||||
n = _reset_translations(session, artist_ids)
|
||||
log.info(
|
||||
"retranslate_posts: reset %d post(s) (artist_ids=%s)",
|
||||
n, artist_ids,
|
||||
)
|
||||
|
||||
posts = _select_untranslated(session, artist_ids, _MAX_POSTS_PER_RUN)
|
||||
status, translated, retry_after = _translate_batch(
|
||||
session, posts, base_url, target, min_confidence,
|
||||
)
|
||||
|
||||
# run-until-done: chase the tail when the chunk finished cleanly.
|
||||
# Termination is guaranteed: every handled post leaves the NULL set, so
|
||||
# the remaining count strictly decreases each chunk.
|
||||
if status == "ok":
|
||||
remaining = _count_untranslated(session, artist_ids)
|
||||
if remaining:
|
||||
retranslate_posts.apply_async(
|
||||
(),
|
||||
{"artist_ids": artist_ids, "_reset_done": True},
|
||||
countdown=_RETRANSLATE_COUNTDOWN,
|
||||
)
|
||||
log.info(
|
||||
"retranslate_posts: %d remaining → re-enqueued", remaining,
|
||||
)
|
||||
# Interpreter drained mid-chunk (graceful restart / 429 / 5xx): don't
|
||||
# stall the bulk re-translate until the daily beat — resume it after the
|
||||
# service's Retry-After hint (or the default backoff), with
|
||||
# _reset_done=True so the fresh work is never re-wiped. Self-terminating:
|
||||
# if the service is still down next run, _translation_config's health gate
|
||||
# returns "interpreter unavailable" early and nothing re-enqueues.
|
||||
elif status == "interrupted" and _count_untranslated(session, artist_ids):
|
||||
delay = _interrupt_backoff(retry_after)
|
||||
retranslate_posts.apply_async(
|
||||
(),
|
||||
{"artist_ids": artist_ids, "_reset_done": True},
|
||||
countdown=delay,
|
||||
)
|
||||
log.info(
|
||||
"retranslate_posts: interrupted (service draining) → retry in %ss",
|
||||
delay,
|
||||
)
|
||||
return _summary(status, translated, len(posts))
|
||||
|
||||
|
||||
def _translation_config(session):
|
||||
"""Resolve (base_url, target, min_confidence) if translation is enabled +
|
||||
healthy, else a short status string ("disabled" / "interpreter unavailable")
|
||||
for the caller to return. Centralises the guard so translate/retranslate agree
|
||||
exactly, including the operator-tunable acceptance floor."""
|
||||
cfg = ImportSettings.load_sync(session)
|
||||
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
|
||||
return "disabled"
|
||||
base_url = cfg.interpreter_base_url.strip()
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
min_confidence = cfg.translation_min_confidence
|
||||
if not ic.health(base_url):
|
||||
return "interpreter unavailable"
|
||||
return base_url, target, min_confidence
|
||||
|
||||
|
||||
def _untranslated_filter(stmt, artist_ids):
|
||||
"""Add the untranslated-post predicate (+ optional artist scope) to a
|
||||
select/count over Post. Untranslated = translated_source_lang IS NULL with
|
||||
some text to translate."""
|
||||
stmt = stmt.where(
|
||||
Post.translated_source_lang.is_(None)
|
||||
).where(or_(
|
||||
Post.post_title.is_not(None), Post.description.is_not(None),
|
||||
))
|
||||
if artist_ids:
|
||||
stmt = stmt.where(Post.artist_id.in_(artist_ids))
|
||||
return stmt
|
||||
|
||||
|
||||
def _select_untranslated(session, artist_ids, limit):
|
||||
return session.execute(
|
||||
_untranslated_filter(select(Post), artist_ids).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def _count_untranslated(session, artist_ids) -> int:
|
||||
return int(session.execute(
|
||||
_untranslated_filter(select(func.count(Post.id)), artist_ids)
|
||||
).scalar_one())
|
||||
|
||||
|
||||
def _reset_translations(session, artist_ids) -> int:
|
||||
"""Clear the stored translation columns for scoped, already-handled posts so
|
||||
the untranslated sweep re-runs them. Only touches rows that were translated
|
||||
(translated_source_lang IS NOT NULL) — untranslated rows are already NULL.
|
||||
Skips 'keep original' posts (translation_override = 'original') so that choice
|
||||
survives a Re-translate-all (milestone 155). Returns the row count reset
|
||||
(commits it)."""
|
||||
stmt = (
|
||||
update(Post)
|
||||
.where(Post.translated_source_lang.is_not(None))
|
||||
.where(Post.translation_override != "original")
|
||||
.values(
|
||||
post_title_translated=None,
|
||||
description_translated=None,
|
||||
translated_source_lang=None,
|
||||
translation_engine_version=None,
|
||||
translated_at=None,
|
||||
)
|
||||
)
|
||||
if artist_ids:
|
||||
stmt = stmt.where(Post.artist_id.in_(artist_ids))
|
||||
n = session.execute(stmt).rowcount
|
||||
session.commit()
|
||||
return n
|
||||
|
||||
|
||||
def _translate_batch(session, posts, base_url: str, target: str, min_confidence: float):
|
||||
"""Translate each post in the chunk with a per-post commit (an interrupted
|
||||
run keeps progress). Returns (status, translated, retry_after) where status is
|
||||
one of "ok" / "interrupted" (unavailable/drain) / "stopped" (400) / "timeout";
|
||||
retry_after is the server's Retry-After hint in seconds on an interrupt, else
|
||||
None."""
|
||||
translated = 0
|
||||
for post in posts:
|
||||
try:
|
||||
for post in posts:
|
||||
translated += _translate_one(session, post, base_url, target)
|
||||
session.commit() # per-post → an interrupted run keeps progress
|
||||
except ic.InterpreterUnavailable:
|
||||
translated += _translate_one(session, post, base_url, target, min_confidence)
|
||||
session.commit()
|
||||
except ic.InterpreterUnavailable as e:
|
||||
session.rollback()
|
||||
return f"interrupted (service down) — translated={translated}"
|
||||
return "interrupted", translated, getattr(e, "retry_after", None)
|
||||
except ic.InterpreterBadRequest as e:
|
||||
session.rollback()
|
||||
log.warning("translate_posts bad request: %s", e)
|
||||
return f"stopped (bad request) — translated={translated}"
|
||||
log.warning("translate bad request: %s", e)
|
||||
return "stopped", translated, None
|
||||
except SoftTimeLimitExceeded:
|
||||
return f"time limit — translated={translated}"
|
||||
return f"translated={translated} scanned={len(posts)}"
|
||||
return "timeout", translated, None
|
||||
return "ok", translated, None
|
||||
|
||||
|
||||
def _translate_one(session, post, base_url: str, target: str) -> int:
|
||||
"""Translate one post's title/description in place. Returns 1 if it stored a
|
||||
translation, 0 for passthrough/empty (which still marks the post handled)."""
|
||||
def _summary(status: str, translated: int, scanned: int) -> str:
|
||||
if status == "interrupted":
|
||||
return f"interrupted (service down) — translated={translated}"
|
||||
if status == "stopped":
|
||||
return f"stopped (bad request) — translated={translated}"
|
||||
if status == "timeout":
|
||||
return f"time limit — translated={translated}"
|
||||
return f"translated={translated} scanned={scanned}"
|
||||
|
||||
|
||||
def _accept(
|
||||
language: str, confidence, min_confidence: float = _DEFAULT_MIN_LATIN_CONFIDENCE,
|
||||
) -> bool:
|
||||
"""Should curator store Interpreter's translation, or keep the original?
|
||||
Consumes ONLY Interpreter's own detection (curator does none, Scribe rule
|
||||
133): CJK languages are trusted regardless of the reported number; a
|
||||
latin-script detection must clear ``min_confidence`` (the operator-tunable
|
||||
ImportSettings.translation_min_confidence). A missing confidence fails OPEN
|
||||
(accept), so a service that omits the field never silently drops a
|
||||
translation. Per-post ``force`` overrides bypass this entirely (see
|
||||
``_translate_one``)."""
|
||||
if (language or "").split("-", 1)[0].lower() in _CJK_LANGS:
|
||||
return True
|
||||
if confidence is None:
|
||||
return True
|
||||
return confidence >= min_confidence
|
||||
|
||||
|
||||
def _translate_field(
|
||||
text: str, base_url: str, target: str, min_confidence: float, force: bool = False,
|
||||
):
|
||||
"""Translate ONE field independently. Returns (translated, source_lang,
|
||||
engine_version), or (None, None, None) when there's nothing to store — empty
|
||||
text, already the target language, a passthrough (engine "none"), or a
|
||||
latin-script detection the acceptance gate rejects as too low-confidence (kept
|
||||
as the original). ``force`` (a per-post 'force' override) bypasses the
|
||||
confidence gate so a legitimately-foreign title Interpreter is only ~0.86 sure
|
||||
about is still stored; a genuine passthrough stores nothing regardless (there
|
||||
is no translation to force). Per-field (not aggregate first-item) detection so
|
||||
a non-English description still translates when the title is already English."""
|
||||
if not text:
|
||||
return None, None, None
|
||||
res = ic.translate([text], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
return None, None, None
|
||||
# Trust only Interpreter's own detection (Scribe rule 133): keep the original
|
||||
# when a latin-script detection doesn't clear the confidence floor, so a short
|
||||
# English title mis-labelled as e.g. German isn't rewritten into the archive.
|
||||
# A 'force' override skips the gate — the operator is overriding a rejection.
|
||||
if not force and not _accept(
|
||||
detected, res.get("detected_confidence"), min_confidence,
|
||||
):
|
||||
return None, None, None
|
||||
return res["translations"][0], detected, res["engine_version"]
|
||||
|
||||
|
||||
def _store_translation(post, title_res, desc_res, target: str) -> int:
|
||||
"""Apply per-field (translated, source_lang, engine_version) results to a post
|
||||
in place. Returns 1 if a translation was stored, 0 when the post is all
|
||||
passthrough/empty — in which case the translated columns are CLEARED and the
|
||||
post is marked handled (translated_source_lang = target). Clearing (not just
|
||||
leaving) matters on re-apply: a 'keep original' override, or a re-translate
|
||||
that now rejects a previously-accepted mis-flag, must remove the stale
|
||||
translation, not keep it."""
|
||||
title_tr, title_lang, title_ev = title_res
|
||||
desc_tr, desc_lang, desc_ev = desc_res
|
||||
if title_tr is None and desc_tr is None:
|
||||
post.post_title_translated = None
|
||||
post.description_translated = None
|
||||
post.translated_source_lang = target
|
||||
post.translation_engine_version = None
|
||||
post.translated_at = None
|
||||
return 0
|
||||
post.post_title_translated = title_tr
|
||||
post.description_translated = desc_tr
|
||||
# Source lang = whichever field was actually non-target (for a mixed post,
|
||||
# the translated field's language, not the already-English one).
|
||||
post.translated_source_lang = title_lang or desc_lang
|
||||
post.translation_engine_version = title_ev or desc_ev
|
||||
post.translated_at = datetime.now(UTC)
|
||||
return 1
|
||||
|
||||
|
||||
def _translate_one(
|
||||
session, post, base_url: str, target: str, min_confidence: float,
|
||||
) -> int:
|
||||
"""Translate a post's title/description in place, each field independently,
|
||||
honoring the per-post override (milestone 155): 'original' keeps the original
|
||||
(clears any stored translation, no Interpreter call); 'force' translates even
|
||||
below the confidence floor; 'auto' (default) runs the acceptance gate. Returns
|
||||
1 if it stored a translation, else 0 (still marks the post handled so the sweep
|
||||
won't revisit it)."""
|
||||
if post.translation_override == "original":
|
||||
return _store_translation(post, (None, None, None), (None, None, None), target)
|
||||
force = post.translation_override == "force"
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
fields: list[tuple[str, str]] = []
|
||||
if title:
|
||||
fields.append(("title", title))
|
||||
if desc:
|
||||
fields.append(("desc", desc))
|
||||
if not fields:
|
||||
post.translated_source_lang = target # nothing to translate → handled
|
||||
return 0
|
||||
res = ic.translate([t for _, t in fields], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
post.translated_source_lang = target # already target language → handled
|
||||
return 0
|
||||
by_field = {f: res["translations"][i] for i, (f, _) in enumerate(fields)}
|
||||
post.post_title_translated = by_field.get("title")
|
||||
post.description_translated = by_field.get("desc")
|
||||
post.translated_source_lang = detected
|
||||
post.translation_engine_version = res["engine_version"]
|
||||
post.translated_at = datetime.now(UTC)
|
||||
return 1
|
||||
title_res = _translate_field(title, base_url, target, min_confidence, force=force)
|
||||
desc_res = _translate_field(desc, base_url, target, min_confidence, force=force)
|
||||
return _store_translation(post, title_res, desc_res, target)
|
||||
|
||||
@@ -8,6 +8,35 @@
|
||||
# run `docker compose up` from this directory and switches images to
|
||||
# local builds + DEBUG logging.
|
||||
|
||||
# Rolling-deploy safety (Swarm / `docker stack deploy`): update one task at a
|
||||
# time, START the new task before stopping the old (zero-downtime via the ingress
|
||||
# mesh), and if the new task doesn't reach a healthy state within `monitor`, roll
|
||||
# back to the previous image automatically. `monitor` is sized above web's
|
||||
# healthcheck start_period so a broken image that never goes healthy is caught.
|
||||
# Plain `docker compose up` ignores `deploy:` (it warns + skips), so the dev
|
||||
# override is unaffected. Referenced by each long-lived service below.
|
||||
x-deploy-policy: &deploy_policy
|
||||
update_config:
|
||||
order: start-first
|
||||
failure_action: rollback
|
||||
monitor: 90s
|
||||
rollback_config:
|
||||
order: start-first
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 10s
|
||||
|
||||
# Worker liveness: ping THIS container's celery node over the broker. Lenient
|
||||
# (60s interval, 3 retries, 60s start_period) so a transient broker blip never
|
||||
# false-flags a worker into a rollback. `$$HOSTNAME` → `$HOSTNAME` for the shell;
|
||||
# celery's default node name is celery@<hostname> (the container id).
|
||||
x-celery-healthcheck: &celery_healthcheck
|
||||
test: ["CMD-SHELL", "celery -A backend.app.celery_app:celery inspect ping -d celery@$$HOSTNAME --timeout 10 >/dev/null 2>&1"]
|
||||
interval: 60s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -47,6 +76,24 @@ services:
|
||||
web:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
command: ["web"]
|
||||
# Graceful shutdown: give the container time to drain in-flight work on a
|
||||
# deploy (docker SIGTERMs, then SIGKILLs after this window — default is only
|
||||
# 10s, far too short for real jobs). Hypercorn/Celery both warm-shut-down on
|
||||
# SIGTERM; per-lane values sized to typical task length. Anything that still
|
||||
# outruns the window is re-queued (task_reject_on_worker_lost) and re-driven
|
||||
# by the 5-min recovery sweeps, so a kill never corrupts. web = short HTTP
|
||||
# requests + the occasional file download.
|
||||
stop_grace_period: 30s
|
||||
# Liveness for rolling deploys: /api/health is a no-DB 200 (just proves the
|
||||
# app booted + serves HTTP after `alembic upgrade head`). start_period covers
|
||||
# the migration + boot so a slow start isn't mis-flagged.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/api/health', timeout=5).status==200 else 1)\""]
|
||||
interval: 15s
|
||||
timeout: 6s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy: *deploy_policy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment: &app_env
|
||||
@@ -77,6 +124,10 @@ services:
|
||||
worker:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
command: ["worker"]
|
||||
# Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy.
|
||||
stop_grace_period: 90s
|
||||
healthcheck: *celery_healthcheck
|
||||
deploy: *deploy_policy
|
||||
environment:
|
||||
<<: *app_env
|
||||
CELERY_QUEUES: default,import,thumbnail,download
|
||||
@@ -93,6 +144,10 @@ services:
|
||||
scheduler:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
command: ["scheduler"]
|
||||
# Quick maintenance/scan lane + beat — short tasks, modest drain window.
|
||||
stop_grace_period: 60s
|
||||
healthcheck: *celery_healthcheck
|
||||
deploy: *deploy_policy
|
||||
environment:
|
||||
<<: *app_env
|
||||
CELERY_QUEUES: maintenance,scan
|
||||
@@ -110,6 +165,12 @@ services:
|
||||
maintenance-long:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
command: ["worker"]
|
||||
# Longest lane (DB backups, library audits, translation backfill) — give it
|
||||
# the most room to finish a chunk gracefully. Chunked + idempotent, so a job
|
||||
# that still outruns this resumes cleanly next run rather than corrupting.
|
||||
stop_grace_period: 180s
|
||||
healthcheck: *celery_healthcheck
|
||||
deploy: *deploy_policy
|
||||
environment:
|
||||
<<: *app_env
|
||||
CELERY_QUEUES: maintenance_long
|
||||
@@ -125,6 +186,10 @@ services:
|
||||
ml-worker:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:dev
|
||||
command: ["ml-worker"]
|
||||
# A single GPU inference pass can run tens of seconds — let it finish.
|
||||
stop_grace_period: 120s
|
||||
healthcheck: *celery_healthcheck
|
||||
deploy: *deploy_policy
|
||||
environment:
|
||||
<<: *app_env
|
||||
volumes:
|
||||
|
||||
@@ -31,6 +31,68 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
||||
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
||||
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 ----
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
@@ -148,6 +210,21 @@ browser.webRequest.onBeforeRedirect.addListener(
|
||||
{ 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 ----
|
||||
|
||||
browser.runtime.onMessage.addListener(async (msg) => {
|
||||
@@ -192,22 +269,14 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
if (!platform) return { error: `Unknown platform: ${key}` };
|
||||
try {
|
||||
if (platform.authType === 'cookies') {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
|
||||
// Verify the captured cookies are actually live BEFORE
|
||||
// uploading. Skips upload on confirmed-stale sessions so we
|
||||
// don't overwrite FC-side credentials with garbage. Platforms
|
||||
// without a verify config (verify.ok === null) fall through
|
||||
// to upload as before.
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
const r = await exportPlatformCookies(key);
|
||||
if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
|
||||
if (r.status === 'stale') {
|
||||
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);
|
||||
await api.uploadCredentials(key, 'cookies', data);
|
||||
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
return { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||
}
|
||||
if (key === 'discord') {
|
||||
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||
@@ -235,18 +304,10 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) {
|
||||
results[key] = { skipped: true, reason: 'no cookies' };
|
||||
continue;
|
||||
}
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
results[key] = { error: `verify failed: ${v.reason}` };
|
||||
continue;
|
||||
}
|
||||
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
const r = await exportPlatformCookies(key);
|
||||
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
|
||||
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
|
||||
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||
} catch (e) {
|
||||
results[key] = { error: e.message };
|
||||
}
|
||||
@@ -283,11 +344,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
}
|
||||
|
||||
case 'OPEN_ARTIST_PAGE': {
|
||||
// apiUrl is configured with the /api suffix (see
|
||||
// options/options.html placeholder); the SPA artist route is
|
||||
// /artist/:slug, served from the same origin. Strip /api so the
|
||||
// browser-level URL hits the Vue router, not the JSON API.
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
// The SPA artist route (/artist/:slug) is served from the web root, not
|
||||
// the JSON API — see api.webRoot().
|
||||
const base = api.webRoot();
|
||||
const slug = encodeURIComponent(msg.slug || '');
|
||||
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
||||
try {
|
||||
@@ -298,6 +357,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
}
|
||||
}
|
||||
|
||||
case 'CHECK_UPDATE':
|
||||
return await checkForUpdateInfo();
|
||||
|
||||
default:
|
||||
return { error: `Unknown message type: ${msg.type}` };
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ class FabledCuratorAPI {
|
||||
const qs = new URLSearchParams({ url }).toString();
|
||||
return this.request('GET', `/extension/probe?${qs}`);
|
||||
}
|
||||
// Latest published extension version on this instance — drives the in-app
|
||||
// update prompt. Public endpoint (no key needed, but request() sends it
|
||||
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
|
||||
getExtensionManifest() {
|
||||
return this.request('GET', '/extension/manifest');
|
||||
}
|
||||
|
||||
// The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped.
|
||||
// Where the Vue router (artist pages) and the served XPI live, NOT the JSON
|
||||
// API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||
webRoot() {
|
||||
return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
testConnection() {
|
||||
|
||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
||||
* script to decide whether to show the floating "Add as source" button.
|
||||
*/
|
||||
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,
|
||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -22,7 +22,8 @@
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
"webRequestBlocking",
|
||||
"alarms"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,6 @@
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
},
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.0.0"
|
||||
"web-ext": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ body {
|
||||
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
||||
.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 {
|
||||
background: none; border: none; color: var(--on-surface-variant);
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<button class="tab active" data-tab="platforms">Platforms</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;
|
||||
|
||||
// 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() {
|
||||
try {
|
||||
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
@@ -14,6 +23,7 @@ async function init() {
|
||||
setupEventListeners();
|
||||
showPlatformsLoading();
|
||||
testConnectionIfNeeded();
|
||||
checkForUpdate();
|
||||
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
||||
} catch (e) {
|
||||
showSetupRequired();
|
||||
@@ -37,10 +47,7 @@ function showSetupRequired() {
|
||||
function showPlatformsLoading() {
|
||||
const c = document.getElementById('platforms-list');
|
||||
c.textContent = '';
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
d.textContent = 'Loading platforms…';
|
||||
c.appendChild(d);
|
||||
c.appendChild(mutedNote('Loading platforms…'));
|
||||
}
|
||||
|
||||
async function testConnectionIfNeeded() {
|
||||
@@ -63,6 +70,26 @@ function updateConnectionDot(connected) {
|
||||
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() {
|
||||
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||
const c = document.getElementById('platforms-list');
|
||||
@@ -162,10 +189,7 @@ async function exportAllCookies() {
|
||||
async function loadSources() {
|
||||
const c = document.getElementById('sources-list');
|
||||
c.textContent = '';
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
d.textContent = 'Loading sources…';
|
||||
c.appendChild(d);
|
||||
c.appendChild(mutedNote('Loading sources…'));
|
||||
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
||||
c.textContent = '';
|
||||
if (r.error) {
|
||||
@@ -176,10 +200,7 @@ async function loadSources() {
|
||||
return;
|
||||
}
|
||||
if (!r.sources || r.sources.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
empty.textContent = 'No sources yet.';
|
||||
c.appendChild(empty);
|
||||
c.appendChild(mutedNote('No sources yet.'));
|
||||
return;
|
||||
}
|
||||
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
||||
|
||||
+11
-13
@@ -4,30 +4,28 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest run",
|
||||
"check": "vue-tsc --noEmit"
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vuetify": "^3.5.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vuetify": "^4.0.0",
|
||||
"@mdi/font": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"vite-plugin-vuetify": "^2.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-vuetify": "^2.1.0",
|
||||
"sass": "^1.71.0",
|
||||
"vitest": "^2.1.0",
|
||||
"vitest": "^4.0.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"happy-dom": "^15.0.0"
|
||||
"happy-dom": "^20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ const route = useRoute()
|
||||
<style scoped>
|
||||
.fc-content {
|
||||
min-height: 100vh;
|
||||
/* Push initial viewport content below the sticky TopNav. Without
|
||||
this, some views' first rows / form fields / table headers can
|
||||
end up obscured by the navbar (depending on parent overflow
|
||||
context interacting with position: sticky). Scrolled-down content
|
||||
still slides under the nav — the gradient-fade design is intact. */
|
||||
padding-top: 64px;
|
||||
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
|
||||
own space in the v-app flex column — content flows directly below it. The
|
||||
old 64px padding-top was a leftover from a FIXED navbar and double-counted
|
||||
that space, leaving a large empty band at the top of EVERY view (and pushing
|
||||
the full-height calc(100vh - 64px) views down so they overflowed). Removed
|
||||
2026-07-13. Scrolled content still slides under the sticky nav as before. */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="show" :color="color" location="bottom right" timeout="4000"
|
||||
multi-line elevation="4"
|
||||
min-height="68" elevation="4"
|
||||
>
|
||||
{{ message }}
|
||||
<template #actions>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="fc-topnav">
|
||||
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
||||
<div class="fc-nav-left">
|
||||
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
||||
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
||||
@@ -64,13 +64,39 @@
|
||||
</template>
|
||||
|
||||
<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 { useSystemStore } from '../stores/system.js'
|
||||
import PipelineStatusChip from './PipelineStatusChip.vue'
|
||||
|
||||
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 —
|
||||
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
|
||||
@@ -119,16 +145,35 @@ const health = computed(() => {
|
||||
align-items: center;
|
||||
gap: 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(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.84) 50%,
|
||||
rgba(var(--fc-chrome-rgb), 0.55) 75%,
|
||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||
);
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -101,6 +101,47 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<section class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Translation</h2>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Re-translate every post by {{ overview.name }} through the current
|
||||
Interpreter model — clears the stored translations and rebuilds them.
|
||||
Use this after switching translation models to refresh just this artist.
|
||||
</p>
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 10px;">
|
||||
<v-btn
|
||||
size="small" variant="tonal" rounded="pill"
|
||||
prepend-icon="mdi-translate"
|
||||
:loading="retranslating" :disabled="!translationEnabled"
|
||||
@click="confirmRetranslate = true"
|
||||
>Re-translate posts</v-btn>
|
||||
<span v-if="!translationEnabled" class="fc-muted text-caption">
|
||||
Translation is off — enable it in Settings → Maintenance.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- m146: per-artist re-translate — clears + rebuilds this artist's stored
|
||||
translations (used after a translation-model change). -->
|
||||
<v-dialog v-model="confirmRetranslate" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title class="fc-h2">Re-translate {{ overview.name }}?</v-card-title>
|
||||
<v-card-text class="text-body-2">
|
||||
Clears the stored translation for every post by
|
||||
<strong>{{ overview.name }}</strong> and re-runs them through your
|
||||
current Interpreter model. Unchanged text comes back from the cache
|
||||
instantly. Runs in the background until done.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmRetranslate = false">Cancel</v-btn>
|
||||
<v-btn color="accent" :loading="retranslating" @click="onRetranslate">
|
||||
Re-translate
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<section class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Danger zone</h2>
|
||||
<ArtistDangerZone
|
||||
@@ -113,9 +154,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||
@@ -125,8 +168,38 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const importStore = useImportStore()
|
||||
const sources = useSourcesStore()
|
||||
|
||||
// m146: per-artist re-translate. Gated on translation being enabled globally
|
||||
// (the endpoint 400s otherwise) — read the shared import settings once.
|
||||
const translationEnabled = ref(false)
|
||||
const retranslating = ref(false)
|
||||
const confirmRetranslate = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await importStore.loadSettings()
|
||||
translationEnabled.value = !!importStore.settings?.translation_enabled
|
||||
} catch { /* non-fatal — the button just stays disabled */ }
|
||||
})
|
||||
|
||||
async function onRetranslate () {
|
||||
retranslating.value = true
|
||||
try {
|
||||
await api.post('/api/settings/translation/retranslate', {
|
||||
body: { artist_id: props.overview.id },
|
||||
})
|
||||
confirmRetranslate.value = false
|
||||
toast({ text: `Re-translating ${props.overview.name}…`, type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Re-translate failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
retranslating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// #130: move a source into another artist.
|
||||
const moveOpen = ref(false)
|
||||
const moveSource = ref(null)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
filter, applied retroactively to the existing library.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-row density="compact">
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="minW" label="Min width (px)" type="number"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
cadence as the transparency audit.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-row density="compact">
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
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:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #item="{ props: itemProps, internalItem }">
|
||||
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||
<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>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar-wrap fc-chrome-continues">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
@@ -13,14 +13,14 @@
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #item="{ props: itemProps, internalItem }">
|
||||
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
|
||||
<v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ item.raw.kind === 'artist' ? 'artist'
|
||||
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
|
||||
{{ internalItem.raw.kind === 'artist' ? 'artist'
|
||||
: (internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind) }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
|
||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 5;
|
||||
/* 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
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||
the two read as one continuous piece of chrome — images scroll visibly
|
||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
||||
the nav's transparent edge) separates them when scrolled to the very top,
|
||||
while under-scroll they frost as one. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
|
||||
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
|
||||
rather than re-darkening, so the nav + bar read as one gradient (operator
|
||||
2026-07-13). This block only owns the sticky positioning now. */
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
|
||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||
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__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<!-- Auto-hidden chrome that ALSO looked like real content — surfaced PROACTIVELY
|
||||
atop the gallery whenever there's something to review (NOT gated on the
|
||||
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
|
||||
with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
|
||||
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
|
||||
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
|
||||
like real content — surfaced PROACTIVELY atop the gallery whenever there's
|
||||
something to review (NOT gated on the Show-hidden toggle, so misfires can't
|
||||
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
|
||||
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">
|
||||
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
|
||||
<span class="fc-review__title">
|
||||
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
|
||||
may be real content — review before they stay hidden
|
||||
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
|
||||
may be real content — review
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-review__cards">
|
||||
@@ -26,16 +27,16 @@
|
||||
>
|
||||
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
|
||||
</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">
|
||||
<button
|
||||
type="button" class="fc-review-btn fc-review-btn--keep"
|
||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
|
||||
>Keep hidden</button>
|
||||
>{{ keepLabel(it) }}</button>
|
||||
<button
|
||||
type="button" class="fc-review-btn fc-review-btn--unhide"
|
||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
||||
>Un-hide</button>
|
||||
>{{ removeLabel(it) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,6 +55,11 @@ const items = ref([])
|
||||
const busy = ref([])
|
||||
|
||||
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() {
|
||||
// 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}`)
|
||||
items.value = items.value.filter((x) => keyOf(x) !== k)
|
||||
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) {
|
||||
toast({
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Treat as alias for…</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-caption mb-2">
|
||||
Pick the existing tag the model's prediction should map to. All
|
||||
future predictions of this name will resolve to your tag.
|
||||
</p>
|
||||
<v-autocomplete
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="results"
|
||||
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
||||
:item-value="(t) => t.id"
|
||||
:loading="loading"
|
||||
label="Canonical tag"
|
||||
no-filter clearable density="compact"
|
||||
@update:search="onSearch"
|
||||
@keydown.enter.capture="onEnter"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :disabled="!selectedId"
|
||||
@click="$emit('confirm', selectedId)"
|
||||
>Use this tag</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||
|
||||
const props = defineProps({ category: { type: String, required: true } })
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const api = useApi()
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedId = ref(null)
|
||||
let debounce = null
|
||||
|
||||
// Enter on the closed dropdown confirms the selection instead of re-opening it.
|
||||
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||
if (selectedId.value != null) emit('confirm', selectedId.value)
|
||||
})
|
||||
|
||||
function onSearch(q) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(async () => {
|
||||
const query = (q || '').trim()
|
||||
if (!query) { results.value = []; return }
|
||||
loading.value = true
|
||||
try {
|
||||
// Scope the autocomplete to the prediction's category where it
|
||||
// maps to a tag kind. Only 'character' surfaces as both a
|
||||
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||
// retired); other categories search unscoped.
|
||||
const kind = props.category === 'character' ? 'character' : null
|
||||
const params = { q: query, limit: 20 }
|
||||
if (kind) params.kind = kind
|
||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
</script>
|
||||
@@ -11,10 +11,6 @@
|
||||
{{ suggestion.display_name }}
|
||||
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
||||
title="You rejected this for this image — un-reject to recover">rejected</span>
|
||||
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
||||
title="No matching tag yet — accepting creates it">+ new</span>
|
||||
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
|
||||
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
|
||||
</span>
|
||||
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
||||
<!-- Green ✓ / red ✗ pair (operator-asked 2026-06-28) mirrors the eval
|
||||
@@ -46,40 +42,14 @@
|
||||
@click="$emit('dismiss', suggestion)"
|
||||
><v-icon size="16">mdi-close</v-icon></button>
|
||||
</div>
|
||||
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
|
||||
teleported image modal — #711). Only rendered when an alias action
|
||||
applies — dismiss now lives on the red ✗, so a centroid hit with no
|
||||
alias option has no menu. -->
|
||||
<KebabMenu
|
||||
v-if="hasMenu"
|
||||
class="fc-suggestion__menu" size="small" variant="outlined"
|
||||
:label="`More actions for ${suggestion.display_name}`"
|
||||
>
|
||||
<!-- Alias is a tagger-prediction remap, so only offer it for tagger
|
||||
suggestions with a raw model key that aren't already aliased.
|
||||
Centroid hits (raw_name null) have nothing to alias. -->
|
||||
<v-list-item
|
||||
v-if="suggestion.raw_name && !suggestion.via_alias"
|
||||
@click="$emit('alias', suggestion)"
|
||||
>
|
||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="suggestion.via_alias"
|
||||
@click="$emit('remove-alias', suggestion)"
|
||||
>
|
||||
<v-list-item-title>Remove alias</v-list-item-title>
|
||||
</v-list-item>
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import KebabMenu from '../common/KebabMenu.vue'
|
||||
|
||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
|
||||
defineEmits(['accept', 'dismiss', 'undismiss'])
|
||||
|
||||
// #1206: on hover, tell the image viewer which crop produced this suggestion so
|
||||
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
|
||||
@@ -92,12 +62,6 @@ function onLeave () {
|
||||
}
|
||||
|
||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
// Kebab now only carries alias actions: show it when this suggestion can be
|
||||
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
|
||||
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
|
||||
const hasMenu = computed(() =>
|
||||
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -119,26 +83,6 @@ const hasMenu = computed(() =>
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fc-suggestion__new {
|
||||
display: inline-block;
|
||||
font-size: 10px; font-weight: 600;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
background: rgba(var(--v-theme-accent), 0.12);
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.4);
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.fc-suggestion__alias {
|
||||
display: inline-block;
|
||||
font-size: 10px; font-weight: 600;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.fc-suggestion__score {
|
||||
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||
font-size: 11px;
|
||||
@@ -166,10 +110,6 @@ const hasMenu = computed(() =>
|
||||
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
|
||||
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
|
||||
}
|
||||
.fc-suggestion__menu {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
|
||||
reads as "handled, negative" without shouting over live suggestions. */
|
||||
.fc-suggestion--rejected {
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
|
||||
:suggestion="s"
|
||||
@accept="$emit('accept', $event)"
|
||||
@alias="$emit('alias', $event)"
|
||||
@remove-alias="$emit('remove-alias', $event)"
|
||||
@dismiss="$emit('dismiss', $event)"
|
||||
@undismiss="$emit('undismiss', $event)"
|
||||
/>
|
||||
@@ -45,7 +43,7 @@ const props = defineProps({
|
||||
collapsible: { type: Boolean, default: false },
|
||||
defaultOpen: { type: Boolean, default: true }
|
||||
})
|
||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
|
||||
defineEmits(['accept', 'dismiss', 'undismiss', 'reject-all'])
|
||||
|
||||
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
|
||||
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
|
||||
|
||||
@@ -20,48 +20,39 @@
|
||||
first, so false positives are easy to spot and reject (reject-to-train
|
||||
for these heads). Small set — collapsible, open by default. -->
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.system && store.byCategory.system.length"
|
||||
label="System" :items="store.byCategory.system"
|
||||
v-if="store.aboveByCategory.system && store.aboveByCategory.system.length"
|
||||
label="System" :items="store.aboveByCategory.system"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('system')"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-for="cat in peopleCats" :key="cat"
|
||||
v-show="store.byCategory[cat] && store.byCategory[cat].length"
|
||||
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length"
|
||||
:label="labelFor(cat)" :items="store.aboveByCategory[cat] || []"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll(cat)"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
label="General" :items="store.byCategory.general"
|
||||
v-if="store.aboveByCategory.general && store.aboveByCategory.general.length"
|
||||
label="General" :items="store.aboveByCategory.general"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('general')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="aliasDialog" max-width="480">
|
||||
<AliasPickerDialog
|
||||
v-if="aliasTarget"
|
||||
:category="aliasTarget.category"
|
||||
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
||||
import AliasPickerDialog from './AliasPickerDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
imageId: { type: Number, required: true },
|
||||
@@ -100,13 +91,14 @@ const peopleCats = ['character']
|
||||
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
||||
|
||||
const isEmpty = computed(() =>
|
||||
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
||||
Object.values(store.aboveByCategory).every(list => !list || list.length === 0)
|
||||
)
|
||||
|
||||
watch(() => props.imageId, (id) => {
|
||||
if (id == null) return
|
||||
store.load(id) // panel: curated, ≥ threshold
|
||||
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
|
||||
// One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the
|
||||
// typed tag-input dropdown reads the full set. No second request.
|
||||
store.load(id)
|
||||
}, { immediate: true })
|
||||
|
||||
// After a successful accept/alias-accept, refresh the modal's current
|
||||
@@ -125,31 +117,6 @@ async function onAccept(s) {
|
||||
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const aliasDialog = ref(false)
|
||||
const aliasTarget = ref(null)
|
||||
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
|
||||
async function onAliasConfirm(canonicalTagId) {
|
||||
try {
|
||||
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
||||
aliasDialog.value = false
|
||||
await host.reloadTags()
|
||||
emit('accepted')
|
||||
} catch (e) {
|
||||
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Undo the model-key→tag mapping behind an aliased suggestion. The store
|
||||
// reloads suggestions so the prediction reverts to its raw form; the applied
|
||||
// canonical tag (if any) stays, so no tag-rail reload is needed.
|
||||
async function onRemoveAlias(s) {
|
||||
try {
|
||||
await store.removeAlias(s)
|
||||
} catch (e) {
|
||||
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<template v-for="(row, idx) in rows" :key="row.key">
|
||||
<!-- Existing tag match (server autocomplete). -->
|
||||
<!-- One unified row per DB tag (server autocomplete). Every suggestion is
|
||||
a canonical tag now, so when the model ALSO scored this tag for the
|
||||
image the row just carries its confidence (🔧 %) in place of the
|
||||
redundant kind label — the coloured leading icon already shows kind.
|
||||
That means a searched tag that's also a suggestion shows its % on ONE
|
||||
row, with no dedup and no flicker. Picking it emits pick-existing;
|
||||
TagPanel.findPending routes a matching suggestion through accept() so
|
||||
the acceptance is still recorded + it drops from the panel. -->
|
||||
<v-list-item
|
||||
v-if="row.type === 'hit'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
@@ -32,32 +39,18 @@
|
||||
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||
it routes through the same accept path as the Suggestions panel, so
|
||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||
<v-list-item
|
||||
v-else-if="row.type === 'suggestion'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
class="fc-tag-autocomplete__sugg"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
|
||||
{{ iconFor(row.sugg.category) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.sugg.display_name }}
|
||||
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="fc-tag-autocomplete__sugg-tag">
|
||||
<span
|
||||
v-if="row.sugg"
|
||||
class="fc-tag-autocomplete__sugg-tag"
|
||||
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
|
||||
:title="row.sugg.above_threshold
|
||||
? 'The model suggests this tag for this image'
|
||||
: 'The model scored this tag below its suggest threshold'"
|
||||
>
|
||||
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
||||
{{ scorePct(row.sugg) }}
|
||||
</span>
|
||||
<span v-else class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -100,11 +93,10 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import { useInflightToken } from '../../composables/useInflightToken.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
||||
// The host surface's applied tags (host.current?.tags). Both dropdown sections
|
||||
// filter against this so a just-added tag drops out of the search the moment
|
||||
// the chip rail updates, not after the next modal refresh (operator-asked
|
||||
// 2026-07-03).
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
// The host surface's applied tags (host.current?.tags). The dropdown filters
|
||||
// against this so a just-added tag drops out of the search the moment the chip
|
||||
// rail updates, not after the next modal refresh (operator-asked 2026-07-03).
|
||||
const props = defineProps({
|
||||
appliedTags: { type: Array, default: () => [] },
|
||||
})
|
||||
@@ -238,9 +230,6 @@ const createLabel = computed(() =>
|
||||
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
||||
|
||||
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
|
||||
const appliedNames = computed(() =>
|
||||
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
|
||||
)
|
||||
|
||||
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
|
||||
// keep reading the RAW hits — they reason about the tag universe (does this tag
|
||||
@@ -249,49 +238,29 @@ const visibleHits = computed(() =>
|
||||
hits.value.filter(h => !appliedIds.value.has(h.id)),
|
||||
)
|
||||
|
||||
// This image's suggestions that match the typed query, minus any the server
|
||||
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
||||
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
|
||||
// the threshold-filtered panel list — so a low-confidence action/feature the
|
||||
// model saw can be typed and accepted in canonical formatting instead of being
|
||||
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
|
||||
// the only filter; the threshold no longer hides anything here.
|
||||
const suggestionHits = computed(() => {
|
||||
const q = parsedName.value.toLowerCase()
|
||||
if (!q) return []
|
||||
const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
||||
const out = []
|
||||
for (const list of Object.values(suggestions.allByCategory)) {
|
||||
// This image's suggestions keyed by canonical tag id, so a matching DB-tag row
|
||||
// can show the model's confidence inline. Every suggestion is a canonical tag
|
||||
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
|
||||
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
|
||||
// advertise a score in the type-to-add dropdown (un-reject lives in the panel).
|
||||
const suggByTagId = computed(() => {
|
||||
const m = new Map()
|
||||
for (const list of Object.values(suggestions.byCategory)) {
|
||||
for (const s of list || []) {
|
||||
// Rejected suggestions now stay in allByCategory (flagged) so the panel
|
||||
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
|
||||
// whose job is finding a tag to ADD (un-reject lives in the panel).
|
||||
if (s.rejected) continue
|
||||
// Already on the image (matched by canonical id or name+kind): nothing
|
||||
// to add. Covers the window between an add and the next suggestions
|
||||
// fetch, where the stale row would otherwise still be pickable.
|
||||
if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue
|
||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||
if (appliedNames.value.has(key)) continue
|
||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(s)
|
||||
if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
|
||||
}
|
||||
}
|
||||
// Best matches first; cap generously so a specific typed query surfaces its
|
||||
// matches even when many predictions exist, while the list stays scrollable.
|
||||
out.sort((a, b) => b.score - a.score)
|
||||
return out.slice(0, 20)
|
||||
return m
|
||||
})
|
||||
|
||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||
// highlight index maps 1:1 to a row regardless of which section it's in.
|
||||
// highlight index maps 1:1 to a row. Each hit is annotated with its suggestion
|
||||
// (score) when the model scored that tag for this image.
|
||||
const rows = computed(() => {
|
||||
const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
|
||||
for (const s of suggestionHits.value) {
|
||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
||||
}
|
||||
const r = visibleHits.value.map(h => ({
|
||||
type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
|
||||
}))
|
||||
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
||||
return r
|
||||
})
|
||||
@@ -313,16 +282,9 @@ function moveHighlight (delta) {
|
||||
// Dispatch a chosen dropdown row by its type.
|
||||
function onPickRow (row) {
|
||||
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
||||
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
|
||||
else { onCreate() }
|
||||
}
|
||||
|
||||
// Picking an ML suggestion hands it to the parent, which runs the same accept
|
||||
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
|
||||
// drops it from the panel). reset() clears the query; the panel-drop makes it
|
||||
// fall out of suggestionHits reactively.
|
||||
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
|
||||
|
||||
function onCreate () {
|
||||
const name = parsedName.value
|
||||
const kind = parsedKind.value
|
||||
@@ -389,14 +351,16 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
|
||||
.fc-tag-autocomplete__sugg {
|
||||
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
/* The model-confidence badge on a row whose tag the model also scored. Accent
|
||||
when above the head's suggest threshold; muted when below (a low-confidence
|
||||
match you can still type + pick). */
|
||||
.fc-tag-autocomplete__sugg-tag {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-tag-autocomplete__sugg-tag--below {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
|
||||
<v-chip
|
||||
size="small" :closable="!unconfirmedAuto"
|
||||
size="default" :closable="!unconfirmedAuto"
|
||||
:color="store.colorFor(tag.kind)" variant="tonal"
|
||||
class="fc-tag-chip__nav"
|
||||
role="link"
|
||||
@@ -9,7 +9,7 @@
|
||||
@click="$emit('navigate', tag)"
|
||||
@click:close="$emit('remove', tag.id)"
|
||||
>
|
||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
<v-icon start size="small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
<span class="fc-tag-chip__name">{{ tag.name }}</span><v-icon
|
||||
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
|
||||
title="System tag — tagged items are excluded from training other concepts"
|
||||
@@ -27,13 +27,13 @@
|
||||
:title="`Yes — keep “${tag.name}” (trains the model, won't be retracted)`"
|
||||
:aria-label="`Confirm ${tag.name}`"
|
||||
@click.stop="$emit('confirm', tag)"
|
||||
><v-icon size="13">mdi-check</v-icon></button>
|
||||
><v-icon size="15">mdi-check</v-icon></button>
|
||||
<button
|
||||
type="button" class="fc-tag-chip__no"
|
||||
:title="`No — remove “${tag.name}”`"
|
||||
:aria-label="`Reject ${tag.name}`"
|
||||
@click.stop="$emit('remove', tag.id)"
|
||||
><v-icon size="13">mdi-close</v-icon></button>
|
||||
><v-icon size="15">mdi-close</v-icon></button>
|
||||
</span>
|
||||
</v-chip>
|
||||
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
|
||||
@@ -143,7 +143,19 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
chip's hover title. */
|
||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
|
||||
.fc-tag-chip__nav { max-width: 100%; min-width: 0; }
|
||||
/* Tonal chips (esp. character = info) wash out against the dark rail — the fill
|
||||
is intentionally faint. Give every tag chip a thin border in its OWN kind
|
||||
colour (currentColor = the tonal chip's themed foreground) so the edge reads
|
||||
clearly without touching the fill (operator-asked 2026-07-08). Theme-aware:
|
||||
currentColor tracks the kind colour in either light or dark. */
|
||||
.fc-tag-chip__nav { border: thin solid color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; }
|
||||
/* The bigger size=default chip widens Vuetify's negative start-margin on the
|
||||
leading kind-icon, pulling it LEFT of the content box above — where that
|
||||
overflow:hidden then clips the icon's left edge as a vertical slice
|
||||
(operator-flagged 2026-07-08). Zero the pull so the icon sits inside the clip
|
||||
box; the chip's own 12px padding keeps a comfortable left inset. */
|
||||
.fc-tag-chip__nav :deep(.v-icon--start) { margin-inline-start: 0; }
|
||||
.fc-tag-chip__name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
min-width: 0; flex: 0 1 auto;
|
||||
@@ -159,23 +171,27 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
.fc-tag-chip__kebab { opacity: 0.7; }
|
||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
||||
/* Provisional auto-tag: a compact green ✓ / red ✗ pair in place of the ✕. The
|
||||
yes/no is obvious enough on its own, so there's no "auto" label (operator-asked
|
||||
/* Provisional auto-tag: a green ✓ / red ✗ pair in place of the ✕. The yes/no is
|
||||
obvious enough on its own, so there's no "auto" label (operator-asked
|
||||
2026-07-07). flex:0 0 auto keeps it visible while the name ellipsis-truncates. */
|
||||
.fc-tag-chip__verdict {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 1px;
|
||||
margin-left: 3px;
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 3px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
/* Solid-filled (white glyph on a full success/error circle) so accept/reject
|
||||
stay well-defined even on a muted tonal chip — e.g. character (info) — instead
|
||||
of a faint icon that only lit up on hover. Mirrors the Suggestions rail's
|
||||
verdict buttons so the affordance reads identically (operator-asked 2026-07-08). */
|
||||
.fc-tag-chip__yes, .fc-tag-chip__no {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; padding: 0; border: none; border-radius: 50%;
|
||||
background: transparent; cursor: pointer; transition: background 0.1s;
|
||||
width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%;
|
||||
color: #fff; cursor: pointer; opacity: 0.92;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.fc-tag-chip__yes { color: rgb(var(--v-theme-success)); }
|
||||
.fc-tag-chip__no { color: rgb(var(--v-theme-error)); }
|
||||
.fc-tag-chip__yes:hover { background: rgb(var(--v-theme-success), 0.16); }
|
||||
.fc-tag-chip__no:hover { background: rgb(var(--v-theme-error), 0.16); }
|
||||
.fc-tag-chip__yes { background: rgb(var(--v-theme-success)); }
|
||||
.fc-tag-chip__no { background: rgb(var(--v-theme-error)); }
|
||||
.fc-tag-chip__yes:hover, .fc-tag-chip__no:hover { opacity: 1; transform: scale(1.1); }
|
||||
.fc-tag-chip__yes:focus-visible, .fc-tag-chip__no:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
ref="tagInputRef"
|
||||
:applied-tags="host.current?.tags || []"
|
||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||
@accept-suggestion="onAcceptSuggestion"
|
||||
/>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -134,7 +133,6 @@ async function onRemove(tagId) {
|
||||
// until the next modal open (operator-asked 2026-07-03).
|
||||
if (host.currentImageId != null) {
|
||||
suggestions.load(host.currentImageId)
|
||||
suggestions.loadAll(host.currentImageId)
|
||||
}
|
||||
focusTagInput()
|
||||
}
|
||||
@@ -178,18 +176,6 @@ async function onPickNew(payload) {
|
||||
}
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
|
||||
// acceptance, and drops it from the panel; then we refresh the chip rail.
|
||||
async function onAcceptSuggestion(s) {
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await suggestions.accept(s)
|
||||
await host.reloadTags()
|
||||
focusTagInput()
|
||||
} catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
|
||||
const renameDialog = ref(false)
|
||||
const renameTarget = ref(null)
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@
|
||||
@click.stop="showOriginal = !showOriginal"
|
||||
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||
|
||||
<!-- Per-post translation override (#155): force a skipped translation on,
|
||||
or keep the original for a confidently mis-flagged title. Only where
|
||||
there's text to translate. -->
|
||||
<PostTranslationControl
|
||||
v-if="post.post_title || post.description_plain"
|
||||
:post="post"
|
||||
/>
|
||||
|
||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||
HTML (headings, lists, links, inline images). Collapsed and the
|
||||
no-detail fallback stay plain text. -->
|
||||
@@ -136,6 +144,7 @@ import { useModalStore } from '../../stores/modal.js'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import PostSeriesMenu from './PostSeriesMenu.vue'
|
||||
import PostTranslationControl from './PostTranslationControl.vue'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<!-- Sticky per-post translation override (#155). Quiet inline control under the
|
||||
post title: force a skipped legit-foreign title on, or knock a wrongly
|
||||
mis-translated one back to the original. The choice sticks through a
|
||||
Re-translate-all. -->
|
||||
<div class="fc-post-tx">
|
||||
<span class="fc-post-tx__label">Translation:</span>
|
||||
<v-menu :disabled="busy">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<button
|
||||
type="button" class="fc-post-tx__btn" v-bind="menuProps" :disabled="busy"
|
||||
:aria-label="`Translation handling: ${currentLabel}`"
|
||||
>
|
||||
{{ currentLabel }}
|
||||
<v-icon size="14">mdi-menu-down</v-icon>
|
||||
</button>
|
||||
</template>
|
||||
<v-list density="compact" min-width="240" class="fc-post-tx__list">
|
||||
<v-list-item
|
||||
v-for="opt in OPTIONS" :key="opt.value"
|
||||
:active="opt.value === current" @click="choose(opt.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ opt.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ opt.help }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-progress-circular v-if="busy" indeterminate size="13" width="2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
|
||||
const props = defineProps({ post: { type: Object, required: true } })
|
||||
const posts = usePostsStore()
|
||||
const busy = ref(false)
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto', icon: 'mdi-cog-outline',
|
||||
help: 'Translate only when confident enough' },
|
||||
{ value: 'force', label: 'Force translate', icon: 'mdi-translate',
|
||||
help: 'Always translate, even at low confidence' },
|
||||
{ value: 'original', label: 'Keep original', icon: 'mdi-translate-off',
|
||||
help: 'Never translate — keep the original text' },
|
||||
]
|
||||
|
||||
const current = computed(() => props.post.translation_override || 'auto')
|
||||
const currentLabel = computed(
|
||||
() => (OPTIONS.find((o) => o.value === current.value) || OPTIONS[0]).label,
|
||||
)
|
||||
|
||||
async function choose (value) {
|
||||
if (value === current.value || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await posts.applyTranslationOverride(props.post.id, value)
|
||||
if (res.applied === 'queued') {
|
||||
toast({
|
||||
text: 'Saved — this post will translate on the next sweep (service offline).',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ text: `Couldn't update translation: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-tx {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.fc-post-tx__label {
|
||||
font-size: 0.72rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn {
|
||||
display: inline-flex; align-items: center; gap: 1px;
|
||||
padding: 0; background: none; border: 0; cursor: pointer;
|
||||
font-size: 0.72rem; font-weight: 700;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-tx__btn:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fc-post-tx__btn:disabled { cursor: default; opacity: 0.6; }
|
||||
.fc-post-tx__list :deep(.v-list-item-subtitle) {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
@@ -15,28 +15,23 @@
|
||||
</p>
|
||||
|
||||
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
|
||||
<span class="fc-section-h">{{ p.label }}</span>
|
||||
<v-switch
|
||||
v-model="p.on" :loading="busy" hide-details density="compact"
|
||||
color="success" class="ml-auto"
|
||||
@update:model-value="v => saveToggle(p, v)"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="p.on" :loading="busy" :icon="p.icon"
|
||||
:icon-color="p.on ? 'accent' : null" :label="p.label"
|
||||
@change="v => saveToggle(p, v)"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
||||
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model="p.weights" label="Weights" density="compact" hide-details
|
||||
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
||||
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
|
||||
v-model.number="p.conf" label="Confidence" type="number"
|
||||
min="0" max="1" step="0.05" density="compact" hide-details
|
||||
style="max-width: 140px;" :disabled="busy || !p.on"
|
||||
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
<SettingNumberField
|
||||
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
|
||||
max-width="140px" :disabled="busy || !p.on"
|
||||
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,12 +43,12 @@
|
||||
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||
<v-text-field
|
||||
<SettingNumberField
|
||||
v-for="c in caps" :key="c.key"
|
||||
v-model.number="c.val" :label="c.label" type="number"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
|
||||
hide-details style="max-width: 165px;" :disabled="busy"
|
||||
@change="save({ [c.key]: Number(c.val) })"
|
||||
v-model="c.val" :label="c.label"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1"
|
||||
max-width="165px" :disabled="busy"
|
||||
@change="saveField({ [c.key]: Number(c.val) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,14 +56,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { onMounted, ref } from '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'
|
||||
|
||||
const mlSettings = useMLStore()
|
||||
const busy = ref(false)
|
||||
const { busy, save } = useSettingSave(mlSettings.patchSettings)
|
||||
const proposers = ref([])
|
||||
const caps = ref([])
|
||||
|
||||
@@ -111,31 +108,20 @@ onMounted(async () => {
|
||||
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
||||
})
|
||||
|
||||
async function save(patch, revert) {
|
||||
busy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings(patch)
|
||||
toast({ text: 'Saved', type: 'success' })
|
||||
} catch (e) {
|
||||
if (revert) revert()
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
|
||||
// already clamped numeric values to their [min,max] before this fires.
|
||||
function saveField(patch) {
|
||||
save(patch, { successMessage: 'Saved' })
|
||||
}
|
||||
|
||||
function saveToggle (p, v) {
|
||||
async function saveToggle(p, v) {
|
||||
// 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>
|
||||
|
||||
<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 {
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ async function onCommit() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-code {
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border-radius: 4px; padding: 2px 8px;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</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.
|
||||
</p>
|
||||
</MaintenanceTile>
|
||||
|
||||
@@ -72,6 +72,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
|
||||
@@ -95,7 +95,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-cells { display: flex; gap: 28px; }
|
||||
.fc-cell__n {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||
@@ -105,6 +104,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
|
||||
@@ -367,11 +367,6 @@ async function onReprocess() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-section-h {
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-token {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -155,11 +155,6 @@ async function onRecover(it) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-section-h {
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-queue { display: flex; gap: 24px; }
|
||||
.fc-q__n {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||
@@ -169,8 +164,6 @@ async function onRecover(it) {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
||||
.fc-defect {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
||||
|
||||
@@ -95,14 +95,10 @@
|
||||
|
||||
<!-- Earned auto-apply -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
|
||||
<span class="fc-section-h">Auto-apply</span>
|
||||
<v-switch
|
||||
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
|
||||
color="success" class="ml-auto" @update:model-value="onToggleAuto"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="autoEnabled" :loading="settingBusy"
|
||||
icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Graduated heads (⚡, with ≥ {{ autoMinPosInput }} examples) apply their tag
|
||||
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
|
||||
@@ -111,17 +107,14 @@
|
||||
</p>
|
||||
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="autoPrecisionInput" label="Precision target"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="autoPrecisionInput" label="Precision target"
|
||||
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||
@change="onSaveSettings"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="autoMinPosInput" label="Min examples to fire"
|
||||
type="number" min="1" density="compact" hide-details
|
||||
style="max-width: 200px;" :disabled="settingBusy"
|
||||
@change="onSaveSettings"
|
||||
<SettingNumberField
|
||||
v-model="autoMinPosInput" label="Min examples to fire"
|
||||
:min="1" :disabled="settingBusy" @change="onSaveSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,40 +154,65 @@
|
||||
|
||||
<!-- Presentation chrome auto-hide (#141) -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
|
||||
<span class="fc-section-h">Hide presentation chrome</span>
|
||||
<v-switch
|
||||
v-model="presentationEnabled" :loading="settingBusy" hide-details
|
||||
density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onTogglePresentation"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="presentationEnabled" :loading="settingBusy"
|
||||
icon="mdi-image-off-outline" label="Hide presentation chrome"
|
||||
@change="onTogglePresentation"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-hide banners and editor screenshots from the gallery once a head has
|
||||
learned them (≥ {{ minPositives }} examples) and clears
|
||||
Auto-hide <code>banner</code> chrome from the gallery once a head has
|
||||
learned it (≥ {{ minPositives }} examples) and clears
|
||||
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
|
||||
<code>wip</code> is never auto-hidden. If a hidden image also looks like
|
||||
real content (≥ {{ Math.round((presentationConflictInput || 0) * 100) }}%
|
||||
on a content tag), it's flagged in the Hidden view instead of buried.
|
||||
Every auto-hide is reversible.
|
||||
(<code>wip</code> and <code>editor screenshot</code> are handled by the
|
||||
process auto-tagger below.) If a hidden image also looks like real content
|
||||
(≥ {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
|
||||
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="presentationThresholdInput" label="Hide confidence"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="presentationThresholdInput" label="Hide confidence"
|
||||
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||
@change="onSavePresentation"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="presentationConflictInput" label="Flag if content ≥"
|
||||
type="number" min="0" max="1" step="0.05" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="presentationConflictInput" label="Flag if content ≥"
|
||||
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
|
||||
@change="onSavePresentation"
|
||||
/>
|
||||
</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 -->
|
||||
<div v-if="metricsConcepts.length" class="mt-5">
|
||||
<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_misfires }}</td>
|
||||
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
|
||||
{{ ratePct(c.misfire_rate) }}
|
||||
{{ pct(c.misfire_rate) }}
|
||||
</td>
|
||||
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
|
||||
</tr>
|
||||
@@ -235,6 +253,9 @@ import { toast } from '../../utils/toast.js'
|
||||
import { computed, onMounted, onUnmounted, ref } from '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 { useMLStore } from '../../stores/ml.js'
|
||||
|
||||
@@ -248,7 +269,9 @@ let pollTimer = null
|
||||
const autoEnabled = ref(false)
|
||||
const autoPrecisionInput = ref(0.97)
|
||||
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 autoStatus = ref(null)
|
||||
const metricsData = ref(null)
|
||||
@@ -258,6 +281,9 @@ let autoTimer = null
|
||||
const presentationEnabled = ref(true)
|
||||
const presentationThresholdInput = ref(0.90)
|
||||
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 lastSweep = computed(() =>
|
||||
@@ -292,6 +318,9 @@ onMounted(async () => {
|
||||
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
|
||||
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
|
||||
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 */ }
|
||||
await refresh()
|
||||
if (running.value) startPoll()
|
||||
@@ -352,55 +381,39 @@ function startAutoPoll() {
|
||||
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
|
||||
|
||||
async function onToggleAuto(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
|
||||
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
|
||||
} catch (e) {
|
||||
autoEnabled.value = !val // revert the switch
|
||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
const ok = await save({ head_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' })
|
||||
if (!ok) autoEnabled.value = !val // revert the switch
|
||||
}
|
||||
async function onSaveSettings() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
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
|
||||
}
|
||||
await save({
|
||||
head_auto_apply_precision: Number(autoPrecisionInput.value),
|
||||
head_auto_apply_min_positives: Number(autoMinPosInput.value),
|
||||
})
|
||||
}
|
||||
|
||||
async function onTogglePresentation(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
|
||||
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
|
||||
} catch (e) {
|
||||
presentationEnabled.value = !val // revert the switch
|
||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
const ok = await save({ presentation_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' })
|
||||
if (!ok) presentationEnabled.value = !val // revert the switch
|
||||
}
|
||||
async function onSavePresentation() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
|
||||
presentation_conflict_threshold: Number(presentationConflictInput.value),
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
await save({
|
||||
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
|
||||
presentation_conflict_threshold: Number(presentationConflictInput.value),
|
||||
})
|
||||
}
|
||||
|
||||
async function onToggleProcess(val) {
|
||||
const ok = await save({ process_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
|
||||
if (!ok) processEnabled.value = !val // revert the switch
|
||||
}
|
||||
async function onSaveProcess() {
|
||||
await save({
|
||||
process_auto_apply_threshold: Number(processThresholdInput.value),
|
||||
process_conflict_threshold: Number(processConflictInput.value),
|
||||
})
|
||||
}
|
||||
function onPreview() { startSweep(true) }
|
||||
function onApplyNow() { startSweep(false) }
|
||||
@@ -426,7 +439,6 @@ function sweepConcepts(run) {
|
||||
.sort((a, b) => b.applied - a.applied)
|
||||
}
|
||||
function sweepTotal(run) { return run?.n_applied ?? 0 }
|
||||
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
|
||||
function rateClass(x) {
|
||||
if (x == null) return ''
|
||||
if (x <= 0.03) return 'fc-good'
|
||||
@@ -457,12 +469,6 @@ function relTime(iso) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
.fc-section-h {
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-auto {
|
||||
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));
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
}
|
||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
|
||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
being dropped as duplicates;</strong> raise it to collapse more
|
||||
look-alikes. Applies to new imports.
|
||||
</div>
|
||||
<v-row align="center" no-gutters>
|
||||
<v-row no-gutters class="align-center">
|
||||
<v-col cols="12" sm="9">
|
||||
<v-slider
|
||||
v-model="local.phash_threshold"
|
||||
@@ -79,6 +79,46 @@
|
||||
</v-col>
|
||||
</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>
|
||||
{{ store.settingsError }}
|
||||
</v-alert>
|
||||
@@ -109,6 +149,8 @@ const local = reactive({
|
||||
skip_transparent: false, transparency_threshold: 0.9,
|
||||
skip_single_color: false, single_color_threshold: 0.95,
|
||||
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 })
|
||||
@@ -124,11 +166,18 @@ async function save() {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fc-phash__title {
|
||||
.fc-phash__title,
|
||||
.fc-wip__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
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. */
|
||||
.fc-phash__slider { margin-bottom: 18px; }
|
||||
</style>
|
||||
|
||||
@@ -39,13 +39,14 @@
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
const store = useMLStore()
|
||||
const { busy: saving, save } = useSettingSave(store.patchSettings)
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
const enabled = ref(true)
|
||||
const saving = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
@@ -55,21 +56,12 @@ onMounted(async () => {
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
async function onToggle() {
|
||||
saving.value = true
|
||||
try {
|
||||
await store.patchSettings({ cpu_embed_enabled: enabled.value })
|
||||
toast({
|
||||
text: enabled.value
|
||||
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
||||
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
enabled.value = !enabled.value
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
const ok = await save({ cpu_embed_enabled: enabled.value }, {
|
||||
successMessage: enabled.value
|
||||
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
||||
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
|
||||
})
|
||||
if (!ok) enabled.value = !enabled.value
|
||||
}
|
||||
async function run() {
|
||||
busy.value = true
|
||||
@@ -80,5 +72,4 @@ async function run() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
the CPU fallback.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<VideoEmbeddingCard />
|
||||
<GpuAgentCard />
|
||||
<GpuTriageCard />
|
||||
<MLBackfillCard />
|
||||
@@ -36,7 +37,6 @@
|
||||
Suggestion thresholds, trained heads and tag aliases.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<MLThresholdSliders />
|
||||
<CropProposersCard />
|
||||
<HeadsCard />
|
||||
<AliasTable />
|
||||
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||
import GpuTriageCard from './GpuTriageCard.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
|
||||
import CropProposersCard from './CropProposersCard.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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-card class="fc-stat">
|
||||
<v-card-text>
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
@change="onSave"
|
||||
/>
|
||||
|
||||
<!-- Acceptance floor (#155): latin-script translations below this Interpreter
|
||||
confidence are kept as the original. Per-post overrides handle exceptions. -->
|
||||
<v-text-field
|
||||
v-model.number="minConfidence" label="Acceptance confidence"
|
||||
type="number" min="0" max="1" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" class="mb-1" :disabled="busy"
|
||||
@change="onSaveConfidence"
|
||||
/>
|
||||
<div class="fc-muted text-caption mb-3">
|
||||
Latin-script titles Interpreter is less than this sure about (0–1) are kept
|
||||
as the original; CJK is always translated. Default 0.90 — raise it to reject
|
||||
more mis-detections, lower it to translate more. Per-post controls in the
|
||||
posts feed override this either way.
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;">
|
||||
<v-icon size="12" :color="statusColor">mdi-circle</v-icon>
|
||||
<span class="fc-muted text-body-2">{{ statusText }}</span>
|
||||
@@ -47,21 +62,123 @@
|
||||
prepend-icon="mdi-translate" :loading="running"
|
||||
:disabled="!enabled || !baseUrl" @click="onRun"
|
||||
>Translate now</v-btn>
|
||||
<span v-if="status" class="fc-muted text-caption">
|
||||
<v-btn
|
||||
size="small" variant="tonal" rounded="pill"
|
||||
prepend-icon="mdi-refresh" :loading="retranslating"
|
||||
:disabled="!enabled || !baseUrl" @click="confirmAll = true"
|
||||
>Re-translate all</v-btn>
|
||||
<span
|
||||
v-if="status && status.active"
|
||||
class="fc-muted text-caption d-inline-flex align-center" style="gap: 6px;"
|
||||
>
|
||||
<v-progress-circular indeterminate size="12" width="2" color="accent" />
|
||||
Translating… {{ status.untranslated_count }} remaining
|
||||
</span>
|
||||
<span v-else-if="status" class="fc-muted text-caption">
|
||||
{{ status.untranslated_count }}
|
||||
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="status && status.last_run && ['error', 'timeout'].includes(status.last_run.status)"
|
||||
class="text-caption text-error mt-1 mb-0"
|
||||
>
|
||||
Last translation run ended with “{{ status.last_run.status }}”. It resumes on
|
||||
the next sweep; check the Interpreter connection if it persists.
|
||||
</p>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Use <strong>Re-translate all</strong> after switching the Interpreter model —
|
||||
it clears every stored translation and re-runs it through the new model
|
||||
(unchanged text is served from cache, so it's cheap). To refresh just one
|
||||
artist, use the Re-translate button on that artist's page.
|
||||
</p>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<!-- Diagnostic probe (task #1376): paste text → what Interpreter DETECTS
|
||||
(language + confidence) and returns, without saving. Used to see why a
|
||||
short/abbreviation-heavy English title gets mis-detected, and to pick a
|
||||
detection-confidence / min-length guard from real numbers. -->
|
||||
<div class="mb-1">
|
||||
<span class="fc-section-h">Test translation</span>
|
||||
<p class="fc-muted text-caption mt-1 mb-2">
|
||||
Paste a title or snippet to see what Interpreter detects and returns —
|
||||
handy for checking why a short string is mis-detected. Nothing is saved.
|
||||
</p>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="probeText" label="Text to test" rows="2" auto-grow
|
||||
density="compact" hide-details class="mb-2" :disabled="!baseUrl"
|
||||
/>
|
||||
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
|
||||
<v-btn
|
||||
size="small" variant="tonal" rounded="pill" prepend-icon="mdi-magnify"
|
||||
:loading="probing" :disabled="!baseUrl || !probeText.trim()"
|
||||
@click="onProbe"
|
||||
>Test translation</v-btn>
|
||||
</div>
|
||||
<v-sheet
|
||||
v-if="probeResult" rounded class="pa-3 mb-1 text-body-2"
|
||||
color="rgba(var(--v-theme-accent), 0.06)"
|
||||
>
|
||||
<div class="d-flex flex-wrap" style="gap: 4px 16px;">
|
||||
<span>
|
||||
<span class="fc-muted">Detected:</span>
|
||||
<strong>{{ probeResult.detected_lang || '—' }}</strong>
|
||||
</span>
|
||||
<span v-if="probeResult.detected_confidence != null">
|
||||
<span class="fc-muted">Confidence:</span>
|
||||
<strong>{{ fmtConf(probeResult.detected_confidence) }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<span class="fc-muted">Engine:</span>
|
||||
{{ probeResult.engine || '—' }}<template
|
||||
v-if="probeResult.engine_version"
|
||||
> ({{ probeResult.engine_version }})</template>
|
||||
</span>
|
||||
<span><span class="fc-muted">Target:</span> {{ probeResult.target }}</span>
|
||||
</div>
|
||||
<v-divider class="my-2" />
|
||||
<div class="fc-muted mb-1">
|
||||
Result<template v-if="probeResult.detected_lang === probeResult.target">
|
||||
— already {{ probeResult.target }}, so the sweep leaves it as-is</template>:
|
||||
</div>
|
||||
<p class="mb-0" style="white-space: pre-wrap;">{{
|
||||
probeResult.translated || '(no change)'
|
||||
}}</p>
|
||||
</v-sheet>
|
||||
|
||||
<v-alert
|
||||
v-if="err" type="error" variant="tonal" density="compact"
|
||||
class="mt-3" closable @click:close="err = null"
|
||||
>{{ err }}</v-alert>
|
||||
|
||||
<!-- m146: re-translate-everything is a bigger hammer than 'Translate now'
|
||||
(it clears + rebuilds all translations), so it asks first. -->
|
||||
<v-dialog v-model="confirmAll" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title>Re-translate everything?</v-card-title>
|
||||
<v-card-text class="text-body-2">
|
||||
This clears the stored translation for <strong>every</strong> post and
|
||||
re-runs them through your current Interpreter model. Text that hasn't
|
||||
changed comes back from the cache instantly; anything the new model
|
||||
translates differently is refreshed. It runs in the background until
|
||||
done.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmAll = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="accent" :loading="retranslating" @click="onRetranslateAll"
|
||||
>Re-translate all</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
@@ -73,13 +190,27 @@ const store = useImportStore()
|
||||
const enabled = ref(false)
|
||||
const baseUrl = ref('')
|
||||
const targetLang = ref('en')
|
||||
const minConfidence = ref(0.9)
|
||||
const busy = ref(false)
|
||||
const running = ref(false)
|
||||
const retranslating = ref(false)
|
||||
const confirmAll = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null) // null = not tested this session; true/false = last result
|
||||
const status = ref(null)
|
||||
const err = ref(null)
|
||||
|
||||
// #1376 "Test translation" probe: paste text → detected lang/confidence + result.
|
||||
const probeText = ref('')
|
||||
const probing = ref(false)
|
||||
const probeResult = ref(null)
|
||||
|
||||
// Confidence scale is Interpreter-defined (0–100 or 0–1) — show it as-is, just
|
||||
// trimmed to 2 decimals, so the operator reads the true number.
|
||||
function fmtConf (c) {
|
||||
return c == null ? '' : Math.round(c * 100) / 100
|
||||
}
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!enabled.value || !baseUrl.value) return 'grey'
|
||||
return status.value?.healthy ? 'success' : 'error'
|
||||
@@ -91,10 +222,16 @@ const statusText = computed(() => {
|
||||
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
|
||||
})
|
||||
|
||||
let pollTimer = null
|
||||
async function loadStatus() {
|
||||
try { status.value = await api.get('/api/settings/translation/status') }
|
||||
catch { status.value = null }
|
||||
// Poll live while a sweep is running so the remaining count ticks down; stop
|
||||
// when idle (a fresh trigger restarts it via its own setTimeout(loadStatus)).
|
||||
clearTimeout(pollTimer)
|
||||
if (status.value?.active) pollTimer = setTimeout(loadStatus, 3000)
|
||||
}
|
||||
onUnmounted(() => clearTimeout(pollTimer))
|
||||
|
||||
async function onTest() {
|
||||
// Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new
|
||||
@@ -115,6 +252,21 @@ async function onTest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onProbe () {
|
||||
probing.value = true
|
||||
err.value = null
|
||||
probeResult.value = null
|
||||
try {
|
||||
probeResult.value = await api.post('/api/settings/translation/probe', {
|
||||
body: { text: probeText.value.trim() },
|
||||
})
|
||||
} catch (e) {
|
||||
err.value = e.message
|
||||
} finally {
|
||||
probing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
@@ -122,6 +274,7 @@ onMounted(async () => {
|
||||
enabled.value = !!s.translation_enabled
|
||||
baseUrl.value = s.interpreter_base_url || ''
|
||||
targetLang.value = s.translation_target_lang || 'en'
|
||||
minConfidence.value = s.translation_min_confidence ?? 0.9
|
||||
} catch { /* non-fatal */ }
|
||||
await loadStatus()
|
||||
})
|
||||
@@ -146,6 +299,14 @@ async function onSave() {
|
||||
})
|
||||
await loadStatus()
|
||||
}
|
||||
async function onSaveConfidence() {
|
||||
// Clamp to [0, 1] so a stray value never bounces off the API 400.
|
||||
let v = Number(minConfidence.value)
|
||||
if (!Number.isFinite(v)) v = 0.9
|
||||
v = Math.min(1, Math.max(0, v))
|
||||
minConfidence.value = v
|
||||
await save({ translation_min_confidence: v })
|
||||
}
|
||||
async function onRun() {
|
||||
running.value = true
|
||||
err.value = null
|
||||
@@ -159,4 +320,18 @@ async function onRun() {
|
||||
setTimeout(loadStatus, 1500) // let the sweep make a dent, then refresh
|
||||
}
|
||||
}
|
||||
async function onRetranslateAll() {
|
||||
retranslating.value = true
|
||||
err.value = null
|
||||
try {
|
||||
await api.post('/api/settings/translation/retranslate', { body: { all: true } })
|
||||
confirmAll.value = false
|
||||
toast({ text: 'Re-translating all posts…', type: 'success' })
|
||||
} catch (e) {
|
||||
err.value = e.message
|
||||
} finally {
|
||||
retranslating.value = false
|
||||
setTimeout(loadStatus, 1500) // reset spikes the untranslated count — refresh it
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+18
-16
@@ -12,17 +12,17 @@
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||
density="comfortable" hide-details @change="save"
|
||||
<SettingNumberField
|
||||
v-model="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" :min="0.5" :step="0.5"
|
||||
density="comfortable" max-width="none" @change="onSave"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_max_frames"
|
||||
label="Max frames" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
<SettingNumberField
|
||||
v-model="local.video_max_frames"
|
||||
label="Max frames" :min="1" :step="1"
|
||||
density="comfortable" max-width="none" @change="onSave"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -32,21 +32,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
|
||||
const store = useMLStore()
|
||||
const { save } = useSettingSave(store.patchSettings)
|
||||
const local = reactive({})
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
async function save() {
|
||||
const patch = {
|
||||
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
||||
video_max_frames: local.video_max_frames
|
||||
}
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||
// SettingNumberField clamps interval to ≥0.5 and max-frames to ≥1 before this
|
||||
// fires, so an out-of-range value never reaches the API.
|
||||
function onSave() {
|
||||
save({
|
||||
video_frame_interval_seconds: Number(local.video_frame_interval_seconds),
|
||||
video_max_frames: Number(local.video_max_frames),
|
||||
})
|
||||
}
|
||||
</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
|
||||
{ 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
|
||||
// (left) while tagging the focused image (center viewer + modal-parity tag
|
||||
// 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 —
|
||||
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
||||
// 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).
|
||||
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
|
||||
// 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).
|
||||
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
|
||||
// Series reader — immersive (no top nav, no meta.title).
|
||||
@@ -41,10 +41,10 @@ const routes = [
|
||||
|
||||
// FC-3: subscription backbone — purely management (sources/downloads),
|
||||
// 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).
|
||||
{ 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
|
||||
// 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 loading = ref(false)
|
||||
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()
|
||||
|
||||
@@ -44,7 +48,15 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
// empty and let the view explain why (anchor.has_embedding === false).
|
||||
if (detail.has_embedding) {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
params: { similar_to: numId, limit: NEIGHBOR_LIMIT },
|
||||
// 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.
|
||||
// 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
|
||||
neighbors.value = body.images || []
|
||||
@@ -111,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
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 ---------------------------------------------
|
||||
// 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
|
||||
@@ -187,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
function close () {}
|
||||
|
||||
return {
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
||||
anchorOn, reset, backTarget, forwardTarget,
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
|
||||
anchorOn, reset, backTarget, forwardTarget, setReach,
|
||||
// host surface
|
||||
current, currentImageId,
|
||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const useImportStore = defineStore('import', () => {
|
||||
const settings = ref(null)
|
||||
const settingsLoading = ref(false)
|
||||
const settingsError = ref(null)
|
||||
const wipScanBusy = ref(false)
|
||||
|
||||
async function loadSettings() {
|
||||
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 {
|
||||
settings, settingsLoading, settingsError,
|
||||
loadSettings, patchSettings,
|
||||
settings, settingsLoading, settingsError, wipScanBusy,
|
||||
loadSettings, patchSettings, scanWipTitles,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,6 +71,24 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return await api.get(`/api/posts/${id}`)
|
||||
}
|
||||
|
||||
// Set a post's sticky translation override (auto/force/original, #155) and
|
||||
// patch the loaded item in place with the endpoint's result (it translates /
|
||||
// clears immediately when the service is up), so the card reflects the change
|
||||
// without a feed reload.
|
||||
async function applyTranslationOverride(postId, override) {
|
||||
const res = await api.post(`/api/posts/${postId}/translation-override`, {
|
||||
body: { override },
|
||||
})
|
||||
const item = items.value.find((p) => p.id === postId)
|
||||
if (item) {
|
||||
item.translation_override = res.translation_override
|
||||
item.post_title_translated = res.post_title_translated
|
||||
item.description_translated = res.description_translated
|
||||
item.translated_source_lang = res.translated_source_lang
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Filter overlay for the around/older/newer (in-context anchored)
|
||||
// path. Keep this distinct from `filters.value` (the down-only feed)
|
||||
// so a normal-feed filter change doesn't leak into an active anchored
|
||||
@@ -168,7 +186,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return {
|
||||
items, cursor, loading, done, error, filters,
|
||||
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||
loadInitial, loadMore, getPostFull,
|
||||
loadInitial, loadMore, getPostFull, applyTranslationOverride,
|
||||
loadAround, loadOlder, loadNewer,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { toast } from '../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
@@ -18,182 +18,112 @@ export const CATEGORY_LABELS = {
|
||||
|
||||
export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
const api = useApi()
|
||||
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
||||
// The typed tag-input dropdown searches the model's FULL prediction set for
|
||||
// the image (min=0, down to the store floor) so low-confidence actions/
|
||||
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
||||
const allByCategory = ref({})
|
||||
// ONE source of truth per image: every trained head scored for this image
|
||||
// (min=0), each row carrying above_threshold. The Suggestions PANEL renders
|
||||
// aboveByCategory; the typed tag-input dropdown reads the full set and
|
||||
// annotates matching DB-tag rows with their score. A single fetch backs both —
|
||||
// every suggestion is a canonical tag now (tagging-v2, #114), so there is no
|
||||
// raw-prediction / create-new / alias-remap path to reconcile.
|
||||
const byCategory = ref({})
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
let currentImageId = null
|
||||
const inflightAll = useInflightToken()
|
||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
||||
// /suggestions response from a prior image could overwrite
|
||||
// byCategory while currentImageId pointed at a new one, and
|
||||
// accept() dereferenced currentImageId AFTER an awaited POST so
|
||||
// the subsequent /suggestions/accept could apply A's chosen tag
|
||||
// to image B (and push it to the allowlist). Both fixed below
|
||||
// by capturing imageId at call-time and gating writes on the token.
|
||||
// Audit 2026-06-02: without an inflight guard a late /suggestions response from
|
||||
// a prior image could overwrite byCategory while currentImageId points at a new
|
||||
// one, and accept() could apply image A's tag to image B. Both guarded below by
|
||||
// capturing imageId at call-time and gating writes on the token.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
// The curated above-threshold subset the Suggestions panel shows. A rejected
|
||||
// row that still scores above its cut stays (flagged) so the rejection is
|
||||
// visible + reversible; below-threshold rows live only in the dropdown.
|
||||
const aboveByCategory = computed(() => {
|
||||
const out = {}
|
||||
for (const [cat, list] of Object.entries(byCategory.value)) {
|
||||
const kept = (list || []).filter(s => s.above_threshold)
|
||||
if (kept.length) out[cat] = kept
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function load(imageId) {
|
||||
// Cancel any in-flight load from the previous image so its late
|
||||
// response can't overwrite this image's byCategory.
|
||||
// Cancel any in-flight load from the previous image so its late response
|
||||
// can't overwrite this image's list.
|
||||
inflight.cancel()
|
||||
currentImageId = imageId
|
||||
byCategory.value = {} // cleared upfront so it stays empty on error
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
||||
// min=0 → every head, each flagged above_threshold; the panel + the typed
|
||||
// dropdown are both derived from this one payload.
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
||||
params: { min: 0 },
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
byCategory.value = body.by_category || {}
|
||||
})
|
||||
}
|
||||
|
||||
// Load the full prediction set for the dropdown (separate inflight guard so a
|
||||
// late response from a prior image can't overwrite the current one's list).
|
||||
async function loadAll(imageId) {
|
||||
inflightAll.cancel()
|
||||
allByCategory.value = {}
|
||||
const t = inflightAll.claim()
|
||||
try {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
||||
params: { min: 0 },
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
allByCategory.value = body.by_category || {}
|
||||
} catch {
|
||||
// Dropdown is best-effort — the panel surfaces load errors.
|
||||
}
|
||||
}
|
||||
|
||||
// A stable identity for a suggestion across the two lists (panel vs dropdown
|
||||
// are separate fetches, so object identity differs): tag id when known, else
|
||||
// the raw display key.
|
||||
// A stable identity for a suggestion across the panel + dropdown surfaces:
|
||||
// its canonical tag id (every suggestion has one now).
|
||||
function _keyOf(s) {
|
||||
return s.canonical_tag_id != null
|
||||
? `id:${s.canonical_tag_id}`
|
||||
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
||||
return `id:${s.canonical_tag_id}`
|
||||
}
|
||||
|
||||
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
||||
// dropdown's full list so it can't reappear in either surface.
|
||||
// Drop an accepted suggestion from the list so it can't reappear.
|
||||
function _dropEverywhere(suggestion) {
|
||||
const key = _keyOf(suggestion)
|
||||
const cat = suggestion.category
|
||||
for (const map of [byCategory.value, allByCategory.value]) {
|
||||
const list = map[cat]
|
||||
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
||||
}
|
||||
const list = byCategory.value[suggestion.category]
|
||||
if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
|
||||
}
|
||||
|
||||
// Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
|
||||
// so a reject/un-reject shows immediately without dropping the row (visible,
|
||||
// Flip the `rejected` flag on the matching suggestion in place, so a
|
||||
// reject/un-reject shows immediately without dropping the row (visible,
|
||||
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
|
||||
function _setRejectedEverywhere(suggestion, value) {
|
||||
const key = _keyOf(suggestion)
|
||||
const cat = suggestion.category
|
||||
for (const map of [byCategory.value, allByCategory.value]) {
|
||||
for (const s of map[cat] || []) {
|
||||
if (_keyOf(s) === key) s.rejected = value
|
||||
}
|
||||
for (const s of byCategory.value[suggestion.category] || []) {
|
||||
if (_keyOf(s) === key) s.rejected = value
|
||||
}
|
||||
}
|
||||
|
||||
// opts.tagId: the tag's known id, when the caller already created/resolved
|
||||
// it (TagPanel's manual-add-matches-suggestion path). Passed separately —
|
||||
// NOT spread onto the suggestion — because _dropEverywhere keys off the
|
||||
// suggestion object, and mutating canonical_tag_id on a raw suggestion
|
||||
// would stop it matching its own row in the lists.
|
||||
// opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
|
||||
// manual-add-matches-suggestion path). Passed separately — NOT spread onto the
|
||||
// suggestion — because _dropEverywhere keys off the suggestion object.
|
||||
async function accept(suggestion, { tagId: knownTagId = null } = {}) {
|
||||
// Capture imageId so a mid-flight prev/next can't reroute the
|
||||
// accept POST to a different image AND push the tag to that
|
||||
// image's allowlist.
|
||||
// Capture imageId so a mid-flight prev/next can't reroute the accept POST to
|
||||
// a different image.
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
||||
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
||||
// first via the existing /api/tags endpoint, then accept by id.
|
||||
let tagId = knownTagId ?? suggestion.canonical_tag_id
|
||||
if (tagId == null) {
|
||||
const created = await api.post('/api/tags', {
|
||||
body: { name: suggestion.display_name, kind: suggestion.category }
|
||||
})
|
||||
tagId = created.id
|
||||
}
|
||||
const tagId = knownTagId ?? suggestion.canonical_tag_id
|
||||
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||
body: { tag_id: tagId }
|
||||
})
|
||||
// Only drop from THIS image's category list — if the user navigated,
|
||||
// the new image has its own suggestions and this drop would corrupt them.
|
||||
// Only drop from THIS image's list — if the user navigated, the new image
|
||||
// has its own suggestions and this drop would corrupt them.
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
_acceptToast('Tagged', suggestion.display_name)
|
||||
}
|
||||
|
||||
// One non-blocking toast for accept/alias. The accepted tag is applied to this
|
||||
// image and feeds head training; head auto-apply handles propagation (earned),
|
||||
// so there's no instant fan-out to project.
|
||||
// One non-blocking toast for accept. The accepted tag is applied to this image
|
||||
// and feeds head training; head auto-apply handles propagation (earned).
|
||||
function _acceptToast(verb, displayName) {
|
||||
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
||||
}
|
||||
|
||||
async function aliasAccept(suggestion, canonicalTagId) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// The alias MUST be stored under the raw model key — resolution looks up the
|
||||
// raw prediction key, not the normalized display name. Sending display_name
|
||||
// (the old bug) stored an alias that never resolved, so the prediction kept
|
||||
// reappearing unaliased. raw_name is null only for centroid hits, which
|
||||
// can't be aliased (the UI hides the action for them).
|
||||
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
||||
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
||||
body: {
|
||||
alias_string: aliasString,
|
||||
alias_category: suggestion.category,
|
||||
canonical_tag_id: canonicalTagId
|
||||
}
|
||||
})
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
_acceptToast('Aliased & tagged', suggestion.display_name)
|
||||
}
|
||||
|
||||
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
||||
// its unaliased form on reload). The canonical tag stays applied if it was
|
||||
// accepted — this only undoes the model-key→tag mapping.
|
||||
async function removeAlias(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null || suggestion.raw_name == null) return
|
||||
await api.delete(
|
||||
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
|
||||
)
|
||||
if (currentImageId === imageId) {
|
||||
await load(imageId)
|
||||
await loadAll(imageId)
|
||||
}
|
||||
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
||||
}
|
||||
|
||||
// Find a live (non-rejected) pending suggestion matching a manually-picked
|
||||
// tag — by canonical id when known, else by (kind, name). Manually adding a
|
||||
// tag the model also suggested must be indistinguishable from accepting the
|
||||
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
|
||||
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
|
||||
// set first, then the panel list (loadAll is best-effort and can be empty).
|
||||
// Find a live (non-rejected) suggestion matching a manually-picked tag — by
|
||||
// canonical id when known, else by (kind, name). Manually adding a tag the
|
||||
// model also suggested must be indistinguishable from accepting the suggestion
|
||||
// (recorded + dropped from the panel), so TagPanel routes matches through
|
||||
// accept() (operator-asked 2026-07-03).
|
||||
function findPending(kind, name, tagId = null) {
|
||||
const lname = (name || '').toLowerCase()
|
||||
for (const map of [allByCategory.value, byCategory.value]) {
|
||||
for (const list of Object.values(map)) {
|
||||
for (const s of list || []) {
|
||||
if (s.rejected) continue
|
||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||
if (
|
||||
s.category === kind &&
|
||||
(s.display_name || '').toLowerCase() === lname
|
||||
) return s
|
||||
}
|
||||
for (const list of Object.values(byCategory.value)) {
|
||||
for (const s of list || []) {
|
||||
if (s.rejected) continue
|
||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||
if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
async function dismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
|
||||
// nothing to persist a rejection against — drop them client-side as before.
|
||||
// Canonical tags persist a rejection and STAY in the list flagged rejected,
|
||||
// so the operator can see it and one-click un-reject (misclick recovery).
|
||||
if (suggestion.canonical_tag_id == null) {
|
||||
if (currentImageId === imageId) _dropEverywhere(suggestion)
|
||||
return
|
||||
}
|
||||
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
@@ -218,33 +142,31 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Reject every still-unhandled suggestion in a category in one go ("confirm
|
||||
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
|
||||
// persist a rejection and STAY flagged rejected (reversible, one-click
|
||||
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
|
||||
// so a big section clears fast.
|
||||
// Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
|
||||
// category in one go ("confirm the good ones, reject the rest" — operator-asked
|
||||
// 2026-07-06). Only touches what the panel shows, not the low-confidence tail
|
||||
// the dropdown carries. Dispatched in parallel so a big section clears fast.
|
||||
async function dismissRemaining(category) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
|
||||
const targets = (byCategory.value[category] || []).filter(
|
||||
(s) => s.above_threshold && !s.rejected
|
||||
)
|
||||
if (!targets.length) return
|
||||
const canon = targets.filter((s) => s.canonical_tag_id != null)
|
||||
const raw = targets.filter((s) => s.canonical_tag_id == null)
|
||||
await Promise.all(canon.map((s) =>
|
||||
await Promise.all(targets.map((s) =>
|
||||
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: s.canonical_tag_id },
|
||||
})
|
||||
))
|
||||
if (currentImageId === imageId) {
|
||||
canon.forEach((s) => _setRejectedEverywhere(s, true))
|
||||
raw.forEach((s) => _dropEverywhere(s))
|
||||
targets.forEach((s) => _setRejectedEverywhere(s, true))
|
||||
}
|
||||
}
|
||||
|
||||
// Undo a per-image dismissal — the suggestion reverts to a live row.
|
||||
async function undismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null || suggestion.canonical_tag_id == null) return
|
||||
if (imageId == null) return
|
||||
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
@@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
byCategory, allByCategory, loading, error,
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
|
||||
undismiss, findPending
|
||||
byCategory, aboveByCategory, loading, error,
|
||||
load, accept, dismiss, dismissRemaining, undismiss, findPending
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,3 +39,91 @@
|
||||
deliberately not used here. `.fc-muted` is a custom class Vuetify never
|
||||
emits, so no specificity/reorder fight — no !important needed. */
|
||||
.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.
|
||||
Background uses the theme surface token so content scrolls cleanly
|
||||
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">
|
||||
<!-- Tabs and search share one row: the axis switcher on the left, the
|
||||
search field + active-scope chips on the right (operator-asked
|
||||
@@ -154,9 +154,10 @@ function clearFilter(key) {
|
||||
<style scoped>
|
||||
.fc-browse__head {
|
||||
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;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<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
|
||||
into the heads without a trip to Settings (the nightly beat is the
|
||||
passive cadence). -->
|
||||
@@ -153,6 +167,12 @@ const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || null)
|
||||
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
|
||||
// the anchor image (same provide/inject as the modal viewer).
|
||||
@@ -264,12 +284,14 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -296,6 +318,14 @@ onUnmounted(() => {
|
||||
.fc-ex__trail-actions {
|
||||
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.
|
||||
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
|
||||
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. -->
|
||||
<div class="fc-series__head">
|
||||
<div class="fc-series__head fc-chrome-continues">
|
||||
<v-tabs v-model="tab" density="compact">
|
||||
<v-tab value="browse">Browse</v-tab>
|
||||
<v-tab value="suggestions">
|
||||
@@ -294,12 +294,13 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 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 {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.fc-series-browse__controls {
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||
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
|
||||
tab strip lives directly under it. Background uses the theme surface
|
||||
token so it visually merges with the page rather than the
|
||||
translucent v-tabs default. -->
|
||||
tab strip lives directly under it. The .fc-chrome-continues fade
|
||||
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
||||
<v-tabs
|
||||
v-model="tab" color="accent" class="mb-4"
|
||||
style="position: sticky; top: 64px; z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));"
|
||||
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
||||
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
|
||||
>
|
||||
<v-tab value="overview">Overview</v-tab>
|
||||
<v-tab value="activity">Activity</v-tab>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-tabs="start"
|
||||
color="accent"
|
||||
density="compact"
|
||||
class="fc-subs-tabs"
|
||||
class="fc-subs-tabs fc-chrome-continues"
|
||||
>
|
||||
<v-tab value="subscriptions">
|
||||
<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
|
||||
put while ONLY the tab content scrolls — previously the whole view
|
||||
scrolled instead of just the subscription list (operator-flagged
|
||||
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
|
||||
height: calc(100vh - 64px);
|
||||
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-subs-tabs {
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -349,7 +349,6 @@ async function onDeleteTagConfirm() {
|
||||
.fc-tags__sentinel {
|
||||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||||
}
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-merge-preview {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
|
||||
@@ -16,25 +16,44 @@ function stubFetch(handler) {
|
||||
})
|
||||
}
|
||||
|
||||
// Every suggestion is a canonical DB tag now (tagging-v2): a real id, flagged
|
||||
// above/below its head's suggest threshold. No raw / creates-new / alias cases.
|
||||
const sugg = (over = {}) => ({
|
||||
canonical_tag_id: 7,
|
||||
display_name: 'Ichigo',
|
||||
category: 'character',
|
||||
score: 0.9,
|
||||
source: 'head',
|
||||
creates_new_tag: false,
|
||||
above_threshold: true,
|
||||
rejected: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// Seed the store through its own load/loadAll so currentImageId is set the
|
||||
// way the panel sets it (accept() derives the image from it).
|
||||
// Seed the store through its single load() so currentImageId is set the way the
|
||||
// panel sets it (accept() derives the image from it).
|
||||
async function seed(store, byCategory) {
|
||||
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
|
||||
await store.load(1)
|
||||
await store.loadAll(1)
|
||||
}
|
||||
|
||||
describe('suggestions store: load derives aboveByCategory', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, {
|
||||
general: [
|
||||
sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }),
|
||||
sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }),
|
||||
],
|
||||
})
|
||||
expect(s.byCategory.general).toHaveLength(2) // dropdown sees all
|
||||
expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only
|
||||
expect(s.aboveByCategory.general[0].display_name).toBe('sword')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -45,15 +64,12 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
|
||||
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
|
||||
})
|
||||
|
||||
it('matches raw suggestions by kind + name, case-insensitively', async () => {
|
||||
it('matches by kind + name, case-insensitively', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, {
|
||||
general: [sugg({
|
||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
||||
category: 'general', creates_new_tag: true,
|
||||
})],
|
||||
general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })],
|
||||
})
|
||||
expect(s.findPending('general', 'holding sword')).toBeTruthy()
|
||||
expect(s.findPending('general', 'holding sword')?.canonical_tag_id).toBe(12)
|
||||
// Kind must match: same name under another kind is a different tag.
|
||||
expect(s.findPending('character', 'holding sword')).toBeNull()
|
||||
})
|
||||
@@ -66,37 +82,31 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions store: accept with a known tag id', () => {
|
||||
describe('suggestions store: accept', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('POSTs only the accept endpoint (no tag create) and drops the row', async () => {
|
||||
it('POSTs only the accept endpoint (canonical id) and drops the row', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, { character: [sugg()] })
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, method: init?.method })
|
||||
calls.push({ url, method: init?.method, body: init?.body })
|
||||
return { status: 200, body: { accepted: true, tag_id: 7 } }
|
||||
})
|
||||
await s.accept(s.byCategory.character[0], { tagId: 7 })
|
||||
await s.accept(s.byCategory.character[0])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 })
|
||||
expect(s.byCategory.character).toHaveLength(0)
|
||||
expect(s.allByCategory.character).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => {
|
||||
// TagPanel's manual-create path: the tag was just created by
|
||||
// host.createAndAdd, so accept must not create again — and the drop must
|
||||
// key off the original raw suggestion object, not the resolved id
|
||||
// (regression: spreading canonical_tag_id onto the suggestion changed its
|
||||
// identity key and left the panel row behind).
|
||||
it('honors a known tagId and still drops the row by the suggestion identity', async () => {
|
||||
// TagPanel's manual-add-matches-suggestion path passes the resolved tag id;
|
||||
// accept sends exactly it and drops the original suggestion row (which keys
|
||||
// off the suggestion object, not the passed id).
|
||||
const s = useSuggestionsStore()
|
||||
const raw = sugg({
|
||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
||||
category: 'general', creates_new_tag: true,
|
||||
})
|
||||
await seed(s, { general: [raw] })
|
||||
await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] })
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, body: init?.body })
|
||||
@@ -107,6 +117,5 @@ describe('suggestions store: accept with a known tag id', () => {
|
||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
|
||||
expect(s.byCategory.general).toHaveLength(0)
|
||||
expect(s.allByCategory.general).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Bundle the interlocking frontend build/test toolchain into ONE PR. vite, vitest, @vitejs/plugin-vue, happy-dom and @vue/test-utils are version-coupled — a lone major bump red-fails the build/tests until its peers land, so they must move together.",
|
||||
"matchPackageNames": [
|
||||
"vite",
|
||||
"vitest",
|
||||
"@vitejs/plugin-vue",
|
||||
"happy-dom",
|
||||
"@vue/test-utils"
|
||||
],
|
||||
"groupName": "frontend build/test toolchain"
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-5
@@ -7,14 +7,14 @@ sqlalchemy[asyncio]>=2.0,<2.1
|
||||
asyncpg>=0.31,<0.32
|
||||
psycopg[binary]>=3.3,<3.4
|
||||
alembic>=1.18,<1.19
|
||||
pgvector>=0.4,<0.5
|
||||
pgvector>=0.5,<0.6
|
||||
|
||||
# Task queue
|
||||
celery>=5.6,<5.7
|
||||
redis>=7.4,<8.0
|
||||
|
||||
# Crypto for credential storage (lands in FC-3, but pinned now for stability)
|
||||
cryptography>=48,<49
|
||||
cryptography>=49,<50
|
||||
|
||||
# Image handling (lands in FC-2)
|
||||
pillow>=12,<13
|
||||
@@ -30,10 +30,10 @@ yt-dlp>=2025.1
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.2,<2.0
|
||||
structlog>=25.5,<26.0
|
||||
structlog>=26.1,<26.2
|
||||
|
||||
# HTML sanitization for scraped post descriptions (FC-2d provenance)
|
||||
nh3>=0.2,<0.3
|
||||
nh3>=0.3,<0.4
|
||||
|
||||
# Archive extraction (FC-2d-iii filesystem-import aid)
|
||||
rarfile>=4.2,<5
|
||||
@@ -42,4 +42,4 @@ py7zr>=1,<2
|
||||
# Google Drive fetcher for off-platform file-host links (external downloads,
|
||||
# #830). Handles Drive's confirm-token + virus-scan interstitial. mega.nz uses
|
||||
# the `megatools` binary instead (Debian apt pkg in the runtime image, not pip).
|
||||
gdown>=5,<6
|
||||
gdown>=6,<7
|
||||
|
||||
@@ -184,6 +184,68 @@ async def test_rejects_bad_direction(client):
|
||||
assert (await resp.get_json())["error"] == "invalid_direction"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_rejects_bad_value(client, seeded_post):
|
||||
_, _, post = seeded_post
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "nope"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert (await resp.get_json())["error"] == "invalid_override"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_404_for_unknown(client):
|
||||
resp = await client.post(
|
||||
"/api/posts/999999/translation-override", json={"override": "original"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_original_clears_and_keeps(client, db, seeded_post):
|
||||
# 'keep original' clears a stored translation immediately (no Interpreter) and
|
||||
# marks the post handled (== target). Asserts on the response, which reflects
|
||||
# the endpoint's own committed session.
|
||||
_, _, post = seeded_post
|
||||
post.post_title_translated = "STALE"
|
||||
post.description_translated = "STALE"
|
||||
post.translated_source_lang = "de"
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "original"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["translation_override"] == "original"
|
||||
assert body["applied"] == "cleared"
|
||||
assert body["post_title_translated"] is None
|
||||
assert body["translated_source_lang"] == "en"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_force_queues_when_disabled(client, seeded_post):
|
||||
# Translation disabled (default) → can't translate inline; the override is
|
||||
# saved and the post is queued (columns NULLed) for the next sweep.
|
||||
_, _, post = seeded_post
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "force"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["translation_override"] == "force"
|
||||
assert body["applied"] == "queued"
|
||||
assert body["translated_source_lang"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_exposes_translation_override(client, seeded_post):
|
||||
resp = await client.get("/api/posts")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["items"][0]["translation_override"] == "auto" # default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_returns_uncapped_thumbnails(client, db):
|
||||
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
||||
|
||||
+152
-1
@@ -53,6 +53,26 @@ async def test_translation_settings_defaults_and_patch(client):
|
||||
"/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_min_confidence_default_and_patch(client):
|
||||
# #155: the latin-script acceptance floor is an operator-tunable setting,
|
||||
# default 0.9 (stricter than the old hardcoded 0.8).
|
||||
body = await (await client.get("/api/settings/import")).get_json()
|
||||
assert body["translation_min_confidence"] == 0.9
|
||||
|
||||
ok = await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": 0.95}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert (await ok.get_json())["translation_min_confidence"] == 0.95
|
||||
|
||||
# Out-of-range + wrong-type are rejected.
|
||||
assert (await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": 1.5})).status_code == 400
|
||||
assert (await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": "high"})).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_status_defaults(client):
|
||||
# Off + no URL → no network health call, healthy False (#143).
|
||||
@@ -61,6 +81,34 @@ async def test_translation_status_defaults(client):
|
||||
assert body["base_url_set"] is False
|
||||
assert body["healthy"] is False
|
||||
assert "untranslated_count" in body
|
||||
assert body["active"] is False # no sweep running
|
||||
assert body["last_run"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_status_reports_active_and_last_run(client, db):
|
||||
# A running translate/retranslate TaskRun → active True; the most recent
|
||||
# finished one → last_run (task basename + status).
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.app.models import TaskRun
|
||||
|
||||
db.add(TaskRun(
|
||||
celery_task_id="r-run", queue="maintenance_long",
|
||||
task_name="backend.app.tasks.translation.retranslate_posts",
|
||||
started_at=datetime.now(UTC), status="running",
|
||||
))
|
||||
db.add(TaskRun(
|
||||
celery_task_id="r-done", queue="maintenance_long",
|
||||
task_name="backend.app.tasks.translation.translate_posts",
|
||||
started_at=datetime.now(UTC), finished_at=datetime.now(UTC),
|
||||
status="success",
|
||||
))
|
||||
await db.commit()
|
||||
body = await (await client.get("/api/settings/translation/status")).get_json()
|
||||
assert body["active"] is True
|
||||
assert body["last_run"]["task"] == "translate_posts"
|
||||
assert body["last_run"]["status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -85,9 +133,10 @@ async def test_translation_test_connection(client, monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
sink = {}
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.translate_posts.delay",
|
||||
lambda *a, **k: type("R", (), {"id": "t1"})(),
|
||||
lambda *a, **k: sink.update(k) or type("R", (), {"id": "t1"})(),
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
@@ -95,6 +144,108 @@ async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
resp = await client.post("/api/settings/translation/run")
|
||||
assert resp.status_code == 202
|
||||
assert (await resp.get_json())["celery_task_id"] == "t1"
|
||||
assert sink["drain"] is True # "Translate now" chases the whole backlog
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_text(client):
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_base_url(client):
|
||||
# Text given but no Interpreter URL saved → nothing to probe against.
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "hello"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_returns_detection(client, monkeypatch):
|
||||
# The diagnostic surfaces detected language + confidence + result, unsaved.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.settings.ic.translate",
|
||||
lambda texts, **k: {
|
||||
"translations": ["Work in Progress"],
|
||||
"detected_lang": "de", "detected_confidence": 71.5,
|
||||
"engine": "llm", "engine_version": "v1",
|
||||
},
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "WIP"})
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["detected_lang"] == "de"
|
||||
assert body["detected_confidence"] == 71.5
|
||||
assert body["translated"] == "Work in Progress"
|
||||
assert body["target"] == "en"
|
||||
|
||||
|
||||
def _fake_retranslate(monkeypatch, sink):
|
||||
# Record the kwargs the endpoint enqueues with, return a fake AsyncResult.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.retranslate_posts.delay",
|
||||
lambda *a, **k: sink.update(k) or type("R", (), {"id": "r1"})(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_requires_config(client):
|
||||
# Disabled → 400 even with an explicit scope (no reset is enqueued).
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"all": True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_requires_target(client, monkeypatch):
|
||||
# 'all' must be explicit: an empty body is a 400, so a stray call can't
|
||||
# wipe every translation.
|
||||
_fake_retranslate(monkeypatch, {})
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post("/api/settings/translation/retranslate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_artist_scope(client, monkeypatch):
|
||||
sink = {}
|
||||
_fake_retranslate(monkeypatch, sink)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"artist_id": 7})
|
||||
assert resp.status_code == 202
|
||||
assert (await resp.get_json())["celery_task_id"] == "r1"
|
||||
assert sink["artist_ids"] == [7]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_all_scope(client, monkeypatch):
|
||||
sink = {}
|
||||
_fake_retranslate(monkeypatch, sink)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"all": True})
|
||||
assert resp.status_code == 202
|
||||
assert sink["artist_ids"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_bad_artist_id(client):
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"artist_id": "abc"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -52,6 +52,7 @@ async def test_get_suggestions(client, db):
|
||||
general = body["by_category"].get("general", [])
|
||||
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
|
||||
assert s2["source"] == "head"
|
||||
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -114,12 +115,3 @@ async def test_undismiss_reverses_rejection(client, db):
|
||||
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
||||
)
|
||||
assert resp2.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_requires_fields(client, db):
|
||||
img = await _img(db)
|
||||
resp = await client.post(
|
||||
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -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")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user