Compare commits
24 Commits
cefc064e0f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a444cf82d1 | |||
| 6ab7fd5c7f | |||
| 7ddf94220d | |||
| ffdbdbaf07 | |||
| a017771621 | |||
| 25e555cab6 | |||
| 3c7ab44e74 | |||
| 06f98acf3e | |||
| 4371ddb7e7 | |||
| 40cc11be5b | |||
| 0b78264d62 | |||
| 9eae636047 | |||
| f8105046dc | |||
| d631ed023c | |||
| c64261593d | |||
| 1f6d94f51d | |||
| 6a255482ea | |||
| af5aa21e45 | |||
| 83c1745fd0 | |||
| ead60978e3 | |||
| 7a4de7278d | |||
| 7f8073c4c8 | |||
| a3bc98a53c | |||
| e7c3f4e9c9 |
@@ -0,0 +1,73 @@
|
|||||||
|
"""post-text translation via Interpreter (milestone 143) — Post columns + settings
|
||||||
|
|
||||||
|
Post gains the translated title/description + the detected source language,
|
||||||
|
Interpreter engine_version (cache key), and translated_at — filled by the
|
||||||
|
translate sweep. ImportSettings gains translation_enabled (OFF by default),
|
||||||
|
interpreter_base_url (EMPTY — the operator sets their own, behind a reverse
|
||||||
|
proxy), and translation_target_lang (en).
|
||||||
|
|
||||||
|
Revision ID: 0083
|
||||||
|
Revises: 0082
|
||||||
|
Create Date: 2026-07-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0083"
|
||||||
|
down_revision: Union[str, None] = "0082"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"post", sa.Column("post_title_translated", sa.Text(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post", sa.Column("description_translated", sa.Text(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translated_source_lang", sa.String(8), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translation_engine_version", sa.String(128), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"translation_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"interpreter_base_url", sa.Text(), nullable=False, server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"translation_target_lang", sa.Text(), nullable=False,
|
||||||
|
server_default="en",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_settings", "translation_target_lang")
|
||||||
|
op.drop_column("import_settings", "interpreter_base_url")
|
||||||
|
op.drop_column("import_settings", "translation_enabled")
|
||||||
|
op.drop_column("post", "translated_at")
|
||||||
|
op.drop_column("post", "translation_engine_version")
|
||||||
|
op.drop_column("post", "translated_source_lang")
|
||||||
|
op.drop_column("post", "description_translated")
|
||||||
|
op.drop_column("post", "post_title_translated")
|
||||||
@@ -145,6 +145,9 @@ async def similar():
|
|||||||
filters, _sort = _parse_filters()
|
filters, _sort = _parse_filters()
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return jsonify({"error": "similar_to query param required"}), 400
|
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")
|
||||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||||
@@ -154,7 +157,8 @@ async def similar():
|
|||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
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, **scope)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
if images is None:
|
if images is None:
|
||||||
|
|||||||
+185
-2
@@ -1,12 +1,24 @@
|
|||||||
"""Settings API: import filters, system stats."""
|
"""Settings API: import filters, system stats."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag
|
from ..models import (
|
||||||
|
AppSetting,
|
||||||
|
Artist,
|
||||||
|
ImageRecord,
|
||||||
|
ImportBatch,
|
||||||
|
ImportSettings,
|
||||||
|
ImportTask,
|
||||||
|
Post,
|
||||||
|
Tag,
|
||||||
|
TaskRun,
|
||||||
|
)
|
||||||
|
from ..services import interpreter_client as ic
|
||||||
|
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
|
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -32,6 +44,9 @@ _EDITABLE_FIELDS = (
|
|||||||
"extdl_mediafire_enabled",
|
"extdl_mediafire_enabled",
|
||||||
"extdl_dropbox_enabled",
|
"extdl_dropbox_enabled",
|
||||||
"extdl_pixeldrain_enabled",
|
"extdl_pixeldrain_enabled",
|
||||||
|
"translation_enabled",
|
||||||
|
"interpreter_base_url",
|
||||||
|
"translation_target_lang",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||||
@@ -69,6 +84,9 @@ async def get_import_settings():
|
|||||||
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
||||||
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
||||||
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +146,15 @@ async def update_import_settings():
|
|||||||
for tog in _EXTDL_TOGGLE_FIELDS:
|
for tog in _EXTDL_TOGGLE_FIELDS:
|
||||||
if tog in body and not isinstance(body[tog], bool):
|
if tog in body and not isinstance(body[tog], bool):
|
||||||
return jsonify({"error": f"{tog} must be a boolean"}), 400
|
return jsonify({"error": f"{tog} must be a boolean"}), 400
|
||||||
|
# Translation (#143): base URL may be empty (feature off until set — no
|
||||||
|
# default host; the operator points it at their own Interpreter proxy).
|
||||||
|
if "translation_enabled" in body and not isinstance(
|
||||||
|
body["translation_enabled"], bool
|
||||||
|
):
|
||||||
|
return jsonify({"error": "translation_enabled must be a boolean"}), 400
|
||||||
|
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
|
||||||
if "series_suggest_threshold" in body:
|
if "series_suggest_threshold" in body:
|
||||||
v = body["series_suggest_threshold"]
|
v = body["series_suggest_threshold"]
|
||||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||||
@@ -270,3 +297,159 @@ async def rotate_extension_api_key():
|
|||||||
row.value = new_value
|
row.value = new_value
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify({"key": new_value})
|
return jsonify({"key": new_value})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Translation (#143): live status + manual "Translate now" --------------
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/translation/status", methods=["GET"])
|
||||||
|
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(
|
||||||
|
select(func.count(Post.id))
|
||||||
|
.where(Post.translated_source_lang.is_(None))
|
||||||
|
.where(or_(
|
||||||
|
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({
|
||||||
|
"enabled": cfg.translation_enabled,
|
||||||
|
"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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/translation/test", methods=["POST"])
|
||||||
|
async def translation_test():
|
||||||
|
"""On-demand reachability check for a GIVEN Interpreter base URL (the Settings
|
||||||
|
'Test connection' button) — pings /v1/health without saving, so the operator
|
||||||
|
can verify a URL before enabling. Health runs in a thread (sync client)."""
|
||||||
|
body = await request.get_json()
|
||||||
|
base_url = ""
|
||||||
|
if isinstance(body, dict):
|
||||||
|
base_url = (body.get("base_url") or "").strip()
|
||||||
|
healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
|
||||||
|
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).
|
||||||
|
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():
|
||||||
|
return jsonify(
|
||||||
|
{"error": "translation is disabled or no base URL is set"}
|
||||||
|
), 400
|
||||||
|
from ..tasks.translation import translate_posts
|
||||||
|
|
||||||
|
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"])
|
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||||
async def get_suggestions(image_id: int):
|
async def get_suggestions(image_id: int):
|
||||||
# ?min=<float> overrides the configured per-category thresholds so the typed
|
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
|
||||||
# tag-input dropdown can surface EVERY stored prediction (min=0), including
|
# rail sends min=0 in its single per-image fetch to get EVERY head (each row
|
||||||
# low-confidence actions/features, in canonical formatting. Omitted → the
|
# still carries above_threshold vs its natural cut), then derives the panel
|
||||||
# curated above-threshold list the Suggestions panel uses.
|
# (above_threshold) and the typed dropdown (all, filtered by text) client-side
|
||||||
|
# — no second request. Omitted → only above-threshold rows.
|
||||||
override = None
|
override = None
|
||||||
raw_min = request.args.get("min")
|
raw_min = request.args.get("min")
|
||||||
if raw_min is not None:
|
if raw_min is not None:
|
||||||
@@ -36,12 +37,11 @@ async def get_suggestions(image_id: int):
|
|||||||
"category": s.category,
|
"category": s.category,
|
||||||
"score": round(s.score, 4),
|
"score": round(s.score, 4),
|
||||||
"source": s.source,
|
"source": s.source,
|
||||||
"creates_new_tag": s.creates_new_tag,
|
# whether the score cleared the head's own suggest cut.
|
||||||
# raw model key (alias is stored under this) + whether an
|
# The single min=0 fetch returns every head; the panel
|
||||||
# operator alias produced this suggestion — drive the
|
# shows above_threshold, the typed dropdown shows all and
|
||||||
# modal's "Treat as alias"/"Remove alias" affordances.
|
# annotates each match with its score.
|
||||||
"raw_name": s.raw_name,
|
"above_threshold": s.above_threshold,
|
||||||
"via_alias": s.via_alias,
|
|
||||||
# operator dismissed this tag for this image — surfaced
|
# operator dismissed this tag for this image — surfaced
|
||||||
# (not dropped) so the rail can show it rejected + offer
|
# (not dropped) so the rail can show it rejected + offer
|
||||||
# one-click un-reject.
|
# one-click un-reject.
|
||||||
@@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int):
|
|||||||
return jsonify({"accepted": True, "tag_id": tag_id})
|
return jsonify({"accepted": True, "tag_id": tag_id})
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
|
||||||
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
|
|
||||||
)
|
|
||||||
async def alias_suggestion(image_id: int):
|
|
||||||
body = await request.get_json()
|
|
||||||
required = {"alias_string", "alias_category", "canonical_tag_id"}
|
|
||||||
if not body or not required.issubset(body):
|
|
||||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
|
||||||
canonical_tag_id = body["canonical_tag_id"]
|
|
||||||
async with get_session() as session:
|
|
||||||
await AllowlistService(session).add_alias_and_accept(
|
|
||||||
image_id,
|
|
||||||
body["alias_string"],
|
|
||||||
body["alias_category"],
|
|
||||||
canonical_tag_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
|
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
@suggestions_bp.route(
|
||||||
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
|
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.backup",
|
"backend.app.tasks.backup",
|
||||||
"backend.app.tasks.admin",
|
"backend.app.tasks.admin",
|
||||||
"backend.app.tasks.library_audit",
|
"backend.app.tasks.library_audit",
|
||||||
|
"backend.app.tasks.translation",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
app.conf.update(
|
app.conf.update(
|
||||||
@@ -63,9 +64,20 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
||||||
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
||||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
||||||
|
# Translation backfill hits the LLM (~1–6s/item) → the long lane so it
|
||||||
|
# never starves the quick self-healing sweeps (#143).
|
||||||
|
"backend.app.tasks.translation.*": {"queue": "maintenance_long"},
|
||||||
},
|
},
|
||||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||||
task_acks_late=True,
|
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,
|
worker_prefetch_multiplier=1,
|
||||||
# Broker resilience (2026-06-24): a swarm overlay-network blip after a
|
# Broker resilience (2026-06-24): a swarm overlay-network blip after a
|
||||||
# redeploy left Redis healthy but transiently unreachable, and a worker
|
# redeploy left Redis healthy but transiently unreachable, and a worker
|
||||||
@@ -103,6 +115,11 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||||
"schedule": 86400.0, # daily
|
"schedule": 86400.0, # daily
|
||||||
},
|
},
|
||||||
|
"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": {
|
"train-heads-nightly": {
|
||||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||||
@@ -161,6 +178,14 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||||
},
|
},
|
||||||
|
"translate-posts-8h": {
|
||||||
|
"task": "backend.app.tasks.translation.translate_posts",
|
||||||
|
"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": {
|
"snapshot-head-metrics-daily": {
|
||||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ class ImportSettings(Base):
|
|||||||
Boolean, nullable=False, default=True, server_default="true",
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -- Post-text translation via the Interpreter LAN service (milestone 143).
|
||||||
|
# Off by default with NO default host — it needs a reachable Interpreter
|
||||||
|
# service (the operator's, behind a reverse proxy), which not every install
|
||||||
|
# has; the operator sets the URL and flips it on. Empty base_url OR disabled
|
||||||
|
# → the translate sweep no-ops.
|
||||||
|
translation_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false",
|
||||||
|
)
|
||||||
|
interpreter_base_url: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, default="", server_default="",
|
||||||
|
)
|
||||||
|
translation_target_lang: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, default="en", server_default="en",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def load(cls, session) -> ImportSettings:
|
async def load(cls, session) -> ImportSettings:
|
||||||
"""The singleton settings row (id=1), via an async session."""
|
"""The singleton settings row (id=1), via an async session."""
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ class Post(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# -- Post-text translation (milestone 143). Filled by the translate_posts
|
||||||
|
# sweep via the Interpreter LAN service so viewing is instant.
|
||||||
|
# translated_source_lang is the DETECTED original language; "en" (or a
|
||||||
|
# passthrough) means nothing to translate and the *_translated columns stay
|
||||||
|
# NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a
|
||||||
|
# model upgrade re-translates instead of serving stale.
|
||||||
|
post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
description_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
translated_source_lang: Mapped[str | None] = mapped_column(
|
||||||
|
String(8), nullable=True
|
||||||
|
)
|
||||||
|
translation_engine_version: Mapped[str | None] = mapped_column(
|
||||||
|
String(128), nullable=True
|
||||||
|
)
|
||||||
|
translated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
downloaded_at: Mapped[datetime] = mapped_column(
|
downloaded_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class TagKind(StrEnum):
|
|||||||
# content. `wip` is real art: only the training pipelines exclude it.
|
# content. `wip` is real art: only the training pipelines exclude it.
|
||||||
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
||||||
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
||||||
|
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
|
||||||
|
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
|
||||||
|
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
|
||||||
|
WIP_SYSTEM_TAG = "wip"
|
||||||
|
|
||||||
image_tag = Table(
|
image_tag = Table(
|
||||||
"image_tag",
|
"image_tag",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..models import (
|
|||||||
Tag,
|
Tag,
|
||||||
TagPositiveConfirmation,
|
TagPositiveConfirmation,
|
||||||
)
|
)
|
||||||
from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
|
from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
|
||||||
from .pagination import decode_cursor, encode_cursor
|
from .pagination import decode_cursor, encode_cursor
|
||||||
from .tag_query import (
|
from .tag_query import (
|
||||||
fandom_join_alias,
|
fandom_join_alias,
|
||||||
@@ -715,6 +715,7 @@ class GalleryService:
|
|||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
untagged: bool = False, no_artist: bool = False,
|
untagged: bool = False, no_artist: bool = False,
|
||||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
|
exclude_wip: bool = False,
|
||||||
) -> list[GalleryImage] | None:
|
) -> list[GalleryImage] | None:
|
||||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||||
@@ -751,14 +752,18 @@ class GalleryService:
|
|||||||
# Presentation images (banner / editor-screenshot system tags, #128)
|
# Presentation images (banner / editor-screenshot system tags, #128)
|
||||||
# cluster on UI chrome rather than content, so near any one of them
|
# cluster on UI chrome rather than content, so near any one of them
|
||||||
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
||||||
# itself may be a banner, and `wip` stays surfaced (real art; only
|
# itself may be a banner. `wip` stays surfaced here by default (real art;
|
||||||
# the training pipelines exclude it).
|
# only the training pipelines exclude it), but the Explore rabbit-hole
|
||||||
|
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
|
||||||
|
excluded_system_tags = PRESENTATION_SYSTEM_TAGS
|
||||||
|
if exclude_wip:
|
||||||
|
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
|
||||||
presentation = (
|
presentation = (
|
||||||
select(image_tag.c.image_record_id)
|
select(image_tag.c.image_record_id)
|
||||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||||
.where(
|
.where(
|
||||||
Tag.is_system.is_(True),
|
Tag.is_system.is_(True),
|
||||||
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
|
Tag.name.in_(excluded_system_tags),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Interpreter translation client (milestone 143) — a thin SYNC wrapper over the
|
||||||
|
self-hosted Interpreter LAN service (LibreTranslate-compatible `/v1/translate`).
|
||||||
|
|
||||||
|
Sync (requests) because the only caller is the sync celery translate sweep, and
|
||||||
|
it mirrors FC's other platform clients. The service knows nothing about Curator —
|
||||||
|
all Curator logic stays here. base_url is operator-configured (empty until set;
|
||||||
|
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 / 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):
|
||||||
|
"""Bad request params (HTTP 400)."""
|
||||||
|
|
||||||
|
|
||||||
|
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 = session.get(_url(base_url, "/v1/health"), timeout=timeout)
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
if r.status_code != 200:
|
||||||
|
return False
|
||||||
|
engines = (r.json() or {}).get("engines") or {}
|
||||||
|
return bool(engines.get("llm"))
|
||||||
|
|
||||||
|
|
||||||
|
def translate(
|
||||||
|
texts: list[str], *, base_url: str, target: str = "en",
|
||||||
|
source: str = "auto", timeout: float = 120.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Translate a batch. Returns::
|
||||||
|
|
||||||
|
{"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 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 = session.post(
|
||||||
|
_url(base_url, "/v1/translate"),
|
||||||
|
json={"q": list(texts), "source": source,
|
||||||
|
"target": target, "engine": "auto"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise InterpreterUnavailable(str(e)) from e
|
||||||
|
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")
|
||||||
|
# q was an array → translatedText is an array (same order & length). Guard a
|
||||||
|
# scalar reply just in case (shouldn't happen for an array q).
|
||||||
|
if not isinstance(out, list):
|
||||||
|
out = [out]
|
||||||
|
interp = body.get("interpreter") or {}
|
||||||
|
detected = body.get("detectedLanguage") or {}
|
||||||
|
return {
|
||||||
|
"translations": out,
|
||||||
|
"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 import TagSuggestionRejection
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .aliases import AliasService
|
|
||||||
|
|
||||||
|
|
||||||
class AllowlistService:
|
class AllowlistService:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.aliases = AliasService(session)
|
|
||||||
|
|
||||||
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
||||||
stmt = insert(image_tag).values(
|
stmt = insert(image_tag).values(
|
||||||
@@ -41,18 +39,6 @@ class AllowlistService:
|
|||||||
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
||||||
await self._clear_rejection(image_id, tag_id)
|
await self._clear_rejection(image_id, tag_id)
|
||||||
|
|
||||||
async def add_alias_and_accept(
|
|
||||||
self,
|
|
||||||
image_id: int,
|
|
||||||
alias_string: str,
|
|
||||||
alias_category: str,
|
|
||||||
canonical_tag_id: int,
|
|
||||||
) -> None:
|
|
||||||
await self.aliases.create(
|
|
||||||
alias_string, alias_category, canonical_tag_id
|
|
||||||
)
|
|
||||||
await self.accept(image_id, canonical_tag_id)
|
|
||||||
|
|
||||||
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
||||||
stmt = insert(TagSuggestionRejection).values(
|
stmt = insert(TagSuggestionRejection).values(
|
||||||
image_record_id=image_id, tag_id=tag_id
|
image_record_id=image_id, tag_id=tag_id
|
||||||
|
|||||||
@@ -500,13 +500,19 @@ async def score_image(
|
|||||||
session: AsyncSession, image_id: int, threshold_override: float | None = None,
|
session: AsyncSession, image_id: int, threshold_override: float | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Suggestions for one image from the trained heads: [{tag_id, name,
|
"""Suggestions for one image from the trained heads: [{tag_id, name,
|
||||||
category, score}], ranked. A concept surfaces when its score clears the
|
category, score, above_threshold, grounding}], ranked. A concept is INCLUDED
|
||||||
head's own suggest_threshold — or, when threshold_override is given (the
|
when its score clears the head's own suggest_threshold — or, when
|
||||||
typed-dropdown "show everything" mode), that flat floor instead (0 → every
|
threshold_override is given (the typed-dropdown "show everything" mode), that
|
||||||
head). System-tag heads (wip/banner/editor) instead use a flat
|
flat floor instead (0 → every head). System-tag heads (wip/banner/editor)
|
||||||
_SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection
|
instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface
|
||||||
(still overridden by threshold_override). Empty if the image has no
|
for rejection (still overridden by threshold_override). Empty if the image has
|
||||||
embedding or no heads exist yet.
|
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
|
MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image
|
||||||
vector PLUS every concept-region crop the agent embedded (same model
|
vector PLUS every concept-region crop the agent embedded (same model
|
||||||
@@ -537,21 +543,25 @@ async def score_image(
|
|||||||
winners = probs_bag.argmax(axis=0) # (H,)
|
winners = probs_bag.argmax(axis=0) # (H,)
|
||||||
out = []
|
out = []
|
||||||
for i, p in enumerate(probs):
|
for i, p in enumerate(probs):
|
||||||
if threshold_override is not None:
|
m = heads["meta"][i]
|
||||||
cut = threshold_override
|
# The head's NATURAL suggest cut — system tags use the flat floor (see
|
||||||
elif heads["meta"][i]["is_system"]:
|
# _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the
|
||||||
# System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR)
|
# operator to reject; content heads use their own precision-tuned
|
||||||
# so their false positives show up for the operator to reject.
|
# threshold. This is what "above threshold" means (drives the panel).
|
||||||
cut = _SYSTEM_TAG_SUGGEST_FLOOR
|
natural = (
|
||||||
else:
|
_SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i])
|
||||||
cut = heads["thr"][i]
|
)
|
||||||
|
# INCLUSION is looser under threshold_override (dropdown show-all,
|
||||||
|
# override=0): every head comes back so a low-confidence concept can still
|
||||||
|
# be typed + picked, each carrying its own above_threshold flag.
|
||||||
|
cut = threshold_override if threshold_override is not None else natural
|
||||||
if p >= cut:
|
if p >= cut:
|
||||||
m = heads["meta"][i]
|
|
||||||
out.append({
|
out.append({
|
||||||
"tag_id": m["tag_id"],
|
"tag_id": m["tag_id"],
|
||||||
"name": m["name"],
|
"name": m["name"],
|
||||||
"category": m["category"],
|
"category": m["category"],
|
||||||
"score": float(p),
|
"score": float(p),
|
||||||
|
"above_threshold": bool(p >= natural),
|
||||||
"grounding": bag_meta[int(winners[i])],
|
"grounding": bag_meta[int(winners[i])],
|
||||||
})
|
})
|
||||||
out.sort(key=lambda d: d["score"], reverse=True)
|
out.sort(key=lambda d: d["score"], reverse=True)
|
||||||
|
|||||||
@@ -22,22 +22,20 @@ from .heads import score_image
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Suggestion:
|
class Suggestion:
|
||||||
# canonical_tag_id is None when this is a raw Camie tag with no alias and
|
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
|
||||||
# no existing Tag row — accepting it will create the tag.
|
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
|
||||||
canonical_tag_id: int | None
|
# cases are gone — a suggestion always maps to a real tag id.
|
||||||
|
canonical_tag_id: int
|
||||||
display_name: str
|
display_name: str
|
||||||
category: str
|
category: str
|
||||||
score: float
|
score: float
|
||||||
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
|
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
|
||||||
creates_new_tag: bool
|
# above_threshold = the score cleared the head's own suggest cut (or the
|
||||||
# raw_name = the booru model vocab key behind this suggestion. It's the key
|
# system floor). The Suggestions PANEL shows only these; the typed-tag
|
||||||
# an alias MUST be stored under (resolution looks up the raw key), so the
|
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each
|
||||||
# modal needs it to author an alias correctly. None for centroid-only hits
|
# matching row with its score, so a low-confidence concept can still be typed
|
||||||
# (no underlying prediction → nothing to alias).
|
# and picked. CCIP character matches are always above their match threshold.
|
||||||
raw_name: str | None = None
|
above_threshold: bool
|
||||||
# via_alias = this suggestion was surfaced because an operator alias remapped
|
|
||||||
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
|
|
||||||
via_alias: bool = False
|
|
||||||
# rejected = the operator dismissed this tag for this image (a stored
|
# rejected = the operator dismissed this tag for this image (a stored
|
||||||
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
||||||
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
||||||
@@ -108,6 +106,7 @@ class SuggestionService:
|
|||||||
for h in hits:
|
for h in hits:
|
||||||
merged[(h["category"], h["tag_id"])] = {
|
merged[(h["category"], h["tag_id"])] = {
|
||||||
"name": h["name"], "score": h["score"], "source": "head",
|
"name": h["name"], "score": h["score"], "source": "head",
|
||||||
|
"above_threshold": h["above_threshold"],
|
||||||
"grounding": h.get("grounding"),
|
"grounding": h.get("grounding"),
|
||||||
}
|
}
|
||||||
for c in ccip_hits:
|
for c in ccip_hits:
|
||||||
@@ -116,12 +115,16 @@ class SuggestionService:
|
|||||||
if ex is not None:
|
if ex is not None:
|
||||||
ex["source"] = "both"
|
ex["source"] = "both"
|
||||||
ex["score"] = max(ex["score"], c["score"])
|
ex["score"] = max(ex["score"], c["score"])
|
||||||
|
# CCIP only returns matches above its own threshold, so a CCIP
|
||||||
|
# corroboration always makes the merged suggestion above-threshold.
|
||||||
|
ex["above_threshold"] = True
|
||||||
# Keep the head's localized crop if it had one; else fall back to
|
# Keep the head's localized crop if it had one; else fall back to
|
||||||
# the CCIP figure so a corroborated character still grounds (#1206).
|
# the CCIP figure so a corroborated character still grounds (#1206).
|
||||||
ex["grounding"] = ex.get("grounding") or c.get("grounding")
|
ex["grounding"] = ex.get("grounding") or c.get("grounding")
|
||||||
else:
|
else:
|
||||||
merged[key] = {
|
merged[key] = {
|
||||||
"name": c["name"], "score": c["score"], "source": "ccip",
|
"name": c["name"], "score": c["score"], "source": "ccip",
|
||||||
|
"above_threshold": True,
|
||||||
"grounding": c.get("grounding"),
|
"grounding": c.get("grounding"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ class SuggestionService:
|
|||||||
category=cat,
|
category=cat,
|
||||||
score=m["score"],
|
score=m["score"],
|
||||||
source=m["source"],
|
source=m["source"],
|
||||||
creates_new_tag=False,
|
above_threshold=m["above_threshold"],
|
||||||
rejected=tag_id in rejected,
|
rejected=tag_id in rejected,
|
||||||
grounding=m.get("grounding"),
|
grounding=m.get("grounding"),
|
||||||
)
|
)
|
||||||
@@ -157,8 +160,7 @@ class SuggestionService:
|
|||||||
was suggested for (or already applied to) >= threshold fraction of
|
was suggested for (or already applied to) >= threshold fraction of
|
||||||
the selection AND was acceptable on >= 1 image. Confidence is the
|
the selection AND was acceptable on >= 1 image. Confidence is the
|
||||||
mean over images where it was suggested. Aggregated by
|
mean over images where it was suggested. Aggregated by
|
||||||
canonical_tag_id; creates-new (no canonical id) suggestions are
|
canonical_tag_id (every suggestion is a canonical tag now)."""
|
||||||
skipped (bulk Accept applies by tag id)."""
|
|
||||||
if not image_ids:
|
if not image_ids:
|
||||||
return {}
|
return {}
|
||||||
threshold = min(1.0, max(0.0, threshold))
|
threshold = min(1.0, max(0.0, threshold))
|
||||||
@@ -169,8 +171,6 @@ class SuggestionService:
|
|||||||
sl = await self.for_image(image_id)
|
sl = await self.for_image(image_id)
|
||||||
for category, items in sl.by_category.items():
|
for category, items in sl.by_category.items():
|
||||||
for s in items:
|
for s in items:
|
||||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
|
||||||
continue
|
|
||||||
# for_image keeps rejected tags (flagged) for the rail;
|
# for_image keeps rejected tags (flagged) for the rail;
|
||||||
# bulk consensus must still ignore them — a tag dismissed on
|
# bulk consensus must still ignore them — a tag dismissed on
|
||||||
# an image isn't a suggestion for that image.
|
# an image isn't a suggestion for that image.
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ class PostFeedService:
|
|||||||
atts_map = await self._attachments_for([post.id])
|
atts_map = await self._attachments_for([post.id])
|
||||||
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
||||||
item["description_full"] = html_to_plain(post.description)
|
item["description_full"] = html_to_plain(post.description)
|
||||||
|
# Full (uncapped) translated description for the detail view (#143).
|
||||||
|
item["description_translated_full"] = post.description_translated
|
||||||
# Sanitized HTML body for faithful (semantic) rendering in the post view;
|
# Sanitized HTML body for faithful (semantic) rendering in the post view;
|
||||||
# detail-only (the feed list stays lightweight plain text). None when the
|
# detail-only (the feed list stays lightweight plain text). None when the
|
||||||
# post has no body. Inline `<img>` sources are remapped to locally-served
|
# post has no body. Inline `<img>` sources are remapped to locally-served
|
||||||
@@ -371,6 +373,12 @@ class PostFeedService:
|
|||||||
description_plain, truncated = None, False
|
description_plain, truncated = None, False
|
||||||
else:
|
else:
|
||||||
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
|
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
|
||||||
|
# Translation (#143): the stored translated description is already plain
|
||||||
|
# text; truncate it the same way for the card.
|
||||||
|
desc_trans = post.description_translated
|
||||||
|
desc_trans_short = (
|
||||||
|
truncate_at_word(desc_trans, DESCRIPTION_LIMIT)[0] if desc_trans else None
|
||||||
|
)
|
||||||
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
|
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
|
||||||
# `source` is null for filesystem-imported posts with no live
|
# `source` is null for filesystem-imported posts with no live
|
||||||
# subscription (alembic 0030). Frontend renders that as a
|
# subscription (alembic 0030). Frontend renders that as a
|
||||||
@@ -384,6 +392,11 @@ class PostFeedService:
|
|||||||
"downloaded_at": post.downloaded_at.isoformat(),
|
"downloaded_at": post.downloaded_at.isoformat(),
|
||||||
"description_plain": description_plain,
|
"description_plain": description_plain,
|
||||||
"description_truncated": truncated,
|
"description_truncated": truncated,
|
||||||
|
# Translation-forward fields (#143): shown by default when present;
|
||||||
|
# UI toggles back to the originals above. Source lang labels the toggle.
|
||||||
|
"post_title_translated": post.post_title_translated,
|
||||||
|
"description_translated": desc_trans_short,
|
||||||
|
"translated_source_lang": post.translated_source_lang,
|
||||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||||
"source": (
|
"source": (
|
||||||
{"id": source.id, "platform": source.platform}
|
{"id": source.id, "platform": source.platform}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ def _post_dict(p: Post) -> dict:
|
|||||||
"date": p.post_date.isoformat() if p.post_date else None,
|
"date": p.post_date.isoformat() if p.post_date else None,
|
||||||
"description_html": sanitize_post_html(p.description),
|
"description_html": sanitize_post_html(p.description),
|
||||||
"attachment_count": p.attachment_count,
|
"attachment_count": p.attachment_count,
|
||||||
|
# Translation (#143): the English title/description shown by default when
|
||||||
|
# a translation exists; the UI toggles to the original. Source lang labels
|
||||||
|
# the original.
|
||||||
|
"title_translated": p.post_title_translated,
|
||||||
|
"description_translated": p.description_translated,
|
||||||
|
"translated_source_lang": p.translated_source_lang,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ VERIFY_PAGE = 200
|
|||||||
FFPROBE_TIMEOUT_SECONDS = 10
|
FFPROBE_TIMEOUT_SECONDS = 10
|
||||||
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
||||||
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
# 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
|
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
|
||||||
# > the entity's longest legitimate runtime (its task's time_limit + a
|
# > the entity's longest legitimate runtime (its task's time_limit + a
|
||||||
@@ -339,6 +347,34 @@ def cleanup_old_tasks() -> int:
|
|||||||
return result.rowcount or 0
|
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")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_task_runs")
|
||||||
def recover_stalled_task_runs() -> int:
|
def recover_stalled_task_runs() -> int:
|
||||||
"""Flip task_run rows stuck in 'running' past their queue-specific
|
"""Flip task_run rows stuck in 'running' past their queue-specific
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""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 func, or_, select, update
|
||||||
|
|
||||||
|
from ..celery_app import celery
|
||||||
|
from ..models import ImportSettings, Post
|
||||||
|
from ..services import interpreter_client as ic
|
||||||
|
from ..utils.text import html_to_plain
|
||||||
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bound one run's work so it commits progress rather than dying at the time
|
||||||
|
# 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 the floor sits at 0.80,
|
||||||
|
# below that band, to keep legitimate ambiguous non-English while still rejecting
|
||||||
|
# genuinely-unsure guesses. Real langdetect also fixed the original mis-flag at
|
||||||
|
# the source (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"})
|
||||||
|
_MIN_LATIN_CONFIDENCE = 0.80
|
||||||
|
|
||||||
|
|
||||||
|
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(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.
|
||||||
|
|
||||||
|
``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:
|
||||||
|
ready = _translation_config(session)
|
||||||
|
if isinstance(ready, str):
|
||||||
|
return ready
|
||||||
|
base_url, target = ready
|
||||||
|
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
|
||||||
|
status, translated, retry_after = _translate_batch(
|
||||||
|
session, posts, base_url, target,
|
||||||
|
)
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
|
||||||
|
@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 = 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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) 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."""
|
||||||
|
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"
|
||||||
|
return base_url, target
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
Returns the row count reset (commits it)."""
|
||||||
|
stmt = (
|
||||||
|
update(Post)
|
||||||
|
.where(Post.translated_source_lang.is_not(None))
|
||||||
|
.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):
|
||||||
|
"""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:
|
||||||
|
translated += _translate_one(session, post, base_url, target)
|
||||||
|
session.commit()
|
||||||
|
except ic.InterpreterUnavailable as e:
|
||||||
|
session.rollback()
|
||||||
|
return "interrupted", translated, getattr(e, "retry_after", None)
|
||||||
|
except ic.InterpreterBadRequest as e:
|
||||||
|
session.rollback()
|
||||||
|
log.warning("translate bad request: %s", e)
|
||||||
|
return "stopped", translated, None
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
return "timeout", translated, None
|
||||||
|
return "ok", translated, None
|
||||||
|
|
||||||
|
|
||||||
|
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) -> 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_LATIN_CONFIDENCE``. A missing
|
||||||
|
confidence fails OPEN (accept), so a service that omits the field never causes
|
||||||
|
translations to be silently dropped."""
|
||||||
|
if (language or "").split("-", 1)[0].lower() in _CJK_LANGS:
|
||||||
|
return True
|
||||||
|
if confidence is None:
|
||||||
|
return True
|
||||||
|
return confidence >= _MIN_LATIN_CONFIDENCE
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_field(text: str, base_url: str, target: str):
|
||||||
|
"""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). Per-field (not aggregate first-item) detection so a
|
||||||
|
non-English description still gets translated when the title is already
|
||||||
|
English (mixed-language posts)."""
|
||||||
|
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.
|
||||||
|
if not _accept(detected, res.get("detected_confidence")):
|
||||||
|
return None, None, None
|
||||||
|
return res["translations"][0], detected, res["engine_version"]
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_one(session, post, base_url: str, target: str) -> int:
|
||||||
|
"""Translate a post's title/description in place, each field independently.
|
||||||
|
Returns 1 if it stored at least one translation, 0 when the post is all
|
||||||
|
passthrough/empty (still marks it handled so the sweep won't revisit it)."""
|
||||||
|
title = (post.post_title or "").strip()
|
||||||
|
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||||
|
desc = desc.strip()
|
||||||
|
title_tr, title_lang, title_ev = _translate_field(title, base_url, target)
|
||||||
|
desc_tr, desc_lang, desc_ev = _translate_field(desc, base_url, target)
|
||||||
|
if title_tr is None and desc_tr is None:
|
||||||
|
# Nothing non-target (or nothing to translate) → handled, store nothing.
|
||||||
|
post.translated_source_lang = target
|
||||||
|
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
|
||||||
@@ -8,6 +8,35 @@
|
|||||||
# run `docker compose up` from this directory and switches images to
|
# run `docker compose up` from this directory and switches images to
|
||||||
# local builds + DEBUG logging.
|
# 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:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -47,6 +76,24 @@ services:
|
|||||||
web:
|
web:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||||
command: ["web"]
|
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:
|
ports:
|
||||||
- "${PORT:-8080}:8080"
|
- "${PORT:-8080}:8080"
|
||||||
environment: &app_env
|
environment: &app_env
|
||||||
@@ -77,6 +124,10 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||||
command: ["worker"]
|
command: ["worker"]
|
||||||
|
# Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy.
|
||||||
|
stop_grace_period: 90s
|
||||||
|
healthcheck: *celery_healthcheck
|
||||||
|
deploy: *deploy_policy
|
||||||
environment:
|
environment:
|
||||||
<<: *app_env
|
<<: *app_env
|
||||||
CELERY_QUEUES: default,import,thumbnail,download
|
CELERY_QUEUES: default,import,thumbnail,download
|
||||||
@@ -93,6 +144,10 @@ services:
|
|||||||
scheduler:
|
scheduler:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||||
command: ["scheduler"]
|
command: ["scheduler"]
|
||||||
|
# Quick maintenance/scan lane + beat — short tasks, modest drain window.
|
||||||
|
stop_grace_period: 60s
|
||||||
|
healthcheck: *celery_healthcheck
|
||||||
|
deploy: *deploy_policy
|
||||||
environment:
|
environment:
|
||||||
<<: *app_env
|
<<: *app_env
|
||||||
CELERY_QUEUES: maintenance,scan
|
CELERY_QUEUES: maintenance,scan
|
||||||
@@ -110,6 +165,12 @@ services:
|
|||||||
maintenance-long:
|
maintenance-long:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||||
command: ["worker"]
|
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:
|
environment:
|
||||||
<<: *app_env
|
<<: *app_env
|
||||||
CELERY_QUEUES: maintenance_long
|
CELERY_QUEUES: maintenance_long
|
||||||
@@ -125,6 +186,10 @@ services:
|
|||||||
ml-worker:
|
ml-worker:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:dev
|
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:dev
|
||||||
command: ["ml-worker"]
|
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:
|
environment:
|
||||||
<<: *app_env
|
<<: *app_env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -101,6 +101,47 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</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">
|
<section class="fc-artist-mgmt__sec">
|
||||||
<h2 class="fc-h2">Danger zone</h2>
|
<h2 class="fc-h2">Danger zone</h2>
|
||||||
<ArtistDangerZone
|
<ArtistDangerZone
|
||||||
@@ -113,9 +154,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
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 { useSourcesStore } from '../../stores/sources.js'
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import ArtistDangerZone from './ArtistDangerZone.vue'
|
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||||
@@ -125,8 +168,38 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
|
const importStore = useImportStore()
|
||||||
const sources = useSourcesStore()
|
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.
|
// #130: move a source into another artist.
|
||||||
const moveOpen = ref(false)
|
const moveOpen = ref(false)
|
||||||
const moveSource = ref(null)
|
const moveSource = ref(null)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -42,13 +42,23 @@
|
|||||||
· {{ e.post.attachment_count }} files
|
· {{ e.post.attachment_count }} files
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="hasTranslation(e)" class="fc-prov__actions">
|
||||||
|
<a
|
||||||
|
href="#" @click.prevent="toggleOrig(e.provenance_id)"
|
||||||
|
>{{ showOrig[e.provenance_id] ? 'Show translation' : `Show original${langLabel(e)}` }}</a>
|
||||||
|
</div>
|
||||||
<div v-if="e.post.description_html" class="fc-prov__actions">
|
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||||
<a
|
<a
|
||||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Translated body = plain text; original = sanitized HTML (#143). -->
|
||||||
|
<p
|
||||||
|
v-if="showTranslated(e) && expanded[e.provenance_id] && e.post.description_translated"
|
||||||
|
class="fc-prov__desc"
|
||||||
|
>{{ e.post.description_translated }}</p>
|
||||||
<div
|
<div
|
||||||
v-if="e.post.description_html && expanded[e.provenance_id]"
|
v-else-if="e.post.description_html && expanded[e.provenance_id]"
|
||||||
class="fc-prov__desc" v-html="e.post.description_html"
|
class="fc-prov__desc" v-html="e.post.description_html"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
@@ -117,8 +127,12 @@ const effectiveImage = computed(() => props.image ?? modal.current)
|
|||||||
// 2026-05-28. Reset when the viewed image changes.
|
// 2026-05-28. Reset when the viewed image changes.
|
||||||
const expanded = reactive({})
|
const expanded = reactive({})
|
||||||
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
||||||
|
// Per-entry "show the original (untranslated) text" toggle (#143).
|
||||||
|
const showOrig = reactive({})
|
||||||
|
function toggleOrig(id) { showOrig[id] = !showOrig[id] }
|
||||||
watch(() => effectiveId.value, () => {
|
watch(() => effectiveId.value, () => {
|
||||||
for (const k of Object.keys(expanded)) delete expanded[k]
|
for (const k of Object.keys(expanded)) delete expanded[k]
|
||||||
|
for (const k of Object.keys(showOrig)) delete showOrig[k]
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -157,10 +171,23 @@ const show = computed(() => {
|
|||||||
const attachments = computed(() => state.value?.attachments || [])
|
const attachments = computed(() => state.value?.attachments || [])
|
||||||
|
|
||||||
function postDate(e) { return formatPostDate(e.post.date) }
|
function postDate(e) { return formatPostDate(e.post.date) }
|
||||||
|
// Translation-forward (#143): show the English by default when present.
|
||||||
|
function hasTranslation(e) {
|
||||||
|
return !!(e.post.title_translated || e.post.description_translated)
|
||||||
|
}
|
||||||
|
function showTranslated(e) {
|
||||||
|
return hasTranslation(e) && !showOrig[e.provenance_id]
|
||||||
|
}
|
||||||
|
function langLabel(e) {
|
||||||
|
return e.post.translated_source_lang ? ` (${e.post.translated_source_lang})` : ''
|
||||||
|
}
|
||||||
function postTitle(e) {
|
function postTitle(e) {
|
||||||
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
||||||
// as plain text (the CSS makes it bold).
|
// as plain text (the CSS makes it bold).
|
||||||
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
|
const t = showTranslated(e) && e.post.title_translated
|
||||||
|
? e.post.title_translated
|
||||||
|
: e.post.title
|
||||||
|
return toPlainText(t) || `Post ${e.post.external_post_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPost(postId, artistId) {
|
function openPost(postId, artistId) {
|
||||||
|
|||||||
@@ -11,10 +11,6 @@
|
|||||||
{{ suggestion.display_name }}
|
{{ suggestion.display_name }}
|
||||||
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
||||||
title="You rejected this for this image — un-reject to recover">rejected</span>
|
title="You rejected this for this image — un-reject to recover">rejected</span>
|
||||||
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
|
||||||
title="No matching tag yet — accepting creates it">+ new</span>
|
|
||||||
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
|
|
||||||
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
||||||
<!-- Green ✓ / red ✗ pair (operator-asked 2026-06-28) mirrors the eval
|
<!-- Green ✓ / red ✗ pair (operator-asked 2026-06-28) mirrors the eval
|
||||||
@@ -46,40 +42,14 @@
|
|||||||
@click="$emit('dismiss', suggestion)"
|
@click="$emit('dismiss', suggestion)"
|
||||||
><v-icon size="16">mdi-close</v-icon></button>
|
><v-icon size="16">mdi-close</v-icon></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
|
|
||||||
teleported image modal — #711). Only rendered when an alias action
|
|
||||||
applies — dismiss now lives on the red ✗, so a centroid hit with no
|
|
||||||
alias option has no menu. -->
|
|
||||||
<KebabMenu
|
|
||||||
v-if="hasMenu"
|
|
||||||
class="fc-suggestion__menu" size="small" variant="outlined"
|
|
||||||
:label="`More actions for ${suggestion.display_name}`"
|
|
||||||
>
|
|
||||||
<!-- Alias is a tagger-prediction remap, so only offer it for tagger
|
|
||||||
suggestions with a raw model key that aren't already aliased.
|
|
||||||
Centroid hits (raw_name null) have nothing to alias. -->
|
|
||||||
<v-list-item
|
|
||||||
v-if="suggestion.raw_name && !suggestion.via_alias"
|
|
||||||
@click="$emit('alias', suggestion)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item
|
|
||||||
v-if="suggestion.via_alias"
|
|
||||||
@click="$emit('remove-alias', suggestion)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>Remove alias</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</KebabMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import KebabMenu from '../common/KebabMenu.vue'
|
|
||||||
|
|
||||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
|
defineEmits(['accept', 'dismiss', 'undismiss'])
|
||||||
|
|
||||||
// #1206: on hover, tell the image viewer which crop produced this suggestion so
|
// #1206: on hover, tell the image viewer which crop produced this suggestion so
|
||||||
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
|
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
|
||||||
@@ -92,12 +62,6 @@ function onLeave () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||||
// Kebab now only carries alias actions: show it when this suggestion can be
|
|
||||||
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
|
|
||||||
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
|
|
||||||
const hasMenu = computed(() =>
|
|
||||||
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -119,26 +83,6 @@ const hasMenu = computed(() =>
|
|||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.fc-suggestion__new {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px; font-weight: 600;
|
|
||||||
color: rgb(var(--v-theme-accent));
|
|
||||||
background: rgba(var(--v-theme-accent), 0.12);
|
|
||||||
border: 1px solid rgb(var(--v-theme-accent), 0.4);
|
|
||||||
padding: 1px 6px; border-radius: 999px;
|
|
||||||
margin-left: 6px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.fc-suggestion__alias {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px; font-weight: 600;
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
background: rgb(var(--v-theme-surface-light));
|
|
||||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
||||||
padding: 1px 6px; border-radius: 999px;
|
|
||||||
margin-left: 6px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.fc-suggestion__score {
|
.fc-suggestion__score {
|
||||||
flex: 0 0 auto; min-width: 38px; text-align: right;
|
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -166,10 +110,6 @@ const hasMenu = computed(() =>
|
|||||||
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
|
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
|
||||||
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
|
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
|
||||||
}
|
}
|
||||||
.fc-suggestion__menu {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
|
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
|
||||||
reads as "handled, negative" without shouting over live suggestions. */
|
reads as "handled, negative" without shouting over live suggestions. */
|
||||||
.fc-suggestion--rejected {
|
.fc-suggestion--rejected {
|
||||||
|
|||||||
@@ -26,8 +26,6 @@
|
|||||||
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
|
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
|
||||||
:suggestion="s"
|
:suggestion="s"
|
||||||
@accept="$emit('accept', $event)"
|
@accept="$emit('accept', $event)"
|
||||||
@alias="$emit('alias', $event)"
|
|
||||||
@remove-alias="$emit('remove-alias', $event)"
|
|
||||||
@dismiss="$emit('dismiss', $event)"
|
@dismiss="$emit('dismiss', $event)"
|
||||||
@undismiss="$emit('undismiss', $event)"
|
@undismiss="$emit('undismiss', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -45,7 +43,7 @@ const props = defineProps({
|
|||||||
collapsible: { type: Boolean, default: false },
|
collapsible: { type: Boolean, default: false },
|
||||||
defaultOpen: { type: Boolean, default: true }
|
defaultOpen: { type: Boolean, default: true }
|
||||||
})
|
})
|
||||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
|
defineEmits(['accept', 'dismiss', 'undismiss', 'reject-all'])
|
||||||
|
|
||||||
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
|
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
|
||||||
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
|
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
|
||||||
|
|||||||
@@ -20,48 +20,39 @@
|
|||||||
first, so false positives are easy to spot and reject (reject-to-train
|
first, so false positives are easy to spot and reject (reject-to-train
|
||||||
for these heads). Small set — collapsible, open by default. -->
|
for these heads). Small set — collapsible, open by default. -->
|
||||||
<SuggestionsCategoryGroup
|
<SuggestionsCategoryGroup
|
||||||
v-if="store.byCategory.system && store.byCategory.system.length"
|
v-if="store.aboveByCategory.system && store.aboveByCategory.system.length"
|
||||||
label="System" :items="store.byCategory.system"
|
label="System" :items="store.aboveByCategory.system"
|
||||||
collapsible :default-open="true"
|
collapsible :default-open="true"
|
||||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
@accept="onAccept"
|
||||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||||
@reject-all="onRejectAll('system')"
|
@reject-all="onRejectAll('system')"
|
||||||
/>
|
/>
|
||||||
<SuggestionsCategoryGroup
|
<SuggestionsCategoryGroup
|
||||||
v-for="cat in peopleCats" :key="cat"
|
v-for="cat in peopleCats" :key="cat"
|
||||||
v-show="store.byCategory[cat] && store.byCategory[cat].length"
|
v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length"
|
||||||
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
|
:label="labelFor(cat)" :items="store.aboveByCategory[cat] || []"
|
||||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
@accept="onAccept"
|
||||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||||
@reject-all="onRejectAll(cat)"
|
@reject-all="onRejectAll(cat)"
|
||||||
/>
|
/>
|
||||||
<SuggestionsCategoryGroup
|
<SuggestionsCategoryGroup
|
||||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
v-if="store.aboveByCategory.general && store.aboveByCategory.general.length"
|
||||||
label="General" :items="store.byCategory.general"
|
label="General" :items="store.aboveByCategory.general"
|
||||||
collapsible :default-open="true"
|
collapsible :default-open="true"
|
||||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
@accept="onAccept"
|
||||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||||
@reject-all="onRejectAll('general')"
|
@reject-all="onRejectAll('general')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-dialog v-model="aliasDialog" max-width="480">
|
|
||||||
<AliasPickerDialog
|
|
||||||
v-if="aliasTarget"
|
|
||||||
:category="aliasTarget.category"
|
|
||||||
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
|
|
||||||
/>
|
|
||||||
</v-dialog>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
|
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
||||||
import AliasPickerDialog from './AliasPickerDialog.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
imageId: { type: Number, required: true },
|
imageId: { type: Number, required: true },
|
||||||
@@ -100,13 +91,14 @@ const peopleCats = ['character']
|
|||||||
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
||||||
|
|
||||||
const isEmpty = computed(() =>
|
const isEmpty = computed(() =>
|
||||||
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
Object.values(store.aboveByCategory).every(list => !list || list.length === 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(() => props.imageId, (id) => {
|
watch(() => props.imageId, (id) => {
|
||||||
if (id == null) return
|
if (id == null) return
|
||||||
store.load(id) // panel: curated, ≥ threshold
|
// One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the
|
||||||
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
|
// typed tag-input dropdown reads the full set. No second request.
|
||||||
|
store.load(id)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// After a successful accept/alias-accept, refresh the modal's current
|
// After a successful accept/alias-accept, refresh the modal's current
|
||||||
@@ -125,31 +117,6 @@ async function onAccept(s) {
|
|||||||
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliasDialog = ref(false)
|
|
||||||
const aliasTarget = ref(null)
|
|
||||||
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
|
|
||||||
async function onAliasConfirm(canonicalTagId) {
|
|
||||||
try {
|
|
||||||
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
|
||||||
aliasDialog.value = false
|
|
||||||
await host.reloadTags()
|
|
||||||
emit('accepted')
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo the model-key→tag mapping behind an aliased suggestion. The store
|
|
||||||
// reloads suggestions so the prediction reverts to its raw form; the applied
|
|
||||||
// canonical tag (if any) stays, so no tag-rail reload is needed.
|
|
||||||
async function onRemoveAlias(s) {
|
|
||||||
try {
|
|
||||||
await store.removeAlias(s)
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
density="compact" class="fc-tag-autocomplete__list"
|
density="compact" class="fc-tag-autocomplete__list"
|
||||||
>
|
>
|
||||||
<template v-for="(row, idx) in rows" :key="row.key">
|
<template v-for="(row, idx) in rows" :key="row.key">
|
||||||
<!-- 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-list-item
|
||||||
v-if="row.type === 'hit'"
|
v-if="row.type === 'hit'"
|
||||||
:active="idx === highlight" @click="onPickRow(row)"
|
: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>
|
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<template #append>
|
<template #append>
|
||||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
<span
|
||||||
</template>
|
v-if="row.sugg"
|
||||||
</v-list-item>
|
class="fc-tag-autocomplete__sugg-tag"
|
||||||
|
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
|
||||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
:title="row.sugg.above_threshold
|
||||||
it routes through the same accept path as the Suggestions panel, so
|
? 'The model suggests this tag for this image'
|
||||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
: 'The model scored this tag below its suggest threshold'"
|
||||||
<v-list-item
|
>
|
||||||
v-else-if="row.type === 'suggestion'"
|
|
||||||
:active="idx === highlight" @click="onPickRow(row)"
|
|
||||||
class="fc-tag-autocomplete__sugg"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
|
|
||||||
{{ iconFor(row.sugg.category) }}
|
|
||||||
</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>
|
|
||||||
{{ row.sugg.display_name }}
|
|
||||||
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
|
|
||||||
</v-list-item-title>
|
|
||||||
<template #append>
|
|
||||||
<span class="fc-tag-autocomplete__sugg-tag">
|
|
||||||
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
||||||
{{ scorePct(row.sugg) }}
|
{{ scorePct(row.sugg) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else class="text-caption">{{ row.hit.kind }}</span>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
@@ -100,11 +93,10 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
|
|||||||
import { useInflightToken } from '../../composables/useInflightToken.js'
|
import { useInflightToken } from '../../composables/useInflightToken.js'
|
||||||
import FandomPicker from './FandomPicker.vue'
|
import FandomPicker from './FandomPicker.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||||
// The host surface's applied tags (host.current?.tags). Both dropdown sections
|
// The host surface's applied tags (host.current?.tags). The dropdown filters
|
||||||
// filter against this so a just-added tag drops out of the search the moment
|
// against this so a just-added tag drops out of the search the moment the chip
|
||||||
// the chip rail updates, not after the next modal refresh (operator-asked
|
// rail updates, not after the next modal refresh (operator-asked 2026-07-03).
|
||||||
// 2026-07-03).
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
appliedTags: { type: Array, default: () => [] },
|
appliedTags: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
@@ -238,9 +230,6 @@ const createLabel = computed(() =>
|
|||||||
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
||||||
|
|
||||||
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
|
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
|
||||||
const appliedNames = computed(() =>
|
|
||||||
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
|
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
|
||||||
// keep reading the RAW hits — they reason about the tag universe (does this tag
|
// keep reading the RAW hits — they reason about the tag universe (does this tag
|
||||||
@@ -249,49 +238,29 @@ const visibleHits = computed(() =>
|
|||||||
hits.value.filter(h => !appliedIds.value.has(h.id)),
|
hits.value.filter(h => !appliedIds.value.has(h.id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// This image's suggestions that match the typed query, minus any the server
|
// This image's suggestions keyed by canonical tag id, so a matching DB-tag row
|
||||||
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
// can show the model's confidence inline. Every suggestion is a canonical tag
|
||||||
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
|
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
|
||||||
// the threshold-filtered panel list — so a low-confidence action/feature the
|
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
|
||||||
// model saw can be typed and accepted in canonical formatting instead of being
|
// advertise a score in the type-to-add dropdown (un-reject lives in the panel).
|
||||||
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
|
const suggByTagId = computed(() => {
|
||||||
// the only filter; the threshold no longer hides anything here.
|
const m = new Map()
|
||||||
const suggestionHits = computed(() => {
|
for (const list of Object.values(suggestions.byCategory)) {
|
||||||
const q = parsedName.value.toLowerCase()
|
|
||||||
if (!q) return []
|
|
||||||
const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
|
||||||
const out = []
|
|
||||||
for (const list of Object.values(suggestions.allByCategory)) {
|
|
||||||
for (const s of list || []) {
|
for (const s of list || []) {
|
||||||
// Rejected suggestions now stay in allByCategory (flagged) so the panel
|
|
||||||
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
|
|
||||||
// whose job is finding a tag to ADD (un-reject lives in the panel).
|
|
||||||
if (s.rejected) continue
|
if (s.rejected) continue
|
||||||
// Already on the image (matched by canonical id or name+kind): nothing
|
if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Best matches first; cap generously so a specific typed query surfaces its
|
return m
|
||||||
// matches even when many predictions exist, while the list stays scrollable.
|
|
||||||
out.sort((a, b) => b.score - a.score)
|
|
||||||
return out.slice(0, 20)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||||
// highlight index maps 1:1 to a row 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 rows = computed(() => {
|
||||||
const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
|
const r = visibleHits.value.map(h => ({
|
||||||
for (const s of suggestionHits.value) {
|
type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
|
||||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
}))
|
||||||
}
|
|
||||||
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
||||||
return r
|
return r
|
||||||
})
|
})
|
||||||
@@ -313,16 +282,9 @@ function moveHighlight (delta) {
|
|||||||
// Dispatch a chosen dropdown row by its type.
|
// Dispatch a chosen dropdown row by its type.
|
||||||
function onPickRow (row) {
|
function onPickRow (row) {
|
||||||
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
||||||
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
|
|
||||||
else { onCreate() }
|
else { onCreate() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picking an ML suggestion hands it to the parent, which runs the same accept
|
|
||||||
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
|
|
||||||
// drops it from the panel). reset() clears the query; the panel-drop makes it
|
|
||||||
// fall out of suggestionHits reactively.
|
|
||||||
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
|
|
||||||
|
|
||||||
function onCreate () {
|
function onCreate () {
|
||||||
const name = parsedName.value
|
const name = parsedName.value
|
||||||
const kind = parsedKind.value
|
const kind = parsedKind.value
|
||||||
@@ -389,14 +351,16 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
|||||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
|
/* The model-confidence badge on a row whose tag the model also scored. Accent
|
||||||
.fc-tag-autocomplete__sugg {
|
when above the head's suggest threshold; muted when below (a low-confidence
|
||||||
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
|
match you can still type + pick). */
|
||||||
}
|
|
||||||
.fc-tag-autocomplete__sugg-tag {
|
.fc-tag-autocomplete__sugg-tag {
|
||||||
display: inline-flex; align-items: center; gap: 2px;
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
}
|
}
|
||||||
|
.fc-tag-autocomplete__sugg-tag--below {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
|
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
|
||||||
<v-chip
|
<v-chip
|
||||||
size="small" closable
|
size="default" :closable="!unconfirmedAuto"
|
||||||
:color="store.colorFor(tag.kind)" variant="tonal"
|
:color="store.colorFor(tag.kind)" variant="tonal"
|
||||||
class="fc-tag-chip__nav"
|
class="fc-tag-chip__nav"
|
||||||
role="link"
|
role="link"
|
||||||
@@ -9,27 +9,33 @@
|
|||||||
@click="$emit('navigate', tag)"
|
@click="$emit('navigate', tag)"
|
||||||
@click:close="$emit('remove', tag.id)"
|
@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
|
<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"
|
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"
|
title="System tag — tagged items are excluded from training other concepts"
|
||||||
>mdi-shield-outline</v-icon><span
|
>mdi-shield-outline</v-icon><span
|
||||||
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
|
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
|
||||||
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
|
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
|
||||||
>→<template v-if="fandomLabel"> {{ fandomLabel }}</template></span><span
|
>→<template v-if="fandomLabel"> {{ fandomLabel }}</template></span>
|
||||||
v-if="unconfirmedAuto" class="fc-tag-chip__auto"
|
<!-- Provisional auto-tag: an in-pill yes/no pair REPLACES the ✕ (the pair
|
||||||
title="Auto-applied — provisional: it won't train the model and can be retracted until you confirm it."
|
itself signals "auto" — no separate label, operator 2026-07-07). Yes
|
||||||
>auto</span>
|
confirms it (trains + shields from retraction); No removes it (records
|
||||||
|
a negative). Both hand focus back to the tag input via TagPanel. -->
|
||||||
|
<span v-if="unconfirmedAuto" class="fc-tag-chip__verdict">
|
||||||
|
<button
|
||||||
|
type="button" class="fc-tag-chip__yes"
|
||||||
|
: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="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="15">mdi-close</v-icon></button>
|
||||||
|
</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<!-- Keep/confirm an auto-applied tag: promotes it to a training positive and
|
|
||||||
shields it from the retraction sweep (milestone 139). Only shown for
|
|
||||||
provisional (unconfirmed) auto-tags. -->
|
|
||||||
<button
|
|
||||||
v-if="unconfirmedAuto" class="fc-tag-chip__confirm" type="button"
|
|
||||||
:title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`"
|
|
||||||
:aria-label="`Confirm ${tag.name}`"
|
|
||||||
@click.stop="$emit('confirm', tag)"
|
|
||||||
><v-icon size="16">mdi-check</v-icon></button>
|
|
||||||
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
|
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
|
||||||
teleported image modal — #711). System tags hide it entirely: rename
|
teleported image modal — #711). System tags hide it entirely: rename
|
||||||
is refused server-side (the hygiene machinery keys on the row) and
|
is refused server-side (the hygiene machinery keys on the row) and
|
||||||
@@ -71,8 +77,8 @@ const store = useTagStore()
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
// An auto-applied tag the operator hasn't confirmed yet — provisional (milestone
|
// An auto-applied tag the operator hasn't confirmed yet — provisional (milestone
|
||||||
// 139): it doesn't train the model and the retraction sweep can drop it. Shows
|
// 139): it doesn't train the model and the retraction sweep can drop it. Shows an
|
||||||
// the "auto" badge + a Keep/confirm button. `source`/`confirmed` come from the
|
// in-pill yes/no pair in place of the ✕. `source`/`confirmed` come from the
|
||||||
// applied-tags payload (list_for_image / get_image_with_tags).
|
// applied-tags payload (list_for_image / get_image_with_tags).
|
||||||
const unconfirmedAuto = computed(() =>
|
const unconfirmedAuto = computed(() =>
|
||||||
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
|
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
|
||||||
@@ -130,13 +136,26 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* A chip must never exceed the rail — a long character+fandom(+AUTO) chip used
|
/* A chip must never exceed the rail — a long character+fandom chip used to
|
||||||
to overflow the right edge and clip its close ✕ (operator-flagged 2026-07-06).
|
overflow the right edge and clip its trailing control (operator-flagged
|
||||||
The name is the elastic part: it ellipsis-truncates so the ✕, fandom, and AUTO
|
2026-07-06). The name is the elastic part: it ellipsis-truncates so the ✕ (or
|
||||||
badge stay reachable. Full name is still available via the chip's hover title. */
|
the auto-tag yes/no pair) and fandom stay reachable. Full name is on the
|
||||||
|
chip's hover title. */
|
||||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
|
.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; }
|
.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; }
|
.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 {
|
.fc-tag-chip__name {
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
min-width: 0; flex: 0 1 auto;
|
min-width: 0; flex: 0 1 auto;
|
||||||
@@ -152,28 +171,27 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
|||||||
.fc-tag-chip__kebab { opacity: 0.7; }
|
.fc-tag-chip__kebab { opacity: 0.7; }
|
||||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||||
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
||||||
/* "auto" = provisional (auto-applied, unconfirmed). Quiet pill inside the chip. */
|
/* Provisional auto-tag: a green ✓ / red ✗ pair in place of the ✕. The yes/no is
|
||||||
.fc-tag-chip__auto {
|
obvious enough on its own, so there's no "auto" label (operator-asked
|
||||||
display: inline-block; vertical-align: middle; margin-left: 4px;
|
2026-07-07). flex:0 0 auto keeps it visible while the name ellipsis-truncates. */
|
||||||
font-size: 9px; font-weight: 700;
|
.fc-tag-chip__verdict {
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 3px;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
margin-left: 5px;
|
||||||
background: rgb(var(--v-theme-surface-light));
|
|
||||||
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3);
|
|
||||||
padding: 0 4px; border-radius: 999px;
|
|
||||||
}
|
}
|
||||||
/* Keep/confirm — the SAME filled green ✓ circle as the suggestion accept button
|
/* Solid-filled (white glyph on a full success/error circle) so accept/reject
|
||||||
(.fc-act--yes in SuggestionItem), so "accept this tag" reads identically
|
stay well-defined even on a muted tonal chip — e.g. character (info) — instead
|
||||||
wherever it appears (operator-asked 2026-07-06). */
|
of a faint icon that only lit up on hover. Mirrors the Suggestions rail's
|
||||||
.fc-tag-chip__confirm {
|
verdict buttons so the affordance reads identically (operator-asked 2026-07-08). */
|
||||||
flex: 0 0 auto;
|
.fc-tag-chip__yes, .fc-tag-chip__no {
|
||||||
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
color: #fff; background: rgb(var(--v-theme-success));
|
width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%;
|
||||||
opacity: 0.9; transition: transform 0.1s, opacity 0.1s;
|
color: #fff; cursor: pointer; opacity: 0.92;
|
||||||
|
transition: transform 0.1s, opacity 0.1s;
|
||||||
}
|
}
|
||||||
.fc-tag-chip__confirm:hover { opacity: 1; transform: scale(1.1); }
|
.fc-tag-chip__yes { background: rgb(var(--v-theme-success)); }
|
||||||
.fc-tag-chip__confirm:focus-visible {
|
.fc-tag-chip__no { background: rgb(var(--v-theme-error)); }
|
||||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
.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: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
ref="tagInputRef"
|
ref="tagInputRef"
|
||||||
:applied-tags="host.current?.tags || []"
|
:applied-tags="host.current?.tags || []"
|
||||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||||
@accept-suggestion="onAcceptSuggestion"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||||
@@ -116,6 +115,10 @@ async function onConfirm(tag) {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
|
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
|
||||||
await host.reloadTags()
|
await host.reloadTags()
|
||||||
|
// Return the cursor to the tag input — its resting position in these views,
|
||||||
|
// matching every other chip action (remove/accept) so the keyboard flow
|
||||||
|
// never leaves the field (operator-asked 2026-07-07).
|
||||||
|
focusTagInput()
|
||||||
}
|
}
|
||||||
catch (e) { errorMsg.value = e.message }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
@@ -130,7 +133,6 @@ async function onRemove(tagId) {
|
|||||||
// until the next modal open (operator-asked 2026-07-03).
|
// until the next modal open (operator-asked 2026-07-03).
|
||||||
if (host.currentImageId != null) {
|
if (host.currentImageId != null) {
|
||||||
suggestions.load(host.currentImageId)
|
suggestions.load(host.currentImageId)
|
||||||
suggestions.loadAll(host.currentImageId)
|
|
||||||
}
|
}
|
||||||
focusTagInput()
|
focusTagInput()
|
||||||
}
|
}
|
||||||
@@ -174,18 +176,6 @@ async function onPickNew(payload) {
|
|||||||
}
|
}
|
||||||
catch (e) { errorMsg.value = e.message }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
|
||||||
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
|
|
||||||
// acceptance, and drops it from the panel; then we refresh the chip rail.
|
|
||||||
async function onAcceptSuggestion(s) {
|
|
||||||
errorMsg.value = null
|
|
||||||
try {
|
|
||||||
await suggestions.accept(s)
|
|
||||||
await host.reloadTags()
|
|
||||||
focusTagInput()
|
|
||||||
} catch (e) { errorMsg.value = e.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
const renameDialog = ref(false)
|
const renameDialog = ref(false)
|
||||||
const renameTarget = ref(null)
|
const renameTarget = ref(null)
|
||||||
|
|
||||||
|
|||||||
@@ -58,16 +58,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fc-post-card__text">
|
<div class="fc-post-card__text">
|
||||||
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
|
||||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
Post {{ post.external_post_id }}
|
Post {{ post.external_post_id }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<!-- Translation toggle (#143): English shown by default; reveal the
|
||||||
|
original, labelled with its detected language. -->
|
||||||
|
<button
|
||||||
|
v-if="hasTranslation" type="button" class="fc-post-card__lang"
|
||||||
|
:title="showOriginal ? 'Show the English translation' : `Show the original${sourceLangLabel} text`"
|
||||||
|
@click.stop="showOriginal = !showOriginal"
|
||||||
|
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||||
|
|
||||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||||
HTML (headings, lists, links, inline images). Collapsed and the
|
HTML (headings, lists, links, inline images). Collapsed and the
|
||||||
no-detail fallback stay plain text. -->
|
no-detail fallback stay plain text. -->
|
||||||
<div
|
<div
|
||||||
v-if="hasDescription && descExpanded && descHtml"
|
v-if="hasDescription && showHtmlBody"
|
||||||
class="fc-post-card__desc fc-post-card__desc--html"
|
class="fc-post-card__desc fc-post-card__desc--html"
|
||||||
v-html="descHtml"
|
v-html="descHtml"
|
||||||
/>
|
/>
|
||||||
@@ -75,7 +82,7 @@
|
|||||||
v-else-if="hasDescription" ref="descEl"
|
v-else-if="hasDescription" ref="descEl"
|
||||||
class="fc-post-card__desc"
|
class="fc-post-card__desc"
|
||||||
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
||||||
>{{ descText }}</p>
|
>{{ descTextDisplay }}</p>
|
||||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
(no description)
|
(no description)
|
||||||
</p>
|
</p>
|
||||||
@@ -231,6 +238,32 @@ const canExpand = computed(
|
|||||||
() => props.post.description_truncated === true || cssOverflow.value,
|
() => props.post.description_truncated === true || cssOverflow.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- translation-forward (#143): the English is shown by default when a
|
||||||
|
// translation exists; a per-card toggle reveals the original. Translations are
|
||||||
|
// plain text, so showTranslated also decides HTML-vs-plain body rendering.
|
||||||
|
const showOriginal = ref(false)
|
||||||
|
const hasTranslation = computed(
|
||||||
|
() => !!(props.post.post_title_translated || props.post.description_translated),
|
||||||
|
)
|
||||||
|
const showTranslated = computed(() => hasTranslation.value && !showOriginal.value)
|
||||||
|
const sourceLangLabel = computed(() =>
|
||||||
|
props.post.translated_source_lang ? ` (${props.post.translated_source_lang})` : '',
|
||||||
|
)
|
||||||
|
const displayTitle = computed(() =>
|
||||||
|
showTranslated.value && props.post.post_title_translated
|
||||||
|
? toPlainText(props.post.post_title_translated)
|
||||||
|
: plainTitle.value,
|
||||||
|
)
|
||||||
|
const descTextDisplay = computed(() => {
|
||||||
|
if (!showTranslated.value) return descText.value
|
||||||
|
return descExpanded.value
|
||||||
|
? (detail.value?.description_translated_full || props.post.description_translated)
|
||||||
|
: props.post.description_translated
|
||||||
|
})
|
||||||
|
const showHtmlBody = computed(
|
||||||
|
() => !showTranslated.value && descExpanded.value && !!descHtml.value,
|
||||||
|
)
|
||||||
|
|
||||||
function measureOverflow () {
|
function measureOverflow () {
|
||||||
const el = descEl.value
|
const el = descEl.value
|
||||||
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
||||||
@@ -467,6 +500,19 @@ function formatBytes (n) {
|
|||||||
font-size: 0.85rem; font-weight: 600;
|
font-size: 0.85rem; font-weight: 600;
|
||||||
}
|
}
|
||||||
.fc-post-card__more:hover { text-decoration: underline; }
|
.fc-post-card__more:hover { text-decoration: underline; }
|
||||||
|
/* Translation toggle (#143) — quiet secondary affordance under the title. */
|
||||||
|
.fc-post-card__lang {
|
||||||
|
display: block; margin: 2px 0 6px; padding: 0;
|
||||||
|
background: none; border: 0; cursor: pointer;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.fc-post-card__lang:hover {
|
||||||
|
text-decoration: underline; color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-post-card__lang:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.fc-post-card__atts {
|
.fc-post-card__atts {
|
||||||
display: flex; flex-wrap: wrap; gap: 8px;
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
<ImportFiltersForm />
|
<ImportFiltersForm />
|
||||||
|
<TranslationCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
import ImportFiltersForm from './ImportFiltersForm.vue'
|
import ImportFiltersForm from './ImportFiltersForm.vue'
|
||||||
|
import TranslationCard from './TranslationCard.vue'
|
||||||
import MLBackfillCard from './MLBackfillCard.vue'
|
import MLBackfillCard from './MLBackfillCard.vue'
|
||||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<MaintenanceTile
|
||||||
|
icon="mdi-translate"
|
||||||
|
title="Post translation (Interpreter)"
|
||||||
|
blurb="Translate non-English post titles + descriptions to English via your self-hosted Interpreter service, so archived posts read naturally."
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center mb-3" style="gap: 10px;">
|
||||||
|
<span class="fc-section-h">Enabled</span>
|
||||||
|
<v-switch
|
||||||
|
v-model="enabled" :loading="busy" hide-details density="compact"
|
||||||
|
color="success" class="ml-auto" @update:model-value="onToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Point this at your Interpreter service. It stays off until a URL is set, and
|
||||||
|
only translates posts whose text isn't already in the target language — the
|
||||||
|
English is shown by default on post cards, with a toggle back to the original.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="baseUrl" label="Interpreter base URL"
|
||||||
|
placeholder="http://interpreter.your-domain/" density="compact"
|
||||||
|
hide-details class="mb-3" :disabled="busy" @change="onSave"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="targetLang" label="Target language" density="compact"
|
||||||
|
hide-details style="max-width: 160px;" class="mb-3" :disabled="busy"
|
||||||
|
@change="onSave"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<v-btn
|
||||||
|
size="x-small" variant="tonal" rounded="pill" class="ml-2"
|
||||||
|
:loading="testing" :disabled="!baseUrl" @click="onTest"
|
||||||
|
>Test connection</v-btn>
|
||||||
|
<span
|
||||||
|
v-if="testResult !== null" class="text-caption"
|
||||||
|
:class="testResult ? 'text-success' : 'text-error'"
|
||||||
|
>{{ testResult ? '✓ reachable' : '✗ unreachable' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center flex-wrap" style="gap: 10px;">
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="flat" color="accent" rounded="pill"
|
||||||
|
prepend-icon="mdi-translate" :loading="running"
|
||||||
|
:disabled="!enabled || !baseUrl" @click="onRun"
|
||||||
|
>Translate now</v-btn>
|
||||||
|
<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, onUnmounted, ref } from 'vue'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
const store = useImportStore()
|
||||||
|
|
||||||
|
const enabled = ref(false)
|
||||||
|
const baseUrl = ref('')
|
||||||
|
const targetLang = ref('en')
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (!enabled.value) return 'Off'
|
||||||
|
if (!baseUrl.value) return 'No base URL set'
|
||||||
|
if (status.value == null) return 'Checking…'
|
||||||
|
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
|
||||||
|
// URL can be verified before enabling. Also reflects in the status dot.
|
||||||
|
testing.value = true
|
||||||
|
err.value = null
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const r = await api.post('/api/settings/translation/test', {
|
||||||
|
body: { base_url: baseUrl.value.trim() },
|
||||||
|
})
|
||||||
|
testResult.value = !!r.healthy
|
||||||
|
if (status.value) status.value.healthy = !!r.healthy
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e.message
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
const s = store.settings || {}
|
||||||
|
enabled.value = !!s.translation_enabled
|
||||||
|
baseUrl.value = s.interpreter_base_url || ''
|
||||||
|
targetLang.value = s.translation_target_lang || 'en'
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
await loadStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save(patch) {
|
||||||
|
busy.value = true
|
||||||
|
err.value = null
|
||||||
|
try { await store.patchSettings(patch) }
|
||||||
|
catch (e) { err.value = e.message }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(val) {
|
||||||
|
await save({ translation_enabled: !!val })
|
||||||
|
toast({ text: val ? 'Translation on' : 'Translation off', type: 'success' })
|
||||||
|
await loadStatus()
|
||||||
|
}
|
||||||
|
async function onSave() {
|
||||||
|
await save({
|
||||||
|
interpreter_base_url: baseUrl.value.trim(),
|
||||||
|
translation_target_lang: (targetLang.value || 'en').trim() || 'en',
|
||||||
|
})
|
||||||
|
await loadStatus()
|
||||||
|
}
|
||||||
|
async function onRun() {
|
||||||
|
running.value = true
|
||||||
|
err.value = null
|
||||||
|
try {
|
||||||
|
await api.post('/api/settings/translation/run')
|
||||||
|
toast({ text: 'Translation sweep started', type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e.message
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
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>
|
||||||
@@ -44,7 +44,9 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
// empty and let the view explain why (anchor.has_embedding === false).
|
// empty and let the view explain why (anchor.has_embedding === false).
|
||||||
if (detail.has_embedding) {
|
if (detail.has_embedding) {
|
||||||
const body = await api.get('/api/gallery/similar', {
|
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.
|
||||||
|
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
|
||||||
})
|
})
|
||||||
if (!t.isCurrent()) return
|
if (!t.isCurrent()) return
|
||||||
neighbors.value = body.images || []
|
neighbors.value = body.images || []
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { toast } from '../utils/toast.js'
|
import { toast } from '../utils/toast.js'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
@@ -18,182 +18,112 @@ export const CATEGORY_LABELS = {
|
|||||||
|
|
||||||
export const useSuggestionsStore = defineStore('suggestions', () => {
|
export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
// ONE source of truth per image: every trained head scored for this image
|
||||||
// The typed tag-input dropdown searches the model's FULL prediction set for
|
// (min=0), each row carrying above_threshold. The Suggestions PANEL renders
|
||||||
// the image (min=0, down to the store floor) so low-confidence actions/
|
// aboveByCategory; the typed tag-input dropdown reads the full set and
|
||||||
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
// annotates matching DB-tag rows with their score. A single fetch backs both —
|
||||||
const allByCategory = ref({})
|
// 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' })
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||||
let currentImageId = null
|
let currentImageId = null
|
||||||
const inflightAll = useInflightToken()
|
// Audit 2026-06-02: without an inflight guard a late /suggestions response from
|
||||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
// a prior image could overwrite byCategory while currentImageId points at a new
|
||||||
// /suggestions response from a prior image could overwrite
|
// one, and accept() could apply image A's tag to image B. Both guarded below by
|
||||||
// byCategory while currentImageId pointed at a new one, and
|
// capturing imageId at call-time and gating writes on the token.
|
||||||
// accept() dereferenced currentImageId AFTER an awaited POST so
|
|
||||||
// the subsequent /suggestions/accept could apply A's chosen tag
|
|
||||||
// to image B (and push it to the allowlist). Both fixed below
|
|
||||||
// by capturing imageId at call-time and gating writes on the token.
|
|
||||||
const inflight = useInflightToken()
|
const inflight = useInflightToken()
|
||||||
|
|
||||||
|
// The curated above-threshold subset the Suggestions panel shows. A rejected
|
||||||
|
// row that still scores above its cut stays (flagged) so the rejection is
|
||||||
|
// visible + reversible; below-threshold rows live only in the dropdown.
|
||||||
|
const aboveByCategory = computed(() => {
|
||||||
|
const out = {}
|
||||||
|
for (const [cat, list] of Object.entries(byCategory.value)) {
|
||||||
|
const kept = (list || []).filter(s => s.above_threshold)
|
||||||
|
if (kept.length) out[cat] = kept
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
async function load(imageId) {
|
async function load(imageId) {
|
||||||
// Cancel any in-flight load from the previous image so its late
|
// Cancel any in-flight load from the previous image so its late response
|
||||||
// response can't overwrite this image's byCategory.
|
// can't overwrite this image's list.
|
||||||
inflight.cancel()
|
inflight.cancel()
|
||||||
currentImageId = imageId
|
currentImageId = imageId
|
||||||
byCategory.value = {} // cleared upfront so it stays empty on error
|
byCategory.value = {} // cleared upfront so it stays empty on error
|
||||||
const t = inflight.claim()
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
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
|
if (!t.isCurrent()) return
|
||||||
byCategory.value = body.by_category || {}
|
byCategory.value = body.by_category || {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the full prediction set for the dropdown (separate inflight guard so a
|
// A stable identity for a suggestion across the panel + dropdown surfaces:
|
||||||
// late response from a prior image can't overwrite the current one's list).
|
// its canonical tag id (every suggestion has one now).
|
||||||
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.
|
|
||||||
function _keyOf(s) {
|
function _keyOf(s) {
|
||||||
return s.canonical_tag_id != null
|
return `id:${s.canonical_tag_id}`
|
||||||
? `id:${s.canonical_tag_id}`
|
|
||||||
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
// Drop an accepted suggestion from the list so it can't reappear.
|
||||||
// dropdown's full list so it can't reappear in either surface.
|
|
||||||
function _dropEverywhere(suggestion) {
|
function _dropEverywhere(suggestion) {
|
||||||
const key = _keyOf(suggestion)
|
const key = _keyOf(suggestion)
|
||||||
const cat = suggestion.category
|
const list = byCategory.value[suggestion.category]
|
||||||
for (const map of [byCategory.value, allByCategory.value]) {
|
if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
|
||||||
const list = map[cat]
|
|
||||||
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
|
// Flip the `rejected` flag on the matching suggestion in place, so a
|
||||||
// so a reject/un-reject shows immediately without dropping the row (visible,
|
// reject/un-reject shows immediately without dropping the row (visible,
|
||||||
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
|
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
|
||||||
function _setRejectedEverywhere(suggestion, value) {
|
function _setRejectedEverywhere(suggestion, value) {
|
||||||
const key = _keyOf(suggestion)
|
const key = _keyOf(suggestion)
|
||||||
const cat = suggestion.category
|
for (const s of byCategory.value[suggestion.category] || []) {
|
||||||
for (const map of [byCategory.value, allByCategory.value]) {
|
if (_keyOf(s) === key) s.rejected = value
|
||||||
for (const s of map[cat] || []) {
|
|
||||||
if (_keyOf(s) === key) s.rejected = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// opts.tagId: the tag's known id, when the caller already created/resolved
|
// opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
|
||||||
// it (TagPanel's manual-add-matches-suggestion path). Passed separately —
|
// manual-add-matches-suggestion path). Passed separately — NOT spread onto the
|
||||||
// NOT spread onto the suggestion — because _dropEverywhere keys off the
|
// suggestion — because _dropEverywhere keys off the suggestion object.
|
||||||
// suggestion object, and mutating canonical_tag_id on a raw suggestion
|
|
||||||
// would stop it matching its own row in the lists.
|
|
||||||
async function accept(suggestion, { tagId: knownTagId = null } = {}) {
|
async function accept(suggestion, { tagId: knownTagId = null } = {}) {
|
||||||
// Capture imageId so a mid-flight prev/next can't reroute the
|
// Capture imageId so a mid-flight prev/next can't reroute the accept POST to
|
||||||
// accept POST to a different image AND push the tag to that
|
// a different image.
|
||||||
// image's allowlist.
|
|
||||||
const imageId = currentImageId
|
const imageId = currentImageId
|
||||||
if (imageId == null) return
|
if (imageId == null) return
|
||||||
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
const tagId = knownTagId ?? suggestion.canonical_tag_id
|
||||||
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
|
||||||
// first via the existing /api/tags endpoint, then accept by id.
|
|
||||||
let tagId = knownTagId ?? suggestion.canonical_tag_id
|
|
||||||
if (tagId == null) {
|
|
||||||
const created = await api.post('/api/tags', {
|
|
||||||
body: { name: suggestion.display_name, kind: suggestion.category }
|
|
||||||
})
|
|
||||||
tagId = created.id
|
|
||||||
}
|
|
||||||
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||||
body: { tag_id: tagId }
|
body: { tag_id: tagId }
|
||||||
})
|
})
|
||||||
// Only drop from THIS image's category list — if the user navigated,
|
// Only drop from THIS image's list — if the user navigated, the new image
|
||||||
// the new image has its own suggestions and this drop would corrupt them.
|
// has its own suggestions and this drop would corrupt them.
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_dropEverywhere(suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
_acceptToast('Tagged', suggestion.display_name)
|
_acceptToast('Tagged', suggestion.display_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One non-blocking toast for accept/alias. The accepted tag is applied to this
|
// One non-blocking toast for accept. The accepted tag is applied to this image
|
||||||
// image and feeds head training; head auto-apply handles propagation (earned),
|
// and feeds head training; head auto-apply handles propagation (earned).
|
||||||
// so there's no instant fan-out to project.
|
|
||||||
function _acceptToast(verb, displayName) {
|
function _acceptToast(verb, displayName) {
|
||||||
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aliasAccept(suggestion, canonicalTagId) {
|
// Find a live (non-rejected) suggestion matching a manually-picked tag — by
|
||||||
const imageId = currentImageId
|
// canonical id when known, else by (kind, name). Manually adding a tag the
|
||||||
if (imageId == null) return
|
// model also suggested must be indistinguishable from accepting the suggestion
|
||||||
// The alias MUST be stored under the raw model key — resolution looks up the
|
// (recorded + dropped from the panel), so TagPanel routes matches through
|
||||||
// raw prediction key, not the normalized display name. Sending display_name
|
// accept() (operator-asked 2026-07-03).
|
||||||
// (the old bug) stored an alias that never resolved, so the prediction kept
|
|
||||||
// reappearing unaliased. raw_name is null only for centroid hits, which
|
|
||||||
// can't be aliased (the UI hides the action for them).
|
|
||||||
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
|
||||||
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
|
||||||
body: {
|
|
||||||
alias_string: aliasString,
|
|
||||||
alias_category: suggestion.category,
|
|
||||||
canonical_tag_id: canonicalTagId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (currentImageId === imageId) {
|
|
||||||
_dropEverywhere(suggestion)
|
|
||||||
}
|
|
||||||
_acceptToast('Aliased & tagged', suggestion.display_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
|
||||||
// its unaliased form on reload). The canonical tag stays applied if it was
|
|
||||||
// accepted — this only undoes the model-key→tag mapping.
|
|
||||||
async function removeAlias(suggestion) {
|
|
||||||
const imageId = currentImageId
|
|
||||||
if (imageId == null || suggestion.raw_name == null) return
|
|
||||||
await api.delete(
|
|
||||||
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
|
|
||||||
)
|
|
||||||
if (currentImageId === imageId) {
|
|
||||||
await load(imageId)
|
|
||||||
await loadAll(imageId)
|
|
||||||
}
|
|
||||||
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a live (non-rejected) pending suggestion matching a manually-picked
|
|
||||||
// tag — by canonical id when known, else by (kind, name). Manually adding a
|
|
||||||
// tag the model also suggested must be indistinguishable from accepting the
|
|
||||||
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
|
|
||||||
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
|
|
||||||
// set first, then the panel list (loadAll is best-effort and can be empty).
|
|
||||||
function findPending(kind, name, tagId = null) {
|
function findPending(kind, name, tagId = null) {
|
||||||
const lname = (name || '').toLowerCase()
|
const lname = (name || '').toLowerCase()
|
||||||
for (const map of [allByCategory.value, byCategory.value]) {
|
for (const list of Object.values(byCategory.value)) {
|
||||||
for (const list of Object.values(map)) {
|
for (const s of list || []) {
|
||||||
for (const s of list || []) {
|
if (s.rejected) continue
|
||||||
if (s.rejected) continue
|
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s
|
||||||
if (
|
|
||||||
s.category === kind &&
|
|
||||||
(s.display_name || '').toLowerCase() === lname
|
|
||||||
) return s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
async function dismiss(suggestion) {
|
async function dismiss(suggestion) {
|
||||||
const imageId = currentImageId
|
const imageId = currentImageId
|
||||||
if (imageId == null) return
|
if (imageId == null) return
|
||||||
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
|
|
||||||
// nothing to persist a rejection against — drop them client-side as before.
|
|
||||||
// Canonical tags persist a rejection and STAY in the list flagged rejected,
|
// Canonical tags persist a rejection and STAY in the list flagged rejected,
|
||||||
// so the operator can see it and one-click un-reject (misclick recovery).
|
// so the operator can see it and one-click un-reject (misclick recovery).
|
||||||
if (suggestion.canonical_tag_id == null) {
|
|
||||||
if (currentImageId === imageId) _dropEverywhere(suggestion)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||||
body: { tag_id: suggestion.canonical_tag_id }
|
body: { tag_id: suggestion.canonical_tag_id }
|
||||||
})
|
})
|
||||||
@@ -218,33 +142,31 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject every still-unhandled suggestion in a category in one go ("confirm
|
// Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
|
||||||
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
|
// category in one go ("confirm the good ones, reject the rest" — operator-asked
|
||||||
// persist a rejection and STAY flagged rejected (reversible, one-click
|
// 2026-07-06). Only touches what the panel shows, not the low-confidence tail
|
||||||
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
|
// the dropdown carries. Dispatched in parallel so a big section clears fast.
|
||||||
// so a big section clears fast.
|
|
||||||
async function dismissRemaining(category) {
|
async function dismissRemaining(category) {
|
||||||
const imageId = currentImageId
|
const imageId = currentImageId
|
||||||
if (imageId == null) return
|
if (imageId == null) return
|
||||||
const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
|
const targets = (byCategory.value[category] || []).filter(
|
||||||
|
(s) => s.above_threshold && !s.rejected
|
||||||
|
)
|
||||||
if (!targets.length) return
|
if (!targets.length) return
|
||||||
const canon = targets.filter((s) => s.canonical_tag_id != null)
|
await Promise.all(targets.map((s) =>
|
||||||
const raw = targets.filter((s) => s.canonical_tag_id == null)
|
|
||||||
await Promise.all(canon.map((s) =>
|
|
||||||
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||||
body: { tag_id: s.canonical_tag_id },
|
body: { tag_id: s.canonical_tag_id },
|
||||||
})
|
})
|
||||||
))
|
))
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
canon.forEach((s) => _setRejectedEverywhere(s, true))
|
targets.forEach((s) => _setRejectedEverywhere(s, true))
|
||||||
raw.forEach((s) => _dropEverywhere(s))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo a per-image dismissal — the suggestion reverts to a live row.
|
// Undo a per-image dismissal — the suggestion reverts to a live row.
|
||||||
async function undismiss(suggestion) {
|
async function undismiss(suggestion) {
|
||||||
const imageId = currentImageId
|
const imageId = currentImageId
|
||||||
if (imageId == null || suggestion.canonical_tag_id == null) return
|
if (imageId == null) return
|
||||||
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
||||||
body: { tag_id: suggestion.canonical_tag_id }
|
body: { tag_id: suggestion.canonical_tag_id }
|
||||||
})
|
})
|
||||||
@@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byCategory, allByCategory, loading, error,
|
byCategory, aboveByCategory, loading, error,
|
||||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
|
load, accept, dismiss, dismissRemaining, undismiss, findPending
|
||||||
undismiss, findPending
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 = {}) => ({
|
const sugg = (over = {}) => ({
|
||||||
canonical_tag_id: 7,
|
canonical_tag_id: 7,
|
||||||
display_name: 'Ichigo',
|
display_name: 'Ichigo',
|
||||||
category: 'character',
|
category: 'character',
|
||||||
score: 0.9,
|
score: 0.9,
|
||||||
source: 'head',
|
source: 'head',
|
||||||
creates_new_tag: false,
|
above_threshold: true,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
...over,
|
...over,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Seed the store through its own load/loadAll so currentImageId is set the
|
// Seed the store through its single load() so currentImageId is set the way the
|
||||||
// way the panel sets it (accept() derives the image from it).
|
// panel sets it (accept() derives the image from it).
|
||||||
async function seed(store, byCategory) {
|
async function seed(store, byCategory) {
|
||||||
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
|
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
|
||||||
await store.load(1)
|
await store.load(1)
|
||||||
await store.loadAll(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('suggestions store: load derives aboveByCategory', () => {
|
||||||
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => {
|
||||||
|
const s = useSuggestionsStore()
|
||||||
|
await seed(s, {
|
||||||
|
general: [
|
||||||
|
sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }),
|
||||||
|
sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(s.byCategory.general).toHaveLength(2) // dropdown sees all
|
||||||
|
expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only
|
||||||
|
expect(s.aboveByCategory.general[0].display_name).toBe('sword')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
|
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
|
||||||
beforeEach(() => setActivePinia(createPinia()))
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
@@ -45,15 +64,12 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
|
|||||||
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
|
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('matches raw suggestions by kind + name, case-insensitively', async () => {
|
it('matches by kind + name, case-insensitively', async () => {
|
||||||
const s = useSuggestionsStore()
|
const s = useSuggestionsStore()
|
||||||
await seed(s, {
|
await seed(s, {
|
||||||
general: [sugg({
|
general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })],
|
||||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
|
||||||
category: 'general', creates_new_tag: true,
|
|
||||||
})],
|
|
||||||
})
|
})
|
||||||
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.
|
// Kind must match: same name under another kind is a different tag.
|
||||||
expect(s.findPending('character', 'holding sword')).toBeNull()
|
expect(s.findPending('character', 'holding sword')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -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()))
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
afterEach(() => vi.restoreAllMocks())
|
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()
|
const s = useSuggestionsStore()
|
||||||
await seed(s, { character: [sugg()] })
|
await seed(s, { character: [sugg()] })
|
||||||
const calls = []
|
const calls = []
|
||||||
stubFetch((url, init) => {
|
stubFetch((url, init) => {
|
||||||
calls.push({ url, method: init?.method })
|
calls.push({ url, method: init?.method, body: init?.body })
|
||||||
return { status: 200, body: { accepted: true, tag_id: 7 } }
|
return { status: 200, body: { accepted: true, tag_id: 7 } }
|
||||||
})
|
})
|
||||||
await s.accept(s.byCategory.character[0], { tagId: 7 })
|
await s.accept(s.byCategory.character[0])
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||||
|
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 })
|
||||||
expect(s.byCategory.character).toHaveLength(0)
|
expect(s.byCategory.character).toHaveLength(0)
|
||||||
expect(s.allByCategory.character).toHaveLength(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => {
|
it('honors a known tagId and still drops the row by the suggestion identity', async () => {
|
||||||
// TagPanel's manual-create path: the tag was just created by
|
// TagPanel's manual-add-matches-suggestion path passes the resolved tag id;
|
||||||
// host.createAndAdd, so accept must not create again — and the drop must
|
// accept sends exactly it and drops the original suggestion row (which keys
|
||||||
// key off the original raw suggestion object, not the resolved id
|
// off the suggestion object, not the passed id).
|
||||||
// (regression: spreading canonical_tag_id onto the suggestion changed its
|
|
||||||
// identity key and left the panel row behind).
|
|
||||||
const s = useSuggestionsStore()
|
const s = useSuggestionsStore()
|
||||||
const raw = sugg({
|
await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] })
|
||||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
|
||||||
category: 'general', creates_new_tag: true,
|
|
||||||
})
|
|
||||||
await seed(s, { general: [raw] })
|
|
||||||
const calls = []
|
const calls = []
|
||||||
stubFetch((url, init) => {
|
stubFetch((url, init) => {
|
||||||
calls.push({ url, body: init?.body })
|
calls.push({ url, body: init?.body })
|
||||||
@@ -107,6 +117,5 @@ describe('suggestions store: accept with a known tag id', () => {
|
|||||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||||
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
|
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
|
||||||
expect(s.byCategory.general).toHaveLength(0)
|
expect(s.byCategory.general).toHaveLength(0)
|
||||||
expect(s.allByCategory.general).toHaveLength(0)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,206 @@ async def test_patch_import_settings(client):
|
|||||||
assert body["transparency_threshold"] == 0.7
|
assert body["transparency_threshold"] == 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_translation_settings_defaults_and_patch(client):
|
||||||
|
# #143: translation is OFF by default with NO default host — the operator
|
||||||
|
# points it at their own Interpreter proxy and enables it.
|
||||||
|
body = await (await client.get("/api/settings/import")).get_json()
|
||||||
|
assert body["translation_enabled"] is False
|
||||||
|
assert body["interpreter_base_url"] == ""
|
||||||
|
assert body["translation_target_lang"] == "en"
|
||||||
|
|
||||||
|
ok = await client.patch("/api/settings/import", json={
|
||||||
|
"translation_enabled": True,
|
||||||
|
"interpreter_base_url": "http://interpreter.lan",
|
||||||
|
})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
out = await ok.get_json()
|
||||||
|
assert out["translation_enabled"] is True
|
||||||
|
assert out["interpreter_base_url"] == "http://interpreter.lan"
|
||||||
|
|
||||||
|
# Type validation.
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/settings/import", json={"translation_enabled": "yes"})).status_code == 400
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_translation_status_defaults(client):
|
||||||
|
# Off + no URL → no network health call, healthy False (#143).
|
||||||
|
body = await (await client.get("/api/settings/translation/status")).get_json()
|
||||||
|
assert body["enabled"] is False
|
||||||
|
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
|
||||||
|
async def test_translation_run_requires_config(client):
|
||||||
|
resp = await client.post("/api/settings/translation/run")
|
||||||
|
assert resp.status_code == 400 # disabled / no base URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_translation_test_connection(client, monkeypatch):
|
||||||
|
# Tests a GIVEN url (not the saved one) without persisting anything.
|
||||||
|
monkeypatch.setattr("backend.app.api.settings.ic.health", lambda *a, **k: True)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/settings/translation/test", json={"base_url": "http://i.lan"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (await resp.get_json())["healthy"] is True
|
||||||
|
# Empty URL → False, no health call.
|
||||||
|
empty = await client.post(
|
||||||
|
"/api/settings/translation/test", json={"base_url": ""})
|
||||||
|
assert (await empty.get_json())["healthy"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@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: sink.update(k) or type("R", (), {"id": "t1"})(),
|
||||||
|
)
|
||||||
|
await client.patch("/api/settings/import", json={
|
||||||
|
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||||
|
})
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_patch_rejects_non_object(client):
|
async def test_patch_rejects_non_object(client):
|
||||||
resp = await client.patch("/api/settings/import", json=[1, 2, 3])
|
resp = await client.patch("/api/settings/import", json=[1, 2, 3])
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async def test_get_suggestions(client, db):
|
|||||||
general = body["by_category"].get("general", [])
|
general = body["by_category"].get("general", [])
|
||||||
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
|
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
|
||||||
assert s2["source"] == "head"
|
assert s2["source"] == "head"
|
||||||
|
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -114,12 +115,3 @@ async def test_undismiss_reverses_rejection(client, db):
|
|||||||
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
||||||
)
|
)
|
||||||
assert resp2.status_code == 204
|
assert resp2.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_alias_requires_fields(client, db):
|
|
||||||
img = await _img(db)
|
|
||||||
resp = await client.post(
|
|
||||||
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 400
|
|
||||||
|
|||||||
@@ -98,6 +98,30 @@ async def test_similar_excludes_presentation_tagged_images(db):
|
|||||||
assert {i.id for i in res_from_banner} == {src.id, wipped.id, plain.id}
|
assert {i.id for i in res_from_banner} == {src.id, wipped.id, plain.id}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_similar_exclude_wip_drops_wip_neighbors(db):
|
||||||
|
"""Explore passes exclude_wip=True to also hide work-in-progress from the
|
||||||
|
rabbit-hole (banner is always hidden; wip only when asked)."""
|
||||||
|
src = await _img(db, 1, _vec(1, 0))
|
||||||
|
bannered = await _img(db, 2, _vec(1, 0.02)) # always hidden
|
||||||
|
wipped = await _img(db, 3, _vec(1, 0.3)) # hidden only with exclude_wip
|
||||||
|
plain = await _img(db, 4, _vec(1, 0.6))
|
||||||
|
banner_tag = (await db.execute(select(Tag).where(
|
||||||
|
Tag.is_system.is_(True), Tag.name == "banner"))).scalar_one()
|
||||||
|
wip_tag = (await db.execute(select(Tag).where(
|
||||||
|
Tag.is_system.is_(True), Tag.name == "wip"))).scalar_one()
|
||||||
|
await db.execute(image_tag.insert().values(
|
||||||
|
image_record_id=bannered.id, tag_id=banner_tag.id, source="manual"))
|
||||||
|
await db.execute(image_tag.insert().values(
|
||||||
|
image_record_id=wipped.id, tag_id=wip_tag.id, source="manual"))
|
||||||
|
svc = GalleryService(db)
|
||||||
|
res = await svc.similar(src.id, limit=10, exclude_wip=True)
|
||||||
|
assert [i.id for i in res] == [plain.id] # wip + banner both gone
|
||||||
|
# Default (gallery "similar" button) still keeps wip (#1274).
|
||||||
|
res_default = await svc.similar(src.id, limit=10)
|
||||||
|
assert wipped.id in {i.id for i in res_default}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_similar_composes_with_tag_filter(db):
|
async def test_similar_composes_with_tag_filter(db):
|
||||||
src = await _img(db, 1, _vec(1, 0))
|
src = await _img(db, 1, _vec(1, 0))
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Interpreter translation client (#143) — unit tests with mocked HTTP (no DB,
|
||||||
|
no real network)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services import interpreter_client as ic
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, status_code=200, payload=None, headers=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise ic.requests.HTTPError(f"status {self.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_maps_batch_in_order(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, json, timeout):
|
||||||
|
captured["json"] = json
|
||||||
|
return _Resp(200, {
|
||||||
|
"translatedText": ["The cat is cute", "Blonde gal!"],
|
||||||
|
"detectedLanguage": {"language": "ja", "confidence": 0.98},
|
||||||
|
"interpreter": {"engine": "llm", "engine_version": "ollama:x:12b"},
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(ic.session, "post", fake_post)
|
||||||
|
out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan")
|
||||||
|
assert out["translations"] == ["The cat is cute", "Blonde gal!"]
|
||||||
|
assert out["detected_lang"] == "ja"
|
||||||
|
assert out["detected_confidence"] == 0.98 # surfaced for the detection guard
|
||||||
|
assert out["engine_version"] == "ollama:x:12b"
|
||||||
|
assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"]
|
||||||
|
assert captured["json"]["target"] == "en"
|
||||||
|
# Source MUST stay "auto": an explicit source makes Interpreter skip detection
|
||||||
|
# and report confidence 1.0, which would defeat the acceptance gate.
|
||||||
|
assert captured["json"]["source"] == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_passthrough_unchanged(monkeypatch):
|
||||||
|
# Already-English items come back unchanged in their slot (engine "none").
|
||||||
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(200, {
|
||||||
|
"translatedText": ["already english"],
|
||||||
|
"detectedLanguage": {"language": "en"},
|
||||||
|
"interpreter": {"engine": "none", "engine_version": None},
|
||||||
|
}))
|
||||||
|
out = ic.translate(["already english"], base_url="http://i.lan")
|
||||||
|
assert out["translations"] == ["already english"]
|
||||||
|
assert out["detected_lang"] == "en"
|
||||||
|
assert out["engine"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_503_raises_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(503, {}))
|
||||||
|
with pytest.raises(ic.InterpreterUnavailable):
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_connection_error_raises_unavailable(monkeypatch):
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise ic.requests.ConnectionError("refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ic.session, "post", boom)
|
||||||
|
with pytest.raises(ic.InterpreterUnavailable):
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [429, 500, 502, 503, 504])
|
||||||
|
def test_translate_429_and_5xx_raise_unavailable(monkeypatch, code):
|
||||||
|
# A gracefully-draining service behind a reverse proxy returns 429/502/503/504
|
||||||
|
# — all mean "retry later", not an opaque error.
|
||||||
|
monkeypatch.setattr(ic.session, "post", lambda *a, **k: _Resp(code, {}))
|
||||||
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
assert ei.value.retry_after is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_honours_retry_after_seconds(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ic.session, "post",
|
||||||
|
lambda *a, **k: _Resp(503, {}, headers={"Retry-After": "42"}),
|
||||||
|
)
|
||||||
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
assert ei.value.retry_after == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_retry_after_past_http_date_clamps_to_zero(monkeypatch):
|
||||||
|
# HTTP-date form; a past date → non-negative clamp to 0 (deterministic).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ic.session, "post",
|
||||||
|
lambda *a, **k: _Resp(
|
||||||
|
503, {}, headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}),
|
||||||
|
)
|
||||||
|
with pytest.raises(ic.InterpreterUnavailable) as ei:
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
assert ei.value.retry_after == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_400_raises_bad_request(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ic.session, "post", lambda *a, **k: _Resp(400, {"error": "bad target"})
|
||||||
|
)
|
||||||
|
with pytest.raises(ic.InterpreterBadRequest):
|
||||||
|
ic.translate(["x"], base_url="http://i.lan")
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_empty_is_noop(monkeypatch):
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("should not call the service for an empty batch")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ic.session, "post", boom)
|
||||||
|
assert ic.translate([], base_url="http://i.lan")["translations"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_true_when_llm_up(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ic.session, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": True}})
|
||||||
|
)
|
||||||
|
assert ic.health("http://i.lan") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_false_when_engine_down(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ic.session, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": False}})
|
||||||
|
)
|
||||||
|
assert ic.health("http://i.lan") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_false_on_error(monkeypatch):
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise ic.requests.ConnectionError("refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ic.session, "get", boom)
|
||||||
|
assert ic.health("http://i.lan") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_false_when_url_empty():
|
||||||
|
assert ic.health("") is False
|
||||||
@@ -26,6 +26,28 @@ def _make_batch(session) -> int:
|
|||||||
return batch.id
|
return batch.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_orphaned_temp_files_removes_stale_only(tmp_path, monkeypatch):
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.app.tasks import maintenance as m
|
||||||
|
|
||||||
|
monkeypatch.setattr(m, "IMAGES_ROOT", tmp_path)
|
||||||
|
stale = tmp_path / "artist" / "img.jpg.part" # killed download → orphan
|
||||||
|
stale.parent.mkdir(parents=True)
|
||||||
|
stale.write_bytes(b"x")
|
||||||
|
old = datetime.now(UTC).timestamp() - 8 * 3600 # older than the 6h guard
|
||||||
|
os.utime(stale, (old, old))
|
||||||
|
fresh = tmp_path / "in_progress.jpg.partial" # active download → keep
|
||||||
|
fresh.write_bytes(b"x")
|
||||||
|
keep = tmp_path / "real.jpg" # a real image → keep
|
||||||
|
keep.write_bytes(b"x")
|
||||||
|
|
||||||
|
assert m.cleanup_orphaned_temp_files() == 1
|
||||||
|
assert not stale.exists()
|
||||||
|
assert fresh.exists()
|
||||||
|
assert keep.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_recover_interrupted_only_old(db_sync, monkeypatch):
|
def test_recover_interrupted_only_old(db_sync, monkeypatch):
|
||||||
batch_id = _make_batch(db_sync)
|
batch_id = _make_batch(db_sync)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|||||||
@@ -62,9 +62,8 @@ async def test_head_suggestion_surfaces_for_matching_image(db):
|
|||||||
s = general[0]
|
s = general[0]
|
||||||
assert s.canonical_tag_id == tag.id
|
assert s.canonical_tag_id == tag.id
|
||||||
assert s.source == "head"
|
assert s.source == "head"
|
||||||
assert s.creates_new_tag is False
|
|
||||||
assert s.via_alias is False and s.raw_name is None
|
|
||||||
assert s.score > 0.5
|
assert s.score > 0.5
|
||||||
|
assert s.above_threshold is True # ~0.73 clears the 0.5 suggest cut
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -109,7 +108,10 @@ async def test_threshold_override_surfaces_below_cut(db):
|
|||||||
svc = SuggestionService(db)
|
svc = SuggestionService(db)
|
||||||
assert svc and not (await svc.for_image(img.id)).by_category.get("general")
|
assert svc and not (await svc.for_image(img.id)).by_category.get("general")
|
||||||
flooded = await svc.for_image(img.id, threshold_override=0.0)
|
flooded = await svc.for_image(img.id, threshold_override=0.0)
|
||||||
assert any(s.canonical_tag_id == tag.id for s in flooded.by_category["general"])
|
s = next(s for s in flooded.by_category["general"] if s.canonical_tag_id == tag.id)
|
||||||
|
# Included by the floor, but flagged below its own cut (0.5 < 0.6) — this is
|
||||||
|
# what lets the dropdown show it while the panel hides it.
|
||||||
|
assert s.above_threshold is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -281,6 +283,7 @@ async def test_ccip_character_surfaces_in_rail(db):
|
|||||||
if c.canonical_tag_id == raven.id
|
if c.canonical_tag_id == raven.id
|
||||||
)
|
)
|
||||||
assert m.source == "ccip"
|
assert m.source == "ccip"
|
||||||
|
assert m.above_threshold is True # CCIP only returns matches above its cut
|
||||||
|
|
||||||
|
|
||||||
# --- #1206 Step 4: on-demand grounding for ALREADY-APPLIED tag chips ---------
|
# --- #1206 Step 4: on-demand grounding for ALREADY-APPLIED tag chips ---------
|
||||||
|
|||||||
@@ -283,6 +283,30 @@ async def test_scroll_item_shape_minimal(db):
|
|||||||
assert "description_full" not in item
|
assert "description_full" not in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_surfaces_translation_fields(db):
|
||||||
|
# #143: a translated post exposes the translated title/description + source
|
||||||
|
# lang, with the originals still present for the toggle.
|
||||||
|
artist = await _seed_artist(db, "alice-tr")
|
||||||
|
src = await _seed_source(db, artist.id, "pixiv", "https://p/alice-tr")
|
||||||
|
p = await _seed_post(
|
||||||
|
db, src.id, external_id="TR", post_date=datetime.now(UTC),
|
||||||
|
title="ねこ", description="<p>かわいい</p>",
|
||||||
|
)
|
||||||
|
p.post_title_translated = "cat"
|
||||||
|
p.description_translated = "cute"
|
||||||
|
p.translated_source_lang = "ja"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
item = page["items"][0]
|
||||||
|
assert item["post_title_translated"] == "cat"
|
||||||
|
assert item["description_translated"] == "cute"
|
||||||
|
assert item["translated_source_lang"] == "ja"
|
||||||
|
assert item["post_title"] == "ねこ" # original kept for the toggle
|
||||||
|
assert item["description_plain"] == "かわいい"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scroll_description_truncates_long_text(db):
|
async def test_scroll_description_truncates_long_text(db):
|
||||||
artist = await _seed_artist(db, "alice-long")
|
artist = await _seed_artist(db, "alice-long")
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
"""translate_posts sweep (#143) — integration, with a stubbed Interpreter client
|
||||||
|
and the task's session factory pointed at the test's db_sync."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import Artist, ImportSettings, Post
|
||||||
|
from backend.app.services import interpreter_client as ic
|
||||||
|
from backend.app.tasks.translation import (
|
||||||
|
_RETRANSLATE_COUNTDOWN,
|
||||||
|
retranslate_posts,
|
||||||
|
translate_posts,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
def __init__(self, s):
|
||||||
|
self.s = s
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.s
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sf(db_sync):
|
||||||
|
class _SM:
|
||||||
|
def __call__(self):
|
||||||
|
return _Ctx(db_sync)
|
||||||
|
|
||||||
|
return _SM()
|
||||||
|
|
||||||
|
|
||||||
|
def _artist(db, name="A", slug="a"):
|
||||||
|
a = Artist(name=name, slug=slug)
|
||||||
|
db.add(a)
|
||||||
|
db.flush()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def _post(db, artist_id, *, title=None, desc=None, ext="p1"):
|
||||||
|
p = Post(
|
||||||
|
artist_id=artist_id, external_post_id=ext,
|
||||||
|
post_title=title, description=desc,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_translated(p, *, lang="ja", ver="v1"):
|
||||||
|
"""Simulate a prior (old-model) translation so retranslate has something to
|
||||||
|
reset."""
|
||||||
|
p.post_title_translated = "OLD"
|
||||||
|
p.description_translated = "OLD"
|
||||||
|
p.translated_source_lang = lang
|
||||||
|
p.translation_engine_version = ver
|
||||||
|
|
||||||
|
|
||||||
|
def _enable(db, url="http://i.lan"):
|
||||||
|
cfg = db.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
cfg.translation_enabled = True
|
||||||
|
cfg.interpreter_base_url = url
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch, db_sync):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation._sync_session_factory",
|
||||||
|
lambda: _sf(db_sync),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_stores_translation(db_sync, monkeypatch):
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.ic.translate",
|
||||||
|
lambda texts, **k: {
|
||||||
|
"translations": [f"EN:{t}" for t in texts],
|
||||||
|
"detected_lang": "ja", "engine": "llm", "engine_version": "v1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", desc="かわいい")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert "translated=1" in translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated == "EN:ねこ"
|
||||||
|
assert p.description_translated == "EN:かわいい"
|
||||||
|
assert p.translated_source_lang == "ja"
|
||||||
|
assert p.translation_engine_version == "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch):
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.ic.translate",
|
||||||
|
lambda texts, **k: {
|
||||||
|
"translations": list(texts), "detected_lang": "en",
|
||||||
|
"engine": "none", "engine_version": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="hello world", ext="p2")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang == "en" # marked handled...
|
||||||
|
assert p.post_title_translated is None # ...but nothing to show
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_mixed_language_translates_nonenglish_field(db_sync, monkeypatch):
|
||||||
|
# English title + Japanese description: the description is still translated
|
||||||
|
# even though the title is already English (per-field detection, not the old
|
||||||
|
# aggregate first-item bail). Source lang comes from the translated field.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
|
||||||
|
def fake_translate(texts, **k):
|
||||||
|
t = texts[0]
|
||||||
|
if t.isascii(): # already English → passthrough
|
||||||
|
return {"translations": [t], "detected_lang": "en",
|
||||||
|
"engine": "none", "engine_version": None}
|
||||||
|
return {"translations": [f"EN:{t}"], "detected_lang": "ja",
|
||||||
|
"engine": "llm", "engine_version": "v9"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.translate", fake_translate)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="hello", desc="ねこ")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert "translated=1" in translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated is None # English title untouched
|
||||||
|
assert p.description_translated == "EN:ねこ" # Japanese desc translated
|
||||||
|
assert p.translated_source_lang == "ja" # from the translated field
|
||||||
|
assert p.translation_engine_version == "v9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_disabled_is_noop(db_sync, monkeypatch):
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p3")
|
||||||
|
db_sync.commit() # translation_enabled defaults False
|
||||||
|
assert translate_posts() == "disabled"
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_service_down_leaves_untranslated(db_sync, monkeypatch):
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p4")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
assert translate_posts() == "interpreter unavailable"
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang is None # will retry next run
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_interrupt_reenqueues_after_backoff(db_sync, monkeypatch):
|
||||||
|
# A drain mid-sweep (health passed, translate 503s w/ Retry-After) re-enqueues
|
||||||
|
# the daily sweep after the backoff instead of waiting for tomorrow's beat.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
|
||||||
|
def _drain(texts, **k):
|
||||||
|
raise ic.InterpreterUnavailable("draining", retry_after=30)
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.translate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
_post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert "interrupted" in translate_posts()
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0][1]["countdown"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_ok(monkeypatch):
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.ic.translate",
|
||||||
|
lambda texts, **k: {
|
||||||
|
"translations": [f"EN:{t}" for t in texts],
|
||||||
|
"detected_lang": "ja", "engine": "llm", "engine_version": "v1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_drain_chases_tail(db_sync, monkeypatch):
|
||||||
|
# "Translate now" (drain=True): with the chunk capped at 1 and 2 untranslated
|
||||||
|
# posts, the first chunk re-enqueues itself — drain preserved — to finish the
|
||||||
|
# backlog rather than waiting for the next beat.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_ok(monkeypatch)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.translate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
_post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_post(db_sync, a.id, title="いぬ", ext="p2")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts(drain=True)
|
||||||
|
assert len(calls) == 1 # one re-enqueue for the tail
|
||||||
|
args, kw = calls[0]
|
||||||
|
assert args[1] == {"drain": True} # drain flag carried forward
|
||||||
|
assert kw["countdown"] == _RETRANSLATE_COUNTDOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_beat_single_chunk_no_tail_chase(db_sync, monkeypatch):
|
||||||
|
# The periodic beat (drain defaults False) does exactly ONE chunk and never
|
||||||
|
# re-enqueues — the 8h cadence drives the rest.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_ok(monkeypatch)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.translate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
_post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_post(db_sync, a.id, title="いぬ", ext="p2")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts() # drain=False
|
||||||
|
assert calls == [] # no self-re-enqueue
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_lang(monkeypatch, *, lang, conf, ver="v1"):
|
||||||
|
# Interpreter is up and returns a translation with the given detected language
|
||||||
|
# + confidence, so the acceptance gate can be exercised end-to-end.
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.ic.translate",
|
||||||
|
lambda texts, **k: {
|
||||||
|
"translations": [f"EN:{t}" for t in texts],
|
||||||
|
"detected_lang": lang, "detected_confidence": conf,
|
||||||
|
"engine": "llm", "engine_version": ver,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_rejects_low_confidence_latin(db_sync, monkeypatch):
|
||||||
|
# A short English title Interpreter mis-labels as German with LOW confidence is
|
||||||
|
# NOT stored — the original is kept, and the post is marked handled (source ==
|
||||||
|
# target) so the sweep won't revisit it.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_lang(monkeypatch, lang="de", conf=0.42)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="Nami Heroines - WIP Part 1", ext="p1")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated is None # mis-flag rejected, kept
|
||||||
|
assert p.translated_source_lang == "en" # marked handled (== target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_accepts_high_confidence_latin(db_sync, monkeypatch):
|
||||||
|
# A confident latin-script detection (real German) clears the floor → stored.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_lang(monkeypatch, lang="de", conf=0.98)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="Das Mädchen mit dem Perlenohrring", ext="p2")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated == "EN:Das Mädchen mit dem Perlenohrring"
|
||||||
|
assert p.translated_source_lang == "de"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_posts_accepts_low_confidence_cjk(db_sync, monkeypatch):
|
||||||
|
# CJK is script-detected and trusted regardless of the reported number — a
|
||||||
|
# zh 0.75 (e.g. pure-kanji Japanese) is still translated, not rejected.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_lang(monkeypatch, lang="zh", conf=0.75)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="猫が可愛い", ext="p3")
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
translate_posts()
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated == "EN:猫が可愛い"
|
||||||
|
assert p.translated_source_lang == "zh"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_new_model(monkeypatch, *, ver="v2"):
|
||||||
|
"""Interpreter is up and translates with a NEW engine version."""
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.ic.translate",
|
||||||
|
lambda texts, **k: {
|
||||||
|
"translations": [f"NEW:{t}" for t in texts],
|
||||||
|
"detected_lang": "ja", "engine": "llm", "engine_version": ver,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_resets_and_reruns(db_sync, monkeypatch):
|
||||||
|
# A post translated by the old model gets cleared + re-run through the new
|
||||||
|
# one (new engine_version proves the reset happened, not a cache short-circuit).
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_new_model(monkeypatch)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", desc="かわいい")
|
||||||
|
_mark_translated(p)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
retranslate_posts(artist_ids=[a.id])
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.post_title_translated == "NEW:ねこ"
|
||||||
|
assert p.description_translated == "NEW:かわいい"
|
||||||
|
assert p.translation_engine_version == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_artist_scope_leaves_others(db_sync, monkeypatch):
|
||||||
|
# Scoping to one artist must not touch another artist's translations.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_new_model(monkeypatch)
|
||||||
|
a1 = _artist(db_sync)
|
||||||
|
a2 = _artist(db_sync, name="B", slug="b")
|
||||||
|
p1 = _post(db_sync, a1.id, title="ねこ", ext="p1")
|
||||||
|
p2 = _post(db_sync, a2.id, title="いぬ", ext="p2")
|
||||||
|
_mark_translated(p1)
|
||||||
|
_mark_translated(p2)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
retranslate_posts(artist_ids=[a1.id])
|
||||||
|
db_sync.refresh(p1)
|
||||||
|
db_sync.refresh(p2)
|
||||||
|
assert p1.post_title_translated == "NEW:ねこ" # re-run
|
||||||
|
assert p1.translation_engine_version == "v2"
|
||||||
|
assert p2.post_title_translated == "OLD" # untouched
|
||||||
|
assert p2.translation_engine_version == "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_runs_until_done(db_sync, monkeypatch):
|
||||||
|
# With the chunk capped at 1 and 2 posts to redo, the first chunk re-enqueues
|
||||||
|
# itself (with _reset_done=True so it doesn't wipe the fresh work) to finish.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
_mock_new_model(monkeypatch)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.retranslate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p1 = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
p2 = _post(db_sync, a.id, title="いぬ", ext="p2")
|
||||||
|
_mark_translated(p1)
|
||||||
|
_mark_translated(p2)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
retranslate_posts(artist_ids=[a.id])
|
||||||
|
assert len(calls) == 1 # one re-enqueue for the tail
|
||||||
|
args, _kw = calls[0]
|
||||||
|
assert args[1]["_reset_done"] is True
|
||||||
|
assert args[1]["artist_ids"] == [a.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_disabled_does_not_reset(db_sync, monkeypatch):
|
||||||
|
# Never wipe translations we can't rebuild — a disabled service leaves them.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_mark_translated(p)
|
||||||
|
db_sync.commit() # translation_enabled defaults False
|
||||||
|
assert retranslate_posts(artist_ids=[a.id]) == "disabled"
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang == "ja"
|
||||||
|
assert p.post_title_translated == "OLD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_unhealthy_does_not_reset(db_sync, monkeypatch):
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_mark_translated(p)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
assert retranslate_posts(artist_ids=[a.id]) == "interpreter unavailable"
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang == "ja"
|
||||||
|
assert p.post_title_translated == "OLD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_interrupt_resumes_after_retry_after(db_sync, monkeypatch):
|
||||||
|
# Interpreter drains mid-chunk (health passed, then translate 503s with a
|
||||||
|
# Retry-After): the bulk re-translate re-enqueues itself after the hinted
|
||||||
|
# backoff, with _reset_done=True so it never re-wipes fresh work — instead of
|
||||||
|
# stalling until the daily beat.
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
|
||||||
|
def _drain(texts, **k):
|
||||||
|
raise ic.InterpreterUnavailable("draining", retry_after=42)
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.retranslate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_mark_translated(p)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
assert "interrupted" in retranslate_posts(artist_ids=[a.id])
|
||||||
|
assert len(calls) == 1 # resume re-enqueued
|
||||||
|
args, kw = calls[0]
|
||||||
|
assert args[1]["_reset_done"] is True
|
||||||
|
assert args[1]["artist_ids"] == [a.id]
|
||||||
|
assert kw["countdown"] == 42 # honoured the Retry-After
|
||||||
|
db_sync.refresh(p)
|
||||||
|
assert p.translated_source_lang is None # reset happened, not re-run
|
||||||
|
|
||||||
|
|
||||||
|
def test_retranslate_interrupt_default_backoff_without_hint(db_sync, monkeypatch):
|
||||||
|
# No Retry-After header → fall back to the default backoff (60s).
|
||||||
|
_patch(monkeypatch, db_sync)
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||||
|
|
||||||
|
def _drain(texts, **k):
|
||||||
|
raise ic.InterpreterUnavailable("draining")
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.tasks.translation.retranslate_posts.apply_async",
|
||||||
|
lambda *a, **k: calls.append((a, k)),
|
||||||
|
)
|
||||||
|
a = _artist(db_sync)
|
||||||
|
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||||
|
_mark_translated(p)
|
||||||
|
_enable(db_sync)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
retranslate_posts(artist_ids=[a.id])
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0][1]["countdown"] == 60
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Translation acceptance gate (#1376) — pure unit tests for ``_accept``, which
|
||||||
|
decides whether curator stores Interpreter's translation or keeps the original.
|
||||||
|
Curator does no detection of its own; it only thresholds Interpreter's reported
|
||||||
|
detected language + confidence (Scribe rule 133). No DB, no network."""
|
||||||
|
from backend.app.tasks.translation import _MIN_LATIN_CONFIDENCE, _accept
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_trusts_cjk_regardless_of_confidence():
|
||||||
|
# ja/ko/zh are script-detected — accepted even at a low reported number
|
||||||
|
# (pure-kanji Japanese can come back labelled zh ~0.75, still translated).
|
||||||
|
assert _accept("ja", None) is True
|
||||||
|
assert _accept("ja", 0.10) is True
|
||||||
|
assert _accept("ko", 0.99) is True
|
||||||
|
assert _accept("zh", 0.75) is True
|
||||||
|
assert _accept("zh-CN", 0.20) is True # region suffix is still CJK
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_requires_floor_for_latin():
|
||||||
|
assert _accept("de", 0.98) is True
|
||||||
|
assert _accept("de", 0.86) is True # calibrated: ambiguous-but-correct German stays
|
||||||
|
assert _accept("de", _MIN_LATIN_CONFIDENCE) is True # exactly at floor
|
||||||
|
assert _accept("de", _MIN_LATIN_CONFIDENCE - 0.01) is False
|
||||||
|
assert _accept("fr", 0.40) is False # short mis-flag
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_is_case_insensitive():
|
||||||
|
assert _accept("JA", None) is True
|
||||||
|
assert _accept("DE", 0.40) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_missing_confidence_fails_open():
|
||||||
|
# No confidence reported → don't silently drop a translation.
|
||||||
|
assert _accept("de", None) is True
|
||||||
|
assert _accept("", None) is True
|
||||||
Reference in New Issue
Block a user