feat: a teaser's card references the drop it announced and the piece's variants (4402, 4401)

The operator's problem: a Patreon teaser is a pointer, and its card showed the
censored crop plus a text link while the content it pointed at sat on another
card. The fix is a REFERENCE, not an absorption: "the nested items on the
unified post are a duplicate or reference of existing content". Nothing is
written. Discord posts keep their own rows, dates and places in the feed.

- post_unification: for each teaser with a linked association, the drop's
  images and text, plus its variant family: Discord images sharing the
  seed's gated LEADING working name, or a phash near-duplicate, within a
  window of the teaser. One hop only, oldest first.
- Measured on artist 8 before writing it: of 121 message pairs 2-60 days
  apart that share a gated token, 106 share the leading name and all read
  as real families. Of the 15 sharing only a trailing word, 14 are sibling
  pieces and one is a plain collision (`bottom`, 56 days). The family cap
  is 8, not the pairing cap of 6, because `tentacooler` and `0-k1` (6 posts
  each) are real families.
- The feed drops a linked drop's own card only within discord_link_fold_hours
  of its teaser (default 24): "only hidden from the post view they're posted
  the same day". Older referenced posts stay where they landed.
- post_association.linked_by records whether FC or a person made the link,
  so the card can say so. Undo is the existing dismiss.
