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:
@@ -17,6 +17,7 @@
|
||||
<DiscordGroupingCard />
|
||||
<PostAssociationsCard />
|
||||
<MembershipRosterCard />
|
||||
<MembershipSuggestionsCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user