Compare commits
14 Commits
4c67c116b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77bfc32b02 | |||
| aea2701c28 | |||
| d13efe1b69 | |||
| a444cf82d1 | |||
| ca6d26d236 | |||
| 6ab7fd5c7f | |||
| 7ddf94220d | |||
| 5366047d55 | |||
| ffdbdbaf07 | |||
| 2923257529 | |||
| a017771621 | |||
| 25e555cab6 | |||
| 934731e9ef | |||
| 3c7ab44e74 |
@@ -0,0 +1,51 @@
|
||||
"""translation strictness setting + per-post translation override (milestone 155)
|
||||
|
||||
ImportSettings gains ``translation_min_confidence`` (the latin-script acceptance
|
||||
floor, now operator-tunable in the UI; default 0.9 — stricter than the old
|
||||
hardcoded 0.8, since Interpreter confidently mis-detects short ASCII English at
|
||||
~0.86). Post gains ``translation_override`` — a sticky per-post choice of
|
||||
auto / force / original so the operator can force a skipped translation on, or
|
||||
knock a wrongly-translated one back to the original, and have it survive a
|
||||
Re-translate-all.
|
||||
|
||||
Revision ID: 0084
|
||||
Revises: 0083
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0084"
|
||||
down_revision: Union[str, None] = "0083"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"translation_min_confidence", sa.Float(), nullable=False,
|
||||
server_default=sa.text("0.9"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"post",
|
||||
sa.Column(
|
||||
"translation_override", sa.String(16), nullable=False,
|
||||
server_default="auto",
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_post_translation_override",
|
||||
"post",
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_post_translation_override", "post", type_="check")
|
||||
op.drop_column("post", "translation_override")
|
||||
op.drop_column("import_settings", "translation_min_confidence")
|
||||
@@ -3,12 +3,28 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import ImportSettings, Post
|
||||
from ..services import interpreter_client as ic
|
||||
from ..services.post_feed_service import PostFeedService
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ..utils.text import html_to_plain
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
|
||||
|
||||
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
|
||||
|
||||
|
||||
def _queue_for_sweep(post: Post) -> None:
|
||||
"""Mark a post untranslated (all translation columns NULL) so the periodic
|
||||
sweep re-runs it under its new override — used when Interpreter is down and we
|
||||
can't translate inline."""
|
||||
post.post_title_translated = None
|
||||
post.description_translated = None
|
||||
post.translated_source_lang = None
|
||||
post.translation_engine_version = None
|
||||
post.translated_at = None
|
||||
|
||||
|
||||
@posts_bp.route("", methods=["GET"])
|
||||
async def list_posts():
|
||||
@@ -82,3 +98,70 @@ async def get_post(post_id: int):
|
||||
if item is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
@posts_bp.route("/<int:post_id>/translation-override", methods=["POST"])
|
||||
async def set_translation_override(post_id: int):
|
||||
"""Sticky per-post translation override (milestone 155). Body:
|
||||
``{"override": "auto" | "force" | "original"}``.
|
||||
|
||||
'original' keeps the original (clears any stored translation now — no
|
||||
Interpreter needed). 'force'/'auto' translate the post immediately if the
|
||||
service is up (force bypasses the acceptance floor; auto re-runs the gate);
|
||||
if it's down we save the flag and mark the post untranslated so the next sweep
|
||||
applies it. The override persists, so the sweep + Re-translate-all keep
|
||||
honoring it. Returns the updated translation fields + an ``applied`` status."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
override = body.get("override")
|
||||
if override not in _TRANSLATION_OVERRIDES:
|
||||
return _bad(
|
||||
"invalid_override",
|
||||
detail=f"override must be one of {list(_TRANSLATION_OVERRIDES)}",
|
||||
)
|
||||
|
||||
# Lazy import (mirrors settings.py) so the API module doesn't pull the celery
|
||||
# task graph at import time.
|
||||
from ..tasks.translation import _store_translation, _translate_field
|
||||
|
||||
async with get_session() as session:
|
||||
post = await session.get(Post, post_id)
|
||||
if post is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
post.translation_override = override
|
||||
cfg = await ImportSettings.load(session)
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
|
||||
if override == "original":
|
||||
_store_translation(post, (None, None, None), (None, None, None), target)
|
||||
applied = "cleared"
|
||||
else:
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
if cfg.translation_enabled and base_url and ic.health(base_url):
|
||||
force = override == "force"
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
mc = cfg.translation_min_confidence
|
||||
try:
|
||||
title_res = _translate_field(title, base_url, target, mc, force=force)
|
||||
desc_res = _translate_field(desc, base_url, target, mc, force=force)
|
||||
except ic.InterpreterUnavailable:
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
else:
|
||||
_store_translation(post, title_res, desc_res, target)
|
||||
applied = "translated"
|
||||
else:
|
||||
# Disabled / no URL / unhealthy → let the sweep apply it later.
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
|
||||
await session.commit()
|
||||
return jsonify({
|
||||
"id": post.id,
|
||||
"translation_override": post.translation_override,
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": post.description_translated,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
"applied": applied,
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ _EDITABLE_FIELDS = (
|
||||
"translation_enabled",
|
||||
"interpreter_base_url",
|
||||
"translation_target_lang",
|
||||
"translation_min_confidence",
|
||||
)
|
||||
|
||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||
@@ -87,6 +88,7 @@ async def get_import_settings():
|
||||
"translation_enabled": row.translation_enabled,
|
||||
"interpreter_base_url": row.interpreter_base_url,
|
||||
"translation_target_lang": row.translation_target_lang,
|
||||
"translation_min_confidence": row.translation_min_confidence,
|
||||
})
|
||||
|
||||
|
||||
@@ -155,6 +157,14 @@ async def update_import_settings():
|
||||
for key in ("interpreter_base_url", "translation_target_lang"):
|
||||
if key in body and not isinstance(body[key], str):
|
||||
return jsonify({"error": f"{key} must be a string"}), 400
|
||||
# Acceptance floor (milestone 155): latin-script translations below this
|
||||
# Interpreter confidence are kept as the original.
|
||||
if "translation_min_confidence" in body:
|
||||
v = body["translation_min_confidence"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
return jsonify(
|
||||
{"error": "translation_min_confidence must be a number in [0, 1]"}
|
||||
), 400
|
||||
if "series_suggest_threshold" in body:
|
||||
v = body["series_suggest_threshold"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
@@ -364,9 +374,49 @@ async def translation_test():
|
||||
return jsonify({"healthy": healthy})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/probe", methods=["POST"])
|
||||
async def translation_probe():
|
||||
"""Diagnostic for the Settings 'Test translation' box: translate a pasted
|
||||
snippet WITHOUT saving anything, returning what Interpreter *detected*
|
||||
(language + confidence) alongside the result. Lets the operator see why a
|
||||
given string was (mis-)detected — e.g. a short English title flagged as
|
||||
another language — so a detection guard can be tuned from real numbers.
|
||||
Read-only: no post is touched. Uses the currently-saved base URL + target."""
|
||||
body = await request.get_json(silent=True)
|
||||
body = body if isinstance(body, dict) else {}
|
||||
text = (body.get("text") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "provide text to translate"}), 400
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
if not base_url:
|
||||
return jsonify({"error": "no Interpreter base URL is set"}), 400
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
try:
|
||||
res = await asyncio.to_thread(
|
||||
ic.translate, [text], base_url=base_url, target=target,
|
||||
)
|
||||
except ic.InterpreterUnavailable as e:
|
||||
return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
|
||||
except ic.InterpreterBadRequest as e:
|
||||
return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
|
||||
translations = res.get("translations") or []
|
||||
return jsonify({
|
||||
"target": target,
|
||||
"detected_lang": res.get("detected_lang"),
|
||||
"detected_confidence": res.get("detected_confidence"),
|
||||
"engine": res.get("engine"),
|
||||
"engine_version": res.get("engine_version"),
|
||||
"translated": translations[0] if translations else None,
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/run", methods=["POST"])
|
||||
async def translation_run():
|
||||
"""Enqueue the translate sweep now (the Settings 'Translate now' button)."""
|
||||
"""Enqueue the translate sweep now (the Settings 'Translate now' button).
|
||||
Runs in drain mode — run-until-done — so one press chases the whole
|
||||
untranslated backlog to zero rather than a single 300-post chunk."""
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
|
||||
@@ -375,7 +425,7 @@ async def translation_run():
|
||||
), 400
|
||||
from ..tasks.translation import translate_posts
|
||||
|
||||
r = translate_posts.delay()
|
||||
r = translate_posts.delay(drain=True)
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
||||
|
||||
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||
async def get_suggestions(image_id: int):
|
||||
# ?min=<float> overrides the configured per-category thresholds so the typed
|
||||
# tag-input dropdown can surface EVERY stored prediction (min=0), including
|
||||
# low-confidence actions/features, in canonical formatting. Omitted → the
|
||||
# curated above-threshold list the Suggestions panel uses.
|
||||
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
|
||||
# rail sends min=0 in its single per-image fetch to get EVERY head (each row
|
||||
# still carries above_threshold vs its natural cut), then derives the panel
|
||||
# (above_threshold) and the typed dropdown (all, filtered by text) client-side
|
||||
# — no second request. Omitted → only above-threshold rows.
|
||||
override = None
|
||||
raw_min = request.args.get("min")
|
||||
if raw_min is not None:
|
||||
@@ -36,12 +37,11 @@ async def get_suggestions(image_id: int):
|
||||
"category": s.category,
|
||||
"score": round(s.score, 4),
|
||||
"source": s.source,
|
||||
"creates_new_tag": s.creates_new_tag,
|
||||
# raw model key (alias is stored under this) + whether an
|
||||
# operator alias produced this suggestion — drive the
|
||||
# modal's "Treat as alias"/"Remove alias" affordances.
|
||||
"raw_name": s.raw_name,
|
||||
"via_alias": s.via_alias,
|
||||
# whether the score cleared the head's own suggest cut.
|
||||
# The single min=0 fetch returns every head; the panel
|
||||
# shows above_threshold, the typed dropdown shows all and
|
||||
# annotates each match with its score.
|
||||
"above_threshold": s.above_threshold,
|
||||
# operator dismissed this tag for this image — surfaced
|
||||
# (not dropped) so the rail can show it rejected + offer
|
||||
# one-click un-reject.
|
||||
@@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int):
|
||||
return jsonify({"accepted": True, "tag_id": tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
|
||||
)
|
||||
async def alias_suggestion(image_id: int):
|
||||
body = await request.get_json()
|
||||
required = {"alias_string", "alias_category", "canonical_tag_id"}
|
||||
if not body or not required.issubset(body):
|
||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
||||
canonical_tag_id = body["canonical_tag_id"]
|
||||
async with get_session() as session:
|
||||
await AllowlistService(session).add_alias_and_accept(
|
||||
image_id,
|
||||
body["alias_string"],
|
||||
body["alias_category"],
|
||||
canonical_tag_id,
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
|
||||
|
||||
|
||||
@suggestions_bp.route(
|
||||
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
|
||||
)
|
||||
|
||||
@@ -178,9 +178,13 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||
},
|
||||
"translate-posts-daily": {
|
||||
"translate-posts-8h": {
|
||||
"task": "backend.app.tasks.translation.translate_posts",
|
||||
"schedule": 86400.0, # no-op unless translation configured + healthy
|
||||
"schedule": 28800.0, # every 8h: steady-state cadence for the
|
||||
# trickle of newly-imported posts (no-op unless translation
|
||||
# configured + healthy). One bounded 300-chunk per fire — the
|
||||
# one-time backlog drains via the "Translate now" button
|
||||
# (drain=True, run-until-done), not this sweep.
|
||||
},
|
||||
"snapshot-head-metrics-daily": {
|
||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||
|
||||
@@ -106,6 +106,15 @@ class ImportSettings(Base):
|
||||
translation_target_lang: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="en", server_default="en",
|
||||
)
|
||||
# The latin-script acceptance floor for the translation gate: a translation
|
||||
# whose Interpreter-reported confidence is below this is kept as the original
|
||||
# (operator-tunable, milestone 155). Default 0.9 — stricter than the old
|
||||
# hardcoded 0.8, because Interpreter confidently mis-detects short ASCII
|
||||
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
|
||||
# trusted regardless (script-detected). Per-post overrides handle the misses.
|
||||
translation_min_confidence: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.9, server_default="0.9",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, session) -> ImportSettings:
|
||||
|
||||
@@ -8,7 +8,17 @@ artist-filter queries don't depend on the Source detour).
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -23,6 +33,10 @@ class Post(Base):
|
||||
# (created in alembic 0030) covers that case via
|
||||
# (artist_id, external_post_id).
|
||||
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
||||
CheckConstraint(
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
name="ck_post_translation_override",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -64,6 +78,16 @@ class Post(Base):
|
||||
translated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Sticky per-post override of the translation decision (milestone 155):
|
||||
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
|
||||
# translation even below the confidence floor (rescue a skipped legit-foreign
|
||||
# title); 'original' = never translate, keep the original (kill a confidently
|
||||
# mis-flagged one the floor can't catch). The sweep reads this on every run,
|
||||
# and re-translate leaves 'original' posts alone, so the choice survives a
|
||||
# Re-translate-all.
|
||||
translation_override: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="auto", server_default="auto",
|
||||
)
|
||||
|
||||
downloaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -96,8 +96,9 @@ def translate(
|
||||
) -> dict:
|
||||
"""Translate a batch. Returns::
|
||||
|
||||
{"translations": [str, ...], # SAME order & length as `texts`
|
||||
"detected_lang": str | None, # aggregate: describes the FIRST item
|
||||
{"translations": [str, ...], # SAME order & length as `texts`
|
||||
"detected_lang": str | None, # aggregate: describes the FIRST item
|
||||
"detected_confidence": float | None, # detector's confidence, if given
|
||||
"engine": str | None,
|
||||
"engine_version": str | None}
|
||||
|
||||
@@ -110,6 +111,7 @@ def translate(
|
||||
"""
|
||||
if not texts:
|
||||
return {"translations": [], "detected_lang": None,
|
||||
"detected_confidence": None,
|
||||
"engine": None, "engine_version": None}
|
||||
try:
|
||||
r = session.post(
|
||||
@@ -140,9 +142,11 @@ def translate(
|
||||
if not isinstance(out, list):
|
||||
out = [out]
|
||||
interp = body.get("interpreter") or {}
|
||||
detected = body.get("detectedLanguage") or {}
|
||||
return {
|
||||
"translations": out,
|
||||
"detected_lang": (body.get("detectedLanguage") or {}).get("language"),
|
||||
"detected_lang": detected.get("language"),
|
||||
"detected_confidence": detected.get("confidence"),
|
||||
"engine": interp.get("engine"),
|
||||
"engine_version": interp.get("engine_version"),
|
||||
}
|
||||
|
||||
@@ -12,13 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import TagSuggestionRejection
|
||||
from ...models.tag import image_tag
|
||||
from .aliases import AliasService
|
||||
|
||||
|
||||
class AllowlistService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
self.aliases = AliasService(session)
|
||||
|
||||
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
||||
stmt = insert(image_tag).values(
|
||||
@@ -41,18 +39,6 @@ class AllowlistService:
|
||||
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
||||
await self._clear_rejection(image_id, tag_id)
|
||||
|
||||
async def add_alias_and_accept(
|
||||
self,
|
||||
image_id: int,
|
||||
alias_string: str,
|
||||
alias_category: str,
|
||||
canonical_tag_id: int,
|
||||
) -> None:
|
||||
await self.aliases.create(
|
||||
alias_string, alias_category, canonical_tag_id
|
||||
)
|
||||
await self.accept(image_id, canonical_tag_id)
|
||||
|
||||
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
||||
stmt = insert(TagSuggestionRejection).values(
|
||||
image_record_id=image_id, tag_id=tag_id
|
||||
|
||||
@@ -500,13 +500,19 @@ async def score_image(
|
||||
session: AsyncSession, image_id: int, threshold_override: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Suggestions for one image from the trained heads: [{tag_id, name,
|
||||
category, score}], ranked. A concept surfaces when its score clears the
|
||||
head's own suggest_threshold — or, when threshold_override is given (the
|
||||
typed-dropdown "show everything" mode), that flat floor instead (0 → every
|
||||
head). System-tag heads (wip/banner/editor) instead use a flat
|
||||
_SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection
|
||||
(still overridden by threshold_override). Empty if the image has no
|
||||
embedding or no heads exist yet.
|
||||
category, score, above_threshold, grounding}], ranked. A concept is INCLUDED
|
||||
when its score clears the head's own suggest_threshold — or, when
|
||||
threshold_override is given (the typed-dropdown "show everything" mode), that
|
||||
flat floor instead (0 → every head). System-tag heads (wip/banner/editor)
|
||||
instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface
|
||||
for rejection (still overridden by threshold_override). Empty if the image has
|
||||
no embedding or no heads exist yet.
|
||||
|
||||
``above_threshold`` is reported SEPARATELY from inclusion: it's always whether
|
||||
the score cleared the head's NATURAL cut (suggest_threshold, or the system
|
||||
floor), regardless of any override. So the single min=0 fetch returns every
|
||||
head, and the caller can split panel (above_threshold) from dropdown (all)
|
||||
without a second request.
|
||||
|
||||
MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image
|
||||
vector PLUS every concept-region crop the agent embedded (same model
|
||||
@@ -537,21 +543,25 @@ async def score_image(
|
||||
winners = probs_bag.argmax(axis=0) # (H,)
|
||||
out = []
|
||||
for i, p in enumerate(probs):
|
||||
if threshold_override is not None:
|
||||
cut = threshold_override
|
||||
elif heads["meta"][i]["is_system"]:
|
||||
# System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR)
|
||||
# so their false positives show up for the operator to reject.
|
||||
cut = _SYSTEM_TAG_SUGGEST_FLOOR
|
||||
else:
|
||||
cut = heads["thr"][i]
|
||||
m = heads["meta"][i]
|
||||
# The head's NATURAL suggest cut — system tags use the flat floor (see
|
||||
# _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the
|
||||
# operator to reject; content heads use their own precision-tuned
|
||||
# threshold. This is what "above threshold" means (drives the panel).
|
||||
natural = (
|
||||
_SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i])
|
||||
)
|
||||
# INCLUSION is looser under threshold_override (dropdown show-all,
|
||||
# override=0): every head comes back so a low-confidence concept can still
|
||||
# be typed + picked, each carrying its own above_threshold flag.
|
||||
cut = threshold_override if threshold_override is not None else natural
|
||||
if p >= cut:
|
||||
m = heads["meta"][i]
|
||||
out.append({
|
||||
"tag_id": m["tag_id"],
|
||||
"name": m["name"],
|
||||
"category": m["category"],
|
||||
"score": float(p),
|
||||
"above_threshold": bool(p >= natural),
|
||||
"grounding": bag_meta[int(winners[i])],
|
||||
})
|
||||
out.sort(key=lambda d: d["score"], reverse=True)
|
||||
|
||||
@@ -22,22 +22,20 @@ from .heads import score_image
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Suggestion:
|
||||
# canonical_tag_id is None when this is a raw Camie tag with no alias and
|
||||
# no existing Tag row — accepting it will create the tag.
|
||||
canonical_tag_id: int | None
|
||||
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
|
||||
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
|
||||
# cases are gone — a suggestion always maps to a real tag id.
|
||||
canonical_tag_id: int
|
||||
display_name: str
|
||||
category: str
|
||||
score: float
|
||||
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
|
||||
creates_new_tag: bool
|
||||
# raw_name = the booru model vocab key behind this suggestion. It's the key
|
||||
# an alias MUST be stored under (resolution looks up the raw key), so the
|
||||
# modal needs it to author an alias correctly. None for centroid-only hits
|
||||
# (no underlying prediction → nothing to alias).
|
||||
raw_name: str | None = None
|
||||
# via_alias = this suggestion was surfaced because an operator alias remapped
|
||||
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
|
||||
via_alias: bool = False
|
||||
# above_threshold = the score cleared the head's own suggest cut (or the
|
||||
# system floor). The Suggestions PANEL shows only these; the typed-tag
|
||||
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each
|
||||
# matching row with its score, so a low-confidence concept can still be typed
|
||||
# and picked. CCIP character matches are always above their match threshold.
|
||||
above_threshold: bool
|
||||
# rejected = the operator dismissed this tag for this image (a stored
|
||||
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
||||
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
||||
@@ -108,6 +106,7 @@ class SuggestionService:
|
||||
for h in hits:
|
||||
merged[(h["category"], h["tag_id"])] = {
|
||||
"name": h["name"], "score": h["score"], "source": "head",
|
||||
"above_threshold": h["above_threshold"],
|
||||
"grounding": h.get("grounding"),
|
||||
}
|
||||
for c in ccip_hits:
|
||||
@@ -116,12 +115,16 @@ class SuggestionService:
|
||||
if ex is not None:
|
||||
ex["source"] = "both"
|
||||
ex["score"] = max(ex["score"], c["score"])
|
||||
# CCIP only returns matches above its own threshold, so a CCIP
|
||||
# corroboration always makes the merged suggestion above-threshold.
|
||||
ex["above_threshold"] = True
|
||||
# Keep the head's localized crop if it had one; else fall back to
|
||||
# the CCIP figure so a corroborated character still grounds (#1206).
|
||||
ex["grounding"] = ex.get("grounding") or c.get("grounding")
|
||||
else:
|
||||
merged[key] = {
|
||||
"name": c["name"], "score": c["score"], "source": "ccip",
|
||||
"above_threshold": True,
|
||||
"grounding": c.get("grounding"),
|
||||
}
|
||||
|
||||
@@ -136,7 +139,7 @@ class SuggestionService:
|
||||
category=cat,
|
||||
score=m["score"],
|
||||
source=m["source"],
|
||||
creates_new_tag=False,
|
||||
above_threshold=m["above_threshold"],
|
||||
rejected=tag_id in rejected,
|
||||
grounding=m.get("grounding"),
|
||||
)
|
||||
@@ -157,8 +160,7 @@ class SuggestionService:
|
||||
was suggested for (or already applied to) >= threshold fraction of
|
||||
the selection AND was acceptable on >= 1 image. Confidence is the
|
||||
mean over images where it was suggested. Aggregated by
|
||||
canonical_tag_id; creates-new (no canonical id) suggestions are
|
||||
skipped (bulk Accept applies by tag id)."""
|
||||
canonical_tag_id (every suggestion is a canonical tag now)."""
|
||||
if not image_ids:
|
||||
return {}
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
@@ -169,8 +171,6 @@ class SuggestionService:
|
||||
sl = await self.for_image(image_id)
|
||||
for category, items in sl.by_category.items():
|
||||
for s in items:
|
||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
||||
continue
|
||||
# for_image keeps rejected tags (flagged) for the rail;
|
||||
# bulk consensus must still ignore them — a tag dismissed on
|
||||
# an image isn't a suggestion for that image.
|
||||
|
||||
@@ -397,6 +397,8 @@ class PostFeedService:
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": desc_trans_short,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
# Sticky per-post translation choice (auto/force/original, #155).
|
||||
"translation_override": post.translation_override,
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
"source": (
|
||||
{"id": source.id, "platform": source.platform}
|
||||
|
||||
@@ -35,6 +35,7 @@ def _post_dict(p: Post) -> dict:
|
||||
"title_translated": p.post_title_translated,
|
||||
"description_translated": p.description_translated,
|
||||
"translated_source_lang": p.translated_source_lang,
|
||||
"translation_override": p.translation_override,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,16 @@ 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 daily untranslated sweep (translated_source_lang IS
|
||||
NULL only); also the Settings "Translate now" button.
|
||||
- ``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
|
||||
@@ -43,6 +50,28 @@ _RETRANSLATE_COUNTDOWN = 5
|
||||
_INTERRUPT_BACKOFF = 60
|
||||
_INTERRUPT_BACKOFF_MAX = 900
|
||||
|
||||
# --- Translation acceptance gate (Scribe rule 133; contract note #1347) -------
|
||||
# Curator does NO language detection of its own — it accepts or rejects a
|
||||
# translation purely on Interpreter's reported detection. CJK is script-detected
|
||||
# (kana/hangul/kanji) and reliably high-confidence, so ja/ko/zh are trusted
|
||||
# outright (pure-kanji Japanese can even come back labelled zh ~0.75, still
|
||||
# correctly translated). Latin-script detection is a real statistical
|
||||
# probability, so a short English title Interpreter mis-labels as a European
|
||||
# language scores low; require it to clear this floor, else keep the original.
|
||||
# Calibrated 2026-07-09 against fresh (uncached) probes once Interpreter returned
|
||||
# real langdetect confidence: genuine German detected at 1.0, while a correctly-
|
||||
# detected but ambiguous latin string dipped to 0.86 — so a single constant can't win.
|
||||
# The floor is now operator-tunable (ImportSettings.translation_min_confidence,
|
||||
# milestone 155) with a stricter 0.90 default; per-post overrides
|
||||
# (Post.translation_override) rescue the false negatives it skips. Genuine German
|
||||
# and mis-flagged short English both land ~0.86, and single-word mis-flags sit at
|
||||
# a confident 1.0 no floor catches. Real langdetect fixed some cases at the
|
||||
# source (some short English titles now detect as English → passthrough), so this
|
||||
# floor is a safety net, not the primary fix. Re-tune via the "Test translation"
|
||||
# box (send fresh text — cache hits report 1.0).
|
||||
_CJK_LANGS = frozenset({"ja", "ko", "zh"})
|
||||
_DEFAULT_MIN_LATIN_CONFIDENCE = 0.90
|
||||
|
||||
|
||||
def _interrupt_backoff(retry_after) -> int:
|
||||
"""Seconds to wait before resuming after an Interpreter interruption: the
|
||||
@@ -56,31 +85,51 @@ def _interrupt_backoff(retry_after) -> int:
|
||||
name="backend.app.tasks.translation.translate_posts",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def translate_posts() -> str:
|
||||
def translate_posts(drain: bool = False) -> str:
|
||||
"""Translate untranslated non-English post title/description (default target
|
||||
en) via Interpreter. Per-post [title, description] batch → per-post detected
|
||||
language. Passthrough / already-target posts are marked handled (source ==
|
||||
target) with no stored translation. 503 or a connection error interrupts the
|
||||
run (retry next cycle); 400 stops it (fix config); posts done so far stay
|
||||
committed. No-op unless configured + healthy. Returns a summary string."""
|
||||
committed. No-op unless configured + healthy. Returns a summary string.
|
||||
|
||||
``drain`` (the Settings "Translate now" button) chases the tail
|
||||
run-until-done: on a clean chunk with work still remaining it re-enqueues
|
||||
itself until the untranslated backlog is zero, like ``retranslate_posts``. The
|
||||
periodic beat leaves it False → one bounded chunk per fire (the 8h cadence
|
||||
drives the rest, enough for the trickle of newly-imported posts)."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target = ready
|
||||
base_url, target, min_confidence = ready
|
||||
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
|
||||
status, translated, retry_after = _translate_batch(
|
||||
session, posts, base_url, target,
|
||||
session, posts, base_url, target, min_confidence,
|
||||
)
|
||||
# Steady-state daily sweep: one chunk per run on success (the beat drives
|
||||
# the rest — no tail-chase). But if Interpreter drained mid-chunk, don't
|
||||
# make a manual "Translate now" wait a whole day — re-enqueue after its
|
||||
# Retry-After hint (or the default backoff). Self-terminating: once the
|
||||
# service is down the health gate returns "interpreter unavailable" early.
|
||||
if status == "interrupted" and _count_untranslated(session, None):
|
||||
# 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((), {}, countdown=delay)
|
||||
translate_posts.apply_async((), {"drain": drain}, countdown=delay)
|
||||
log.info(
|
||||
"translate_posts: interrupted (service draining) → retry in %ss",
|
||||
delay,
|
||||
@@ -106,7 +155,7 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target = ready
|
||||
base_url, target, min_confidence = ready
|
||||
|
||||
if not _reset_done:
|
||||
n = _reset_translations(session, artist_ids)
|
||||
@@ -117,7 +166,7 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
|
||||
|
||||
posts = _select_untranslated(session, artist_ids, _MAX_POSTS_PER_RUN)
|
||||
status, translated, retry_after = _translate_batch(
|
||||
session, posts, base_url, target,
|
||||
session, posts, base_url, target, min_confidence,
|
||||
)
|
||||
|
||||
# run-until-done: chase the tail when the chunk finished cleanly.
|
||||
@@ -155,17 +204,19 @@ def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
|
||||
|
||||
|
||||
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."""
|
||||
"""Resolve (base_url, target, min_confidence) if translation is enabled +
|
||||
healthy, else a short status string ("disabled" / "interpreter unavailable")
|
||||
for the caller to return. Centralises the guard so translate/retranslate agree
|
||||
exactly, including the operator-tunable acceptance floor."""
|
||||
cfg = ImportSettings.load_sync(session)
|
||||
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
|
||||
return "disabled"
|
||||
base_url = cfg.interpreter_base_url.strip()
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
min_confidence = cfg.translation_min_confidence
|
||||
if not ic.health(base_url):
|
||||
return "interpreter unavailable"
|
||||
return base_url, target
|
||||
return base_url, target, min_confidence
|
||||
|
||||
|
||||
def _untranslated_filter(stmt, artist_ids):
|
||||
@@ -198,10 +249,13 @@ 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)."""
|
||||
Skips 'keep original' posts (translation_override = 'original') so that choice
|
||||
survives a Re-translate-all (milestone 155). Returns the row count reset
|
||||
(commits it)."""
|
||||
stmt = (
|
||||
update(Post)
|
||||
.where(Post.translated_source_lang.is_not(None))
|
||||
.where(Post.translation_override != "original")
|
||||
.values(
|
||||
post_title_translated=None,
|
||||
description_translated=None,
|
||||
@@ -217,7 +271,7 @@ def _reset_translations(session, artist_ids) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _translate_batch(session, posts, base_url: str, target: str):
|
||||
def _translate_batch(session, posts, base_url: str, target: str, min_confidence: float):
|
||||
"""Translate each post in the chunk with a per-post commit (an interrupted
|
||||
run keeps progress). Returns (status, translated, retry_after) where status is
|
||||
one of "ok" / "interrupted" (unavailable/drain) / "stopped" (400) / "timeout";
|
||||
@@ -226,7 +280,7 @@ def _translate_batch(session, posts, base_url: str, target: str):
|
||||
translated = 0
|
||||
for post in posts:
|
||||
try:
|
||||
translated += _translate_one(session, post, base_url, target)
|
||||
translated += _translate_one(session, post, base_url, target, min_confidence)
|
||||
session.commit()
|
||||
except ic.InterpreterUnavailable as e:
|
||||
session.rollback()
|
||||
@@ -250,29 +304,95 @@ def _summary(status: str, translated: int, scanned: int) -> str:
|
||||
return f"translated={translated} scanned={scanned}"
|
||||
|
||||
|
||||
def _translate_one(session, post, base_url: str, target: str) -> int:
|
||||
"""Translate one post's title/description in place. Returns 1 if it stored a
|
||||
translation, 0 for passthrough/empty (which still marks the post handled)."""
|
||||
def _accept(
|
||||
language: str, confidence, min_confidence: float = _DEFAULT_MIN_LATIN_CONFIDENCE,
|
||||
) -> bool:
|
||||
"""Should curator store Interpreter's translation, or keep the original?
|
||||
Consumes ONLY Interpreter's own detection (curator does none, Scribe rule
|
||||
133): CJK languages are trusted regardless of the reported number; a
|
||||
latin-script detection must clear ``min_confidence`` (the operator-tunable
|
||||
ImportSettings.translation_min_confidence). A missing confidence fails OPEN
|
||||
(accept), so a service that omits the field never silently drops a
|
||||
translation. Per-post ``force`` overrides bypass this entirely (see
|
||||
``_translate_one``)."""
|
||||
if (language or "").split("-", 1)[0].lower() in _CJK_LANGS:
|
||||
return True
|
||||
if confidence is None:
|
||||
return True
|
||||
return confidence >= min_confidence
|
||||
|
||||
|
||||
def _translate_field(
|
||||
text: str, base_url: str, target: str, min_confidence: float, force: bool = False,
|
||||
):
|
||||
"""Translate ONE field independently. Returns (translated, source_lang,
|
||||
engine_version), or (None, None, None) when there's nothing to store — empty
|
||||
text, already the target language, a passthrough (engine "none"), or a
|
||||
latin-script detection the acceptance gate rejects as too low-confidence (kept
|
||||
as the original). ``force`` (a per-post 'force' override) bypasses the
|
||||
confidence gate so a legitimately-foreign title Interpreter is only ~0.86 sure
|
||||
about is still stored; a genuine passthrough stores nothing regardless (there
|
||||
is no translation to force). Per-field (not aggregate first-item) detection so
|
||||
a non-English description still translates when the title is already English."""
|
||||
if not text:
|
||||
return None, None, None
|
||||
res = ic.translate([text], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
return None, None, None
|
||||
# Trust only Interpreter's own detection (Scribe rule 133): keep the original
|
||||
# when a latin-script detection doesn't clear the confidence floor, so a short
|
||||
# English title mis-labelled as e.g. German isn't rewritten into the archive.
|
||||
# A 'force' override skips the gate — the operator is overriding a rejection.
|
||||
if not force and not _accept(
|
||||
detected, res.get("detected_confidence"), min_confidence,
|
||||
):
|
||||
return None, None, None
|
||||
return res["translations"][0], detected, res["engine_version"]
|
||||
|
||||
|
||||
def _store_translation(post, title_res, desc_res, target: str) -> int:
|
||||
"""Apply per-field (translated, source_lang, engine_version) results to a post
|
||||
in place. Returns 1 if a translation was stored, 0 when the post is all
|
||||
passthrough/empty — in which case the translated columns are CLEARED and the
|
||||
post is marked handled (translated_source_lang = target). Clearing (not just
|
||||
leaving) matters on re-apply: a 'keep original' override, or a re-translate
|
||||
that now rejects a previously-accepted mis-flag, must remove the stale
|
||||
translation, not keep it."""
|
||||
title_tr, title_lang, title_ev = title_res
|
||||
desc_tr, desc_lang, desc_ev = desc_res
|
||||
if title_tr is None and desc_tr is None:
|
||||
post.post_title_translated = None
|
||||
post.description_translated = None
|
||||
post.translated_source_lang = target
|
||||
post.translation_engine_version = None
|
||||
post.translated_at = None
|
||||
return 0
|
||||
post.post_title_translated = title_tr
|
||||
post.description_translated = desc_tr
|
||||
# Source lang = whichever field was actually non-target (for a mixed post,
|
||||
# the translated field's language, not the already-English one).
|
||||
post.translated_source_lang = title_lang or desc_lang
|
||||
post.translation_engine_version = title_ev or desc_ev
|
||||
post.translated_at = datetime.now(UTC)
|
||||
return 1
|
||||
|
||||
|
||||
def _translate_one(
|
||||
session, post, base_url: str, target: str, min_confidence: float,
|
||||
) -> int:
|
||||
"""Translate a post's title/description in place, each field independently,
|
||||
honoring the per-post override (milestone 155): 'original' keeps the original
|
||||
(clears any stored translation, no Interpreter call); 'force' translates even
|
||||
below the confidence floor; 'auto' (default) runs the acceptance gate. Returns
|
||||
1 if it stored a translation, else 0 (still marks the post handled so the sweep
|
||||
won't revisit it)."""
|
||||
if post.translation_override == "original":
|
||||
return _store_translation(post, (None, None, None), (None, None, None), target)
|
||||
force = post.translation_override == "force"
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
fields: list[tuple[str, str]] = []
|
||||
if title:
|
||||
fields.append(("title", title))
|
||||
if desc:
|
||||
fields.append(("desc", desc))
|
||||
if not fields:
|
||||
post.translated_source_lang = target # nothing to translate → handled
|
||||
return 0
|
||||
res = ic.translate([t for _, t in fields], base_url=base_url, target=target)
|
||||
detected = res["detected_lang"] or target
|
||||
if detected == target or res["engine"] == "none":
|
||||
post.translated_source_lang = target # already target language → handled
|
||||
return 0
|
||||
by_field = {f: res["translations"][i] for i, (f, _) in enumerate(fields)}
|
||||
post.post_title_translated = by_field.get("title")
|
||||
post.description_translated = by_field.get("desc")
|
||||
post.translated_source_lang = detected
|
||||
post.translation_engine_version = res["engine_version"]
|
||||
post.translated_at = datetime.now(UTC)
|
||||
return 1
|
||||
title_res = _translate_field(title, base_url, target, min_confidence, force=force)
|
||||
desc_res = _translate_field(desc, base_url, target, min_confidence, force=force)
|
||||
return _store_translation(post, title_res, desc_res, target)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Treat as alias for…</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-caption mb-2">
|
||||
Pick the existing tag the model's prediction should map to. All
|
||||
future predictions of this name will resolve to your tag.
|
||||
</p>
|
||||
<v-autocomplete
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="results"
|
||||
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
||||
:item-value="(t) => t.id"
|
||||
:loading="loading"
|
||||
label="Canonical tag"
|
||||
no-filter clearable density="compact"
|
||||
@update:search="onSearch"
|
||||
@keydown.enter.capture="onEnter"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :disabled="!selectedId"
|
||||
@click="$emit('confirm', selectedId)"
|
||||
>Use this tag</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||
|
||||
const props = defineProps({ category: { type: String, required: true } })
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const api = useApi()
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedId = ref(null)
|
||||
let debounce = null
|
||||
|
||||
// Enter on the closed dropdown confirms the selection instead of re-opening it.
|
||||
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||
if (selectedId.value != null) emit('confirm', selectedId.value)
|
||||
})
|
||||
|
||||
function onSearch(q) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(async () => {
|
||||
const query = (q || '').trim()
|
||||
if (!query) { results.value = []; return }
|
||||
loading.value = true
|
||||
try {
|
||||
// Scope the autocomplete to the prediction's category where it
|
||||
// maps to a tag kind. Only 'character' surfaces as both a
|
||||
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||
// retired); other categories search unscoped.
|
||||
const kind = props.category === 'character' ? 'character' : null
|
||||
const params = { q: query, limit: 20 }
|
||||
if (kind) params.kind = kind
|
||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
</script>
|
||||
@@ -11,10 +11,6 @@
|
||||
{{ suggestion.display_name }}
|
||||
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
||||
title="You rejected this for this image — un-reject to recover">rejected</span>
|
||||
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
||||
title="No matching tag yet — accepting creates it">+ new</span>
|
||||
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
|
||||
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
|
||||
</span>
|
||||
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
||||
<!-- Green ✓ / red ✗ pair (operator-asked 2026-06-28) mirrors the eval
|
||||
@@ -46,40 +42,14 @@
|
||||
@click="$emit('dismiss', suggestion)"
|
||||
><v-icon size="16">mdi-close</v-icon></button>
|
||||
</div>
|
||||
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
|
||||
teleported image modal — #711). Only rendered when an alias action
|
||||
applies — dismiss now lives on the red ✗, so a centroid hit with no
|
||||
alias option has no menu. -->
|
||||
<KebabMenu
|
||||
v-if="hasMenu"
|
||||
class="fc-suggestion__menu" size="small" variant="outlined"
|
||||
:label="`More actions for ${suggestion.display_name}`"
|
||||
>
|
||||
<!-- Alias is a tagger-prediction remap, so only offer it for tagger
|
||||
suggestions with a raw model key that aren't already aliased.
|
||||
Centroid hits (raw_name null) have nothing to alias. -->
|
||||
<v-list-item
|
||||
v-if="suggestion.raw_name && !suggestion.via_alias"
|
||||
@click="$emit('alias', suggestion)"
|
||||
>
|
||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="suggestion.via_alias"
|
||||
@click="$emit('remove-alias', suggestion)"
|
||||
>
|
||||
<v-list-item-title>Remove alias</v-list-item-title>
|
||||
</v-list-item>
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import KebabMenu from '../common/KebabMenu.vue'
|
||||
|
||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
|
||||
defineEmits(['accept', 'dismiss', 'undismiss'])
|
||||
|
||||
// #1206: on hover, tell the image viewer which crop produced this suggestion so
|
||||
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
|
||||
@@ -92,12 +62,6 @@ function onLeave () {
|
||||
}
|
||||
|
||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
// Kebab now only carries alias actions: show it when this suggestion can be
|
||||
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
|
||||
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
|
||||
const hasMenu = computed(() =>
|
||||
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -119,26 +83,6 @@ const hasMenu = computed(() =>
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fc-suggestion__new {
|
||||
display: inline-block;
|
||||
font-size: 10px; font-weight: 600;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
background: rgba(var(--v-theme-accent), 0.12);
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.4);
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.fc-suggestion__alias {
|
||||
display: inline-block;
|
||||
font-size: 10px; font-weight: 600;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.fc-suggestion__score {
|
||||
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||
font-size: 11px;
|
||||
@@ -166,10 +110,6 @@ const hasMenu = computed(() =>
|
||||
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
|
||||
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
|
||||
}
|
||||
.fc-suggestion__menu {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
|
||||
reads as "handled, negative" without shouting over live suggestions. */
|
||||
.fc-suggestion--rejected {
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
|
||||
:suggestion="s"
|
||||
@accept="$emit('accept', $event)"
|
||||
@alias="$emit('alias', $event)"
|
||||
@remove-alias="$emit('remove-alias', $event)"
|
||||
@dismiss="$emit('dismiss', $event)"
|
||||
@undismiss="$emit('undismiss', $event)"
|
||||
/>
|
||||
@@ -45,7 +43,7 @@ const props = defineProps({
|
||||
collapsible: { type: Boolean, default: false },
|
||||
defaultOpen: { type: Boolean, default: true }
|
||||
})
|
||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
|
||||
defineEmits(['accept', 'dismiss', 'undismiss', 'reject-all'])
|
||||
|
||||
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
|
||||
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
|
||||
|
||||
@@ -20,48 +20,39 @@
|
||||
first, so false positives are easy to spot and reject (reject-to-train
|
||||
for these heads). Small set — collapsible, open by default. -->
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.system && store.byCategory.system.length"
|
||||
label="System" :items="store.byCategory.system"
|
||||
v-if="store.aboveByCategory.system && store.aboveByCategory.system.length"
|
||||
label="System" :items="store.aboveByCategory.system"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('system')"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-for="cat in peopleCats" :key="cat"
|
||||
v-show="store.byCategory[cat] && store.byCategory[cat].length"
|
||||
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length"
|
||||
:label="labelFor(cat)" :items="store.aboveByCategory[cat] || []"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll(cat)"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
label="General" :items="store.byCategory.general"
|
||||
v-if="store.aboveByCategory.general && store.aboveByCategory.general.length"
|
||||
label="General" :items="store.aboveByCategory.general"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@accept="onAccept"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('general')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="aliasDialog" max-width="480">
|
||||
<AliasPickerDialog
|
||||
v-if="aliasTarget"
|
||||
:category="aliasTarget.category"
|
||||
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
||||
import AliasPickerDialog from './AliasPickerDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
imageId: { type: Number, required: true },
|
||||
@@ -100,13 +91,14 @@ const peopleCats = ['character']
|
||||
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
||||
|
||||
const isEmpty = computed(() =>
|
||||
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
||||
Object.values(store.aboveByCategory).every(list => !list || list.length === 0)
|
||||
)
|
||||
|
||||
watch(() => props.imageId, (id) => {
|
||||
if (id == null) return
|
||||
store.load(id) // panel: curated, ≥ threshold
|
||||
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
|
||||
// One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the
|
||||
// typed tag-input dropdown reads the full set. No second request.
|
||||
store.load(id)
|
||||
}, { immediate: true })
|
||||
|
||||
// After a successful accept/alias-accept, refresh the modal's current
|
||||
@@ -125,31 +117,6 @@ async function onAccept(s) {
|
||||
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const aliasDialog = ref(false)
|
||||
const aliasTarget = ref(null)
|
||||
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
|
||||
async function onAliasConfirm(canonicalTagId) {
|
||||
try {
|
||||
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
||||
aliasDialog.value = false
|
||||
await host.reloadTags()
|
||||
emit('accepted')
|
||||
} catch (e) {
|
||||
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Undo the model-key→tag mapping behind an aliased suggestion. The store
|
||||
// reloads suggestions so the prediction reverts to its raw form; the applied
|
||||
// canonical tag (if any) stays, so no tag-rail reload is needed.
|
||||
async function onRemoveAlias(s) {
|
||||
try {
|
||||
await store.removeAlias(s)
|
||||
} catch (e) {
|
||||
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<template v-for="(row, idx) in rows" :key="row.key">
|
||||
<!-- Existing tag match (server autocomplete). -->
|
||||
<!-- One unified row per DB tag (server autocomplete). Every suggestion is
|
||||
a canonical tag now, so when the model ALSO scored this tag for the
|
||||
image the row just carries its confidence (🔧 %) in place of the
|
||||
redundant kind label — the coloured leading icon already shows kind.
|
||||
That means a searched tag that's also a suggestion shows its % on ONE
|
||||
row, with no dedup and no flicker. Picking it emits pick-existing;
|
||||
TagPanel.findPending routes a matching suggestion through accept() so
|
||||
the acceptance is still recorded + it drops from the panel. -->
|
||||
<v-list-item
|
||||
v-if="row.type === 'hit'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
@@ -32,32 +39,18 @@
|
||||
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||
it routes through the same accept path as the Suggestions panel, so
|
||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||
<v-list-item
|
||||
v-else-if="row.type === 'suggestion'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
class="fc-tag-autocomplete__sugg"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
|
||||
{{ iconFor(row.sugg.category) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.sugg.display_name }}
|
||||
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="fc-tag-autocomplete__sugg-tag">
|
||||
<span
|
||||
v-if="row.sugg"
|
||||
class="fc-tag-autocomplete__sugg-tag"
|
||||
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
|
||||
:title="row.sugg.above_threshold
|
||||
? 'The model suggests this tag for this image'
|
||||
: 'The model scored this tag below its suggest threshold'"
|
||||
>
|
||||
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
||||
{{ scorePct(row.sugg) }}
|
||||
</span>
|
||||
<span v-else class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -100,11 +93,10 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import { useInflightToken } from '../../composables/useInflightToken.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
||||
// The host surface's applied tags (host.current?.tags). Both dropdown sections
|
||||
// filter against this so a just-added tag drops out of the search the moment
|
||||
// the chip rail updates, not after the next modal refresh (operator-asked
|
||||
// 2026-07-03).
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
// The host surface's applied tags (host.current?.tags). The dropdown filters
|
||||
// against this so a just-added tag drops out of the search the moment the chip
|
||||
// rail updates, not after the next modal refresh (operator-asked 2026-07-03).
|
||||
const props = defineProps({
|
||||
appliedTags: { type: Array, default: () => [] },
|
||||
})
|
||||
@@ -238,9 +230,6 @@ const createLabel = computed(() =>
|
||||
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
||||
|
||||
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
|
||||
const appliedNames = computed(() =>
|
||||
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
|
||||
)
|
||||
|
||||
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
|
||||
// keep reading the RAW hits — they reason about the tag universe (does this tag
|
||||
@@ -249,49 +238,29 @@ const visibleHits = computed(() =>
|
||||
hits.value.filter(h => !appliedIds.value.has(h.id)),
|
||||
)
|
||||
|
||||
// This image's suggestions that match the typed query, minus any the server
|
||||
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
||||
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
|
||||
// the threshold-filtered panel list — so a low-confidence action/feature the
|
||||
// model saw can be typed and accepted in canonical formatting instead of being
|
||||
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
|
||||
// the only filter; the threshold no longer hides anything here.
|
||||
const suggestionHits = computed(() => {
|
||||
const q = parsedName.value.toLowerCase()
|
||||
if (!q) return []
|
||||
const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
||||
const out = []
|
||||
for (const list of Object.values(suggestions.allByCategory)) {
|
||||
// This image's suggestions keyed by canonical tag id, so a matching DB-tag row
|
||||
// can show the model's confidence inline. Every suggestion is a canonical tag
|
||||
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
|
||||
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
|
||||
// advertise a score in the type-to-add dropdown (un-reject lives in the panel).
|
||||
const suggByTagId = computed(() => {
|
||||
const m = new Map()
|
||||
for (const list of Object.values(suggestions.byCategory)) {
|
||||
for (const s of list || []) {
|
||||
// Rejected suggestions now stay in allByCategory (flagged) so the panel
|
||||
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
|
||||
// whose job is finding a tag to ADD (un-reject lives in the panel).
|
||||
if (s.rejected) continue
|
||||
// Already on the image (matched by canonical id or name+kind): nothing
|
||||
// to add. Covers the window between an add and the next suggestions
|
||||
// fetch, where the stale row would otherwise still be pickable.
|
||||
if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue
|
||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||
if (appliedNames.value.has(key)) continue
|
||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(s)
|
||||
if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
|
||||
}
|
||||
}
|
||||
// Best matches first; cap generously so a specific typed query surfaces its
|
||||
// matches even when many predictions exist, while the list stays scrollable.
|
||||
out.sort((a, b) => b.score - a.score)
|
||||
return out.slice(0, 20)
|
||||
return m
|
||||
})
|
||||
|
||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||
// highlight index maps 1:1 to a row regardless of which section it's in.
|
||||
// highlight index maps 1:1 to a row. Each hit is annotated with its suggestion
|
||||
// (score) when the model scored that tag for this image.
|
||||
const rows = computed(() => {
|
||||
const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
|
||||
for (const s of suggestionHits.value) {
|
||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
||||
}
|
||||
const r = visibleHits.value.map(h => ({
|
||||
type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
|
||||
}))
|
||||
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
||||
return r
|
||||
})
|
||||
@@ -313,16 +282,9 @@ function moveHighlight (delta) {
|
||||
// Dispatch a chosen dropdown row by its type.
|
||||
function onPickRow (row) {
|
||||
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
||||
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
|
||||
else { onCreate() }
|
||||
}
|
||||
|
||||
// Picking an ML suggestion hands it to the parent, which runs the same accept
|
||||
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
|
||||
// drops it from the panel). reset() clears the query; the panel-drop makes it
|
||||
// fall out of suggestionHits reactively.
|
||||
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
|
||||
|
||||
function onCreate () {
|
||||
const name = parsedName.value
|
||||
const kind = parsedKind.value
|
||||
@@ -389,14 +351,16 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
|
||||
.fc-tag-autocomplete__sugg {
|
||||
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
/* The model-confidence badge on a row whose tag the model also scored. Accent
|
||||
when above the head's suggest threshold; muted when below (a low-confidence
|
||||
match you can still type + pick). */
|
||||
.fc-tag-autocomplete__sugg-tag {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-tag-autocomplete__sugg-tag--below {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,19 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
chip's hover title. */
|
||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
|
||||
.fc-tag-chip__nav { max-width: 100%; min-width: 0; }
|
||||
/* Tonal chips (esp. character = info) wash out against the dark rail — the fill
|
||||
is intentionally faint. Give every tag chip a thin border in its OWN kind
|
||||
colour (currentColor = the tonal chip's themed foreground) so the edge reads
|
||||
clearly without touching the fill (operator-asked 2026-07-08). Theme-aware:
|
||||
currentColor tracks the kind colour in either light or dark. */
|
||||
.fc-tag-chip__nav { border: thin solid color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; }
|
||||
/* The bigger size=default chip widens Vuetify's negative start-margin on the
|
||||
leading kind-icon, pulling it LEFT of the content box above — where that
|
||||
overflow:hidden then clips the icon's left edge as a vertical slice
|
||||
(operator-flagged 2026-07-08). Zero the pull so the icon sits inside the clip
|
||||
box; the chip's own 12px padding keeps a comfortable left inset. */
|
||||
.fc-tag-chip__nav :deep(.v-icon--start) { margin-inline-start: 0; }
|
||||
.fc-tag-chip__name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
min-width: 0; flex: 0 1 auto;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
ref="tagInputRef"
|
||||
:applied-tags="host.current?.tags || []"
|
||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||
@accept-suggestion="onAcceptSuggestion"
|
||||
/>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -134,7 +133,6 @@ async function onRemove(tagId) {
|
||||
// until the next modal open (operator-asked 2026-07-03).
|
||||
if (host.currentImageId != null) {
|
||||
suggestions.load(host.currentImageId)
|
||||
suggestions.loadAll(host.currentImageId)
|
||||
}
|
||||
focusTagInput()
|
||||
}
|
||||
@@ -178,18 +176,6 @@ async function onPickNew(payload) {
|
||||
}
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
|
||||
// acceptance, and drops it from the panel; then we refresh the chip rail.
|
||||
async function onAcceptSuggestion(s) {
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await suggestions.accept(s)
|
||||
await host.reloadTags()
|
||||
focusTagInput()
|
||||
} catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
|
||||
const renameDialog = ref(false)
|
||||
const renameTarget = ref(null)
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@
|
||||
@click.stop="showOriginal = !showOriginal"
|
||||
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||
|
||||
<!-- Per-post translation override (#155): force a skipped translation on,
|
||||
or keep the original for a confidently mis-flagged title. Only where
|
||||
there's text to translate. -->
|
||||
<PostTranslationControl
|
||||
v-if="post.post_title || post.description_plain"
|
||||
:post="post"
|
||||
/>
|
||||
|
||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||
HTML (headings, lists, links, inline images). Collapsed and the
|
||||
no-detail fallback stay plain text. -->
|
||||
@@ -136,6 +144,7 @@ import { useModalStore } from '../../stores/modal.js'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import PostSeriesMenu from './PostSeriesMenu.vue'
|
||||
import PostTranslationControl from './PostTranslationControl.vue'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<!-- Sticky per-post translation override (#155). Quiet inline control under the
|
||||
post title: force a skipped legit-foreign title on, or knock a wrongly
|
||||
mis-translated one back to the original. The choice sticks through a
|
||||
Re-translate-all. -->
|
||||
<div class="fc-post-tx">
|
||||
<span class="fc-post-tx__label">Translation:</span>
|
||||
<v-menu :disabled="busy">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<button
|
||||
type="button" class="fc-post-tx__btn" v-bind="menuProps" :disabled="busy"
|
||||
:aria-label="`Translation handling: ${currentLabel}`"
|
||||
>
|
||||
{{ currentLabel }}
|
||||
<v-icon size="14">mdi-menu-down</v-icon>
|
||||
</button>
|
||||
</template>
|
||||
<v-list density="compact" min-width="240" class="fc-post-tx__list">
|
||||
<v-list-item
|
||||
v-for="opt in OPTIONS" :key="opt.value"
|
||||
:active="opt.value === current" @click="choose(opt.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ opt.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ opt.help }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-progress-circular v-if="busy" indeterminate size="13" width="2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
|
||||
const props = defineProps({ post: { type: Object, required: true } })
|
||||
const posts = usePostsStore()
|
||||
const busy = ref(false)
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto', icon: 'mdi-cog-outline',
|
||||
help: 'Translate only when confident enough' },
|
||||
{ value: 'force', label: 'Force translate', icon: 'mdi-translate',
|
||||
help: 'Always translate, even at low confidence' },
|
||||
{ value: 'original', label: 'Keep original', icon: 'mdi-translate-off',
|
||||
help: 'Never translate — keep the original text' },
|
||||
]
|
||||
|
||||
const current = computed(() => props.post.translation_override || 'auto')
|
||||
const currentLabel = computed(
|
||||
() => (OPTIONS.find((o) => o.value === current.value) || OPTIONS[0]).label,
|
||||
)
|
||||
|
||||
async function choose (value) {
|
||||
if (value === current.value || busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await posts.applyTranslationOverride(props.post.id, value)
|
||||
if (res.applied === 'queued') {
|
||||
toast({
|
||||
text: 'Saved — this post will translate on the next sweep (service offline).',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ text: `Couldn't update translation: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-tx {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.fc-post-tx__label {
|
||||
font-size: 0.72rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn {
|
||||
display: inline-flex; align-items: center; gap: 1px;
|
||||
padding: 0; background: none; border: 0; cursor: pointer;
|
||||
font-size: 0.72rem; font-weight: 700;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-tx__btn:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-tx__btn:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fc-post-tx__btn:disabled { cursor: default; opacity: 0.6; }
|
||||
.fc-post-tx__list :deep(.v-list-item-subtitle) {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,21 @@
|
||||
@change="onSave"
|
||||
/>
|
||||
|
||||
<!-- Acceptance floor (#155): latin-script translations below this Interpreter
|
||||
confidence are kept as the original. Per-post overrides handle exceptions. -->
|
||||
<v-text-field
|
||||
v-model.number="minConfidence" label="Acceptance confidence"
|
||||
type="number" min="0" max="1" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" class="mb-1" :disabled="busy"
|
||||
@change="onSaveConfidence"
|
||||
/>
|
||||
<div class="fc-muted text-caption mb-3">
|
||||
Latin-script titles Interpreter is less than this sure about (0–1) are kept
|
||||
as the original; CJK is always translated. Default 0.90 — raise it to reject
|
||||
more mis-detections, lower it to translate more. Per-post controls in the
|
||||
posts feed override this either way.
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;">
|
||||
<v-icon size="12" :color="statusColor">mdi-circle</v-icon>
|
||||
<span class="fc-muted text-body-2">{{ statusText }}</span>
|
||||
@@ -78,6 +93,61 @@
|
||||
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"
|
||||
@@ -120,6 +190,7 @@ const store = useImportStore()
|
||||
const enabled = ref(false)
|
||||
const baseUrl = ref('')
|
||||
const targetLang = ref('en')
|
||||
const minConfidence = ref(0.9)
|
||||
const busy = ref(false)
|
||||
const running = ref(false)
|
||||
const retranslating = ref(false)
|
||||
@@ -129,6 +200,17 @@ const testResult = ref(null) // null = not tested this session; true/false = l
|
||||
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'
|
||||
@@ -170,6 +252,21 @@ async function onTest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onProbe () {
|
||||
probing.value = true
|
||||
err.value = null
|
||||
probeResult.value = null
|
||||
try {
|
||||
probeResult.value = await api.post('/api/settings/translation/probe', {
|
||||
body: { text: probeText.value.trim() },
|
||||
})
|
||||
} catch (e) {
|
||||
err.value = e.message
|
||||
} finally {
|
||||
probing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
@@ -177,6 +274,7 @@ onMounted(async () => {
|
||||
enabled.value = !!s.translation_enabled
|
||||
baseUrl.value = s.interpreter_base_url || ''
|
||||
targetLang.value = s.translation_target_lang || 'en'
|
||||
minConfidence.value = s.translation_min_confidence ?? 0.9
|
||||
} catch { /* non-fatal */ }
|
||||
await loadStatus()
|
||||
})
|
||||
@@ -201,6 +299,14 @@ async function onSave() {
|
||||
})
|
||||
await loadStatus()
|
||||
}
|
||||
async function onSaveConfidence() {
|
||||
// Clamp to [0, 1] so a stray value never bounces off the API 400.
|
||||
let v = Number(minConfidence.value)
|
||||
if (!Number.isFinite(v)) v = 0.9
|
||||
v = Math.min(1, Math.max(0, v))
|
||||
minConfidence.value = v
|
||||
await save({ translation_min_confidence: v })
|
||||
}
|
||||
async function onRun() {
|
||||
running.value = true
|
||||
err.value = null
|
||||
|
||||
@@ -71,6 +71,24 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return await api.get(`/api/posts/${id}`)
|
||||
}
|
||||
|
||||
// Set a post's sticky translation override (auto/force/original, #155) and
|
||||
// patch the loaded item in place with the endpoint's result (it translates /
|
||||
// clears immediately when the service is up), so the card reflects the change
|
||||
// without a feed reload.
|
||||
async function applyTranslationOverride(postId, override) {
|
||||
const res = await api.post(`/api/posts/${postId}/translation-override`, {
|
||||
body: { override },
|
||||
})
|
||||
const item = items.value.find((p) => p.id === postId)
|
||||
if (item) {
|
||||
item.translation_override = res.translation_override
|
||||
item.post_title_translated = res.post_title_translated
|
||||
item.description_translated = res.description_translated
|
||||
item.translated_source_lang = res.translated_source_lang
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Filter overlay for the around/older/newer (in-context anchored)
|
||||
// path. Keep this distinct from `filters.value` (the down-only feed)
|
||||
// so a normal-feed filter change doesn't leak into an active anchored
|
||||
@@ -168,7 +186,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return {
|
||||
items, cursor, loading, done, error, filters,
|
||||
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||
loadInitial, loadMore, getPostFull,
|
||||
loadInitial, loadMore, getPostFull, applyTranslationOverride,
|
||||
loadAround, loadOlder, loadNewer,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { toast } from '../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
@@ -18,182 +18,112 @@ export const CATEGORY_LABELS = {
|
||||
|
||||
export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
const api = useApi()
|
||||
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
||||
// The typed tag-input dropdown searches the model's FULL prediction set for
|
||||
// the image (min=0, down to the store floor) so low-confidence actions/
|
||||
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
||||
const allByCategory = ref({})
|
||||
// ONE source of truth per image: every trained head scored for this image
|
||||
// (min=0), each row carrying above_threshold. The Suggestions PANEL renders
|
||||
// aboveByCategory; the typed tag-input dropdown reads the full set and
|
||||
// annotates matching DB-tag rows with their score. A single fetch backs both —
|
||||
// every suggestion is a canonical tag now (tagging-v2, #114), so there is no
|
||||
// raw-prediction / create-new / alias-remap path to reconcile.
|
||||
const byCategory = ref({})
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
let currentImageId = null
|
||||
const inflightAll = useInflightToken()
|
||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
||||
// /suggestions response from a prior image could overwrite
|
||||
// byCategory while currentImageId pointed at a new one, and
|
||||
// accept() dereferenced currentImageId AFTER an awaited POST so
|
||||
// the subsequent /suggestions/accept could apply A's chosen tag
|
||||
// to image B (and push it to the allowlist). Both fixed below
|
||||
// by capturing imageId at call-time and gating writes on the token.
|
||||
// Audit 2026-06-02: without an inflight guard a late /suggestions response from
|
||||
// a prior image could overwrite byCategory while currentImageId points at a new
|
||||
// one, and accept() could apply image A's tag to image B. Both guarded below by
|
||||
// capturing imageId at call-time and gating writes on the token.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
// The curated above-threshold subset the Suggestions panel shows. A rejected
|
||||
// row that still scores above its cut stays (flagged) so the rejection is
|
||||
// visible + reversible; below-threshold rows live only in the dropdown.
|
||||
const aboveByCategory = computed(() => {
|
||||
const out = {}
|
||||
for (const [cat, list] of Object.entries(byCategory.value)) {
|
||||
const kept = (list || []).filter(s => s.above_threshold)
|
||||
if (kept.length) out[cat] = kept
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function load(imageId) {
|
||||
// Cancel any in-flight load from the previous image so its late
|
||||
// response can't overwrite this image's byCategory.
|
||||
// Cancel any in-flight load from the previous image so its late response
|
||||
// can't overwrite this image's list.
|
||||
inflight.cancel()
|
||||
currentImageId = imageId
|
||||
byCategory.value = {} // cleared upfront so it stays empty on error
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
||||
// min=0 → every head, each flagged above_threshold; the panel + the typed
|
||||
// dropdown are both derived from this one payload.
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
||||
params: { min: 0 },
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
byCategory.value = body.by_category || {}
|
||||
})
|
||||
}
|
||||
|
||||
// Load the full prediction set for the dropdown (separate inflight guard so a
|
||||
// late response from a prior image can't overwrite the current one's list).
|
||||
async function loadAll(imageId) {
|
||||
inflightAll.cancel()
|
||||
allByCategory.value = {}
|
||||
const t = inflightAll.claim()
|
||||
try {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
||||
params: { min: 0 },
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
allByCategory.value = body.by_category || {}
|
||||
} catch {
|
||||
// Dropdown is best-effort — the panel surfaces load errors.
|
||||
}
|
||||
}
|
||||
|
||||
// A stable identity for a suggestion across the two lists (panel vs dropdown
|
||||
// are separate fetches, so object identity differs): tag id when known, else
|
||||
// the raw display key.
|
||||
// A stable identity for a suggestion across the panel + dropdown surfaces:
|
||||
// its canonical tag id (every suggestion has one now).
|
||||
function _keyOf(s) {
|
||||
return s.canonical_tag_id != null
|
||||
? `id:${s.canonical_tag_id}`
|
||||
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
||||
return `id:${s.canonical_tag_id}`
|
||||
}
|
||||
|
||||
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
||||
// dropdown's full list so it can't reappear in either surface.
|
||||
// Drop an accepted suggestion from the list so it can't reappear.
|
||||
function _dropEverywhere(suggestion) {
|
||||
const key = _keyOf(suggestion)
|
||||
const cat = suggestion.category
|
||||
for (const map of [byCategory.value, allByCategory.value]) {
|
||||
const list = map[cat]
|
||||
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
||||
}
|
||||
const list = byCategory.value[suggestion.category]
|
||||
if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
|
||||
}
|
||||
|
||||
// Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
|
||||
// so a reject/un-reject shows immediately without dropping the row (visible,
|
||||
// Flip the `rejected` flag on the matching suggestion in place, so a
|
||||
// reject/un-reject shows immediately without dropping the row (visible,
|
||||
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
|
||||
function _setRejectedEverywhere(suggestion, value) {
|
||||
const key = _keyOf(suggestion)
|
||||
const cat = suggestion.category
|
||||
for (const map of [byCategory.value, allByCategory.value]) {
|
||||
for (const s of map[cat] || []) {
|
||||
if (_keyOf(s) === key) s.rejected = value
|
||||
}
|
||||
for (const s of byCategory.value[suggestion.category] || []) {
|
||||
if (_keyOf(s) === key) s.rejected = value
|
||||
}
|
||||
}
|
||||
|
||||
// opts.tagId: the tag's known id, when the caller already created/resolved
|
||||
// it (TagPanel's manual-add-matches-suggestion path). Passed separately —
|
||||
// NOT spread onto the suggestion — because _dropEverywhere keys off the
|
||||
// suggestion object, and mutating canonical_tag_id on a raw suggestion
|
||||
// would stop it matching its own row in the lists.
|
||||
// opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
|
||||
// manual-add-matches-suggestion path). Passed separately — NOT spread onto the
|
||||
// suggestion — because _dropEverywhere keys off the suggestion object.
|
||||
async function accept(suggestion, { tagId: knownTagId = null } = {}) {
|
||||
// Capture imageId so a mid-flight prev/next can't reroute the
|
||||
// accept POST to a different image AND push the tag to that
|
||||
// image's allowlist.
|
||||
// Capture imageId so a mid-flight prev/next can't reroute the accept POST to
|
||||
// a different image.
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
||||
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
||||
// first via the existing /api/tags endpoint, then accept by id.
|
||||
let tagId = knownTagId ?? suggestion.canonical_tag_id
|
||||
if (tagId == null) {
|
||||
const created = await api.post('/api/tags', {
|
||||
body: { name: suggestion.display_name, kind: suggestion.category }
|
||||
})
|
||||
tagId = created.id
|
||||
}
|
||||
const tagId = knownTagId ?? suggestion.canonical_tag_id
|
||||
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||
body: { tag_id: tagId }
|
||||
})
|
||||
// Only drop from THIS image's category list — if the user navigated,
|
||||
// the new image has its own suggestions and this drop would corrupt them.
|
||||
// Only drop from THIS image's list — if the user navigated, the new image
|
||||
// has its own suggestions and this drop would corrupt them.
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
_acceptToast('Tagged', suggestion.display_name)
|
||||
}
|
||||
|
||||
// One non-blocking toast for accept/alias. The accepted tag is applied to this
|
||||
// image and feeds head training; head auto-apply handles propagation (earned),
|
||||
// so there's no instant fan-out to project.
|
||||
// One non-blocking toast for accept. The accepted tag is applied to this image
|
||||
// and feeds head training; head auto-apply handles propagation (earned).
|
||||
function _acceptToast(verb, displayName) {
|
||||
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
||||
}
|
||||
|
||||
async function aliasAccept(suggestion, canonicalTagId) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// The alias MUST be stored under the raw model key — resolution looks up the
|
||||
// raw prediction key, not the normalized display name. Sending display_name
|
||||
// (the old bug) stored an alias that never resolved, so the prediction kept
|
||||
// reappearing unaliased. raw_name is null only for centroid hits, which
|
||||
// can't be aliased (the UI hides the action for them).
|
||||
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
||||
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
||||
body: {
|
||||
alias_string: aliasString,
|
||||
alias_category: suggestion.category,
|
||||
canonical_tag_id: canonicalTagId
|
||||
}
|
||||
})
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
_acceptToast('Aliased & tagged', suggestion.display_name)
|
||||
}
|
||||
|
||||
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
||||
// its unaliased form on reload). The canonical tag stays applied if it was
|
||||
// accepted — this only undoes the model-key→tag mapping.
|
||||
async function removeAlias(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null || suggestion.raw_name == null) return
|
||||
await api.delete(
|
||||
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
|
||||
)
|
||||
if (currentImageId === imageId) {
|
||||
await load(imageId)
|
||||
await loadAll(imageId)
|
||||
}
|
||||
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
||||
}
|
||||
|
||||
// Find a live (non-rejected) pending suggestion matching a manually-picked
|
||||
// tag — by canonical id when known, else by (kind, name). Manually adding a
|
||||
// tag the model also suggested must be indistinguishable from accepting the
|
||||
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
|
||||
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
|
||||
// set first, then the panel list (loadAll is best-effort and can be empty).
|
||||
// Find a live (non-rejected) suggestion matching a manually-picked tag — by
|
||||
// canonical id when known, else by (kind, name). Manually adding a tag the
|
||||
// model also suggested must be indistinguishable from accepting the suggestion
|
||||
// (recorded + dropped from the panel), so TagPanel routes matches through
|
||||
// accept() (operator-asked 2026-07-03).
|
||||
function findPending(kind, name, tagId = null) {
|
||||
const lname = (name || '').toLowerCase()
|
||||
for (const map of [allByCategory.value, byCategory.value]) {
|
||||
for (const list of Object.values(map)) {
|
||||
for (const s of list || []) {
|
||||
if (s.rejected) continue
|
||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||
if (
|
||||
s.category === kind &&
|
||||
(s.display_name || '').toLowerCase() === lname
|
||||
) return s
|
||||
}
|
||||
for (const list of Object.values(byCategory.value)) {
|
||||
for (const s of list || []) {
|
||||
if (s.rejected) continue
|
||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||
if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
async function dismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
|
||||
// nothing to persist a rejection against — drop them client-side as before.
|
||||
// Canonical tags persist a rejection and STAY in the list flagged rejected,
|
||||
// so the operator can see it and one-click un-reject (misclick recovery).
|
||||
if (suggestion.canonical_tag_id == null) {
|
||||
if (currentImageId === imageId) _dropEverywhere(suggestion)
|
||||
return
|
||||
}
|
||||
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
@@ -218,33 +142,31 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Reject every still-unhandled suggestion in a category in one go ("confirm
|
||||
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
|
||||
// persist a rejection and STAY flagged rejected (reversible, one-click
|
||||
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
|
||||
// so a big section clears fast.
|
||||
// Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
|
||||
// category in one go ("confirm the good ones, reject the rest" — operator-asked
|
||||
// 2026-07-06). Only touches what the panel shows, not the low-confidence tail
|
||||
// the dropdown carries. Dispatched in parallel so a big section clears fast.
|
||||
async function dismissRemaining(category) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
|
||||
const targets = (byCategory.value[category] || []).filter(
|
||||
(s) => s.above_threshold && !s.rejected
|
||||
)
|
||||
if (!targets.length) return
|
||||
const canon = targets.filter((s) => s.canonical_tag_id != null)
|
||||
const raw = targets.filter((s) => s.canonical_tag_id == null)
|
||||
await Promise.all(canon.map((s) =>
|
||||
await Promise.all(targets.map((s) =>
|
||||
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: s.canonical_tag_id },
|
||||
})
|
||||
))
|
||||
if (currentImageId === imageId) {
|
||||
canon.forEach((s) => _setRejectedEverywhere(s, true))
|
||||
raw.forEach((s) => _dropEverywhere(s))
|
||||
targets.forEach((s) => _setRejectedEverywhere(s, true))
|
||||
}
|
||||
}
|
||||
|
||||
// Undo a per-image dismissal — the suggestion reverts to a live row.
|
||||
async function undismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null || suggestion.canonical_tag_id == null) return
|
||||
if (imageId == null) return
|
||||
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
@@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
byCategory, allByCategory, loading, error,
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
|
||||
undismiss, findPending
|
||||
byCategory, aboveByCategory, loading, error,
|
||||
load, accept, dismiss, dismissRemaining, undismiss, findPending
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,25 +16,44 @@ function stubFetch(handler) {
|
||||
})
|
||||
}
|
||||
|
||||
// Every suggestion is a canonical DB tag now (tagging-v2): a real id, flagged
|
||||
// above/below its head's suggest threshold. No raw / creates-new / alias cases.
|
||||
const sugg = (over = {}) => ({
|
||||
canonical_tag_id: 7,
|
||||
display_name: 'Ichigo',
|
||||
category: 'character',
|
||||
score: 0.9,
|
||||
source: 'head',
|
||||
creates_new_tag: false,
|
||||
above_threshold: true,
|
||||
rejected: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// Seed the store through its own load/loadAll so currentImageId is set the
|
||||
// way the panel sets it (accept() derives the image from it).
|
||||
// Seed the store through its single load() so currentImageId is set the way the
|
||||
// panel sets it (accept() derives the image from it).
|
||||
async function seed(store, byCategory) {
|
||||
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
|
||||
await store.load(1)
|
||||
await store.loadAll(1)
|
||||
}
|
||||
|
||||
describe('suggestions store: load derives aboveByCategory', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, {
|
||||
general: [
|
||||
sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }),
|
||||
sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }),
|
||||
],
|
||||
})
|
||||
expect(s.byCategory.general).toHaveLength(2) // dropdown sees all
|
||||
expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only
|
||||
expect(s.aboveByCategory.general[0].display_name).toBe('sword')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -45,15 +64,12 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
|
||||
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
|
||||
})
|
||||
|
||||
it('matches raw suggestions by kind + name, case-insensitively', async () => {
|
||||
it('matches by kind + name, case-insensitively', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, {
|
||||
general: [sugg({
|
||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
||||
category: 'general', creates_new_tag: true,
|
||||
})],
|
||||
general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })],
|
||||
})
|
||||
expect(s.findPending('general', 'holding sword')).toBeTruthy()
|
||||
expect(s.findPending('general', 'holding sword')?.canonical_tag_id).toBe(12)
|
||||
// Kind must match: same name under another kind is a different tag.
|
||||
expect(s.findPending('character', 'holding sword')).toBeNull()
|
||||
})
|
||||
@@ -66,37 +82,31 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions store: accept with a known tag id', () => {
|
||||
describe('suggestions store: accept', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('POSTs only the accept endpoint (no tag create) and drops the row', async () => {
|
||||
it('POSTs only the accept endpoint (canonical id) and drops the row', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, { character: [sugg()] })
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, method: init?.method })
|
||||
calls.push({ url, method: init?.method, body: init?.body })
|
||||
return { status: 200, body: { accepted: true, tag_id: 7 } }
|
||||
})
|
||||
await s.accept(s.byCategory.character[0], { tagId: 7 })
|
||||
await s.accept(s.byCategory.character[0])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 })
|
||||
expect(s.byCategory.character).toHaveLength(0)
|
||||
expect(s.allByCategory.character).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => {
|
||||
// TagPanel's manual-create path: the tag was just created by
|
||||
// host.createAndAdd, so accept must not create again — and the drop must
|
||||
// key off the original raw suggestion object, not the resolved id
|
||||
// (regression: spreading canonical_tag_id onto the suggestion changed its
|
||||
// identity key and left the panel row behind).
|
||||
it('honors a known tagId and still drops the row by the suggestion identity', async () => {
|
||||
// TagPanel's manual-add-matches-suggestion path passes the resolved tag id;
|
||||
// accept sends exactly it and drops the original suggestion row (which keys
|
||||
// off the suggestion object, not the passed id).
|
||||
const s = useSuggestionsStore()
|
||||
const raw = sugg({
|
||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
||||
category: 'general', creates_new_tag: true,
|
||||
})
|
||||
await seed(s, { general: [raw] })
|
||||
await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] })
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, body: init?.body })
|
||||
@@ -107,6 +117,5 @@ describe('suggestions store: accept with a known tag id', () => {
|
||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
|
||||
expect(s.byCategory.general).toHaveLength(0)
|
||||
expect(s.allByCategory.general).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,6 +184,68 @@ async def test_rejects_bad_direction(client):
|
||||
assert (await resp.get_json())["error"] == "invalid_direction"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_rejects_bad_value(client, seeded_post):
|
||||
_, _, post = seeded_post
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "nope"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert (await resp.get_json())["error"] == "invalid_override"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_404_for_unknown(client):
|
||||
resp = await client.post(
|
||||
"/api/posts/999999/translation-override", json={"override": "original"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_original_clears_and_keeps(client, db, seeded_post):
|
||||
# 'keep original' clears a stored translation immediately (no Interpreter) and
|
||||
# marks the post handled (== target). Asserts on the response, which reflects
|
||||
# the endpoint's own committed session.
|
||||
_, _, post = seeded_post
|
||||
post.post_title_translated = "STALE"
|
||||
post.description_translated = "STALE"
|
||||
post.translated_source_lang = "de"
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "original"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["translation_override"] == "original"
|
||||
assert body["applied"] == "cleared"
|
||||
assert body["post_title_translated"] is None
|
||||
assert body["translated_source_lang"] == "en"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_override_force_queues_when_disabled(client, seeded_post):
|
||||
# Translation disabled (default) → can't translate inline; the override is
|
||||
# saved and the post is queued (columns NULLed) for the next sweep.
|
||||
_, _, post = seeded_post
|
||||
resp = await client.post(
|
||||
f"/api/posts/{post.id}/translation-override", json={"override": "force"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["translation_override"] == "force"
|
||||
assert body["applied"] == "queued"
|
||||
assert body["translated_source_lang"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_exposes_translation_override(client, seeded_post):
|
||||
resp = await client.get("/api/posts")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["items"][0]["translation_override"] == "auto" # default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_returns_uncapped_thumbnails(client, db):
|
||||
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
||||
|
||||
@@ -53,6 +53,26 @@ async def test_translation_settings_defaults_and_patch(client):
|
||||
"/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_min_confidence_default_and_patch(client):
|
||||
# #155: the latin-script acceptance floor is an operator-tunable setting,
|
||||
# default 0.9 (stricter than the old hardcoded 0.8).
|
||||
body = await (await client.get("/api/settings/import")).get_json()
|
||||
assert body["translation_min_confidence"] == 0.9
|
||||
|
||||
ok = await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": 0.95}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert (await ok.get_json())["translation_min_confidence"] == 0.95
|
||||
|
||||
# Out-of-range + wrong-type are rejected.
|
||||
assert (await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": 1.5})).status_code == 400
|
||||
assert (await client.patch(
|
||||
"/api/settings/import", json={"translation_min_confidence": "high"})).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_status_defaults(client):
|
||||
# Off + no URL → no network health call, healthy False (#143).
|
||||
@@ -113,9 +133,10 @@ async def test_translation_test_connection(client, monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
sink = {}
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.translate_posts.delay",
|
||||
lambda *a, **k: type("R", (), {"id": "t1"})(),
|
||||
lambda *a, **k: sink.update(k) or type("R", (), {"id": "t1"})(),
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
@@ -123,6 +144,46 @@ async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
resp = await client.post("/api/settings/translation/run")
|
||||
assert resp.status_code == 202
|
||||
assert (await resp.get_json())["celery_task_id"] == "t1"
|
||||
assert sink["drain"] is True # "Translate now" chases the whole backlog
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_text(client):
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_base_url(client):
|
||||
# Text given but no Interpreter URL saved → nothing to probe against.
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "hello"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_returns_detection(client, monkeypatch):
|
||||
# The diagnostic surfaces detected language + confidence + result, unsaved.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.settings.ic.translate",
|
||||
lambda texts, **k: {
|
||||
"translations": ["Work in Progress"],
|
||||
"detected_lang": "de", "detected_confidence": 71.5,
|
||||
"engine": "llm", "engine_version": "v1",
|
||||
},
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "WIP"})
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["detected_lang"] == "de"
|
||||
assert body["detected_confidence"] == 71.5
|
||||
assert body["translated"] == "Work in Progress"
|
||||
assert body["target"] == "en"
|
||||
|
||||
|
||||
def _fake_retranslate(monkeypatch, sink):
|
||||
|
||||
@@ -52,6 +52,7 @@ async def test_get_suggestions(client, db):
|
||||
general = body["by_category"].get("general", [])
|
||||
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
|
||||
assert s2["source"] == "head"
|
||||
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -114,12 +115,3 @@ async def test_undismiss_reverses_rejection(client, db):
|
||||
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
||||
)
|
||||
assert resp2.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_requires_fields(client, db):
|
||||
img = await _img(db)
|
||||
resp = await client.post(
|
||||
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -34,9 +34,13 @@ def test_translate_maps_batch_in_order(monkeypatch):
|
||||
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):
|
||||
|
||||
@@ -62,9 +62,8 @@ async def test_head_suggestion_surfaces_for_matching_image(db):
|
||||
s = general[0]
|
||||
assert s.canonical_tag_id == tag.id
|
||||
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.above_threshold is True # ~0.73 clears the 0.5 suggest cut
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -109,7 +108,10 @@ async def test_threshold_override_surfaces_below_cut(db):
|
||||
svc = SuggestionService(db)
|
||||
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)
|
||||
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
|
||||
@@ -281,6 +283,7 @@ async def test_ccip_character_surfaces_in_rail(db):
|
||||
if c.canonical_tag_id == raven.id
|
||||
)
|
||||
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 ---------
|
||||
|
||||
@@ -5,7 +5,11 @@ 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_posts, translate_posts
|
||||
from backend.app.tasks.translation import (
|
||||
_RETRANSLATE_COUNTDOWN,
|
||||
retranslate_posts,
|
||||
translate_posts,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -115,6 +119,35 @@ def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch)
|
||||
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)
|
||||
@@ -162,6 +195,160 @@ def test_translate_posts_interrupt_reenqueues_after_backoff(db_sync, monkeypatch
|
||||
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 test_translate_posts_force_override_translates_below_floor(db_sync, monkeypatch):
|
||||
# override='force' stores the translation even below the confidence floor —
|
||||
# rescuing a legitimately-foreign title the gate would otherwise skip.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_lang(monkeypatch, lang="de", conf=0.42) # below the 0.90 default floor
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="Kurz", ext="p1")
|
||||
p.translation_override = "force"
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
translate_posts()
|
||||
db_sync.refresh(p)
|
||||
assert p.post_title_translated == "EN:Kurz" # forced through despite 0.42
|
||||
assert p.translated_source_lang == "de"
|
||||
|
||||
|
||||
def test_translate_posts_original_override_keeps_original(db_sync, monkeypatch):
|
||||
# override='original' never translates — even a confident (0.98) detection is
|
||||
# kept as the original. The sweep marks it handled (== target) and stores
|
||||
# nothing; Interpreter isn't consulted for the decision.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_lang(monkeypatch, lang="de", conf=0.98) # would be accepted under auto
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="Das Mädchen", ext="p1")
|
||||
p.translation_override = "original"
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
translate_posts()
|
||||
db_sync.refresh(p)
|
||||
assert p.post_title_translated is None # kept original
|
||||
assert p.translated_source_lang == "en" # marked handled
|
||||
|
||||
|
||||
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)
|
||||
@@ -192,6 +379,25 @@ def test_retranslate_resets_and_reruns(db_sync, monkeypatch):
|
||||
assert p.translation_engine_version == "v2"
|
||||
|
||||
|
||||
def test_retranslate_leaves_keep_original_posts(db_sync, monkeypatch):
|
||||
# A 'keep original' post survives a Re-translate-all: _reset_translations skips
|
||||
# override='original', so it stays handled + untranslated instead of being
|
||||
# re-run (which would re-introduce the mis-flag the operator killed).
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_new_model(monkeypatch)
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
p.translation_override = "original"
|
||||
p.translated_source_lang = "en" # handled by a prior 'keep original' apply
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
retranslate_posts(artist_ids=[a.id])
|
||||
db_sync.refresh(p)
|
||||
assert p.translated_source_lang == "en" # not reset
|
||||
assert p.post_title_translated is None # not re-translated
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Translation acceptance gate (#1376, #155) — 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 against the operator-tunable floor
|
||||
(Scribe rule 133). No DB, no network."""
|
||||
from backend.app.tasks.translation import _DEFAULT_MIN_LATIN_CONFIDENCE, _accept
|
||||
|
||||
|
||||
def test_accept_trusts_cjk_regardless_of_confidence_or_floor():
|
||||
# ja/ko/zh are script-detected — accepted even at a low reported number and
|
||||
# even against a high floor (pure-kanji Japanese can come back labelled zh
|
||||
# ~0.75, still translated).
|
||||
assert _accept("ja", None) is True
|
||||
assert _accept("ja", 0.10, 0.99) 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_default_floor_is_strict():
|
||||
# Default floor is 0.90 (#155): the ~0.86 band — where genuine German AND
|
||||
# mis-flagged short English collide — is now REJECTED (the whole point of the
|
||||
# stricter default). Genuine high-confidence non-English (~1.0) still passes.
|
||||
assert _accept("de", 0.98) is True
|
||||
assert _accept("de", 0.86) is False # the collision band, now cut
|
||||
assert _accept("de", _DEFAULT_MIN_LATIN_CONFIDENCE) is True # at floor
|
||||
assert _accept("de", _DEFAULT_MIN_LATIN_CONFIDENCE - 0.01) is False
|
||||
assert _accept("fr", 0.40) is False # short mis-flag
|
||||
|
||||
|
||||
def test_accept_honors_an_explicit_floor():
|
||||
# The floor is a parameter now (fed from ImportSettings). A looser floor keeps
|
||||
# the 0.86 band; a stricter one rejects even a fairly confident detection.
|
||||
assert _accept("de", 0.86, 0.80) is True
|
||||
assert _accept("de", 0.86, 0.95) is False
|
||||
assert _accept("de", 0.96, 0.95) is True
|
||||
|
||||
|
||||
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
|
||||
assert _accept("de", None, 0.99) is True # fail-open beats a high floor
|
||||
Reference in New Issue
Block a user