CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m17s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m45s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
Three defects found while answering "is the linking automatic":
1. The name and image checks never worked on a live drop. The corpus keyed
every image by primary_post_id, but a drop's images belong to its member
messages and the drop claims them only through provenance, so a drop
looked nameless and hashless. The tests attached images to the drop
itself, which discord_grouping never does. Images now count under the
post a reader sees them on: a message's absorbing drop, else the post
itself.
2. The trickle merge (e08401c) dates a drop by its first stage, days before
the release a teaser announces, which put merged trickles outside the 24h
window. Candidates are now found and timed by their closest member
message. The sweep follows recently grown drops by their messages' times
the same way.
3. A pair left pending was skipped forever: the matcher skipped every
recorded pair, not only decided ones. Pending pairs are now re-scored in
place and linked once conclusive. Linked and dismissed pairs are still
never touched.
Also, "Scan now" shared the sweep's 48-hour horizon, so it could not reach
the history it is described as being for. It now scores every post by an
artist with Discord drops (rescan(full=True)).
New tests build drops the way the grouper does, with images owned by the
member messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
215 lines
8.5 KiB
Python
215 lines
8.5 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_association_service import PostAssociationService
|
|
from ..services.post_association_service import rescan as association_rescan
|
|
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,
|
|
})
|
|
|
|
|
|
# --- #388 E5: the announcement review queue -------------------------------
|
|
#
|
|
# Confirm-only, following the series-suggestion routes (api/tags.py). Nothing
|
|
# here links anything on its own: the matcher proposes, the operator decides.
|
|
|
|
|
|
@posts_bp.route("/associations", methods=["GET"])
|
|
async def list_associations():
|
|
async with get_session() as session:
|
|
return jsonify({"items": await PostAssociationService(session).list_pending()})
|
|
|
|
|
|
@posts_bp.route("/associations/<int:association_id>/accept", methods=["POST"])
|
|
async def accept_association(association_id: int):
|
|
async with get_session() as session:
|
|
result = await PostAssociationService(session).accept(association_id)
|
|
if result is None:
|
|
return _bad("association not found", 404)
|
|
await session.commit()
|
|
return jsonify(result)
|
|
|
|
|
|
@posts_bp.route("/associations/<int:association_id>/dismiss", methods=["POST"])
|
|
async def dismiss_association(association_id: int):
|
|
async with get_session() as session:
|
|
result = await PostAssociationService(session).dismiss(association_id)
|
|
if result is None:
|
|
return _bad("association not found", 404)
|
|
await session.commit()
|
|
return jsonify(result)
|
|
|
|
|
|
@posts_bp.route("/associations/rescan", methods=["POST"])
|
|
async def rescan_associations():
|
|
"""Manual re-scan. The beat sweep only looks at recent posts (a pair has to
|
|
be within the window to exist at all); this is the button for a first run
|
|
over a library that predates the feature."""
|
|
async with get_session() as session:
|
|
# full=True: the button reaches the whole history, which the hourly
|
|
# sweep's 48-hour horizon never does.
|
|
result = await association_rescan(session, full=True)
|
|
await session.commit()
|
|
return jsonify(result)
|