CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
The point of the milestone rather than its tail. Two of the operator's artists post a deliberately cropped fragment on Patreon to signal that the real thing has landed in their Discord; this proposes those pairs. Confirm-only, following the FC-6.3 series matcher. A wrongly-asserted association tells the operator two different pieces are one, which is strictly worse than no link: no link leaves them where they already were, a wrong one actively misinforms and then propagates into whatever reads it. So the matcher's job is a SHORT list worth reading, not a long list worth trusting. **The threshold sits above every single signal weight, and that is the design.** Proximity is 0.55, declaration 0.45, the cut 0.60 — so neither signal can carry a pair alone. That makes "time proximity alone is never sufficient" an arithmetic property rather than an aspiration: on a busy day an artist posts several times, and a matcher that could pair on proximity alone would turn every one of those days into false pairs until the review queue got abandoned. A guard test asserts the relationship against WEIGHTS directly, so it survives any refactor of the scorer, and says in its own failure message not to fix it by lowering the assertion. **Crop-to-source matching is HELD, on the plan's instruction** — real work with real false-positive risk, worth building only once signals 1 and 2 are shown insufficient against the operator's actual artists. Worth stating: a naive whole-image SigLIP similarity is NOT that signal. A cropped teaser and its full version are precisely the pair a whole-image comparison handles worst, so adding one as a "bonus" would mostly add noise while looking like progress. Two premises in the plan corrected in the building: * **E4 is not actually a prerequisite.** A Patreon Source and a Discord Source the operator has added under one Artist already share `Post.artist_id`, and the synthetic grouping inherits it. E4 EXTENDS this to creators FC has to learn the association for; it is not needed to represent one FC was told. Same-artist is then a hard filter, not a scored signal — two different creators posting minutes apart is a coincidence, not evidence. * **`link_extract` cannot supply the declaration signal.** It exists, but `SUPPORTED_HOSTS` is file hosts only and `host_for()` returns None for a Discord URL, so no ExternalLink row is ever written for one. The signal reads the post body directly instead. And a bug my own test would have caught: `declared_signal` stripped the HTML before looking for an invite, but `html_to_plain` discards attributes and these creators put the invite in an anchor's `href` — so the strongest form of the signal was being thrown away, leaving only whatever the link text said. The invite now matches the raw body; the bare mention still matches stripped text, so `\bdiscord\b` is tested against prose rather than against markup. Dismissed rows are kept, not deleted: the row is what remembers the rejection, and re-proposing a rejected pair on every scan is the one behaviour that makes a review queue get ignored. Both FKs CASCADE, so E3's one-DELETE reversal cannot leave a proposal pointing at a post that no longer exists. Only ACCEPTED links reach the post payload. A pending proposal is a question for the review queue, not a claim to render beside the artwork. UI (rule 27) follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
213 lines
8.4 KiB
Python
213 lines
8.4 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:
|
|
result = await association_rescan(session)
|
|
await session.commit()
|
|
return jsonify(result)
|