aea2701c28
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
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""FC-3e: /api/posts — cursor-paginated unified posts feed."""
|
|
|
|
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():
|
|
args = request.args
|
|
|
|
cursor = args.get("cursor") or None
|
|
artist_id_raw = args.get("artist_id")
|
|
platform = args.get("platform") or None
|
|
q = (args.get("q") or "").strip() or None
|
|
limit_raw = args.get("limit", "24")
|
|
direction = args.get("direction", "older")
|
|
around_raw = args.get("around")
|
|
|
|
try:
|
|
limit = int(limit_raw)
|
|
except ValueError:
|
|
return _bad("invalid_limit", detail="limit must be an integer")
|
|
if limit < 1 or limit > 100:
|
|
return _bad("invalid_limit", detail="limit must be between 1 and 100")
|
|
|
|
if direction not in ("older", "newer"):
|
|
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
|
|
|
|
around_id = None
|
|
if around_raw is not None:
|
|
try:
|
|
around_id = int(around_raw)
|
|
except ValueError:
|
|
return _bad("invalid_around", detail="around must be an integer post id")
|
|
|
|
artist_id = None
|
|
if artist_id_raw is not None:
|
|
try:
|
|
artist_id = int(artist_id_raw)
|
|
except ValueError:
|
|
return _bad("invalid_artist_id", detail="artist_id must be an integer")
|
|
|
|
if platform is not None and platform not in KNOWN_PLATFORMS:
|
|
return _bad(
|
|
"unknown_platform",
|
|
detail=f"platform must be one of {sorted(KNOWN_PLATFORMS)}",
|
|
)
|
|
|
|
async with get_session() as session:
|
|
svc = PostFeedService(session)
|
|
if around_id is not None:
|
|
result = await svc.around(
|
|
post_id=around_id, artist_id=artist_id,
|
|
platform=platform, q=q, limit=limit,
|
|
)
|
|
if result is None:
|
|
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
|
return jsonify(result)
|
|
try:
|
|
page = await svc.scroll(
|
|
cursor=cursor, artist_id=artist_id,
|
|
platform=platform, q=q, limit=limit, direction=direction,
|
|
)
|
|
except ValueError as exc:
|
|
# Service raises ValueError for malformed cursors only;
|
|
# limit bounds are validated above.
|
|
return _bad("invalid_cursor", detail=str(exc))
|
|
|
|
return jsonify(page)
|
|
|
|
|
|
@posts_bp.route("/<int:post_id>", methods=["GET"])
|
|
async def get_post(post_id: int):
|
|
async with get_session() as session:
|
|
item = await PostFeedService(session).get_post(post_id)
|
|
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,
|
|
})
|