diff --git a/alembic/versions/0083_post_translation.py b/alembic/versions/0083_post_translation.py new file mode 100644 index 0000000..d491d03 --- /dev/null +++ b/alembic/versions/0083_post_translation.py @@ -0,0 +1,73 @@ +"""post-text translation via Interpreter (milestone 143) — Post columns + settings + +Post gains the translated title/description + the detected source language, +Interpreter engine_version (cache key), and translated_at — filled by the +translate sweep. ImportSettings gains translation_enabled (OFF by default), +interpreter_base_url (EMPTY — the operator sets their own, behind a reverse +proxy), and translation_target_lang (en). + +Revision ID: 0083 +Revises: 0082 +Create Date: 2026-07-07 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0083" +down_revision: Union[str, None] = "0082" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "post", sa.Column("post_title_translated", sa.Text(), nullable=True) + ) + op.add_column( + "post", sa.Column("description_translated", sa.Text(), nullable=True) + ) + op.add_column( + "post", + sa.Column("translated_source_lang", sa.String(8), nullable=True), + ) + op.add_column( + "post", + sa.Column("translation_engine_version", sa.String(128), nullable=True), + ) + op.add_column( + "post", + sa.Column("translated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "import_settings", + sa.Column( + "translation_enabled", sa.Boolean(), nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "import_settings", + sa.Column( + "interpreter_base_url", sa.Text(), nullable=False, server_default="", + ), + ) + op.add_column( + "import_settings", + sa.Column( + "translation_target_lang", sa.Text(), nullable=False, + server_default="en", + ), + ) + + +def downgrade() -> None: + op.drop_column("import_settings", "translation_target_lang") + op.drop_column("import_settings", "interpreter_base_url") + op.drop_column("import_settings", "translation_enabled") + op.drop_column("post", "translated_at") + op.drop_column("post", "translation_engine_version") + op.drop_column("post", "translated_source_lang") + op.drop_column("post", "description_translated") + op.drop_column("post", "post_title_translated") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index a8af431..12e850e 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -1,12 +1,23 @@ """Settings API: import filters, system stats.""" +import asyncio import secrets from quart import Blueprint, jsonify, request -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from ..extensions import get_session -from ..models import AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag +from ..models import ( + AppSetting, + Artist, + ImageRecord, + ImportBatch, + ImportSettings, + ImportTask, + Post, + Tag, +) +from ..services import interpreter_client as ic settings_bp = Blueprint("settings", __name__, url_prefix="/api") @@ -32,6 +43,9 @@ _EDITABLE_FIELDS = ( "extdl_mediafire_enabled", "extdl_dropbox_enabled", "extdl_pixeldrain_enabled", + "translation_enabled", + "interpreter_base_url", + "translation_target_lang", ) # Per-host external-download toggles — all plain booleans, validated uniformly. @@ -69,6 +83,9 @@ async def get_import_settings(): "extdl_mediafire_enabled": row.extdl_mediafire_enabled, "extdl_dropbox_enabled": row.extdl_dropbox_enabled, "extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled, + "translation_enabled": row.translation_enabled, + "interpreter_base_url": row.interpreter_base_url, + "translation_target_lang": row.translation_target_lang, }) @@ -128,6 +145,15 @@ async def update_import_settings(): for tog in _EXTDL_TOGGLE_FIELDS: if tog in body and not isinstance(body[tog], bool): return jsonify({"error": f"{tog} must be a boolean"}), 400 + # Translation (#143): base URL may be empty (feature off until set — no + # default host; the operator points it at their own Interpreter proxy). + if "translation_enabled" in body and not isinstance( + body["translation_enabled"], bool + ): + return jsonify({"error": "translation_enabled must be a boolean"}), 400 + for key in ("interpreter_base_url", "translation_target_lang"): + if key in body and not isinstance(body[key], str): + return jsonify({"error": f"{key} must be a string"}), 400 if "series_suggest_threshold" in body: v = body["series_suggest_threshold"] if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: @@ -270,3 +296,58 @@ async def rotate_extension_api_key(): row.value = new_value await session.commit() return jsonify({"key": new_value}) + + +# --- Translation (#143): live status + manual "Translate now" -------------- + + +@settings_bp.route("/settings/translation/status", methods=["GET"]) +async def translation_status(): + """For the Settings card: is it on, is a URL set, is the service reachable, + and how many posts still await translation. Health runs the sync client in a + thread so the event loop isn't blocked.""" + async with get_session() as session: + cfg = await ImportSettings.load(session) + untranslated = (await session.execute( + select(func.count(Post.id)) + .where(Post.translated_source_lang.is_(None)) + .where(or_( + Post.post_title.is_not(None), Post.description.is_not(None), + )) + )).scalar_one() + base_url = (cfg.interpreter_base_url or "").strip() + healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False + return jsonify({ + "enabled": cfg.translation_enabled, + "base_url_set": bool(base_url), + "healthy": healthy, + "untranslated_count": int(untranslated), + }) + + +@settings_bp.route("/settings/translation/test", methods=["POST"]) +async def translation_test(): + """On-demand reachability check for a GIVEN Interpreter base URL (the Settings + 'Test connection' button) — pings /v1/health without saving, so the operator + can verify a URL before enabling. Health runs in a thread (sync client).""" + body = await request.get_json() + base_url = "" + if isinstance(body, dict): + base_url = (body.get("base_url") or "").strip() + healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False + return jsonify({"healthy": healthy}) + + +@settings_bp.route("/settings/translation/run", methods=["POST"]) +async def translation_run(): + """Enqueue the translate sweep now (the Settings 'Translate now' button).""" + async with get_session() as session: + cfg = await ImportSettings.load(session) + if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip(): + return jsonify( + {"error": "translation is disabled or no base URL is set"} + ), 400 + from ..tasks.translation import translate_posts + + r = translate_posts.delay() + return jsonify({"celery_task_id": r.id}), 202 diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index c8cd723..0e47473 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -35,6 +35,7 @@ def make_celery() -> Celery: "backend.app.tasks.backup", "backend.app.tasks.admin", "backend.app.tasks.library_audit", + "backend.app.tasks.translation", ], ) app.conf.update( @@ -63,6 +64,9 @@ def make_celery() -> Celery: "backend.app.tasks.backup.*": {"queue": "maintenance_long"}, "backend.app.tasks.admin.*": {"queue": "maintenance_long"}, "backend.app.tasks.library_audit.*": {"queue": "maintenance_long"}, + # Translation backfill hits the LLM (~1–6s/item) → the long lane so it + # never starves the quick self-healing sweeps (#143). + "backend.app.tasks.translation.*": {"queue": "maintenance_long"}, }, # Heavy ML tasks need fair dispatch — see ImageRepo's precedent. task_acks_late=True, @@ -161,6 +165,10 @@ def make_celery() -> Celery: "task": "backend.app.tasks.ml.prune_presentation_reviews", "schedule": 86400.0, # retention: drop resolved review flags >30d }, + "translate-posts-daily": { + "task": "backend.app.tasks.translation.translate_posts", + "schedule": 86400.0, # no-op unless translation configured + healthy + }, "snapshot-head-metrics-daily": { "task": "backend.app.tasks.maintenance.snapshot_head_metrics", "schedule": 86400.0, diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 5b533fc..62c35ee 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -92,6 +92,21 @@ class ImportSettings(Base): Boolean, nullable=False, default=True, server_default="true", ) + # -- Post-text translation via the Interpreter LAN service (milestone 143). + # Off by default with NO default host — it needs a reachable Interpreter + # service (the operator's, behind a reverse proxy), which not every install + # has; the operator sets the URL and flips it on. Empty base_url OR disabled + # → the translate sweep no-ops. + translation_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false", + ) + interpreter_base_url: Mapped[str] = mapped_column( + Text, nullable=False, default="", server_default="", + ) + translation_target_lang: Mapped[str] = mapped_column( + Text, nullable=False, default="en", server_default="en", + ) + @classmethod async def load(cls, session) -> ImportSettings: """The singleton settings row (id=1), via an async session.""" diff --git a/backend/app/models/post.py b/backend/app/models/post.py index d9830a2..b3687eb 100644 --- a/backend/app/models/post.py +++ b/backend/app/models/post.py @@ -47,6 +47,24 @@ class Post(Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # -- Post-text translation (milestone 143). Filled by the translate_posts + # sweep via the Interpreter LAN service so viewing is instant. + # translated_source_lang is the DETECTED original language; "en" (or a + # passthrough) means nothing to translate and the *_translated columns stay + # NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a + # model upgrade re-translates instead of serving stale. + post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True) + description_translated: Mapped[str | None] = mapped_column(Text, nullable=True) + translated_source_lang: Mapped[str | None] = mapped_column( + String(8), nullable=True + ) + translation_engine_version: Mapped[str | None] = mapped_column( + String(128), nullable=True + ) + translated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + downloaded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/services/interpreter_client.py b/backend/app/services/interpreter_client.py new file mode 100644 index 0000000..0e02029 --- /dev/null +++ b/backend/app/services/interpreter_client.py @@ -0,0 +1,88 @@ +"""Interpreter translation client (milestone 143) — a thin SYNC wrapper over the +self-hosted Interpreter LAN service (LibreTranslate-compatible `/v1/translate`). + +Sync (requests) because the only caller is the sync celery translate sweep, and +it mirrors FC's other platform clients. The service knows nothing about Curator — +all Curator logic stays here. base_url is operator-configured (empty until set; +behind a reverse proxy). Full API contract: Scribe note #1347. +""" +from __future__ import annotations + +import requests + + +class InterpreterUnavailable(Exception): + """The translation engine is down / unreachable (HTTP 503 or a connection + error) — retry later, don't drop the item.""" + + +class InterpreterBadRequest(Exception): + """Bad request params (HTTP 400).""" + + +def _url(base_url: str, path: str) -> str: + return f"{base_url.rstrip('/')}{path}" + + +def health(base_url: str, *, timeout: float = 5.0) -> bool: + """True iff the Interpreter LLM engine is up. Any error (unset URL, network, + non-200, engine down) → False, so the sweep just no-ops rather than raising.""" + if not base_url: + return False + try: + r = requests.get(_url(base_url, "/v1/health"), timeout=timeout) + except requests.RequestException: + return False + if r.status_code != 200: + return False + engines = (r.json() or {}).get("engines") or {} + return bool(engines.get("llm")) + + +def translate( + texts: list[str], *, base_url: str, target: str = "en", + source: str = "auto", timeout: float = 120.0, +) -> dict: + """Translate a batch. Returns:: + + {"translations": [str, ...], # SAME order & length as `texts` + "detected_lang": str | None, # aggregate: describes the FIRST item + "engine": str | None, + "engine_version": str | None} + + Interpreter batch metadata is aggregate (first item only) — fine for one + post's ``[title, description]`` batch since they share a language. Passthrough + items (already target-language / emoji-only) come back UNCHANGED in their + slot. Raises InterpreterUnavailable on 503 / connection error (retry later), + InterpreterBadRequest on 400. (Scribe note #1347.) + """ + if not texts: + return {"translations": [], "detected_lang": None, + "engine": None, "engine_version": None} + try: + r = requests.post( + _url(base_url, "/v1/translate"), + json={"q": list(texts), "source": source, + "target": target, "engine": "auto"}, + timeout=timeout, + ) + except requests.RequestException as e: + raise InterpreterUnavailable(str(e)) from e + if r.status_code == 503: + raise InterpreterUnavailable("interpreter engine unavailable") + if r.status_code == 400: + raise InterpreterBadRequest((r.json() or {}).get("error", "bad request")) + r.raise_for_status() + body = r.json() or {} + out = body.get("translatedText") + # q was an array → translatedText is an array (same order & length). Guard a + # scalar reply just in case (shouldn't happen for an array q). + if not isinstance(out, list): + out = [out] + interp = body.get("interpreter") or {} + return { + "translations": out, + "detected_lang": (body.get("detectedLanguage") or {}).get("language"), + "engine": interp.get("engine"), + "engine_version": interp.get("engine_version"), + } diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index d21f19d..0921ccd 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -200,6 +200,8 @@ class PostFeedService: atts_map = await self._attachments_for([post.id]) item = self._to_dict(post, artist, source, thumbs_map, atts_map) item["description_full"] = html_to_plain(post.description) + # Full (uncapped) translated description for the detail view (#143). + item["description_translated_full"] = post.description_translated # Sanitized HTML body for faithful (semantic) rendering in the post view; # detail-only (the feed list stays lightweight plain text). None when the # post has no body. Inline `` sources are remapped to locally-served @@ -371,6 +373,12 @@ class PostFeedService: description_plain, truncated = None, False else: description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT) + # Translation (#143): the stored translated description is already plain + # text; truncate it the same way for the card. + desc_trans = post.description_translated + desc_trans_short = ( + truncate_at_word(desc_trans, DESCRIPTION_LIMIT)[0] if desc_trans else None + ) thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0}) # `source` is null for filesystem-imported posts with no live # subscription (alembic 0030). Frontend renders that as a @@ -384,6 +392,11 @@ class PostFeedService: "downloaded_at": post.downloaded_at.isoformat(), "description_plain": description_plain, "description_truncated": truncated, + # Translation-forward fields (#143): shown by default when present; + # UI toggles back to the originals above. Source lang labels the toggle. + "post_title_translated": post.post_title_translated, + "description_translated": desc_trans_short, + "translated_source_lang": post.translated_source_lang, "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, "source": ( {"id": source.id, "platform": source.platform} diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py index 4a82b43..f2c7236 100644 --- a/backend/app/services/provenance_service.py +++ b/backend/app/services/provenance_service.py @@ -29,6 +29,12 @@ def _post_dict(p: Post) -> dict: "date": p.post_date.isoformat() if p.post_date else None, "description_html": sanitize_post_html(p.description), "attachment_count": p.attachment_count, + # Translation (#143): the English title/description shown by default when + # a translation exists; the UI toggles to the original. Source lang labels + # the original. + "title_translated": p.post_title_translated, + "description_translated": p.description_translated, + "translated_source_lang": p.translated_source_lang, } diff --git a/backend/app/tasks/translation.py b/backend/app/tasks/translation.py new file mode 100644 index 0000000..89c7b77 --- /dev/null +++ b/backend/app/tasks/translation.py @@ -0,0 +1,101 @@ +"""Post-text translation Celery task (milestone 143). + +Backfills non-English post title/description to the target language via the +self-hosted Interpreter LAN service, storing the result + engine_version so +viewing is instant and re-runs are cache-fast. Sync (Celery workers are sync), +same pattern as the other backfill sweeps. No-op unless translation is enabled, +a base URL is set, and the service is healthy. +""" +import logging +from datetime import UTC, datetime + +from celery.exceptions import SoftTimeLimitExceeded +from sqlalchemy import or_, select + +from ..celery_app import celery +from ..models import ImportSettings, Post +from ..services import interpreter_client as ic +from ..utils.text import html_to_plain +from ._sync_engine import sync_session_factory as _sync_session_factory + +log = logging.getLogger(__name__) + +# Bound one run's work so it commits progress rather than dying at the time +# limit; the rest resumes next cycle (idempotent — only untranslated posts are +# picked, so an interrupted run just re-runs = rule-89 recovery). +_MAX_POSTS_PER_RUN = 300 + + +@celery.task( + name="backend.app.tasks.translation.translate_posts", + soft_time_limit=1800, time_limit=2100, +) +def translate_posts() -> 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.""" + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + cfg = ImportSettings.load_sync(session) + if not cfg.translation_enabled or not cfg.interpreter_base_url.strip(): + return "disabled" + base_url = cfg.interpreter_base_url.strip() + target = (cfg.translation_target_lang or "en").strip() or "en" + if not ic.health(base_url): + return "interpreter unavailable" + + posts = session.execute( + select(Post) + .where(Post.translated_source_lang.is_(None)) + .where(or_( + Post.post_title.is_not(None), Post.description.is_not(None), + )) + .limit(_MAX_POSTS_PER_RUN) + ).scalars().all() + + translated = 0 + try: + for post in posts: + translated += _translate_one(session, post, base_url, target) + session.commit() # per-post → an interrupted run keeps progress + except ic.InterpreterUnavailable: + session.rollback() + return f"interrupted (service down) — translated={translated}" + except ic.InterpreterBadRequest as e: + session.rollback() + log.warning("translate_posts bad request: %s", e) + return f"stopped (bad request) — translated={translated}" + except SoftTimeLimitExceeded: + return f"time limit — translated={translated}" + return f"translated={translated} scanned={len(posts)}" + + +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).""" + 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 diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 2bd8309..3b8f9e5 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -42,13 +42,23 @@ · {{ e.post.attachment_count }} files +
+ {{ showOrig[e.provenance_id] ? 'Show translation' : `Show original${langLabel(e)}` }} +
{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}
+ +

