From 31de54e326217719110fd43e69cb10ce6b909a5d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 24 Sep 2026 08:14:04 -0400 Subject: [PATCH] feat: FC links a conclusive pair itself instead of asking (4392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR --- alembic/versions/0109_discord_link_auto.py | 44 ++++ backend/app/api/settings.py | 5 + backend/app/models/import_settings.py | 19 ++ .../app/services/post_association_service.py | 87 +++++++- backend/app/tasks/maintenance.py | 14 +- tests/test_api_settings_downloader.py | 32 +++ tests/test_post_association.py | 196 ++++++++++++++++-- 7 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 alembic/versions/0109_discord_link_auto.py diff --git a/alembic/versions/0109_discord_link_auto.py b/alembic/versions/0109_discord_link_auto.py new file mode 100644 index 0000000..81725e2 --- /dev/null +++ b/alembic/versions/0109_discord_link_auto.py @@ -0,0 +1,44 @@ +"""discord_link_auto — 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 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. 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 just a chore FC invented for them. + +Defaults ON, which is a real change of posture and deliberate. It only governs +the conclusive band — weaker evidence still queues — and a link is a row the +operator can dismiss, so the reversal is a click rather than a migration. + +Revision ID: 0109 +Revises: 0108 +Create Date: 2026-09-24 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "0109" +down_revision = "0108" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "import_settings", + sa.Column( + "discord_link_auto", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + ) + + +def downgrade(): + op.drop_column("import_settings", "discord_link_auto") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 4d90f3d..ffcbd6f 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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: diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 7ac2cbd..35218a6 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -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__enabled so the worker reads them # via getattr(settings, f"extdl_{host}_enabled", True). diff --git a/backend/app/services/post_association_service.py b/backend/app/services/post_association_service.py index c995363..399713c 100644 --- a/backend/app/services/post_association_service.py +++ b/backend/app/services/post_association_service.py @@ -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, + } diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index d796bec..d3993be 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -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 diff --git a/tests/test_api_settings_downloader.py b/tests/test_api_settings_downloader.py index 426d5f6..dbaebde 100644 --- a/tests/test_api_settings_downloader.py +++ b/tests/test_api_settings_downloader.py @@ -103,3 +103,35 @@ async def test_a_negative_window_is_refused(client): "/api/settings/import", json={"download_revisit_days": -1} ) assert resp.status_code == 400 + + +# --- the announcement matcher's own switch --------------------------------- + + +@pytest.mark.asyncio +async def test_auto_linking_is_on_by_default(client): + """Operator, 2026-09-24: "I don't want this to be manual that defeats the + convenience that I'm going for." Defaulting ON is the change of posture + that request asks for, so the default is what this pins.""" + resp = await client.get("/api/settings/import") + assert resp.status_code == 200 + assert (await resp.get_json())["discord_link_auto"] is True + + +@pytest.mark.asyncio +async def test_auto_linking_can_be_turned_off(client): + """Confirm-only has to stay reachable — linking without asking is a change + of posture, and not everyone wants it.""" + resp = await client.patch( + "/api/settings/import", json={"discord_link_auto": False} + ) + assert resp.status_code == 200 + assert (await resp.get_json())["discord_link_auto"] is False + + +@pytest.mark.asyncio +async def test_auto_linking_refuses_a_non_boolean(client): + resp = await client.patch( + "/api/settings/import", json={"discord_link_auto": "yes"} + ) + assert resp.status_code == 400 diff --git a/tests/test_post_association.py b/tests/test_post_association.py index 15836a4..2535451 100644 --- a/tests/test_post_association.py +++ b/tests/test_post_association.py @@ -185,7 +185,7 @@ async def test_a_teaser_and_its_drop_an_hour_apart_are_proposed(db): teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) await db.commit() - assert made == 1 + assert made == (1, 0) assoc = (await db.execute(select(PostAssociation))).scalar_one() assert assoc.announcement_post_id == teaser.id @@ -214,7 +214,7 @@ async def test_two_unrelated_posts_the_same_day_are_not_proposed(db): teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) await db.commit() - assert made == 0 + assert made == (0, 0) assert (await db.execute(select(PostAssociation))).scalars().all() == [] @@ -231,7 +231,7 @@ async def test_a_drop_outside_the_window_is_not_proposed(db): assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, - ) == 0 + ) == (0, 0) @pytest.mark.asyncio @@ -250,7 +250,7 @@ async def test_another_artists_drop_is_never_proposed(db): assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, - ) == 0 + ) == (0, 0) @pytest.mark.asyncio @@ -272,7 +272,7 @@ async def test_an_artist_with_no_discord_source_produces_nothing_and_no_error(db assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, - ) == 0 + ) == (0, 0) @pytest.mark.asyncio @@ -287,7 +287,7 @@ async def test_a_synthetic_post_cannot_announce_anything(db): assert await PostAssociationService(db).match_post( drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, - ) == 0 + ) == (0, 0) @pytest.mark.asyncio @@ -315,7 +315,7 @@ async def test_a_dismissed_pair_is_never_proposed_again(db): assert await svc.match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, - ) == 0 + ) == (0, 0) await db.commit() assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1 @@ -384,7 +384,9 @@ async def test_the_rescan_is_a_no_op_when_the_switch_is_off(db): settings = await ImportSettings.load(db) settings.discord_link_enabled = False await db.commit() - assert await rescan(db) == {"enabled": False, "scanned": 0, "proposed": 0} + assert await rescan(db) == { + "enabled": False, "scanned": 0, "proposed": 0, "linked": 0, + } # --- the identity route ---------------------------------------------------- @@ -422,7 +424,7 @@ async def test_a_shared_working_name_proposes_a_pair_time_cannot_reach(db): ) await db.commit() - assert made == 1 + assert made == (1, 0) assoc = (await db.execute(select(PostAssociation))).scalar_one() assert assoc.payload_post_id == drop.id assert assoc.signals["identity"] == 1.0 @@ -484,7 +486,7 @@ async def test_a_name_the_creator_reuses_everywhere_links_nothing(db): ) await db.commit() - assert made == 0 + assert made == (0, 0) assert (await db.execute(select(PostAssociation))).scalars().all() == [] @@ -518,7 +520,7 @@ async def test_a_name_below_the_floor_is_recorded_but_carries_nothing(db): ) await db.commit() - assert made == 0 + assert made == (0, 0) corpus = await svc._corpus(artist.id) assert corpus.token_posts["cnni18x"] == 4, "four posts carry the name" @@ -549,7 +551,7 @@ async def test_a_marker_the_creator_uses_once_lifts_a_pair_over_the_line(db): ) await db.commit() - assert made == 1 + assert made == (1, 0) assoc = (await db.execute(select(PostAssociation))).scalar_one() assert assoc.signals["marker"] == 1.0 @@ -582,7 +584,7 @@ async def test_a_marker_the_creator_uses_constantly_lifts_nothing(db): ) await db.commit() - assert made == 0 + assert made == (0, 0) def test_the_creators_own_phrasing_counts_as_a_declaration(): @@ -661,3 +663,171 @@ async def test_the_sweep_still_ignores_a_drop_nothing_is_near(db): assert result["proposed"] == 0 assert (await db.execute(select(PostAssociation))).scalars().all() == [] + + +# --- linking 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 right while every signal +# was circumstantial; a name the creator uses on these two posts and nowhere +# else is different in kind. These pin where that line sits. + + +@pytest.mark.asyncio +async def test_a_conclusive_name_links_without_asking(db): + """A name appearing in exactly these two posts is not strong evidence — + within the artist's library it is conclusive, and there is nothing left for + the operator to adjudicate. 20 hours apart, with nothing else to go on.""" + artist, patreon, discord = await _artist_with_channels(db, "autoartist") + now = datetime.now(UTC) + teaser = await _teaser( + db, artist, patreon, at=now - timedelta(hours=20), + body="a little preview", names=["Frieren1Clean"], + ) + await _drop(db, artist, discord, at=now, names=["01_Frieren1Clean"]) + await db.commit() + + made = await PostAssociationService(db).match_post( + teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, + auto_link=True, + ) + await db.commit() + + assert made == (1, 1) + assoc = (await db.execute(select(PostAssociation))).scalar_one() + assert assoc.status == "linked" + + +@pytest.mark.asyncio +async def test_the_same_pair_still_waits_when_the_switch_is_off(db): + """The setting is the whole difference; nothing else about the pair + changes. Confirm-only has to remain reachable, because "link it yourself" + is a change of posture and not everyone wants it.""" + artist, patreon, discord = await _artist_with_channels(db, "manualartist") + now = datetime.now(UTC) + teaser = await _teaser( + db, artist, patreon, at=now - timedelta(hours=20), + body="a little preview", names=["Frieren1Clean"], + ) + await _drop(db, artist, discord, at=now, names=["01_Frieren1Clean"]) + await db.commit() + + made = await PostAssociationService(db).match_post( + teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, + auto_link=False, + ) + await db.commit() + + assert made == (1, 0) + assoc = (await db.execute(select(PostAssociation))).scalar_one() + assert assoc.status == "pending" + + +@pytest.mark.asyncio +async def test_evidence_short_of_conclusive_is_queued_not_linked(db): + """The gap between IDENTITY_FLOOR and AUTO_LINK_FLOOR, which is the review + queue. This pair proposes on a name spanning three posts — real evidence, + not certain enough for FC to act on by itself. Measured equivalent on + artist 8: `0-k`, the operator's own example, at three posts.""" + artist, patreon, discord = await _artist_with_channels(db, "queuedartist") + now = datetime.now(UTC) + teaser = await _teaser( + db, artist, patreon, at=now - timedelta(hours=20), + body="a little preview", ext="t0", names=["0-k"], + ) + await _drop(db, artist, discord, at=now, names=["01_0-k"]) + await _teaser( + db, artist, patreon, at=now - timedelta(days=30), + body="older", ext="other", names=["0-k_wip1"], + ) + await db.commit() + + made = await PostAssociationService(db).match_post( + teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, + auto_link=True, + ) + await db.commit() + + assert made == (1, 0) + assoc = (await db.execute(select(PostAssociation))).scalar_one() + assert assoc.status == "pending" + assert assoc.signals["identity"] == 0.75 + + +@pytest.mark.asyncio +async def test_two_drops_sharing_one_name_link_neither(db): + """Not a tie to be broken by score. + + One post can tease two pieces that were dropped separately, and then each + drop carries a DIFFERENT name from the same teaser — each spanning exactly + two posts, so each is individually conclusive. Two conclusive answers to + "which drop is this" is not a close call; it means the question was wrong. + Both are queued and FC says nothing. + + (One name cannot do this on its own: under post-span counting, a token in + the teaser and two drops spans three posts and is not conclusive at all. + The ambiguity has to come from two names, which is why this fixture has + two.) + """ + artist, patreon, discord = await _artist_with_channels(db, "ambigartist") + now = datetime.now(UTC) + teaser = await _teaser( + db, artist, patreon, at=now - timedelta(hours=2), + body="a little preview", names=["Sabirth", "Terra3"], + ) + await _drop(db, artist, discord, at=now, ext="fc-drop:1", + names=["01_Sabirth"]) + await _drop(db, artist, discord, at=now - timedelta(hours=4), + ext="fc-drop:2", names=["01_Terra3"]) + await db.commit() + + made = await PostAssociationService(db).match_post( + teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, + auto_link=True, + ) + await db.commit() + + proposed, linked = made + assert (proposed, linked) == (2, 0) + rows = (await db.execute(select(PostAssociation))).scalars().all() + assert {r.status for r in rows} == {"pending"} + + +@pytest.mark.asyncio +async def test_a_drop_another_post_already_claims_is_never_taken(db): + """An accepted link is the operator's decision. FC reassigning its other + end on the next sweep would silently overrule them.""" + artist, patreon, discord = await _artist_with_channels(db, "claimedartist") + now = datetime.now(UTC) + first = await _teaser( + db, artist, patreon, at=now - timedelta(hours=2), + body="first", ext="t0", names=["Terra3"], + ) + # The drop carries both names, so the second teaser's claim on it is + # CONCLUSIVE on `Svtt` — two posts, nowhere else. Only the existing link + # stops it, which is the point being pinned. + drop = await _drop(db, artist, discord, at=now, + names=["01_Terra3", "02_Svtt"]) + second = await _teaser( + db, artist, patreon, at=now - timedelta(hours=3), + body="second", ext="t1", names=["Svtt"], + ) + db.add(PostAssociation( + announcement_post_id=first.id, payload_post_id=drop.id, + score=1.0, signals={"identity": 1.0}, status="linked", + )) + await db.commit() + + made = await PostAssociationService(db).match_post( + second.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, + auto_link=True, + ) + await db.commit() + + assert made[1] == 0, "the drop is already spoken for" + rows = (await db.execute( + select(PostAssociation).where( + PostAssociation.announcement_post_id == second.id + ) + )).scalars().all() + assert [r.status for r in rows] == ["pending"]