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:
+183
-13
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user