{{ e.post.description_translated }}

@@ -117,8 +127,12 @@ const effectiveImage = computed(() => props.image ?? modal.current) // 2026-05-28. Reset when the viewed image changes. const expanded = reactive({}) function toggleDesc(id) { expanded[id] = !expanded[id] } +// Per-entry "show the original (untranslated) text" toggle (#143). +const showOrig = reactive({}) +function toggleOrig(id) { showOrig[id] = !showOrig[id] } watch(() => effectiveId.value, () => { for (const k of Object.keys(expanded)) delete expanded[k] + for (const k of Object.keys(showOrig)) delete showOrig[k] }) watch( @@ -157,10 +171,23 @@ const show = computed(() => { const attachments = computed(() => state.value?.attachments || []) function postDate(e) { return formatPostDate(e.post.date) } +// Translation-forward (#143): show the English by default when present. +function hasTranslation(e) { + return !!(e.post.title_translated || e.post.description_translated) +} +function showTranslated(e) { + return hasTranslation(e) && !showOrig[e.provenance_id] +} +function langLabel(e) { + return e.post.translated_source_lang ? ` (${e.post.translated_source_lang})` : '' +} function postTitle(e) { // Titles can arrive as stored HTML (e.g. ""); render // as plain text (the CSS makes it bold). - return toPlainText(e.post.title) || `Post ${e.post.external_post_id}` + const t = showTranslated(e) && e.post.title_translated + ? e.post.title_translated + : e.post.title + return toPlainText(t) || `Post ${e.post.external_post_id}` } function openPost(postId, artistId) { diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 4fdce08..1b7837b 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -58,16 +58,23 @@
-

