From aea2701c2820927c2a33d3d790ff5a1a36afda6b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 10 Jul 2026 22:38:25 -0400 Subject: [PATCH] feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate at a fixed 0.80 couldn't catch the real pain: Interpreter (fresh == cached, verified by probe) confidently mis-detects short ASCII English like "... WIP Part 1" as German at 0.86 — above the floor — so it was accepted and a re-translate reproduced it. Confidence alone can't separate the 0.86 collision (genuine German lands there too), and single-word mis-flags sit at a confident 1.0 no floor catches. Two operator-approved levers: - Acceptance floor is now a live Settings value (ImportSettings. translation_min_confidence, default 0.90; surfaced in the Translation card), so it's tunable without a redeploy. _accept takes the threshold as a parameter. - Per-post sticky override (Post.translation_override: auto/force/original). 'force' stores a translation even below the floor (rescue a skipped legit-foreign title); 'original' keeps the original and clears any stored translation (kill a confident mis-flag no floor catches). The sweep honors it on every run and _reset_translations skips 'original', so the choice survives a Re-translate-all. POST /api/posts//translation-override applies it immediately (translate now when the service is up, else queue for the sweep). UI: PostTranslationControl on the posts-feed card. Migration 0084 (both columns + a CHECK on the override). The feed + provenance serializers expose translation_override. With a stricter floor the rollback finally works: raise it -> Re-translate all -> the 0.86 mis-flags are rejected and restored to the original; force / keep-original handle the residual either way. Tests: gate thresholds against the param (0.86 rejected at 0.90, explicit-floor cases); sweep force/original + re-translate-skips-original; override endpoint (validation, original clears, force queues when disabled, feed exposes it); settings min_confidence default/save/validate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy --- .../0084_translation_strictness_override.py | 51 ++++++++ backend/app/api/posts.py | 83 +++++++++++++ backend/app/api/settings.py | 10 ++ backend/app/models/import_settings.py | 9 ++ backend/app/models/post.py | 26 +++- backend/app/services/post_feed_service.py | 2 + backend/app/services/provenance_service.py | 1 + backend/app/tasks/translation.py | 115 ++++++++++++------ frontend/src/components/posts/PostCard.vue | 9 ++ .../posts/PostTranslationControl.vue | 102 ++++++++++++++++ .../components/settings/TranslationCard.vue | 25 ++++ frontend/src/stores/posts.js | 20 ++- tests/test_api_posts.py | 62 ++++++++++ tests/test_api_settings.py | 20 +++ tests/test_translate_posts.py | 54 ++++++++ tests/test_translation_gate.py | 42 ++++--- 16 files changed, 579 insertions(+), 52 deletions(-) create mode 100644 alembic/versions/0084_translation_strictness_override.py create mode 100644 frontend/src/components/posts/PostTranslationControl.vue diff --git a/alembic/versions/0084_translation_strictness_override.py b/alembic/versions/0084_translation_strictness_override.py new file mode 100644 index 0000000..cd514b4 --- /dev/null +++ b/alembic/versions/0084_translation_strictness_override.py @@ -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") diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index 90d3fe5..b6334db 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -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("//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, + }) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index fb2c052..8367755 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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: diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 62c35ee..8a1f723 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -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: diff --git a/backend/app/models/post.py b/backend/app/models/post.py index b3687eb..4183330 100644 --- a/backend/app/models/post.py +++ b/backend/app/models/post.py @@ -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() diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index 0921ccd..c1304ac 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -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} diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py index f2c7236..6fee555 100644 --- a/backend/app/services/provenance_service.py +++ b/backend/app/services/provenance_service.py @@ -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, } diff --git a/backend/app/tasks/translation.py b/backend/app/tasks/translation.py index 42871c9..8e62ad3 100644 --- a/backend/app/tasks/translation.py +++ b/backend/app/tasks/translation.py @@ -60,14 +60,17 @@ _INTERRUPT_BACKOFF_MAX = 900 # language scores low; require it to clear this floor, else keep the original. # Calibrated 2026-07-09 against fresh (uncached) probes once Interpreter returned # real langdetect confidence: genuine German detected at 1.0, while a correctly- -# detected but ambiguous latin string dipped to 0.86 — so the floor sits at 0.80, -# below that band, to keep legitimate ambiguous non-English while still rejecting -# genuinely-unsure guesses. Real langdetect also fixed the original mis-flag at -# the source (short English titles now detect as English → passthrough), so this +# 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"}) -_MIN_LATIN_CONFIDENCE = 0.80 +_DEFAULT_MIN_LATIN_CONFIDENCE = 0.90 def _interrupt_backoff(retry_after) -> int: @@ -100,10 +103,10 @@ def translate_posts(drain: bool = False) -> str: 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, ) # 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. @@ -152,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) @@ -163,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. @@ -201,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): @@ -244,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, @@ -263,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"; @@ -272,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() @@ -296,28 +304,36 @@ def _summary(status: str, translated: int, scanned: int) -> str: return f"translated={translated} scanned={scanned}" -def _accept(language: str, confidence) -> bool: +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_LATIN_CONFIDENCE``. A missing - confidence fails OPEN (accept), so a service that omits the field never causes - translations to be silently dropped.""" + 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_LATIN_CONFIDENCE + return confidence >= min_confidence -def _translate_field(text: str, base_url: str, target: str): +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). Per-field (not aggregate first-item) detection so a - non-English description still gets translated when the title is already - English (mixed-language posts).""" + 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) @@ -327,23 +343,30 @@ def _translate_field(text: str, base_url: str, target: str): # Trust only Interpreter's own detection (Scribe rule 133): keep the original # when a latin-script detection doesn't clear the confidence floor, so a short # English title mis-labelled as e.g. German isn't rewritten into the archive. - if not _accept(detected, res.get("detected_confidence")): + # 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 _translate_one(session, post, base_url: str, target: str) -> int: - """Translate a post's title/description in place, each field independently. - Returns 1 if it stored at least one translation, 0 when the post is all - passthrough/empty (still marks it handled so the sweep won't revisit it).""" - title = (post.post_title or "").strip() - desc = (html_to_plain(post.description) if post.description else "") or "" - desc = desc.strip() - title_tr, title_lang, title_ev = _translate_field(title, base_url, target) - desc_tr, desc_lang, desc_ev = _translate_field(desc, base_url, target) +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: - # Nothing non-target (or nothing to translate) → handled, store nothing. + 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 @@ -353,3 +376,23 @@ def _translate_one(session, post, base_url: str, target: str) -> int: 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() + 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) diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 1b7837b..f595a70 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -70,6 +70,14 @@ @click.stop="showOriginal = !showOriginal" >{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }} + + + @@ -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 }, diff --git a/frontend/src/components/posts/PostTranslationControl.vue b/frontend/src/components/posts/PostTranslationControl.vue new file mode 100644 index 0000000..a96872c --- /dev/null +++ b/frontend/src/components/posts/PostTranslationControl.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/components/settings/TranslationCard.vue b/frontend/src/components/settings/TranslationCard.vue index 501e47a..cb68187 100644 --- a/frontend/src/components/settings/TranslationCard.vue +++ b/frontend/src/components/settings/TranslationCard.vue @@ -28,6 +28,21 @@ @change="onSave" /> + + +
+ 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. +
+
mdi-circle {{ statusText }} @@ -175,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) @@ -258,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() }) @@ -282,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 diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index cfdf6e8..5c7ff2f 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -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, } }) diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index 3f90380..835a3e5 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -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 diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py index d2522de..eef1dc6 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -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). diff --git a/tests/test_translate_posts.py b/tests/test_translate_posts.py index 1e0c8e8..3e23262 100644 --- a/tests/test_translate_posts.py +++ b/tests/test_translate_posts.py @@ -314,6 +314,41 @@ def test_translate_posts_accepts_low_confidence_cjk(db_sync, monkeypatch): 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) @@ -344,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) diff --git a/tests/test_translation_gate.py b/tests/test_translation_gate.py index 2e226b7..b1148e4 100644 --- a/tests/test_translation_gate.py +++ b/tests/test_translation_gate.py @@ -1,26 +1,39 @@ -"""Translation acceptance gate (#1376) — pure unit tests for ``_accept``, which -decides whether curator stores Interpreter's translation or keeps the original. -Curator does no detection of its own; it only thresholds Interpreter's reported -detected language + confidence (Scribe rule 133). No DB, no network.""" -from backend.app.tasks.translation import _MIN_LATIN_CONFIDENCE, _accept +"""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(): - # ja/ko/zh are script-detected — accepted even at a low reported number - # (pure-kanji Japanese can come back labelled zh ~0.75, still translated). +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) 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_requires_floor_for_latin(): +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 True # calibrated: ambiguous-but-correct German stays - assert _accept("de", _MIN_LATIN_CONFIDENCE) is True # exactly at floor - assert _accept("de", _MIN_LATIN_CONFIDENCE - 0.01) is False - assert _accept("fr", 0.40) is False # short mis-flag + 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(): @@ -32,3 +45,4 @@ 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