Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
This commit was merged in pull request #201.
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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 `<img>` 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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -42,13 +42,23 @@
|
||||
· {{ e.post.attachment_count }} files
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasTranslation(e)" class="fc-prov__actions">
|
||||
<a
|
||||
href="#" @click.prevent="toggleOrig(e.provenance_id)"
|
||||
>{{ showOrig[e.provenance_id] ? 'Show translation' : `Show original${langLabel(e)}` }}</a>
|
||||
</div>
|
||||
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||
<a
|
||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||
</div>
|
||||
<!-- Translated body = plain text; original = sanitized HTML (#143). -->
|
||||
<p
|
||||
v-if="showTranslated(e) && expanded[e.provenance_id] && e.post.description_translated"
|
||||
class="fc-prov__desc"
|
||||
>{{ e.post.description_translated }}</p>
|
||||
<div
|
||||
v-if="e.post.description_html && expanded[e.provenance_id]"
|
||||
v-else-if="e.post.description_html && expanded[e.provenance_id]"
|
||||
class="fc-prov__desc" v-html="e.post.description_html"
|
||||
/>
|
||||
</article>
|
||||
@@ -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. "<strong>…</strong>"); 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) {
|
||||
|
||||
@@ -58,16 +58,23 @@
|
||||
</div>
|
||||
|
||||
<div class="fc-post-card__text">
|
||||
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
||||
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
|
||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||
Post {{ post.external_post_id }}
|
||||
</h3>
|
||||
<!-- Translation toggle (#143): English shown by default; reveal the
|
||||
original, labelled with its detected language. -->
|
||||
<button
|
||||
v-if="hasTranslation" type="button" class="fc-post-card__lang"
|
||||
:title="showOriginal ? 'Show the English translation' : `Show the original${sourceLangLabel} text`"
|
||||
@click.stop="showOriginal = !showOriginal"
|
||||
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||
|
||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||
HTML (headings, lists, links, inline images). Collapsed and the
|
||||
no-detail fallback stay plain text. -->
|
||||
<div
|
||||
v-if="hasDescription && descExpanded && descHtml"
|
||||
v-if="hasDescription && showHtmlBody"
|
||||
class="fc-post-card__desc fc-post-card__desc--html"
|
||||
v-html="descHtml"
|
||||
/>
|
||||
@@ -75,7 +82,7 @@
|
||||
v-else-if="hasDescription" ref="descEl"
|
||||
class="fc-post-card__desc"
|
||||
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
||||
>{{ descText }}</p>
|
||||
>{{ descTextDisplay }}</p>
|
||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||
(no description)
|
||||
</p>
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<ImportFiltersForm />
|
||||
<TranslationCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-translate"
|
||||
title="Post translation (Interpreter)"
|
||||
blurb="Translate non-English post titles + descriptions to English via your self-hosted Interpreter service, so archived posts read naturally."
|
||||
>
|
||||
<div class="d-flex align-center mb-3" style="gap: 10px;">
|
||||
<span class="fc-section-h">Enabled</span>
|
||||
<v-switch
|
||||
v-model="enabled" :loading="busy" hide-details density="compact"
|
||||
color="success" class="ml-auto" @update:model-value="onToggle"
|
||||
/>
|
||||
</div>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Point this at your Interpreter service. It stays off until a URL is set, and
|
||||
only translates posts whose text isn't already in the target language — the
|
||||
English is shown by default on post cards, with a toggle back to the original.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="baseUrl" label="Interpreter base URL"
|
||||
placeholder="http://interpreter.your-domain/" density="compact"
|
||||
hide-details class="mb-3" :disabled="busy" @change="onSave"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="targetLang" label="Target language" density="compact"
|
||||
hide-details style="max-width: 160px;" class="mb-3" :disabled="busy"
|
||||
@change="onSave"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center flex-wrap mb-3" style="gap: 8px;">
|
||||
<v-icon size="12" :color="statusColor">mdi-circle</v-icon>
|
||||
<span class="fc-muted text-body-2">{{ statusText }}</span>
|
||||
<v-btn
|
||||
size="x-small" variant="tonal" rounded="pill" class="ml-2"
|
||||
:loading="testing" :disabled="!baseUrl" @click="onTest"
|
||||
>Test connection</v-btn>
|
||||
<span
|
||||
v-if="testResult !== null" class="text-caption"
|
||||
:class="testResult ? 'text-success' : 'text-error'"
|
||||
>{{ testResult ? '✓ reachable' : '✗ unreachable' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 10px;">
|
||||
<v-btn
|
||||
size="small" variant="flat" color="accent" rounded="pill"
|
||||
prepend-icon="mdi-translate" :loading="running"
|
||||
:disabled="!enabled || !baseUrl" @click="onRun"
|
||||
>Translate now</v-btn>
|
||||
<span v-if="status" class="fc-muted text-caption">
|
||||
{{ status.untranslated_count }}
|
||||
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="err" type="error" variant="tonal" density="compact"
|
||||
class="mt-3" closable @click:close="err = null"
|
||||
>{{ err }}</v-alert>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const api = useApi()
|
||||
const store = useImportStore()
|
||||
|
||||
const enabled = ref(false)
|
||||
const baseUrl = ref('')
|
||||
const targetLang = ref('en')
|
||||
const busy = ref(false)
|
||||
const running = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null) // null = not tested this session; true/false = last result
|
||||
const status = ref(null)
|
||||
const err = ref(null)
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!enabled.value || !baseUrl.value) return 'grey'
|
||||
return status.value?.healthy ? 'success' : 'error'
|
||||
})
|
||||
const statusText = computed(() => {
|
||||
if (!enabled.value) return 'Off'
|
||||
if (!baseUrl.value) return 'No base URL set'
|
||||
if (status.value == null) return 'Checking…'
|
||||
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
|
||||
})
|
||||
|
||||
async function loadStatus() {
|
||||
try { status.value = await api.get('/api/settings/translation/status') }
|
||||
catch { status.value = null }
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
// Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new
|
||||
// URL can be verified before enabling. Also reflects in the status dot.
|
||||
testing.value = true
|
||||
err.value = null
|
||||
testResult.value = null
|
||||
try {
|
||||
const r = await api.post('/api/settings/translation/test', {
|
||||
body: { base_url: baseUrl.value.trim() },
|
||||
})
|
||||
testResult.value = !!r.healthy
|
||||
if (status.value) status.value.healthy = !!r.healthy
|
||||
} catch (e) {
|
||||
err.value = e.message
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
const s = store.settings || {}
|
||||
enabled.value = !!s.translation_enabled
|
||||
baseUrl.value = s.interpreter_base_url || ''
|
||||
targetLang.value = s.translation_target_lang || 'en'
|
||||
} catch { /* non-fatal */ }
|
||||
await loadStatus()
|
||||
})
|
||||
|
||||
async function save(patch) {
|
||||
busy.value = true
|
||||
err.value = null
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { err.value = e.message }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function onToggle(val) {
|
||||
await save({ translation_enabled: !!val })
|
||||
toast({ text: val ? 'Translation on' : 'Translation off', type: 'success' })
|
||||
await loadStatus()
|
||||
}
|
||||
async function onSave() {
|
||||
await save({
|
||||
interpreter_base_url: baseUrl.value.trim(),
|
||||
translation_target_lang: (targetLang.value || 'en').trim() || 'en',
|
||||
})
|
||||
await loadStatus()
|
||||
}
|
||||
async function onRun() {
|
||||
running.value = true
|
||||
err.value = null
|
||||
try {
|
||||
await api.post('/api/settings/translation/run')
|
||||
toast({ text: 'Translation sweep started', type: 'success' })
|
||||
} catch (e) {
|
||||
err.value = e.message
|
||||
} finally {
|
||||
running.value = false
|
||||
setTimeout(loadStatus, 1500) // let the sweep make a dent, then refresh
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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>かわいい</p>",
|
||||
)
|
||||
p.post_title_translated = "cat"
|
||||
p.description_translated = "cute"
|
||||
p.translated_source_lang = "ja"
|
||||
await db.commit()
|
||||
|
||||
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||
item = page["items"][0]
|
||||
assert item["post_title_translated"] == "cat"
|
||||
assert item["description_translated"] == "cute"
|
||||
assert item["translated_source_lang"] == "ja"
|
||||
assert item["post_title"] == "ねこ" # original kept for the toggle
|
||||
assert item["description_plain"] == "かわいい"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_description_truncates_long_text(db):
|
||||
artist = await _seed_artist(db, "alice-long")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user