diff --git a/alembic/versions/0096_artist_membership_suggestion.py b/alembic/versions/0096_artist_membership_suggestion.py new file mode 100644 index 0000000..431bac2 --- /dev/null +++ b/alembic/versions/0096_artist_membership_suggestion.py @@ -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") diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 2832095..e7c2e15 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -5,6 +5,8 @@ from sqlalchemy import func, select from ..extensions import get_session 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.scheduler_service import active_platform_cooldowns, scheduler_status from ..services.source_service import ( @@ -343,3 +345,44 @@ async def trigger_membership_sync(): sync_memberships.delay() 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//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//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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ae31627..9e085f4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .app_setting import AppSetting from .artist import Artist +from .artist_membership_suggestion import ArtistMembershipSuggestion from .artist_visit import ArtistVisit from .backup_run import BackupRun from .base import Base @@ -50,6 +51,7 @@ __all__ = [ "Base", "AppSetting", "Artist", + "ArtistMembershipSuggestion", "ArtistVisit", "BackupRun", "Source", diff --git a/backend/app/models/artist_membership_suggestion.py b/backend/app/models/artist_membership_suggestion.py new file mode 100644 index 0000000..ebc8f54 --- /dev/null +++ b/backend/app/models/artist_membership_suggestion.py @@ -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(), + ) diff --git a/backend/app/models/platform_membership.py b/backend/app/models/platform_membership.py index 509f2ba..4e3de1a 100644 --- a/backend/app/models/platform_membership.py +++ b/backend/app/models/platform_membership.py @@ -128,3 +128,25 @@ class PlatformMembership(Base): # 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. 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 diff --git a/backend/app/services/artist_membership_service.py b/backend/app/services/artist_membership_service.py new file mode 100644 index 0000000..3d42b74 --- /dev/null +++ b/backend/app/services/artist_membership_service.py @@ -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/` 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} diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 721cc92..fbf94bf 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -1246,6 +1246,7 @@ def sync_memberships() -> str: from ..services.credential_crypto import CredentialCrypto 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.patreon_client import PatreonClient from ._async_session import async_session_factory @@ -1298,7 +1299,17 @@ def sync_memberships() -> str: results.append( 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: await engine.dispose() @@ -1311,4 +1322,6 @@ def sync_memberships() -> str: parts.append(f"{r['platform']}={r['count']}") else: 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" diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 05c5827..d0b3773 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -17,6 +17,7 @@ + @@ -85,6 +86,7 @@ import CropProposersCard from './CropProposersCard.vue' import HeadsCard from './HeadsCard.vue' import DiscordGroupingCard from './DiscordGroupingCard.vue' import MembershipRosterCard from './MembershipRosterCard.vue' +import MembershipSuggestionsCard from './MembershipSuggestionsCard.vue' import PostAssociationsCard from './PostAssociationsCard.vue' import GpuAgentCard from './GpuAgentCard.vue' import AliasTable from './AliasTable.vue' diff --git a/frontend/src/components/settings/MembershipSuggestionsCard.vue b/frontend/src/components/settings/MembershipSuggestionsCard.vue new file mode 100644 index 0000000..a2ad797 --- /dev/null +++ b/frontend/src/components/settings/MembershipSuggestionsCard.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/stores/membershipSuggestions.js b/frontend/src/stores/membershipSuggestions.js new file mode 100644 index 0000000..2cff2b2 --- /dev/null +++ b/frontend/src/stores/membershipSuggestions.js @@ -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 } +}) diff --git a/tests/test_artist_membership_suggestions.py b/tests/test_artist_membership_suggestions.py new file mode 100644 index 0000000..fd2eeb0 --- /dev/null +++ b/tests/test_artist_membership_suggestions.py @@ -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 = '

Support me: here

' + 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 friend' + assert declared_signal(body, "maewix") == 0.0 + + +def test_the_declaration_tolerates_the_c_and_cw_url_variants(): + """Patreon serves /c/ and /cw/ 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='my patreon', + 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"