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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user