{{ plainTitle }}

+

{{ displayTitle }}

Post {{ post.external_post_id }}

+ +
@@ -75,7 +82,7 @@ v-else-if="hasDescription" ref="descEl" class="fc-post-card__desc" :class="{ 'fc-post-card__desc--clamped': !descExpanded }" - >{{ descText }}

+ >{{ descTextDisplay }}

(no description)

@@ -231,6 +238,32 @@ const canExpand = computed( () => props.post.description_truncated === true || cssOverflow.value, ) +// --- translation-forward (#143): the English is shown by default when a +// translation exists; a per-card toggle reveals the original. Translations are +// plain text, so showTranslated also decides HTML-vs-plain body rendering. +const showOriginal = ref(false) +const hasTranslation = computed( + () => !!(props.post.post_title_translated || props.post.description_translated), +) +const showTranslated = computed(() => hasTranslation.value && !showOriginal.value) +const sourceLangLabel = computed(() => + props.post.translated_source_lang ? ` (${props.post.translated_source_lang})` : '', +) +const displayTitle = computed(() => + showTranslated.value && props.post.post_title_translated + ? toPlainText(props.post.post_title_translated) + : plainTitle.value, +) +const descTextDisplay = computed(() => { + if (!showTranslated.value) return descText.value + return descExpanded.value + ? (detail.value?.description_translated_full || props.post.description_translated) + : props.post.description_translated +}) +const showHtmlBody = computed( + () => !showTranslated.value && descExpanded.value && !!descHtml.value, +) + function measureOverflow () { const el = descEl.value cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1 @@ -467,6 +500,19 @@ function formatBytes (n) { font-size: 0.85rem; font-weight: 600; } .fc-post-card__more:hover { text-decoration: underline; } +/* Translation toggle (#143) — quiet secondary affordance under the title. */ +.fc-post-card__lang { + display: block; margin: 2px 0 6px; padding: 0; + background: none; border: 0; cursor: pointer; + color: rgb(var(--v-theme-on-surface-variant)); + font-size: 0.75rem; font-weight: 600; +} +.fc-post-card__lang:hover { + text-decoration: underline; color: rgb(var(--v-theme-accent)); +} +.fc-post-card__lang:focus-visible { + outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; +} .fc-post-card__atts { display: flex; flex-wrap: wrap; gap: 8px; diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index dfc5693..b379015 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -13,6 +13,7 @@

