feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)
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/<id>/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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
This commit is contained in:
@@ -3,12 +3,28 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import ImportSettings, Post
|
||||
from ..services import interpreter_client as ic
|
||||
from ..services.post_feed_service import PostFeedService
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ..utils.text import html_to_plain
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
|
||||
|
||||
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
|
||||
|
||||
|
||||
def _queue_for_sweep(post: Post) -> None:
|
||||
"""Mark a post untranslated (all translation columns NULL) so the periodic
|
||||
sweep re-runs it under its new override — used when Interpreter is down and we
|
||||
can't translate inline."""
|
||||
post.post_title_translated = None
|
||||
post.description_translated = None
|
||||
post.translated_source_lang = None
|
||||
post.translation_engine_version = None
|
||||
post.translated_at = None
|
||||
|
||||
|
||||
@posts_bp.route("", methods=["GET"])
|
||||
async def list_posts():
|
||||
@@ -82,3 +98,70 @@ async def get_post(post_id: int):
|
||||
if item is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
@posts_bp.route("/<int:post_id>/translation-override", methods=["POST"])
|
||||
async def set_translation_override(post_id: int):
|
||||
"""Sticky per-post translation override (milestone 155). Body:
|
||||
``{"override": "auto" | "force" | "original"}``.
|
||||
|
||||
'original' keeps the original (clears any stored translation now — no
|
||||
Interpreter needed). 'force'/'auto' translate the post immediately if the
|
||||
service is up (force bypasses the acceptance floor; auto re-runs the gate);
|
||||
if it's down we save the flag and mark the post untranslated so the next sweep
|
||||
applies it. The override persists, so the sweep + Re-translate-all keep
|
||||
honoring it. Returns the updated translation fields + an ``applied`` status."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
override = body.get("override")
|
||||
if override not in _TRANSLATION_OVERRIDES:
|
||||
return _bad(
|
||||
"invalid_override",
|
||||
detail=f"override must be one of {list(_TRANSLATION_OVERRIDES)}",
|
||||
)
|
||||
|
||||
# Lazy import (mirrors settings.py) so the API module doesn't pull the celery
|
||||
# task graph at import time.
|
||||
from ..tasks.translation import _store_translation, _translate_field
|
||||
|
||||
async with get_session() as session:
|
||||
post = await session.get(Post, post_id)
|
||||
if post is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||
post.translation_override = override
|
||||
cfg = await ImportSettings.load(session)
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
|
||||
if override == "original":
|
||||
_store_translation(post, (None, None, None), (None, None, None), target)
|
||||
applied = "cleared"
|
||||
else:
|
||||
base_url = (cfg.interpreter_base_url or "").strip()
|
||||
if cfg.translation_enabled and base_url and ic.health(base_url):
|
||||
force = override == "force"
|
||||
title = (post.post_title or "").strip()
|
||||
desc = (html_to_plain(post.description) if post.description else "") or ""
|
||||
desc = desc.strip()
|
||||
mc = cfg.translation_min_confidence
|
||||
try:
|
||||
title_res = _translate_field(title, base_url, target, mc, force=force)
|
||||
desc_res = _translate_field(desc, base_url, target, mc, force=force)
|
||||
except ic.InterpreterUnavailable:
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
else:
|
||||
_store_translation(post, title_res, desc_res, target)
|
||||
applied = "translated"
|
||||
else:
|
||||
# Disabled / no URL / unhealthy → let the sweep apply it later.
|
||||
_queue_for_sweep(post)
|
||||
applied = "queued"
|
||||
|
||||
await session.commit()
|
||||
return jsonify({
|
||||
"id": post.id,
|
||||
"translation_override": post.translation_override,
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": post.description_translated,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
"applied": applied,
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ _EDITABLE_FIELDS = (
|
||||
"translation_enabled",
|
||||
"interpreter_base_url",
|
||||
"translation_target_lang",
|
||||
"translation_min_confidence",
|
||||
)
|
||||
|
||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||
@@ -87,6 +88,7 @@ async def get_import_settings():
|
||||
"translation_enabled": row.translation_enabled,
|
||||
"interpreter_base_url": row.interpreter_base_url,
|
||||
"translation_target_lang": row.translation_target_lang,
|
||||
"translation_min_confidence": row.translation_min_confidence,
|
||||
})
|
||||
|
||||
|
||||
@@ -155,6 +157,14 @@ async def update_import_settings():
|
||||
for key in ("interpreter_base_url", "translation_target_lang"):
|
||||
if key in body and not isinstance(body[key], str):
|
||||
return jsonify({"error": f"{key} must be a string"}), 400
|
||||
# Acceptance floor (milestone 155): latin-script translations below this
|
||||
# Interpreter confidence are kept as the original.
|
||||
if "translation_min_confidence" in body:
|
||||
v = body["translation_min_confidence"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
return jsonify(
|
||||
{"error": "translation_min_confidence must be a number in [0, 1]"}
|
||||
), 400
|
||||
if "series_suggest_threshold" in body:
|
||||
v = body["series_suggest_threshold"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
|
||||
@@ -106,6 +106,15 @@ class ImportSettings(Base):
|
||||
translation_target_lang: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="en", server_default="en",
|
||||
)
|
||||
# The latin-script acceptance floor for the translation gate: a translation
|
||||
# whose Interpreter-reported confidence is below this is kept as the original
|
||||
# (operator-tunable, milestone 155). Default 0.9 — stricter than the old
|
||||
# hardcoded 0.8, because Interpreter confidently mis-detects short ASCII
|
||||
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
|
||||
# trusted regardless (script-detected). Per-post overrides handle the misses.
|
||||
translation_min_confidence: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.9, server_default="0.9",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, session) -> ImportSettings:
|
||||
|
||||
@@ -8,7 +8,17 @@ artist-filter queries don't depend on the Source detour).
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -23,6 +33,10 @@ class Post(Base):
|
||||
# (created in alembic 0030) covers that case via
|
||||
# (artist_id, external_post_id).
|
||||
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
||||
CheckConstraint(
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
name="ck_post_translation_override",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -64,6 +78,16 @@ class Post(Base):
|
||||
translated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Sticky per-post override of the translation decision (milestone 155):
|
||||
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
|
||||
# translation even below the confidence floor (rescue a skipped legit-foreign
|
||||
# title); 'original' = never translate, keep the original (kill a confidently
|
||||
# mis-flagged one the floor can't catch). The sweep reads this on every run,
|
||||
# and re-translate leaves 'original' posts alone, so the choice survives a
|
||||
# Re-translate-all.
|
||||
translation_override: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="auto", server_default="auto",
|
||||
)
|
||||
|
||||
downloaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -397,6 +397,8 @@ class PostFeedService:
|
||||
"post_title_translated": post.post_title_translated,
|
||||
"description_translated": desc_trans_short,
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
# Sticky per-post translation choice (auto/force/original, #155).
|
||||
"translation_override": post.translation_override,
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
"source": (
|
||||
{"id": source.id, "platform": source.platform}
|
||||
|
||||
@@ -35,6 +35,7 @@ def _post_dict(p: Post) -> dict:
|
||||
"title_translated": p.post_title_translated,
|
||||
"description_translated": p.description_translated,
|
||||
"translated_source_lang": p.translated_source_lang,
|
||||
"translation_override": p.translation_override,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user