feat: FC links a conclusive pair itself instead of asking (4392)
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Failing after 2m15s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Failing after 2m15s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Operator, 2026-09-24: *"I don't want this to be manual that defeats the
convenience that I'm going for."*
Confirm-only was right while every signal was circumstantial. Time proximity
and a body that mentions Discord can never be more than suggestive, so asking
was the honest response to what FC actually knew. A shared working name is
different in kind: when the creator's own name for a piece appears in exactly
these two posts and nowhere else in their library, there is nothing left for
the operator to adjudicate, and asking is a chore FC invented for them.
AUTO_LINK_FLOOR is 1.0 and sits deliberately above IDENTITY_FLOOR's 0.75. The
gap between them IS the review queue — real evidence, not certain enough for
FC to act on alone. Measured on artist 8, of 15 name-sharing pairs: 11 are
conclusive, 2 more propose, 2 fall short of both.
Three refusals, because an auto-link is FC asserting something the operator
never saw:
* Exactly one candidate may be conclusive. Two is not a tie to be broken by
score — one post can tease two pieces dropped separately, and then each
drop carries a different name from the same teaser, each individually
conclusive. Two conclusive answers to "which drop is this" means the
question was wrong, so both queue and FC says nothing.
* Neither end may already be claimed by an accepted link. That link is the
operator's decision and reassigning its other end would overrule them
silently.
* The whole thing is one setting, defaulting on, reversible in the UI —
an accepted link is a row they can dismiss.
`match_post` now returns (proposed, linked) so a sweep can report what it did
on its own rather than only what it queued.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -44,6 +44,7 @@ _EDITABLE_FIELDS = (
|
||||
"discord_link_enabled",
|
||||
"discord_link_threshold",
|
||||
"discord_link_window_hours",
|
||||
"discord_link_auto",
|
||||
"extdl_mega_enabled",
|
||||
"extdl_gdrive_enabled",
|
||||
"extdl_mediafire_enabled",
|
||||
@@ -165,6 +166,10 @@ async def update_import_settings():
|
||||
body["discord_link_enabled"], bool
|
||||
):
|
||||
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
|
||||
if "discord_link_auto" in body and not isinstance(
|
||||
body["discord_link_auto"], bool
|
||||
):
|
||||
return jsonify({"error": "discord_link_auto must be a boolean"}), 400
|
||||
if "discord_link_threshold" in body:
|
||||
v = body["discord_link_threshold"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
|
||||
@@ -139,6 +139,25 @@ class ImportSettings(Base):
|
||||
server_default="24",
|
||||
)
|
||||
|
||||
# Whether FC links a CONCLUSIVE pair without asking.
|
||||
#
|
||||
# Operator, 2026-09-24: *"I don't want this to be manual that defeats the
|
||||
# convenience that I'm going for."* Confirm-only was the right default
|
||||
# while the only signals were circumstantial — proximity and a body that
|
||||
# mentions Discord can never be more than suggestive, and asking was the
|
||||
# honest response to that. A shared working name is different in kind: when
|
||||
# the name appears in exactly these two posts and nowhere else in the
|
||||
# artist's library, there is nothing for the operator to adjudicate.
|
||||
#
|
||||
# Only the conclusive band is affected (post_association_service.
|
||||
# AUTO_LINK_FLOOR). Everything weaker still queues, and an accepted link is
|
||||
# a row the operator can dismiss, so this is reversible in the UI rather
|
||||
# than only in the database.
|
||||
discord_link_auto: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
|
||||
# #830 off-platform file-host downloads — per-host enable lever (default on,
|
||||
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
|
||||
# via getattr(settings, f"extdl_{host}_enabled", True).
|
||||
|
||||
@@ -129,6 +129,20 @@ MAX_CANDIDATES = 25
|
||||
# the manual button's job.
|
||||
MAX_RECENT_DROPS = 200
|
||||
|
||||
# What a shared name must reach before FC links a pair WITHOUT asking.
|
||||
#
|
||||
# 1.0, which under post_naming's post-span counting means the name appears in
|
||||
# exactly these two posts and nowhere else in the artist's library. That is not
|
||||
# "strong evidence" — within the library it is conclusive, and the remaining
|
||||
# ways to be wrong are a mis-parse or the creator reusing a name for a genuinely
|
||||
# different piece on the same day.
|
||||
#
|
||||
# Deliberately above IDENTITY_FLOOR, which is what a name needs to PROPOSE.
|
||||
# The gap between them is the review queue: real evidence, not certain enough
|
||||
# for FC to act on by itself. Measured on artist 8, 15 name-sharing pairs: 11
|
||||
# are conclusive, 2 more propose, 2 fall short of both.
|
||||
AUTO_LINK_FLOOR = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Corpus:
|
||||
@@ -272,14 +286,38 @@ class PostAssociationService:
|
||||
.limit(MAX_CANDIDATES)
|
||||
)).scalars().all()
|
||||
|
||||
async def _claimed(self, announcement_id: int, payload_id: int) -> bool:
|
||||
"""Is either end of this pair already spoken for by an accepted link?
|
||||
|
||||
An auto-link is FC asserting something the operator never saw, so it
|
||||
only happens where there is nothing to contradict. A drop already
|
||||
linked to a different announcement is exactly such a contradiction, and
|
||||
resolving it is a judgement about which one is right — which is the
|
||||
operator's, not FC's.
|
||||
"""
|
||||
return (await self.session.execute(
|
||||
select(PostAssociation.id).where(
|
||||
PostAssociation.status == "linked",
|
||||
or_(
|
||||
PostAssociation.payload_post_id == payload_id,
|
||||
PostAssociation.announcement_post_id == announcement_id,
|
||||
),
|
||||
).limit(1)
|
||||
)).scalar() is not None
|
||||
|
||||
async def match_post(
|
||||
self, announcement_id: int, *, threshold: float, window_hours: float,
|
||||
) -> int:
|
||||
"""Score one announcement against nearby groupings. Returns proposals made."""
|
||||
auto_link: bool = False,
|
||||
) -> tuple[int, int]:
|
||||
"""Score one announcement against nearby groupings.
|
||||
|
||||
Returns `(proposed, linked)` — how many pairs were written, and how
|
||||
many of those were linked outright rather than queued.
|
||||
"""
|
||||
announcement = await self.session.get(Post, announcement_id)
|
||||
if announcement is None or announcement.synthesized_by is not None:
|
||||
# A synthetic post cannot announce anything — FC wrote it.
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
window = timedelta(hours=window_hours)
|
||||
declared = declared_signal(announcement.description)
|
||||
@@ -289,6 +327,7 @@ class PostAssociationService:
|
||||
here_text = corpus.text_by_post.get(announcement.id, "")
|
||||
|
||||
made = 0
|
||||
scored: list[tuple[Post, float, dict, float]] = []
|
||||
for group in await self._candidate_groups(announcement, window=window):
|
||||
if group.id in already:
|
||||
continue
|
||||
@@ -335,15 +374,35 @@ class PostAssociationService:
|
||||
# Carried so the queue can say WHY. A review queue that cannot
|
||||
# explain itself is one the operator learns to click through.
|
||||
signals["identity_token"] = token
|
||||
scored.append((group, score, signals, identity))
|
||||
|
||||
# Who, if anyone, FC links without asking.
|
||||
#
|
||||
# EXACTLY ONE candidate may be conclusive. Two drops sharing a name
|
||||
# with one teaser at full strength is not a tie to be broken by score —
|
||||
# it means the name identifies something other than what FC thinks it
|
||||
# does, and the right response is to queue both and say nothing.
|
||||
auto_id = None
|
||||
if auto_link:
|
||||
conclusive = [c for c in scored if c[3] >= AUTO_LINK_FLOOR]
|
||||
if len(conclusive) == 1 and not await self._claimed(
|
||||
announcement.id, conclusive[0][0].id
|
||||
):
|
||||
auto_id = conclusive[0][0].id
|
||||
|
||||
linked = 0
|
||||
for group, score, signals, identity in scored:
|
||||
status = "linked" if group.id == auto_id else "pending"
|
||||
linked += status == "linked"
|
||||
self.session.add(PostAssociation(
|
||||
announcement_post_id=announcement.id,
|
||||
payload_post_id=group.id,
|
||||
score=score,
|
||||
signals=signals,
|
||||
status="pending",
|
||||
status=status,
|
||||
))
|
||||
made += 1
|
||||
return made
|
||||
return made, linked
|
||||
|
||||
async def list_pending(self) -> list[dict]:
|
||||
rows = (await self.session.execute(
|
||||
@@ -412,7 +471,7 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
"""Score every recent non-synthetic post against nearby groupings."""
|
||||
settings = await ImportSettings.load(session)
|
||||
if not settings.discord_link_enabled:
|
||||
return {"enabled": False, "scanned": 0, "proposed": 0}
|
||||
return {"enabled": False, "scanned": 0, "proposed": 0, "linked": 0}
|
||||
|
||||
now = now or datetime.now(UTC)
|
||||
window_hours = float(settings.discord_link_window_hours)
|
||||
@@ -466,17 +525,25 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
|
||||
svc = PostAssociationService(session)
|
||||
proposed = 0
|
||||
linked = 0
|
||||
# Sorted because `ids` is now a union of two queries: set iteration order
|
||||
# is arbitrary, and a sweep that visits posts in a different order each
|
||||
# run is one whose failures cannot be reproduced.
|
||||
for pid in sorted(ids):
|
||||
proposed += await svc.match_post(
|
||||
made, auto = await svc.match_post(
|
||||
pid,
|
||||
threshold=float(settings.discord_link_threshold),
|
||||
window_hours=window_hours,
|
||||
auto_link=bool(settings.discord_link_auto),
|
||||
)
|
||||
proposed += made
|
||||
linked += auto
|
||||
log.info(
|
||||
"discord announcement matcher: scanned %d post(s), proposed %d pair(s)",
|
||||
len(ids), proposed,
|
||||
"discord announcement matcher: scanned %d post(s), proposed %d pair(s), "
|
||||
"linked %d outright",
|
||||
len(ids), proposed, linked,
|
||||
)
|
||||
return {"enabled": True, "scanned": len(ids), "proposed": proposed}
|
||||
return {
|
||||
"enabled": True, "scanned": len(ids), "proposed": proposed,
|
||||
"linked": linked,
|
||||
}
|
||||
|
||||
@@ -1184,12 +1184,16 @@ def group_discord_drops() -> str:
|
||||
soft_time_limit=900, time_limit=1200,
|
||||
)
|
||||
def match_post_associations() -> str:
|
||||
"""Milestone 388 E5: propose which Patreon post announced which Discord drop.
|
||||
"""Milestone 388 E5: which Patreon post announced which Discord drop.
|
||||
|
||||
Proposes only — every pair lands in a review queue and nothing is linked
|
||||
until the operator accepts. Maintenance lane for the same reason as the
|
||||
grouper: no inference, no ML library, and it must not depend on the
|
||||
optional ml-worker being present.
|
||||
A CONCLUSIVE pair — one the creator's own working name identifies, where
|
||||
that name appears in these two posts and nowhere else in their library — is
|
||||
linked outright when `discord_link_auto` is on, because there is nothing
|
||||
there for the operator to adjudicate. Everything weaker lands in the review
|
||||
queue and stays unlinked until they accept it.
|
||||
|
||||
Maintenance lane for the same reason as the grouper: no inference, no ML
|
||||
library, and it must not depend on the optional ml-worker being present.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
||||
Reference in New Issue
Block a user