+
@@ -69,6 +70,7 @@ import { onMounted, onUnmounted } from 'vue' import ImportFiltersForm from './ImportFiltersForm.vue' +import TranslationCard from './TranslationCard.vue' import MLBackfillCard from './MLBackfillCard.vue' import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue' import ArchiveReextractCard from './ArchiveReextractCard.vue' diff --git a/frontend/src/components/settings/TranslationCard.vue b/frontend/src/components/settings/TranslationCard.vue new file mode 100644 index 0000000..728d4e6 --- /dev/null +++ b/frontend/src/components/settings/TranslationCard.vue @@ -0,0 +1,162 @@ + + + diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py index 8067c19..28b4fed 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -28,6 +28,75 @@ async def test_patch_import_settings(client): assert body["transparency_threshold"] == 0.7 +@pytest.mark.asyncio +async def test_translation_settings_defaults_and_patch(client): + # #143: translation is OFF by default with NO default host — the operator + # points it at their own Interpreter proxy and enables it. + body = await (await client.get("/api/settings/import")).get_json() + assert body["translation_enabled"] is False + assert body["interpreter_base_url"] == "" + assert body["translation_target_lang"] == "en" + + ok = await client.patch("/api/settings/import", json={ + "translation_enabled": True, + "interpreter_base_url": "http://interpreter.lan", + }) + assert ok.status_code == 200 + out = await ok.get_json() + assert out["translation_enabled"] is True + assert out["interpreter_base_url"] == "http://interpreter.lan" + + # Type validation. + assert (await client.patch( + "/api/settings/import", json={"translation_enabled": "yes"})).status_code == 400 + assert (await client.patch( + "/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400 + + +@pytest.mark.asyncio +async def test_translation_status_defaults(client): + # Off + no URL → no network health call, healthy False (#143). + body = await (await client.get("/api/settings/translation/status")).get_json() + assert body["enabled"] is False + assert body["base_url_set"] is False + assert body["healthy"] is False + assert "untranslated_count" in body + + +@pytest.mark.asyncio +async def test_translation_run_requires_config(client): + resp = await client.post("/api/settings/translation/run") + assert resp.status_code == 400 # disabled / no base URL + + +@pytest.mark.asyncio +async def test_translation_test_connection(client, monkeypatch): + # Tests a GIVEN url (not the saved one) without persisting anything. + monkeypatch.setattr("backend.app.api.settings.ic.health", lambda *a, **k: True) + resp = await client.post( + "/api/settings/translation/test", json={"base_url": "http://i.lan"}) + assert resp.status_code == 200 + assert (await resp.get_json())["healthy"] is True + # Empty URL → False, no health call. + empty = await client.post( + "/api/settings/translation/test", json={"base_url": ""}) + assert (await empty.get_json())["healthy"] is False + + +@pytest.mark.asyncio +async def test_translation_run_enqueues_when_configured(client, monkeypatch): + monkeypatch.setattr( + "backend.app.tasks.translation.translate_posts.delay", + lambda *a, **k: type("R", (), {"id": "t1"})(), + ) + await client.patch("/api/settings/import", json={ + "translation_enabled": True, "interpreter_base_url": "http://i.lan", + }) + resp = await client.post("/api/settings/translation/run") + assert resp.status_code == 202 + assert (await resp.get_json())["celery_task_id"] == "t1" + + @pytest.mark.asyncio async def test_patch_rejects_non_object(client): resp = await client.patch("/api/settings/import", json=[1, 2, 3]) diff --git a/tests/test_interpreter_client.py b/tests/test_interpreter_client.py new file mode 100644 index 0000000..39b2e07 --- /dev/null +++ b/tests/test_interpreter_client.py @@ -0,0 +1,108 @@ +"""Interpreter translation client (#143) — unit tests with mocked HTTP (no DB, +no real network).""" +import pytest + +from backend.app.services import interpreter_client as ic + + +class _Resp: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self._payload = payload or {} + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise ic.requests.HTTPError(f"status {self.status_code}") + + +def test_translate_maps_batch_in_order(monkeypatch): + captured = {} + + def fake_post(url, json, timeout): + captured["json"] = json + return _Resp(200, { + "translatedText": ["The cat is cute", "Blonde gal!"], + "detectedLanguage": {"language": "ja", "confidence": 0.98}, + "interpreter": {"engine": "llm", "engine_version": "ollama:x:12b"}, + }) + + monkeypatch.setattr(ic.requests, "post", fake_post) + out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan") + assert out["translations"] == ["The cat is cute", "Blonde gal!"] + assert out["detected_lang"] == "ja" + assert out["engine_version"] == "ollama:x:12b" + assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"] + assert captured["json"]["target"] == "en" + + +def test_translate_passthrough_unchanged(monkeypatch): + # Already-English items come back unchanged in their slot (engine "none"). + monkeypatch.setattr(ic.requests, "post", lambda *a, **k: _Resp(200, { + "translatedText": ["already english"], + "detectedLanguage": {"language": "en"}, + "interpreter": {"engine": "none", "engine_version": None}, + })) + out = ic.translate(["already english"], base_url="http://i.lan") + assert out["translations"] == ["already english"] + assert out["detected_lang"] == "en" + assert out["engine"] == "none" + + +def test_translate_503_raises_unavailable(monkeypatch): + monkeypatch.setattr(ic.requests, "post", lambda *a, **k: _Resp(503, {})) + with pytest.raises(ic.InterpreterUnavailable): + ic.translate(["x"], base_url="http://i.lan") + + +def test_translate_connection_error_raises_unavailable(monkeypatch): + def boom(*a, **k): + raise ic.requests.ConnectionError("refused") + + monkeypatch.setattr(ic.requests, "post", boom) + with pytest.raises(ic.InterpreterUnavailable): + ic.translate(["x"], base_url="http://i.lan") + + +def test_translate_400_raises_bad_request(monkeypatch): + monkeypatch.setattr( + ic.requests, "post", lambda *a, **k: _Resp(400, {"error": "bad target"}) + ) + with pytest.raises(ic.InterpreterBadRequest): + ic.translate(["x"], base_url="http://i.lan") + + +def test_translate_empty_is_noop(monkeypatch): + def boom(*a, **k): + raise AssertionError("should not call the service for an empty batch") + + monkeypatch.setattr(ic.requests, "post", boom) + assert ic.translate([], base_url="http://i.lan")["translations"] == [] + + +def test_health_true_when_llm_up(monkeypatch): + monkeypatch.setattr( + ic.requests, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": True}}) + ) + assert ic.health("http://i.lan") is True + + +def test_health_false_when_engine_down(monkeypatch): + monkeypatch.setattr( + ic.requests, "get", lambda *a, **k: _Resp(200, {"engines": {"llm": False}}) + ) + assert ic.health("http://i.lan") is False + + +def test_health_false_on_error(monkeypatch): + def boom(*a, **k): + raise ic.requests.ConnectionError("refused") + + monkeypatch.setattr(ic.requests, "get", boom) + assert ic.health("http://i.lan") is False + + +def test_health_false_when_url_empty(): + assert ic.health("") is False diff --git a/tests/test_post_feed_service.py b/tests/test_post_feed_service.py index 78a01da..87d0de5 100644 --- a/tests/test_post_feed_service.py +++ b/tests/test_post_feed_service.py @@ -283,6 +283,30 @@ async def test_scroll_item_shape_minimal(db): assert "description_full" not in item +@pytest.mark.asyncio +async def test_scroll_surfaces_translation_fields(db): + # #143: a translated post exposes the translated title/description + source + # lang, with the originals still present for the toggle. + artist = await _seed_artist(db, "alice-tr") + src = await _seed_source(db, artist.id, "pixiv", "https://p/alice-tr") + p = await _seed_post( + db, src.id, external_id="TR", post_date=datetime.now(UTC), + title="ねこ", description="

