feat: offer the creator you already track as the one you subscribe to (388 E4)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
**The verification the step asked for came back "not the schema".**
`Source.artist_id` is a plain FK so many sources per artist already works;
`POST /api/sources` already takes an `artist_id`; the add-source dialog already
has an artist autocomplete that attaches to an EXISTING artist; and
`SourceService.reassign` already moves a source between artists WITH post and
image re-attribution. A sweep for one-source-per-artist assumptions found only
`func.count()` calls — the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
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,106 @@
|
|||||||
|
"""artist_membership_suggestion — proposing that a creator and a membership match.
|
||||||
|
|
||||||
|
Milestone 388, step E4.
|
||||||
|
|
||||||
|
## What this migration deliberately does NOT add
|
||||||
|
|
||||||
|
No association table between Artist and Source, and no schema change to either.
|
||||||
|
E4's first job was to verify what was actually missing, and the answer was
|
||||||
|
neither the model nor the flows: `Source.artist_id` is a plain FK so many
|
||||||
|
sources per artist already works, `POST /api/sources` already takes an
|
||||||
|
`artist_id`, the add-source dialog already attaches to an EXISTING artist, and
|
||||||
|
`SourceService.reassign` already moves a source between artists with post and
|
||||||
|
image re-attribution. Building a parallel association table for a relationship
|
||||||
|
the schema already expresses would have been the mistake rule 28 names.
|
||||||
|
|
||||||
|
What was missing is the SUGGESTION, and that is all this table holds.
|
||||||
|
|
||||||
|
Accepting a suggestion adds a SOURCE under the existing artist — it never
|
||||||
|
merges two artists. Adding a source is trivially undone; a wrong merge silently
|
||||||
|
mixes two creators' work and corrupts tagging, series and provenance with
|
||||||
|
nothing left to separate them by.
|
||||||
|
|
||||||
|
Revision ID: 0096
|
||||||
|
Revises: 0095
|
||||||
|
Create Date: 2026-09-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0096"
|
||||||
|
down_revision: Union[str, None] = "0095"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"artist_membership_suggestion",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("platform_membership_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("artist_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 and post_association — 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_artist_membership_suggestion")),
|
||||||
|
# CASCADE both ways: a suggestion about a membership or an artist that
|
||||||
|
# no longer exists is not a fact worth keeping, and a dangling proposal
|
||||||
|
# would render as a broken row in the review queue.
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["platform_membership_id"], ["platform_membership.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
name=op.f("fk_artist_membership_suggestion_membership"),
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["artist_id"], ["artist.id"], ondelete="CASCADE",
|
||||||
|
name=op.f("fk_artist_membership_suggestion_artist_id_artist"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"platform_membership_id", "artist_id",
|
||||||
|
name="uq_artist_membership_suggestion_pair",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||||
|
"artist_membership_suggestion", ["platform_membership_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||||
|
"artist_membership_suggestion", ["artist_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_status"),
|
||||||
|
"artist_membership_suggestion", ["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_status"),
|
||||||
|
table_name="artist_membership_suggestion",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||||
|
table_name="artist_membership_suggestion",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||||
|
table_name="artist_membership_suggestion",
|
||||||
|
)
|
||||||
|
op.drop_table("artist_membership_suggestion")
|
||||||
@@ -5,6 +5,8 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||||
|
from ..services.artist_membership_service import ArtistMembershipService
|
||||||
|
from ..services.artist_membership_service import rescan as membership_rescan
|
||||||
from ..services.membership_roster import roster_is_fresh
|
from ..services.membership_roster import roster_is_fresh
|
||||||
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
||||||
from ..services.source_service import (
|
from ..services.source_service import (
|
||||||
@@ -343,3 +345,44 @@ async def trigger_membership_sync():
|
|||||||
|
|
||||||
sync_memberships.delay()
|
sync_memberships.delay()
|
||||||
return jsonify({"queued": True})
|
return jsonify({"queued": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- #388 E4: creator/membership suggestions ------------------------------
|
||||||
|
#
|
||||||
|
# Confirm-only. Accepting ADDS A SOURCE under the existing artist — it never
|
||||||
|
# merges two artists, because adding a source is trivially undone and a wrong
|
||||||
|
# merge silently mixes two creators' work with nothing left to separate them by.
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/membership-suggestions", methods=["GET"])
|
||||||
|
async def list_membership_suggestions():
|
||||||
|
async with get_session() as session:
|
||||||
|
return jsonify({"items": await ArtistMembershipService(session).list_pending()})
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/membership-suggestions/<int:sid>/accept", methods=["POST"])
|
||||||
|
async def accept_membership_suggestion(sid: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await ArtistMembershipService(session).accept(sid)
|
||||||
|
if result is None:
|
||||||
|
return _bad("suggestion_not_found", status=404)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/membership-suggestions/<int:sid>/dismiss", methods=["POST"])
|
||||||
|
async def dismiss_membership_suggestion(sid: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await ArtistMembershipService(session).dismiss(sid)
|
||||||
|
if result is None:
|
||||||
|
return _bad("suggestion_not_found", status=404)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/membership-suggestions/rescan", methods=["POST"])
|
||||||
|
async def rescan_membership_suggestions():
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await membership_rescan(session)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(result)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .app_setting import AppSetting
|
from .app_setting import AppSetting
|
||||||
from .artist import Artist
|
from .artist import Artist
|
||||||
|
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
||||||
from .artist_visit import ArtistVisit
|
from .artist_visit import ArtistVisit
|
||||||
from .backup_run import BackupRun
|
from .backup_run import BackupRun
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
"Base",
|
"Base",
|
||||||
"AppSetting",
|
"AppSetting",
|
||||||
"Artist",
|
"Artist",
|
||||||
|
"ArtistMembershipSuggestion",
|
||||||
"ArtistVisit",
|
"ArtistVisit",
|
||||||
"BackupRun",
|
"BackupRun",
|
||||||
"Source",
|
"Source",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""artist_membership_suggestion — "this creator and that membership are the same".
|
||||||
|
|
||||||
|
Milestone 388, step E4.
|
||||||
|
|
||||||
|
## What was NOT needed here
|
||||||
|
|
||||||
|
E4's first job was to check what is actually missing, and the answer was: not
|
||||||
|
the schema, and not the flows. `Source.artist_id` is a plain FK, so many
|
||||||
|
sources per artist is already the data model; `POST /api/sources` already takes
|
||||||
|
an `artist_id`; the add-source dialog already has an artist autocomplete that
|
||||||
|
attaches to an EXISTING artist; and `SourceService.reassign` already moves a
|
||||||
|
source between artists WITH post and image re-attribution. A sweep for
|
||||||
|
one-source-per-artist assumptions found only `func.count()` calls, which are
|
||||||
|
the opposite of assuming one.
|
||||||
|
|
||||||
|
So no parallel association table was built for a relationship the schema
|
||||||
|
already expresses (rule 28). What was missing is the SUGGESTION — FC proposing
|
||||||
|
the link from the roster instead of waiting to be told.
|
||||||
|
|
||||||
|
## Confirm-only, and what "accept" actually does
|
||||||
|
|
||||||
|
Accepting adds a SOURCE for the membership's platform under the artist that
|
||||||
|
already has the other channel. It does NOT merge two artists. That distinction
|
||||||
|
is the whole safety margin: adding a source is trivially undone, whereas a
|
||||||
|
wrong artist merge silently mixes two creators' work and corrupts tagging,
|
||||||
|
series and provenance downstream — with nothing left to tell them apart by.
|
||||||
|
|
||||||
|
Dismissed rows are kept, not deleted, for the same reason as every other review
|
||||||
|
queue here: the row is what remembers the rejection, and re-proposing a
|
||||||
|
rejected pair on every scan is what makes a queue get ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 ArtistMembershipSuggestion(Base):
|
||||||
|
__tablename__ = "artist_membership_suggestion"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"platform_membership_id", "artist_id",
|
||||||
|
name="uq_artist_membership_suggestion_pair",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
platform_membership_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("platform_membership.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
artist_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
# Per-signal strengths as scored. Without it, "why was this suggested" is
|
||||||
|
# unanswerable the moment a weight or the threshold moves.
|
||||||
|
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# pending | linked | dismissed. Plain String, no CHECK — same call as
|
||||||
|
# series_suggestion.status and post_association.status.
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, server_default="pending", index=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(),
|
||||||
|
)
|
||||||
@@ -128,3 +128,25 @@ class PlatformMembership(Base):
|
|||||||
# be answered without re-fetching — and so a field we did not think to
|
# be answered without re-fetching — and so a field we did not think to
|
||||||
# model is not lost. Displayed and never queried, like service_seen.details.
|
# model is not lost. Displayed and never queried, like service_seen.details.
|
||||||
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
def vanity_or_none(self) -> str | None:
|
||||||
|
"""The platform's URL slug for this creator, if it can be known.
|
||||||
|
|
||||||
|
NOT a column, and that is C1's design working as intended rather than
|
||||||
|
an omission: the roster was modelled before any platform had been
|
||||||
|
characterised, so `details` exists precisely to carry the fields we did
|
||||||
|
not know to model. The vanity turned out to be one of them (#3886), and
|
||||||
|
it is reachable without a migration.
|
||||||
|
|
||||||
|
Falls back to the URL's last segment, which is what a vanity IS on
|
||||||
|
every platform seen so far — but only as a fallback, because the
|
||||||
|
platform's own word for it is the better answer when present.
|
||||||
|
"""
|
||||||
|
campaign = (self.details or {}).get("campaign") or {}
|
||||||
|
vanity = campaign.get("vanity")
|
||||||
|
if isinstance(vanity, str) and vanity:
|
||||||
|
return vanity
|
||||||
|
if self.url:
|
||||||
|
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
return tail or None
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Proposing that a creator FC tracks and a membership it found are the same.
|
||||||
|
|
||||||
|
Milestone 388, step E4. An instance of the confirm-only matcher shape
|
||||||
|
(snippet #3842), and a sibling of `post_association_service`.
|
||||||
|
|
||||||
|
## What E4 turned out NOT to need
|
||||||
|
|
||||||
|
The step's own first instruction was to verify before building, and the
|
||||||
|
verification said: not the schema, not the flows. `Source.artist_id` is a plain
|
||||||
|
FK so many sources per artist already works; `POST /api/sources` already takes
|
||||||
|
an `artist_id`; the add-source dialog already has an artist autocomplete that
|
||||||
|
attaches to an EXISTING artist; `SourceService.reassign` already moves a source
|
||||||
|
between artists WITH post and image re-attribution; and a sweep for
|
||||||
|
one-source-per-artist assumptions found only `func.count()` calls, which are
|
||||||
|
the opposite of assuming one.
|
||||||
|
|
||||||
|
So the association a Discord source and a Patreon source share is already
|
||||||
|
expressible today. What was missing is FC OFFERING it.
|
||||||
|
|
||||||
|
## Accept adds a SOURCE — it never merges artists
|
||||||
|
|
||||||
|
The asymmetry that sets the whole posture: adding a source is trivially undone.
|
||||||
|
A wrong artist merge silently mixes two creators' work and corrupts tagging,
|
||||||
|
series and provenance downstream, with nothing left to tell the two apart by.
|
||||||
|
So the accepted action is "add the missing channel to this artist", and merging
|
||||||
|
is not offered at all.
|
||||||
|
|
||||||
|
## The signals
|
||||||
|
|
||||||
|
1. **Name.** The roster's `display_name` and `vanity`, slugified, against the
|
||||||
|
artist's `slug`. Graded rather than boolean — an exact match is strong
|
||||||
|
evidence, a containment match is a hint.
|
||||||
|
2. **Declared.** A post already under this artist whose body links to
|
||||||
|
`patreon.com/<vanity>` for this exact membership. A creator pointing at
|
||||||
|
their own Patreon from their own Discord is close to a statement.
|
||||||
|
|
||||||
|
Signal 2 is NOT read from `ExternalLink`, and that correction is worth keeping:
|
||||||
|
`link_extract.SUPPORTED_HOSTS` is file hosts only (mega/gdrive/mediafire/
|
||||||
|
dropbox/pixeldrain) and `host_for()` returns None for patreon.com, so no
|
||||||
|
`ExternalLink` row is ever written for one. The same trap already caught E5 for
|
||||||
|
Discord invites.
|
||||||
|
|
||||||
|
## Weights, and what they make impossible
|
||||||
|
|
||||||
|
name 0.65 · declared 0.35, cut at 0.60
|
||||||
|
|
||||||
|
Chosen so the arithmetic encodes the judgement rather than a code path doing it:
|
||||||
|
|
||||||
|
* an EXACT name match alone (0.65) proposes — same slug on both sides is
|
||||||
|
strong, and requiring corroboration would mean proposing almost nothing;
|
||||||
|
* a CONTAINMENT name match alone (0.6 * 0.65 = 0.39) does not — "art" inside
|
||||||
|
"artgirl" is a coincidence generator, and it needs the declaration;
|
||||||
|
* the declaration ALONE (0.35) never proposes, at any setting at or above
|
||||||
|
0.60 — a creator may link another creator's Patreon, and a link is not a
|
||||||
|
claim of identity.
|
||||||
|
|
||||||
|
A guard test pins all three against WEIGHTS directly, so they survive a
|
||||||
|
refactor of the scorer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Artist,
|
||||||
|
ArtistMembershipSuggestion,
|
||||||
|
PlatformMembership,
|
||||||
|
Post,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
from ..utils.slug import slugify
|
||||||
|
from ..utils.text import html_to_plain
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WEIGHTS = {"name": 0.65, "declared": 0.35}
|
||||||
|
DEFAULT_THRESHOLD = 0.60
|
||||||
|
|
||||||
|
NAME_EXACT = 1.0
|
||||||
|
# Containment is a hint, not a match: "art" sits inside "artgirl", and slugs
|
||||||
|
# are short enough that coincidental containment is common.
|
||||||
|
NAME_CONTAINS = 0.6
|
||||||
|
# Below this many characters, containment is noise rather than signal — a
|
||||||
|
# 3-character slug is inside a great many longer ones.
|
||||||
|
_MIN_CONTAINMENT_LEN = 5
|
||||||
|
|
||||||
|
MAX_CANDIDATES = 25
|
||||||
|
|
||||||
|
|
||||||
|
def name_signal(membership: PlatformMembership, artist: Artist) -> float:
|
||||||
|
"""Graded slug agreement between a membership and an artist.
|
||||||
|
|
||||||
|
Both the display name and the vanity are tried, because creators routinely
|
||||||
|
differ between the two ("Team Melon Collie" vs "MelonCollieStudios") and
|
||||||
|
either may be the one the operator typed when they created the artist.
|
||||||
|
"""
|
||||||
|
artist_slug = slugify(artist.name or "") if artist.name else ""
|
||||||
|
if not artist_slug or artist_slug == "untitled":
|
||||||
|
return 0.0
|
||||||
|
candidates = {
|
||||||
|
slugify(v) for v in (membership.display_name, membership.vanity_or_none())
|
||||||
|
if v
|
||||||
|
}
|
||||||
|
candidates.discard("untitled")
|
||||||
|
if not candidates:
|
||||||
|
return 0.0
|
||||||
|
if artist_slug in candidates:
|
||||||
|
return NAME_EXACT
|
||||||
|
for c in candidates:
|
||||||
|
if len(c) < _MIN_CONTAINMENT_LEN or len(artist_slug) < _MIN_CONTAINMENT_LEN:
|
||||||
|
continue
|
||||||
|
if c in artist_slug or artist_slug in c:
|
||||||
|
return NAME_CONTAINS
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def declared_signal(body: str | None, vanity: str | None) -> float:
|
||||||
|
"""Does this post body point at THIS membership's Patreon page?
|
||||||
|
|
||||||
|
Matched against the RAW body, not the stripped text: these links live in an
|
||||||
|
anchor's `href`, and `html_to_plain` discards attributes — the same trap
|
||||||
|
that caught E5's invite detection. The stripped text is checked too, for
|
||||||
|
bodies that paste the URL as plain text.
|
||||||
|
"""
|
||||||
|
if not body or not vanity:
|
||||||
|
return 0.0
|
||||||
|
pattern = re.compile(
|
||||||
|
r"patreon\.com/(?:c/|cw/|checkout/)?" + re.escape(vanity) + r"\b", re.I
|
||||||
|
)
|
||||||
|
if pattern.search(body):
|
||||||
|
return 1.0
|
||||||
|
return 1.0 if pattern.search(html_to_plain(body) or "") else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_score(signals: dict) -> float:
|
||||||
|
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistMembershipService:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def _decided(self, membership_id: int) -> set[int]:
|
||||||
|
"""Artists already proposed for this membership, in ANY status.
|
||||||
|
|
||||||
|
Dismissed included: the row is what remembers the rejection, and
|
||||||
|
re-proposing a rejected pair every scan is what makes a queue ignored.
|
||||||
|
"""
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(ArtistMembershipSuggestion.artist_id).where(
|
||||||
|
ArtistMembershipSuggestion.platform_membership_id == membership_id
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
async def _candidate_artists(self, membership: PlatformMembership) -> list[Artist]:
|
||||||
|
"""Artists that have SOME source but none for this membership's platform.
|
||||||
|
|
||||||
|
A hard filter, not a scored signal. An artist FC already tracks on this
|
||||||
|
platform needs no suggestion — the link exists — and an artist with no
|
||||||
|
sources at all is not a creator FC is following through another channel,
|
||||||
|
which is the whole case this step is about.
|
||||||
|
"""
|
||||||
|
# `select(...).exists()` rather than a bare `exists().where(...)`: the
|
||||||
|
# latter has no FROM to correlate against and does not reliably render.
|
||||||
|
has_any = select(Source.id).where(Source.artist_id == Artist.id).exists()
|
||||||
|
has_this = (
|
||||||
|
select(Source.id)
|
||||||
|
.where(
|
||||||
|
Source.artist_id == Artist.id,
|
||||||
|
Source.platform == membership.platform,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
return (await self.session.execute(
|
||||||
|
select(Artist).where(has_any, ~has_this).limit(MAX_CANDIDATES)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
async def _declared_for(self, artist_id: int, vanity: str | None) -> float:
|
||||||
|
if not vanity:
|
||||||
|
return 0.0
|
||||||
|
# Bounded scan: the newest posts are where a creator's current links
|
||||||
|
# live, and an unbounded body scan per (artist, membership) pair would
|
||||||
|
# be the expensive part of this sweep.
|
||||||
|
bodies = (await self.session.execute(
|
||||||
|
select(Post.description)
|
||||||
|
.where(Post.artist_id == artist_id, Post.description.is_not(None))
|
||||||
|
.order_by(func.coalesce(Post.post_date, Post.downloaded_at).desc())
|
||||||
|
.limit(50)
|
||||||
|
)).scalars().all()
|
||||||
|
for body in bodies:
|
||||||
|
if declared_signal(body, vanity) > 0:
|
||||||
|
return 1.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
async def match_membership(
|
||||||
|
self, membership_id: int, *, threshold: float = DEFAULT_THRESHOLD,
|
||||||
|
) -> int:
|
||||||
|
membership = await self.session.get(PlatformMembership, membership_id)
|
||||||
|
if membership is None:
|
||||||
|
return 0
|
||||||
|
already = await self._decided(membership_id)
|
||||||
|
|
||||||
|
made = 0
|
||||||
|
for artist in await self._candidate_artists(membership):
|
||||||
|
if artist.id in already:
|
||||||
|
continue
|
||||||
|
signals = {
|
||||||
|
"name": name_signal(membership, artist),
|
||||||
|
"declared": await self._declared_for(
|
||||||
|
artist.id, membership.vanity_or_none()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
score = weighted_score(signals)
|
||||||
|
if score < threshold:
|
||||||
|
continue
|
||||||
|
self.session.add(ArtistMembershipSuggestion(
|
||||||
|
platform_membership_id=membership.id,
|
||||||
|
artist_id=artist.id,
|
||||||
|
score=score,
|
||||||
|
signals=signals,
|
||||||
|
status="pending",
|
||||||
|
))
|
||||||
|
made += 1
|
||||||
|
return made
|
||||||
|
|
||||||
|
async def list_pending(self) -> list[dict]:
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(ArtistMembershipSuggestion, PlatformMembership, Artist)
|
||||||
|
.join(
|
||||||
|
PlatformMembership,
|
||||||
|
PlatformMembership.id
|
||||||
|
== ArtistMembershipSuggestion.platform_membership_id,
|
||||||
|
)
|
||||||
|
.join(Artist, Artist.id == ArtistMembershipSuggestion.artist_id)
|
||||||
|
.where(ArtistMembershipSuggestion.status == "pending")
|
||||||
|
.order_by(
|
||||||
|
ArtistMembershipSuggestion.score.desc(),
|
||||||
|
ArtistMembershipSuggestion.id.desc(),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"score": s.score,
|
||||||
|
"signals": s.signals,
|
||||||
|
"artist": {"id": a.id, "name": a.name, "slug": a.slug},
|
||||||
|
"membership": {
|
||||||
|
"id": m.id,
|
||||||
|
"platform": m.platform,
|
||||||
|
"display_name": m.display_name,
|
||||||
|
"url": m.url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for s, m, a in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def accept(self, suggestion_id: int) -> dict | None:
|
||||||
|
"""Add the missing channel to the artist. NEVER merges two artists.
|
||||||
|
|
||||||
|
Returns the created source's id, or `already_linked` when a source for
|
||||||
|
that platform appeared between the proposal and the click — which is
|
||||||
|
not an error, it is the operator having done it by hand.
|
||||||
|
"""
|
||||||
|
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
membership = await self.session.get(PlatformMembership, s.platform_membership_id)
|
||||||
|
if membership is None or not membership.url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing = (await self.session.execute(
|
||||||
|
select(Source.id).where(
|
||||||
|
Source.artist_id == s.artist_id,
|
||||||
|
Source.platform == membership.platform,
|
||||||
|
)
|
||||||
|
)).scalars().first()
|
||||||
|
if existing is not None:
|
||||||
|
s.status = "linked"
|
||||||
|
return {"id": s.id, "status": s.status, "already_linked": existing}
|
||||||
|
|
||||||
|
# Through SourceService, NOT a bare Source() insert. It carries the
|
||||||
|
# platform/URL validation, the duplicate check and the #693
|
||||||
|
# backfill-arming that a hand-added source gets — building a second,
|
||||||
|
# quieter way to create a source is how the two drift until one of them
|
||||||
|
# is subtly broken (rule 28: repurpose the existing surface).
|
||||||
|
from .source_service import DuplicateSourceError, SourceService
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await SourceService(self.session).create(
|
||||||
|
artist_id=s.artist_id,
|
||||||
|
platform=membership.platform,
|
||||||
|
url=membership.url,
|
||||||
|
)
|
||||||
|
except DuplicateSourceError as exc:
|
||||||
|
# The same URL already exists for this artist — the operator got
|
||||||
|
# there first by a different route. Not an error.
|
||||||
|
s.status = "linked"
|
||||||
|
return {"id": s.id, "status": s.status, "already_linked": exc.existing_id}
|
||||||
|
s.status = "linked"
|
||||||
|
return {"id": s.id, "status": s.status, "source_id": record.id}
|
||||||
|
|
||||||
|
async def dismiss(self, suggestion_id: int) -> dict | None:
|
||||||
|
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
# Kept, not deleted — the row is what remembers the rejection.
|
||||||
|
s.status = "dismissed"
|
||||||
|
return {"id": s.id, "status": s.status}
|
||||||
|
|
||||||
|
|
||||||
|
async def rescan(session: AsyncSession, *, threshold: float = DEFAULT_THRESHOLD) -> dict:
|
||||||
|
"""Offer every known membership to the artists FC already tracks."""
|
||||||
|
ids = (await session.execute(select(PlatformMembership.id))).scalars().all()
|
||||||
|
svc = ArtistMembershipService(session)
|
||||||
|
proposed = 0
|
||||||
|
for mid in ids:
|
||||||
|
proposed += await svc.match_membership(mid, threshold=threshold)
|
||||||
|
log.info(
|
||||||
|
"artist/membership matcher: scanned %d membership(s), proposed %d pair(s)",
|
||||||
|
len(ids), proposed,
|
||||||
|
)
|
||||||
|
return {"scanned": len(ids), "proposed": proposed}
|
||||||
@@ -1246,6 +1246,7 @@ def sync_memberships() -> str:
|
|||||||
|
|
||||||
from ..services.credential_crypto import CredentialCrypto
|
from ..services.credential_crypto import CredentialCrypto
|
||||||
from ..services.credential_service import CredentialService
|
from ..services.credential_service import CredentialService
|
||||||
|
from ..services.artist_membership_service import rescan as membership_rescan
|
||||||
from ..services.membership_roster import sync_platform
|
from ..services.membership_roster import sync_platform
|
||||||
from ..services.patreon_client import PatreonClient
|
from ..services.patreon_client import PatreonClient
|
||||||
from ._async_session import async_session_factory
|
from ._async_session import async_session_factory
|
||||||
@@ -1298,7 +1299,17 @@ def sync_memberships() -> str:
|
|||||||
results.append(
|
results.append(
|
||||||
await sync_platform(session, platform=platform, fetch=fetch)
|
await sync_platform(session, platform=platform, fetch=fetch)
|
||||||
)
|
)
|
||||||
return {"results": results}
|
|
||||||
|
# #388 E4: offer the freshly-synced roster to the artists FC already
|
||||||
|
# tracks. Chained here rather than given its own beat entry because
|
||||||
|
# a suggestion can only be as good as the roster behind it — running
|
||||||
|
# it on any other cadence would just propose from staler data.
|
||||||
|
suggested = None
|
||||||
|
if any(r.get("ok") for r in results):
|
||||||
|
async with async_factory() as session:
|
||||||
|
suggested = (await membership_rescan(session))["proposed"]
|
||||||
|
await session.commit()
|
||||||
|
return {"results": results, "suggested": suggested}
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@@ -1311,4 +1322,6 @@ def sync_memberships() -> str:
|
|||||||
parts.append(f"{r['platform']}={r['count']}")
|
parts.append(f"{r['platform']}={r['count']}")
|
||||||
else:
|
else:
|
||||||
parts.append(f"{r['platform']}=FAILED({r['error']})")
|
parts.append(f"{r['platform']}=FAILED({r['error']})")
|
||||||
|
if res.get("suggested") is not None:
|
||||||
|
parts.append(f"suggested={res['suggested']}")
|
||||||
return " ".join(parts) or "no platforms"
|
return " ".join(parts) or "no platforms"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<DiscordGroupingCard />
|
<DiscordGroupingCard />
|
||||||
<PostAssociationsCard />
|
<PostAssociationsCard />
|
||||||
<MembershipRosterCard />
|
<MembershipRosterCard />
|
||||||
|
<MembershipSuggestionsCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ import CropProposersCard from './CropProposersCard.vue'
|
|||||||
import HeadsCard from './HeadsCard.vue'
|
import HeadsCard from './HeadsCard.vue'
|
||||||
import DiscordGroupingCard from './DiscordGroupingCard.vue'
|
import DiscordGroupingCard from './DiscordGroupingCard.vue'
|
||||||
import MembershipRosterCard from './MembershipRosterCard.vue'
|
import MembershipRosterCard from './MembershipRosterCard.vue'
|
||||||
|
import MembershipSuggestionsCard from './MembershipSuggestionsCard.vue'
|
||||||
import PostAssociationsCard from './PostAssociationsCard.vue'
|
import PostAssociationsCard from './PostAssociationsCard.vue'
|
||||||
import GpuAgentCard from './GpuAgentCard.vue'
|
import GpuAgentCard from './GpuAgentCard.vue'
|
||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<MaintenanceTile
|
||||||
|
icon="mdi-account-multiple-plus-outline"
|
||||||
|
title="Same creator, another channel"
|
||||||
|
blurb="Creators you subscribe to who look like creators you already track."
|
||||||
|
>
|
||||||
|
<div class="text-caption fc-muted mb-3">
|
||||||
|
When a membership in your roster looks like a creator you already follow
|
||||||
|
elsewhere — a Discord server, say — FC offers to add the missing channel
|
||||||
|
to that same creator. Accepting <strong>adds a source</strong>; it never
|
||||||
|
merges two creators together.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mb-2" style="gap:12px">
|
||||||
|
<strong class="text-body-2">
|
||||||
|
{{ store.suggestions.length }} waiting for review
|
||||||
|
</strong>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" :loading="store.loading" @click="store.rescan">
|
||||||
|
Look again
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!store.suggestions.length" class="text-caption fc-muted">
|
||||||
|
Nothing proposed. That is the usual state — a pair only appears when the
|
||||||
|
names line up, or when one of your creator's posts links to the other
|
||||||
|
channel.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="s in store.suggestions" :key="s.id" class="fc-sugg">
|
||||||
|
<div class="fc-sugg__body">
|
||||||
|
<div>
|
||||||
|
<strong>{{ s.artist.name }}</strong>
|
||||||
|
<span class="fc-sugg__arrow">and your</span>
|
||||||
|
<strong>{{ s.membership.platform }}</strong>
|
||||||
|
membership
|
||||||
|
<em>{{ s.membership.display_name }}</em>
|
||||||
|
</div>
|
||||||
|
<!-- The per-signal breakdown, not just the total: "why did it suggest
|
||||||
|
this" is the question the operator actually has, and a lone
|
||||||
|
percentage cannot answer it. -->
|
||||||
|
<div class="text-caption fc-muted">
|
||||||
|
{{ Math.round(s.score * 100) }}% ·
|
||||||
|
name {{ Math.round((s.signals?.name ?? 0) * 100) }}% ·
|
||||||
|
links to it {{ (s.signals?.declared ?? 0) > 0 ? 'yes' : 'no' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-btn size="small" variant="tonal" @click="store.accept(s.id)">Add channel</v-btn>
|
||||||
|
<v-btn size="small" variant="text" @click="store.dismiss(s.id)">Dismiss</v-btn>
|
||||||
|
</div>
|
||||||
|
</MaintenanceTile>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
import { useMembershipSuggestionsStore } from '../../stores/membershipSuggestions.js'
|
||||||
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
|
const store = useMembershipSuggestionsStore()
|
||||||
|
onMounted(() => { store.load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-sugg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
}
|
||||||
|
.fc-sugg__body { flex: 1; min-width: 0; }
|
||||||
|
.fc-sugg__arrow { color: rgb(var(--v-theme-on-surface-variant)); margin: 0 6px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { toast } from '../utils/toast.js'
|
||||||
|
|
||||||
|
// Backs the creator/membership review queue (#388 E4). Confirm-only: accepting
|
||||||
|
// ADDS A SOURCE to the artist that already has the other channel — it never
|
||||||
|
// merges two artists. Adding a source is trivially undone; a wrong merge
|
||||||
|
// silently mixes two creators' work with nothing left to separate them by.
|
||||||
|
export const useMembershipSuggestionsStore = defineStore('membershipSuggestions', () => {
|
||||||
|
const api = useApi()
|
||||||
|
const suggestions = ref([])
|
||||||
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
await run(async () => {
|
||||||
|
const body = await api.get('/api/sources/membership-suggestions')
|
||||||
|
suggestions.value = body.items || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept (id) {
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/sources/membership-suggestions/${id}/accept`, {})
|
||||||
|
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||||
|
toast({
|
||||||
|
text: res.already_linked ? 'Already linked' : 'Channel added to this creator',
|
||||||
|
type: 'success'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Link failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismiss (id) {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/sources/membership-suggestions/${id}/dismiss`, {})
|
||||||
|
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Dismiss failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rescan () {
|
||||||
|
await api.post('/api/sources/membership-suggestions/rescan', {})
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions, loading, error, load, accept, dismiss, rescan }
|
||||||
|
})
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Milestone 388 E4: proposing that a creator and a membership are the same.
|
||||||
|
|
||||||
|
E4's own first instruction was to verify before building, and the verification
|
||||||
|
said the association ALREADY works — `Source.artist_id` is a plain FK, the API
|
||||||
|
takes an `artist_id`, the add-source dialog attaches to an existing artist, and
|
||||||
|
`reassign` moves a source with post/image re-attribution. So these test the
|
||||||
|
SUGGESTION, which is what was actually missing.
|
||||||
|
|
||||||
|
The failure to avoid is a wrong link. Accepting adds a SOURCE rather than
|
||||||
|
merging artists precisely because the first is trivially undone and the second
|
||||||
|
silently mixes two creators' work — so most of what follows pins refusals, and
|
||||||
|
the weights are asserted structurally so they survive a refactor of the scorer.
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import (
|
||||||
|
Artist,
|
||||||
|
ArtistMembershipSuggestion,
|
||||||
|
PlatformMembership,
|
||||||
|
Post,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
from backend.app.services.artist_membership_service import (
|
||||||
|
DEFAULT_THRESHOLD,
|
||||||
|
WEIGHTS,
|
||||||
|
ArtistMembershipService,
|
||||||
|
declared_signal,
|
||||||
|
name_signal,
|
||||||
|
weighted_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
# --- the structural guard --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_weights_encode_the_judgement_rather_than_a_code_path():
|
||||||
|
"""Three claims, asserted against WEIGHTS directly so they survive any
|
||||||
|
refactor of the scorer. Do NOT fix a failure here by moving the numbers —
|
||||||
|
the arithmetic IS the decision.
|
||||||
|
|
||||||
|
* an EXACT name match alone proposes (same slug is strong evidence, and
|
||||||
|
demanding corroboration would propose almost nothing);
|
||||||
|
* a CONTAINMENT name match alone does not ("art" sits inside "artgirl");
|
||||||
|
* the declaration ALONE never proposes, because a creator may link
|
||||||
|
another creator's Patreon and a link is not a claim of identity.
|
||||||
|
"""
|
||||||
|
assert sum(WEIGHTS.values()) == pytest.approx(1.0)
|
||||||
|
assert weighted_score({"name": 1.0}) >= DEFAULT_THRESHOLD
|
||||||
|
assert weighted_score({"name": 0.6}) < DEFAULT_THRESHOLD
|
||||||
|
assert weighted_score({"declared": 1.0}) < DEFAULT_THRESHOLD
|
||||||
|
assert weighted_score({"name": 0.6, "declared": 1.0}) >= DEFAULT_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
# --- the signals -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _m(**kw):
|
||||||
|
kw.setdefault("platform", "patreon")
|
||||||
|
kw.setdefault("external_campaign_id", "1")
|
||||||
|
return PlatformMembership(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_matches_on_either_the_display_name_or_the_vanity():
|
||||||
|
"""Creators routinely differ between the two, and either may be what the
|
||||||
|
operator typed when they created the artist."""
|
||||||
|
m = _m(display_name="Team Melon Collie",
|
||||||
|
details={"campaign": {"vanity": "MelonCollieStudios"}})
|
||||||
|
assert name_signal(m, Artist(name="Team Melon Collie", slug="x")) == 1.0
|
||||||
|
assert name_signal(m, Artist(name="meloncolliestudios", slug="x")) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_containment_match_is_a_hint_not_a_match():
|
||||||
|
m = _m(display_name="Maewix Studios", details={})
|
||||||
|
got = name_signal(m, Artist(name="Maewix", slug="x"))
|
||||||
|
assert 0 < got < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_slug_does_not_match_by_containment():
|
||||||
|
"""A 3-character slug is inside a great many longer ones — containment
|
||||||
|
there is a coincidence generator, not a signal."""
|
||||||
|
m = _m(display_name="Artgirl Studios", details={})
|
||||||
|
assert name_signal(m, Artist(name="art", slug="art")) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_names_do_not_match():
|
||||||
|
m = _m(display_name="Maewix", details={})
|
||||||
|
assert name_signal(m, Artist(name="Floppystack", slug="x")) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_declaration_reads_through_an_anchor_href():
|
||||||
|
"""These links live in an `href`, and html_to_plain discards attributes —
|
||||||
|
the trap that already caught E5's invite detection."""
|
||||||
|
body = '<p>Support me: <a href="https://www.patreon.com/maewix">here</a></p>'
|
||||||
|
assert declared_signal(body, "maewix") == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_declaration_is_specific_to_THIS_membership():
|
||||||
|
"""A creator linking SOMEONE ELSE's Patreon must not link the two."""
|
||||||
|
body = '<a href="https://www.patreon.com/someoneelse">a friend</a>'
|
||||||
|
assert declared_signal(body, "maewix") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_declaration_tolerates_the_c_and_cw_url_variants():
|
||||||
|
"""Patreon serves /c/<vanity> and /cw/<vanity> per campaign (#3886)."""
|
||||||
|
assert declared_signal("see https://www.patreon.com/c/maewix", "maewix") == 1.0
|
||||||
|
assert declared_signal("see https://www.patreon.com/cw/maewix", "maewix") == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- end to end ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _artist_with_discord(db, name, slug):
|
||||||
|
a = Artist(name=name, slug=slug)
|
||||||
|
db.add(a)
|
||||||
|
await db.flush()
|
||||||
|
db.add(Source(
|
||||||
|
artist_id=a.id, platform="discord",
|
||||||
|
url=f"https://discord.com/channels/1/{slug}", enabled=True,
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
async def _membership(db, **kw):
|
||||||
|
kw.setdefault("platform", "patreon")
|
||||||
|
kw.setdefault("external_campaign_id", "c1")
|
||||||
|
kw.setdefault("url", "https://www.patreon.com/maewix")
|
||||||
|
kw.setdefault("details", {"campaign": {"vanity": "maewix"}})
|
||||||
|
m = PlatformMembership(**kw)
|
||||||
|
db.add(m)
|
||||||
|
await db.flush()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_matching_name_proposes_the_link(db):
|
||||||
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
made = await ArtistMembershipService(db).match_membership(m.id)
|
||||||
|
await db.commit()
|
||||||
|
assert made == 1
|
||||||
|
|
||||||
|
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||||
|
assert s.artist_id == artist.id
|
||||||
|
assert s.status == "pending", "nothing is linked without the operator"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_artist_already_on_that_platform_is_never_proposed(db):
|
||||||
|
"""The link exists; a suggestion would be noise."""
|
||||||
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
db.add(Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://www.patreon.com/maewix", enabled=True,
|
||||||
|
))
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_artist_with_no_sources_at_all_is_never_proposed(db):
|
||||||
|
"""Not a creator FC follows through another channel, which is the whole
|
||||||
|
case this step is about."""
|
||||||
|
a = Artist(name="Maewix", slug="maewix")
|
||||||
|
db.add(a)
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_weak_name_needs_the_declaration(db):
|
||||||
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
m = await _membership(db, display_name="Maewix Studios Official")
|
||||||
|
await db.commit()
|
||||||
|
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||||
|
|
||||||
|
db.add(Post(
|
||||||
|
artist_id=artist.id, source_id=None, external_post_id="p1",
|
||||||
|
description='<a href="https://www.patreon.com/maewix">my patreon</a>',
|
||||||
|
post_date=datetime.now(UTC),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
assert await ArtistMembershipService(db).match_membership(m.id) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accepting_adds_a_source_and_never_merges_artists(db):
|
||||||
|
"""THE safety property. Adding a source is trivially undone; a wrong merge
|
||||||
|
mixes two creators' work with nothing left to separate them by."""
|
||||||
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
artists_before = len((await db.execute(select(Artist))).scalars().all())
|
||||||
|
|
||||||
|
svc = ArtistMembershipService(db)
|
||||||
|
await svc.match_membership(m.id)
|
||||||
|
await db.commit()
|
||||||
|
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||||
|
|
||||||
|
result = await svc.accept(s.id)
|
||||||
|
await db.commit()
|
||||||
|
assert result["status"] == "linked"
|
||||||
|
|
||||||
|
sources = (await db.execute(
|
||||||
|
select(Source).where(Source.artist_id == artist.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert {x.platform for x in sources} == {"discord", "patreon"}
|
||||||
|
assert len((await db.execute(select(Artist))).scalars().all()) == artists_before, (
|
||||||
|
"no artist may be created or destroyed by accepting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accepting_twice_does_not_add_a_second_source(db):
|
||||||
|
"""A source appearing between the proposal and the click is the operator
|
||||||
|
having done it by hand — not an error."""
|
||||||
|
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
svc = ArtistMembershipService(db)
|
||||||
|
await svc.match_membership(m.id)
|
||||||
|
await db.commit()
|
||||||
|
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||||
|
|
||||||
|
await svc.accept(s.id)
|
||||||
|
await db.commit()
|
||||||
|
second = await svc.accept(s.id)
|
||||||
|
await db.commit()
|
||||||
|
assert "already_linked" in second
|
||||||
|
|
||||||
|
sources = (await db.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.artist_id == artist.id, Source.platform == "patreon",
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(sources) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_dismissed_pair_is_never_proposed_again(db):
|
||||||
|
await _artist_with_discord(db, "Maewix", "maewix")
|
||||||
|
m = await _membership(db, display_name="Maewix")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
svc = ArtistMembershipService(db)
|
||||||
|
assert await svc.match_membership(m.id) == 1
|
||||||
|
await db.commit()
|
||||||
|
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||||
|
await svc.dismiss(s.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
assert await svc.match_membership(m.id) == 0
|
||||||
|
await db.commit()
|
||||||
|
rows = (await db.execute(select(ArtistMembershipSuggestion))).scalars().all()
|
||||||
|
assert len(rows) == 1 and rows[0].status == "dismissed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_vanity_falls_back_to_the_url_when_details_lack_it(db):
|
||||||
|
"""C1 modelled the roster before any platform was characterised, so the
|
||||||
|
vanity lives in `details` rather than a column — and must still be findable
|
||||||
|
for a row written before that field was understood."""
|
||||||
|
m = await _membership(
|
||||||
|
db, display_name="Something Else", details={},
|
||||||
|
url="https://www.patreon.com/maewix",
|
||||||
|
)
|
||||||
|
assert m.vanity_or_none() == "maewix"
|
||||||
Reference in New Issue
Block a user