feat: link the Patreon teaser to the Discord drop it announced (388 step E5)
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
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
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""post_association — "this Patreon post announced that Discord drop".
|
||||
|
||||
Milestone 388, step E5.
|
||||
|
||||
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 table holds the
|
||||
proposed and accepted links between the announcement and the drop.
|
||||
|
||||
Directional and confirm-only. The pair is asymmetric (the teaser announces the
|
||||
drop, not the reverse), the two posts are never merged (the creator published
|
||||
twice, deliberately — flattening that hides the behaviour being modelled), and
|
||||
nothing is linked until the operator accepts, following the FC-6.3 series
|
||||
matcher. A wrongly-asserted association tells them two different pieces are
|
||||
one, which is worse than no link at all.
|
||||
|
||||
Dismissed rows are KEPT. The row is what remembers the rejection, and
|
||||
re-proposing a rejected pair on every scan is what makes a review queue get
|
||||
ignored.
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0094"
|
||||
down_revision: Union[str, None] = "0093"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"post_association",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("announcement_post_id", sa.Integer(), nullable=False),
|
||||
sa.Column("payload_post_id", sa.Integer(), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=False),
|
||||
sa.Column("signals", sa.JSON(), nullable=True),
|
||||
# No CHECK on status (rule 36 considered and declined), matching
|
||||
# series_suggestion.status — the same review-queue vocabulary, and the
|
||||
# same check-existing-enums lesson.
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="pending", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_post_association")),
|
||||
# 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) must not leave a dangling proposal behind.
|
||||
sa.ForeignKeyConstraint(
|
||||
["announcement_post_id"], ["post.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_post_association_announcement_post_id_post"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["payload_post_id"], ["post.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_post_association_payload_post_id_post"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"announcement_post_id", "payload_post_id",
|
||||
name="uq_post_association_pair",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_announcement_post_id"),
|
||||
"post_association", ["announcement_post_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_payload_post_id"),
|
||||
"post_association", ["payload_post_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_status"), "post_association", ["status"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_enabled", sa.Boolean(),
|
||||
server_default="true", nullable=False,
|
||||
),
|
||||
)
|
||||
# 0.60 sits ABOVE the largest single signal weight on purpose — see
|
||||
# post_association_service.WEIGHTS. That is what makes "time proximity
|
||||
# alone is never sufficient" arithmetic rather than aspirational.
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_threshold", sa.Float(),
|
||||
server_default="0.60", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_window_hours", sa.Float(),
|
||||
server_default="24", nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "discord_link_window_hours")
|
||||
op.drop_column("import_settings", "discord_link_threshold")
|
||||
op.drop_column("import_settings", "discord_link_enabled")
|
||||
op.drop_index(op.f("ix_post_association_status"), table_name="post_association")
|
||||
op.drop_index(
|
||||
op.f("ix_post_association_payload_post_id"), table_name="post_association",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_post_association_announcement_post_id"), table_name="post_association",
|
||||
)
|
||||
op.drop_table("post_association")
|
||||
Reference in New Issue
Block a user