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
108 lines
4.6 KiB
Python
108 lines
4.6 KiB
Python
"""PostAssociation — "this Patreon post announced that Discord drop".
|
|
|
|
Milestone 388, step E5, and 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. The Patreon post is
|
|
the announcement; the Discord grouping (milestone 388 E2) is the payload. This
|
|
row is the link between them.
|
|
|
|
## Directional, and NOT a merge
|
|
|
|
`announcement` → `payload` is asymmetric on purpose. The teaser announces the
|
|
drop; the drop does not announce the teaser, and a symmetric "related posts"
|
|
edge would lose the only thing that makes the pair interesting.
|
|
|
|
Nor are the two collapsed into one post. The creator published twice,
|
|
deliberately, on two platforms with different audiences — flattening that
|
|
hides the very behaviour being modelled, and would destroy the operator's
|
|
ability to see that the Patreon post is a teaser at all.
|
|
|
|
## Confirm-only, following FC-6.3 (task 737)
|
|
|
|
`status` starts at `pending` and nothing is linked until the operator accepts.
|
|
A wrongly-asserted association tells them two different pieces are one, which
|
|
is worse than no link: no link leaves them where they already are, a wrong one
|
|
actively misinforms. Same reason the series matcher writes to a review queue
|
|
instead of filing posts on its own.
|
|
|
|
`status` is a plain String, no CHECK — matching `series_suggestion.status`,
|
|
which records the same check-existing-enums lesson.
|
|
|
|
## Why pHash could not do this, and the correction matters
|
|
|
|
The original plan claimed this link was already sitting in `image_provenance`
|
|
via pHash dedup. It is not. `compute_phash` is `imagehash.phash` at
|
|
`hash_size=8` — a DCT hash over the WHOLE image, robust to rescaling and
|
|
recompression but NOT to cropping, because a crop changes the global
|
|
signature. Cross-platform provenance still links straight re-posts; it does
|
|
nothing for a cropped teaser and its full version, which is precisely the pair
|
|
the operator described. Hence a scored proposal rather than a lookup.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class PostAssociation(Base):
|
|
__tablename__ = "post_association"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"announcement_post_id", "payload_post_id",
|
|
name="uq_post_association_pair",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
# The teaser — a real post the creator wrote (Patreon, today).
|
|
announcement_post_id: Mapped[int] = mapped_column(
|
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
# What it announced — a synthetic Discord grouping, today. CASCADE on both
|
|
# sides: an association to a post that no longer exists is not a fact worth
|
|
# keeping, and E3's reversal path (delete the grouping) should not leave a
|
|
# dangling proposal behind.
|
|
payload_post_id: Mapped[int] = mapped_column(
|
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
|
|
score: Mapped[float] = mapped_column(Float, nullable=False)
|
|
# Per-signal strengths as scored, so a proposal stays explicable after the
|
|
# weights or the threshold are tuned. Without it, "why was this suggested"
|
|
# is unanswerable the moment anything moves.
|
|
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
# pending | linked | dismissed. A DISMISSED row is kept, not deleted — it
|
|
# is what stops the matcher proposing the same rejected pair on every
|
|
# subsequent scan, which is the behaviour that makes a review queue
|
|
# unusable.
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, server_default="pending", index=True
|
|
)
|
|
# WHO linked it: "fc" when the matcher linked a conclusive pair by itself
|
|
# (discord_link_auto), "operator" when a person accepted it. The card needs
|
|
# this to be honest — a link FC asserted on its own says so and offers an
|
|
# undo, which the operator chose over a silent merge (#4402). NULL on a row
|
|
# that is not linked, and on rows linked before the column existed, all of
|
|
# which an operator accepted: auto-linking shipped in the same release.
|
|
linked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
server_default=func.now(), onupdate=func.now(),
|
|
)
|