かわいい

", + ) + p.post_title_translated = "cat" + p.description_translated = "cute" + p.translated_source_lang = "ja" + await db.commit() + + page = await PostFeedService(db).scroll(cursor=None, limit=10) + item = page["items"][0] + assert item["post_title_translated"] == "cat" + assert item["description_translated"] == "cute" + assert item["translated_source_lang"] == "ja" + assert item["post_title"] == "ねこ" # original kept for the toggle + assert item["description_plain"] == "かわいい" + + @pytest.mark.asyncio async def test_scroll_description_truncates_long_text(db): artist = await _seed_artist(db, "alice-long") diff --git a/tests/test_translate_posts.py b/tests/test_translate_posts.py new file mode 100644 index 0000000..c59580f --- /dev/null +++ b/tests/test_translate_posts.py @@ -0,0 +1,127 @@ +"""translate_posts sweep (#143) — integration, with a stubbed Interpreter client +and the task's session factory pointed at the test's db_sync.""" +import pytest +from sqlalchemy import select + +from backend.app.models import Artist, ImportSettings, Post +from backend.app.tasks.translation import translate_posts + +pytestmark = pytest.mark.integration + + +class _Ctx: + def __init__(self, s): + self.s = s + + def __enter__(self): + return self.s + + def __exit__(self, *a): + return False + + +def _sf(db_sync): + class _SM: + def __call__(self): + return _Ctx(db_sync) + + return _SM() + + +def _artist(db): + a = Artist(name="A", slug="a") + db.add(a) + db.flush() + return a + + +def _post(db, artist_id, *, title=None, desc=None, ext="p1"): + p = Post( + artist_id=artist_id, external_post_id=ext, + post_title=title, description=desc, + ) + db.add(p) + db.flush() + return p + + +def _enable(db, url="http://i.lan"): + cfg = db.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + cfg.translation_enabled = True + cfg.interpreter_base_url = url + db.flush() + + +def _patch(monkeypatch, db_sync): + monkeypatch.setattr( + "backend.app.tasks.translation._sync_session_factory", + lambda: _sf(db_sync), + ) + + +def test_translate_posts_stores_translation(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True) + monkeypatch.setattr( + "backend.app.tasks.translation.ic.translate", + lambda texts, **k: { + "translations": [f"EN:{t}" for t in texts], + "detected_lang": "ja", "engine": "llm", "engine_version": "v1", + }, + ) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", desc="かわいい") + _enable(db_sync) + db_sync.commit() + + assert "translated=1" in translate_posts() + db_sync.refresh(p) + assert p.post_title_translated == "EN:ねこ" + assert p.description_translated == "EN:かわいい" + assert p.translated_source_lang == "ja" + assert p.translation_engine_version == "v1" + + +def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True) + monkeypatch.setattr( + "backend.app.tasks.translation.ic.translate", + lambda texts, **k: { + "translations": list(texts), "detected_lang": "en", + "engine": "none", "engine_version": None, + }, + ) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="hello world", ext="p2") + _enable(db_sync) + db_sync.commit() + + translate_posts() + db_sync.refresh(p) + assert p.translated_source_lang == "en" # marked handled... + assert p.post_title_translated is None # ...but nothing to show + + +def test_translate_posts_disabled_is_noop(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", ext="p3") + db_sync.commit() # translation_enabled defaults False + assert translate_posts() == "disabled" + db_sync.refresh(p) + assert p.translated_source_lang is None + + +def test_translate_posts_service_down_leaves_untranslated(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", ext="p4") + _enable(db_sync) + db_sync.commit() + assert translate_posts() == "interpreter unavailable" + db_sync.refresh(p) + assert p.translated_source_lang is None # will retry next run