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
375 lines
13 KiB
Python
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}
|