- `image0` (gallery-dl's fallback name) becomes a stopword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 11:25:52 -04:00
co-authored by Claude Opus 5.5
parent f7b3e15014
commit 30a263a47a
11 changed files with 1087 additions and 11 deletions
+395
View File
@@ -0,0 +1,395 @@
"""#4402 / #4401: a teaser's card shows what it points at — by reference.
Operator, 2026-09-24: *"the teaser from the patreon post doesn't show the items
that it's supposed to reference"*, and on how: *"discord 'posts' land as normal
and only hidden from the post view they're posted the same day. the nested
items on the unified post are a duplicate or reference of existing content."*
Two properties carry the whole design, and most of what follows pins them:
* NOTHING MOVES. A referenced Discord post keeps its row, its date and its
place in the feed; only a drop sitting beside its own teaser is left out.
* A family is the creator's LEADING working name, one hop from the seed, inside
a window. The refusals are the measured false positives, not invented ones.
"""
from collections import Counter
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 PostAssociationService
from backend.app.services.post_feed_service import PostFeedService
from backend.app.services.post_unification import (
FAMILY_MAX_POSTS,
Candidate,
family,
)
T0 = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
WINDOW = timedelta(days=60)
def _c(image_id, name, *, days=0, post_id=None, phash=None):
"""One image, Discord-shaped, `days` from T0."""
return Candidate(
image_id=image_id,
post_id=post_id if post_id is not None else image_id,
path=f"20260901_12345678901234{image_id:04d}_01_{name}.png",
phash=phash,
at=T0 + timedelta(days=days),
)
def _family(seed, pool, *, names=None, hashes=None):
return family(
seed, pool, Counter(names or {}), Counter(hashes or {}),
anchor=T0, window=WINDOW,
)
# --- the family rule, pure ---------------------------------------------------
def test_the_older_wips_of_the_same_piece_are_its_family():
"""The operator's ask exactly: *"yellowroom trickles out variants and I want
them to show in the grouped post even if they're older"*. Measured shape:
`Year_20k_wip1 -> wip3 -> Base -> Cndm` over 44 days."""
seed = [_c(1, "Year_20k_Cndm")]
pool = [_c(2, "Year_20k_wip1", days=-44), _c(3, "Year_20K_wip3", days=-44),
_c(4, "Year_20k_Base", days=-20)]
assert [c.image_id for c in _family(seed, pool)] == [2, 3, 4]
def test_a_family_reads_oldest_first():
"""So the card shows the trickle in the order it happened."""
seed = [_c(1, "svtt_drench_b")]
pool = [_c(2, "svtt_wip5", days=-1), _c(3, "svtt_wip1", days=-3)]
assert [c.image_id for c in _family(seed, pool)] == [3, 2]
def test_a_namesake_outside_the_window_is_not_family():
"""Time does most of the work. Measured on artist 8: every collision found
spreads over 500 days — `ashley` 1258, `anya` 1217, `image0` 1974."""
seed = [_c(1, "Ashley_TAIGA")]
pool = [_c(2, "Ashley_Re4_A", days=-1258)]
assert _family(seed, pool) == []
def test_a_shared_trailing_word_is_not_family():
"""The one plain collision inside 60 days on artist 8: `bottom`, three
posts, 56 days apart. No frequency cap can refuse a word that rare — the
leading-name rule does."""
seed = [_c(1, "Undyne_insert_bottom_only-C")]
pool = [_c(2, "Lichgalclc_Lingerie_Bottom_21", days=-56)]
assert _family(seed, pool) == []
def test_a_leading_name_the_creator_uses_everywhere_is_not_a_family():
"""A character is a habit, not a piece. At the cap the name is gated even
inside the window."""
seed = [_c(1, "Bea_Machamp_Shiny")]
pool = [_c(2, "Bea_Machoke_Shiny", days=-5)]
assert _family(seed, pool, names={"bea": FAMILY_MAX_POSTS}) == []
def test_the_family_cap_admits_the_measured_long_families():
"""`tentacooler` spans 6 posts over 7 days and `0-k1` 6 over 10 — real
families, both lost at the pairing cap of 6. That is why the family cap is
its own number."""
seed = [_c(1, "Tentacooler")]
pool = [_c(2, "Tentacooler_c_ins", days=-7)]
assert [c.image_id for c in _family(seed, pool, names={"tentacooler": 6})] == [2]
def test_a_near_duplicate_joins_the_family_without_a_name():
"""Half of this creator's teasers are screenshots, which carry no name. The
same file re-posted is still the same file."""
seed = [_c(1, "image0", phash=0b1011)]
pool = [_c(2, "image0", days=-10, phash=0b1010)]
assert [c.image_id for c in _family(seed, pool)] == [2]
def test_a_distant_hash_is_not_a_duplicate():
"""Lesson #4400: same-artist images are similar whether or not they are the
same piece, so only a NEAR-duplicate counts — the matcher's own line."""
seed = [_c(1, "image0", phash=0)]
pool = [_c(2, "image0", days=-10, phash=(1 << 100) - 1)]
assert _family(seed, pool) == []
def test_a_family_is_one_hop_from_the_seed():
"""Chaining is what lets a family drift: `alpha` reaches an image that also
carries `beta`, and `beta` must not then reach its own family."""
seed = [_c(1, "alpha_final")]
pool = [_c(2, "alpha_beta", days=-3), _c(3, "beta_wip1", days=-4)]
assert [c.image_id for c in _family(seed, pool)] == [2]
def test_the_seed_never_comes_back_as_its_own_family():
seed = [_c(1, "Year_20k_Cndm")]
assert _family(seed, seed + [_c(2, "Year_20k_Base", days=-20)])[0].image_id == 2
assert len(_family(seed, seed)) == 0
# --- the card, end to end ----------------------------------------------------
async def _channels(db, name):
artist = Artist(name=name, slug=name)
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
_seq = iter(range(1, 10_000))
async def _post(db, artist, source, *, at, names, body=None, title=None, synthetic=False):
post = Post(
source_id=source.id, artist_id=artist.id,
external_post_id=f"ext-{next(_seq)}", post_date=at,
post_title=title, description=body,
synthesized_by=DROP_GROUPER if synthetic else None,
)
db.add(post)
await db.flush()
for name in names:
n = next(_seq)
db.add(ImageRecord(
# Discord-shaped whatever the platform: the prefix is stripped, so
# the NAME below is the leading name.
path=f"/images/{artist.slug}/20260901_1234567890{n:06d}_01_{name}.png",
sha256=f"{n:064d}", size_bytes=10, mime="image/png",
width=10, height=10, origin="downloaded",
primary_post_id=post.id, artist_id=artist.id,
))
await db.flush()
return post
async def _link(db, teaser, drop, *, status="linked", linked_by="fc"):
a = PostAssociation(
announcement_post_id=teaser.id, payload_post_id=drop.id, score=1.0,
signals={"identity": 1.0, "identity_token": "year"},
status=status, linked_by=linked_by if status == "linked" else None,
)
db.add(a)
await db.flush()
return a
async def _feed(db, artist):
page = await PostFeedService(db).scroll(artist_id=artist.id, limit=100)
return {item["id"]: item for item in page["items"]}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_teaser_shows_the_drop_it_announced(db):
"""The complaint itself: the card now carries the drop's images and text."""
artist, patreon, discord = await _channels(db, "unifyartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"],
title="Year 20k", body="Full set in the server")
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm", "Year_20k_Cndm_alt"],
body="@everyone 🍈🍈 the full set", synthetic=True)
assoc = await _link(db, teaser, drop)
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert [t["role"] for t in unified["thumbnails"]] == ["drop", "drop"]
assert {t["post_id"] for t in unified["thumbnails"]} == {drop.id}
assert unified["links"] == [{
"association_id": assoc.id, "post_id": drop.id,
"linked_by": "fc", "token": "year",
}]
assert unified["texts"][0]["text"] == "@everyone 🍈🍈 the full set"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_drop_beside_its_teaser_leaves_the_feed(db):
"""*"only hidden from the post view they're posted the same day"*."""
artist, patreon, discord = await _channels(db, "foldartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=2),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
assert teaser.id in feed
assert drop.id not in feed
# Left out of the FEED, not out of existence: reachable by id, as every
# post is, because it is still the images' true origin.
assert (await PostFeedService(db).get_post(drop.id))["id"] == drop.id
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_drop_days_from_its_teaser_keeps_its_place(db):
"""A reference does not take anything out of history."""
artist, patreon, discord = await _channels(db, "keepartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(days=3),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
assert drop.id in feed
assert feed[teaser.id]["unified"]["thumbnails"][0]["post_id"] == drop.id
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_proposal_changes_nothing_on_the_card(db):
"""Only a LINKED pair unifies. A pending proposal is a question for the
review queue, and rendering it would assert a link nobody made."""
artist, patreon, discord = await _channels(db, "pendingartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop, status="pending")
await db.commit()
feed = await _feed(db, artist)
assert feed[teaser.id]["unified"] is None
assert drop.id in feed
@pytest.mark.integration
@pytest.mark.asyncio
async def test_the_older_variants_come_along_and_nothing_else(db):
"""The family reaches back 44 days for the wips — and not 90 days for a
namesake, and not at all for a message that only shares a trailing word."""
artist, patreon, discord = await _channels(db, "familyartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
wip = await _post(db, artist, discord, at=T0 - timedelta(days=44),
names=["Year_20k_wip1"], body="wip, feedback welcome")
base = await _post(db, artist, discord, at=T0 - timedelta(days=20),
names=["Year_20K_Base"])
too_old = await _post(db, artist, discord, at=T0 - timedelta(days=90),
names=["Year_20k_old"])
trailing = await _post(db, artist, discord, at=T0 - timedelta(days=5),
names=["Other_piece_year"])
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
unified = feed[teaser.id]["unified"]
variants = [t["post_id"] for t in unified["thumbnails"] if t["role"] == "variant"]
assert variants == [wip.id, base.id]
assert unified["variant_count"] == 2
assert too_old.id not in variants and trailing.id not in variants
assert "wip, feedback welcome" in [t["text"] for t in unified["texts"]]
# Referenced, not moved: every variant is still in the feed on its own.
assert {wip.id, base.id} <= set(feed)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_the_family_window_is_the_operators_setting(db):
artist, patreon, discord = await _channels(db, "windowartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _post(db, artist, discord, at=T0 - timedelta(days=20), names=["Year_20k_wip1"])
await _link(db, teaser, drop)
settings = await db.get(ImportSettings, 1)
settings.discord_family_window_days = 10
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert unified["variant_count"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_undo_is_a_dismissal_and_everything_returns(db):
"""The operator chose *"nest automatically, with visible undo"*. The undo
is the review queue's own dismiss: the drop comes back to the feed, the
card loses its references, and the dismissed row is what stops the next
sweep linking the pair straight back."""
artist, patreon, discord = await _channels(db, "undoartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
assoc = await _link(db, teaser, drop)
await db.commit()
await PostAssociationService(db).dismiss(assoc.id)
await db.commit()
feed = await _feed(db, artist)
assert feed[teaser.id]["unified"] is None
assert drop.id in feed
row = (await db.execute(select(PostAssociation))).scalar_one()
assert (row.status, row.linked_by) == ("dismissed", None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_an_operator_accept_is_recorded_as_theirs(db):
"""So the card does not claim FC made a link a person made."""
artist, patreon, discord = await _channels(db, "acceptartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
assoc = await _link(db, teaser, drop, status="pending")
await db.commit()
await PostAssociationService(db).accept(assoc.id)
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert unified["links"][0]["linked_by"] == "operator"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_fold_window_of_zero_hides_nothing(db):
artist, patreon, discord = await _channels(db, "nofoldartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
settings = await db.get(ImportSettings, 1)
settings.discord_link_fold_hours = 0
await db.commit()
assert drop.id in await _feed(db, artist)