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

**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:
2026-09-11 07:56:16 -04:00
co-authored by Claude Opus 5
parent 61abd0007c
commit 51e78a329b
11 changed files with 1005 additions and 1 deletions
@@ -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")
+43
View File
@@ -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
View File
@@ -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(),
)
+22
View File
@@ -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}
+14 -1
View File
@@ -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 }
})
+279
View File
@@ -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"