Files
FabledCurator/tests/test_post_association.py
T
bvandeusenandClaude Opus 5 235393c08b
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
feat: link the Patreon teaser to the Discord drop it announced (388 step E5)
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
2026-09-10 11:47:28 -04:00

375 lines
13 KiB
Python

"""Milestone 388 E5: which Patreon post announced which Discord drop.
The failure this step must not have is a WRONG link. Telling the operator that
two different pieces are one is worse than telling them nothing — no link
leaves them where they already were, a wrong one actively misinforms. So most
of what follows pins refusals, and the central one is structural rather than
behavioural: the threshold sits above every single signal weight, which is what
makes "time proximity alone is never sufficient" arithmetic instead of a hope.
"""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import (
Artist,
ImageRecord,
ImportSettings,
Post,
PostAssociation,
Source,
)
from backend.app.services.discord_grouping import DROP_GROUPER
from backend.app.services.post_association_service import (
WEIGHTS,
PostAssociationService,
declared_signal,
proximity_signal,
rescan,
weighted_score,
)
from backend.app.services.post_feed_service import PostFeedService
pytestmark = pytest.mark.integration
DEFAULT_THRESHOLD = 0.60
WINDOW = 24.0
# --- the structural guard -------------------------------------------------
def test_no_single_signal_can_reach_the_threshold():
"""THE load-bearing property of this matcher.
On a busy day an artist posts several times, so a matcher that could pair
on proximity alone would turn every busy day into false pairs and the
review queue would be abandoned. Requiring two signals is what prevents
that — and it is a fact about the WEIGHTS, not about any code path, so it
survives every refactor of the scorer.
If this fails, either a weight grew or the default threshold dropped. Do
not "fix" it by lowering the assertion; the arithmetic IS the safeguard.
"""
assert max(WEIGHTS.values()) < DEFAULT_THRESHOLD
assert sum(WEIGHTS.values()) == pytest.approx(1.0)
def test_perfect_proximity_alone_does_not_propose():
"""The same property, expressed through the scorer."""
score = weighted_score({"proximity": 1.0, "declared": 0.0})
assert score < DEFAULT_THRESHOLD
def test_an_explicit_declaration_alone_does_not_propose():
score = weighted_score({"proximity": 0.0, "declared": 1.0})
assert score < DEFAULT_THRESHOLD
def test_both_signals_together_do_propose():
assert weighted_score({"proximity": 1.0, "declared": 1.0}) >= DEFAULT_THRESHOLD
# --- the signals ----------------------------------------------------------
def test_proximity_decays_to_zero_at_the_window_edge():
window = timedelta(hours=24)
assert proximity_signal(timedelta(0), window) == 1.0
assert proximity_signal(timedelta(hours=24), window) == 0.0
assert proximity_signal(timedelta(hours=48), window) == 0.0
assert 0.4 < proximity_signal(timedelta(hours=12), window) < 0.6
def test_proximity_is_symmetric_because_either_can_land_first():
"""The teaser usually goes up around the drop, not reliably before it."""
window = timedelta(hours=24)
assert proximity_signal(timedelta(hours=-2), window) == proximity_signal(
timedelta(hours=2), window
)
def test_an_invite_link_scores_higher_than_a_bare_mention():
assert declared_signal("Full set on my discord.gg/abc123 now!") == 1.0
assert 0 < declared_signal("Posted the rest on discord earlier") < 1.0
assert declared_signal("New piece, hope you like it") == 0.0
assert declared_signal(None) == 0.0
def test_the_declaration_signal_reads_through_html():
"""Post bodies are HTML; a link inside an anchor tag must still count."""
assert declared_signal(
'<p>Full set: <a href="https://discord.gg/xyz">here</a></p>'
) > 0
def test_a_word_containing_discord_is_not_a_mention():
"""\"discordant\" is not a declaration. Without the word boundary the
signal fires on ordinary prose and drags pairs over the threshold."""
assert declared_signal("a discordant palette, deliberately") == 0.0
# --- end to end -----------------------------------------------------------
async def _artist_with_channels(db, name: str):
artist = Artist(name=name, slug=name.lower().replace(" ", "-"))
db.add(artist)
await db.flush()
patreon = Source(
artist_id=artist.id, platform="patreon",
url=f"https://patreon.com/{name}", enabled=True,
)
discord = Source(
artist_id=artist.id, platform="discord",
url=f"https://discord.com/channels/1/{name}", enabled=True,
)
db.add_all([patreon, discord])
await db.flush()
return artist, patreon, discord
async def _teaser(db, artist, source, *, at, body, ext="teaser"):
post = Post(
source_id=source.id, artist_id=artist.id, external_post_id=ext,
post_date=at, post_title="New piece", description=body,
)
db.add(post)
await db.flush()
db.add(ImageRecord(
path=f"/images/{source.id}-{ext}.jpg", sha256=f"{ext:0>64}"[:64],
size_bytes=10, mime="image/jpeg", width=10, height=10,
origin="downloaded", primary_post_id=post.id, artist_id=artist.id,
))
await db.flush()
return post
async def _drop(db, artist, source, *, at, ext="fc-drop:1"):
post = Post(
source_id=source.id, artist_id=artist.id, external_post_id=ext,
post_date=at, synthesized_by=DROP_GROUPER,
synthesis_details={"message_count": 4, "images_since_surface": 0},
)
db.add(post)
await db.flush()
return post
@pytest.mark.asyncio
async def test_a_teaser_and_its_drop_an_hour_apart_are_proposed(db):
artist, patreon, discord = await _artist_with_channels(db, "pairartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=3),
body="Full set is up on discord.gg/abc now",
)
drop = await _drop(db, artist, discord, at=now - timedelta(hours=2))
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
)
await db.commit()
assert made == 1
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.announcement_post_id == teaser.id
assert assoc.payload_post_id == drop.id
assert assoc.status == "pending", "nothing is linked without the operator"
# The per-signal breakdown survives, so the proposal stays explicable
# after the weights or threshold move.
assert assoc.signals["declared"] == 1.0
assert assoc.signals["proximity"] > 0.9
@pytest.mark.asyncio
async def test_two_unrelated_posts_the_same_day_are_not_proposed(db):
"""Time proximity alone must not be sufficient, or every busy day becomes
a false pair. The teaser here says nothing about Discord."""
artist, patreon, discord = await _artist_with_channels(db, "busyartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=3),
body="Just a sketch I liked",
)
await _drop(db, artist, discord, at=now - timedelta(hours=2))
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
)
await db.commit()
assert made == 0
assert (await db.execute(select(PostAssociation))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_drop_outside_the_window_is_not_proposed(db):
artist, patreon, discord = await _artist_with_channels(db, "farapartartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(days=10),
body="Everything is on discord.gg/abc",
)
await _drop(db, artist, discord, at=now)
await db.commit()
assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
@pytest.mark.asyncio
async def test_another_artists_drop_is_never_proposed(db):
"""Same-artist is a HARD filter, not a scored signal: two different
creators posting minutes apart is a coincidence, not evidence."""
artist_a, patreon_a, _ = await _artist_with_channels(db, "artista")
_artist_b, _patreon_b, discord_b = await _artist_with_channels(db, "artistb")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist_a, patreon_a, at=now - timedelta(hours=1),
body="new drop on discord.gg/abc",
)
await _drop(db, _artist_b, discord_b, at=now)
await db.commit()
assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
@pytest.mark.asyncio
async def test_an_artist_with_no_discord_source_produces_nothing_and_no_error(db):
artist = Artist(name="soloartist", slug="soloartist")
db.add(artist)
await db.flush()
patreon = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/solo", enabled=True,
)
db.add(patreon)
await db.flush()
teaser = await _teaser(
db, artist, patreon, at=datetime.now(UTC),
body="on discord.gg/abc", ext="solo",
)
await db.commit()
assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
@pytest.mark.asyncio
async def test_a_synthetic_post_cannot_announce_anything(db):
"""FC wrote it, so it announces nothing — and a grouping proposing itself
as the teaser for another grouping would be pure noise."""
artist, _patreon, discord = await _artist_with_channels(db, "noselfannounce")
now = datetime.now(UTC)
drop_a = await _drop(db, artist, discord, at=now - timedelta(hours=1), ext="fc-drop:a")
await _drop(db, artist, discord, at=now, ext="fc-drop:b")
await db.commit()
assert await PostAssociationService(db).match_post(
drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
@pytest.mark.asyncio
async def test_a_dismissed_pair_is_never_proposed_again(db):
"""The row is what remembers the rejection. Re-proposing a rejected pair on
every scan is the single behaviour that makes a review queue get ignored."""
artist, patreon, discord = await _artist_with_channels(db, "dismissartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=2),
body="discord.gg/abc has the rest",
)
await _drop(db, artist, discord, at=now - timedelta(hours=1))
await db.commit()
svc = PostAssociationService(db)
assert await svc.match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 1
await db.commit()
assoc = (await db.execute(select(PostAssociation))).scalar_one()
await svc.dismiss(assoc.id)
await db.commit()
assert await svc.match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
await db.commit()
assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1
@pytest.mark.asyncio
async def test_only_an_accepted_link_reaches_the_post_payload(db):
"""A pending proposal is a question for the review queue, not a claim to
render beside the artwork."""
artist, patreon, discord = await _artist_with_channels(db, "payloadartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=2),
body="rest is on discord.gg/abc",
)
drop = await _drop(db, artist, discord, at=now - timedelta(hours=1))
await db.commit()
svc = PostAssociationService(db)
await svc.match_post(teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW)
await db.commit()
feed = PostFeedService(db)
assert (await feed.get_post(teaser.id))["associations"] == []
assoc = (await db.execute(select(PostAssociation))).scalar_one()
await svc.accept(assoc.id)
await db.commit()
# Both ends see it, and each sees the OTHER post with its own role.
teaser_item = await feed.get_post(teaser.id)
assert teaser_item["associations"] == [
{"role": "announces", "post_id": drop.id, "id": assoc.id}
]
drop_item = await feed.get_post(drop.id)
assert drop_item["associations"] == [
{"role": "announced_by", "post_id": teaser.id, "id": assoc.id}
]
@pytest.mark.asyncio
async def test_deleting_the_grouping_takes_its_proposals_with_it(db):
"""E3's reversal path is one DELETE; it must not leave a dangling proposal
pointing at a post that no longer exists."""
artist, patreon, discord = await _artist_with_channels(db, "cascadeartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=2),
body="discord.gg/abc",
)
drop = await _drop(db, artist, discord, at=now - timedelta(hours=1))
await db.commit()
await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
)
await db.commit()
assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1
await db.delete(drop)
await db.commit()
assert (await db.execute(select(PostAssociation))).scalars().all() == []
@pytest.mark.asyncio
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}