feat: a grouped post says so, and the operator can tune the grouping (388 E2)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m7s

Rule 27 — E2's other half. The backend can author posts; this is what makes
that visible and adjustable.

**The honesty marker.** A chip on every synthetic post's card: "grouped by
FabledCurator", titled with what it was built from ("Grouped from 4 Discord
messages"). This chip is the only thing standing between "FC assembled this"
and the card reading as something the artist authored, so it keys off nothing
but the flag, and it states the member count rather than just disclosing that
grouping happened — a claim you can check beats a claim you're asked to trust.

A synthetic post has no title on purpose (inventing one is the one place this
feature could put words in a creator's mouth), and the untitled fallback would
otherwise have printed the internal key: "Post fc-drop:99887766". It now names
the post for what it is. There's a test for that specifically.

**The tuning card.** Ingestion & filters gets a Discord-drop-grouping tile:
the switch, the distance cut and the drop window, each with the sentence that
tells the operator which one to reach for. The window's copy says outright
that it is the setting doing most of the work — without it, everything an
artist ever drew of one character collapses into a single post.

Both directions are pinned in postCard.spec.js, including the one that
actually matters: an ordinary post is never marked. Also covered — a post dict
composed before these fields existed degrades to unmarked rather than throwing
on `synthesis.message_count`.

Also fixes the ruff UP017 that failed the lint job on 73eeb7a (timezone.utc →
datetime.UTC, the convention everywhere else in this repo). The integration
suite on that SHA was green: all 12 grouping tests passed and migration 0092
applied cleanly.

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-10 11:17:56 -04:00
co-authored by Claude Opus 5
parent 73eeb7a377
commit 7071c87cd6
5 changed files with 179 additions and 3 deletions
+3 -3
View File
@@ -60,7 +60,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from sqlalchemy import Select, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -244,7 +244,7 @@ async def _synthesize(
# without this "why did it group these" is unanswerable later.
"max_distance": max_distance,
"window_minutes": window_minutes,
"grouped_at": datetime.now(timezone.utc).isoformat(),
"grouped_at": datetime.now(UTC).isoformat(),
},
)
session.add(post)
@@ -291,7 +291,7 @@ async def group_source(
) -> int:
"""Group one Discord source's ungrouped messages. Returns posts created."""
window = timedelta(minutes=window_minutes)
now = now or datetime.now(timezone.utc)
now = now or datetime.now(UTC)
# Leave the most recent window alone: a drop that is still arriving would
# otherwise be cut in half by whichever sweep happened to land mid-drop,
# and the second half would become a separate post claiming to be its own
@@ -6,6 +6,19 @@
<v-chip size="x-small" variant="tonal">
{{ post.source?.platform ?? 'filesystem import' }}
</v-chip>
<!-- The honesty marker (#388 E2). Discord doesn't publish posts, so FC
groups a creator's drop and writes the post itself. This chip is the
only thing standing between "FC assembled this" and the card reading
as something the artist authored it must never be conditional on
anything but the flag, and it says what it was built FROM so the
claim is checkable rather than just disclosed. -->
<v-chip
v-if="synthesized" size="x-small" variant="outlined"
class="fc-post-card__synthetic" :title="synthesisTitle"
>
<v-icon icon="mdi-auto-fix" size="x-small" start />
grouped by FabledCurator
</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist"
@@ -59,6 +72,13 @@
<div class="fc-post-card__text">
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
<!-- A synthetic post has no title on purpose (inventing one is the one
place this feature could put words in a creator's mouth), so name
it for what it is rather than leaking `fc-drop:<message id>`. -->
<h3
v-else-if="synthesized"
class="fc-post-card__title fc-post-card__title--missing"
>{{ synthesisTitle }}</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h3>
@@ -163,6 +183,18 @@ const images = computed(() => props.post.thumbnails || [])
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
const plainTitle = computed(() => toPlainText(props.post.post_title))
// #388 E2. Non-null `synthesized_by` means FC authored this row by grouping a
// creator's drop; `synthesis` carries what it was built from. Read defensively
// — a post fetched before the field existed, or any surface that composes a
// post dict by hand, must degrade to "not synthetic" rather than throw.
const synthesized = computed(() => Boolean(props.post.synthesized_by))
const messageCount = computed(() => props.post.synthesis?.message_count ?? 0)
const synthesisTitle = computed(() => {
const n = messageCount.value
if (!n) return 'Grouped from Discord'
return `Grouped from ${n} Discord message${n === 1 ? '' : 's'}`
})
const hero = computed(() => images.value[0])
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
@@ -337,6 +369,12 @@ function formatBytes (n) {
font-weight: 600;
}
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
/* Quiet, not decorative: the marker has to be legible on every card without
turning a synthetic post into the loudest thing in the feed. */
.fc-post-card__synthetic {
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
@@ -0,0 +1,82 @@
<template>
<MaintenanceTile
icon="mdi-image-multiple"
title="Discord drop grouping"
blurb="Group a creator's variant drop into one post FC writes itself."
>
<div v-if="store.settings">
<div class="text-caption fc-muted mb-3">
Discord is a delivery channel, not a publisher one message isn't one
post. When this is on, FC groups a creator's variant drop (same piece,
different hair colour or outfit) into a single post it authors, with the
messages' text as the body. Grouped posts are always marked as FC's own,
and deleting one returns its messages to the feed unchanged.
</div>
<v-switch
v-model="local.discord_grouping_enabled" color="accent" hide-details
density="compact" label="Group Discord drops into posts"
@update:model-value="onSave"
/>
<v-row class="mt-2">
<v-col cols="12" sm="6">
<SettingNumberField
v-model="local.discord_group_max_distance"
label="Max visual distance" :min="0" :max="1" :step="0.01"
density="comfortable" max-width="none"
:disabled="!local.discord_grouping_enabled" @change="onSave"
/>
<div class="text-caption fc-muted mt-1">
0 is identical, 1 is unrelated. Lower groups less. Raising this is
the fix when a drop comes out scattered across several posts
but raise it slowly: too high merges pieces that only look alike.
</div>
</v-col>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="local.discord_group_window_minutes"
label="Drop window (minutes)" :min="1" :step="5"
density="comfortable" max-width="none"
:disabled="!local.discord_grouping_enabled" @change="onSave"
/>
<div class="text-caption fc-muted mt-1">
The quiet gap that ends a drop, measured between consecutive
messages so variants trickling out over an evening stay one post.
This is the setting doing most of the work: without it, everything
an artist ever drew of one character would collapse into one post.
</div>
</v-col>
</v-row>
</div>
<div v-else><v-skeleton-loader type="paragraph" /></div>
</MaintenanceTile>
</template>
<script setup>
// #388 E2. Every value here is operator-tunable because the quality bar is a
// judgement no test can settle: grouping too greedy merges distinct pieces,
// too shy leaves a drop scattered. The two failure modes are not symmetric —
// scattered is visible and fixable, a wrong merge is a post asserting that
// unrelated art belongs together — so the shipped default sits on the tight
// side and this card is how it gets loosened.
import { reactive, watch } from 'vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
const store = useMLStore()
const { save } = useSettingSave(store.patchSettings)
const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
function onSave() {
save({
discord_grouping_enabled: Boolean(local.discord_grouping_enabled),
discord_group_max_distance: Number(local.discord_group_max_distance),
discord_group_window_minutes: Number(local.discord_group_window_minutes),
})
}
</script>
@@ -14,6 +14,7 @@
<div class="fc-tile-stack">
<ImportFiltersForm />
<TranslationCard />
<DiscordGroupingCard />
</div>
</section>
@@ -80,6 +81,7 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue'
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import DiscordGroupingCard from './DiscordGroupingCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
+54
View File
@@ -40,6 +40,60 @@ describe('PostCard', () => {
expect(full.text()).not.toContain('Show more')
})
// #388 E2. The honesty marker is the only thing standing between "FC
// assembled this" and the card reading as something the artist authored, so
// these pin BOTH directions: it appears when the flag is set, and — the one
// that actually matters — it is absent on every ordinary post.
describe('synthetic posts', () => {
const SYNTH = {
...BASE,
post_title: null,
synthesized_by: 'discord_drop',
synthesis: { message_count: 4, member_post_ids: [1, 2, 3, 4] },
}
it('says FC grouped it, and what from', () => {
const w = mountComponent(PostCard, { props: { post: SYNTH }, pinia: freshPinia() })
expect(w.find('.fc-post-card__synthetic').exists()).toBe(true)
expect(w.text()).toContain('grouped by FabledCurator')
expect(w.text()).toContain('Grouped from 4 Discord messages')
})
it('never marks an ordinary post', () => {
const w = mountComponent(PostCard, { props: { post: BASE }, pinia: freshPinia() })
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
expect(w.text()).not.toContain('grouped by FabledCurator')
})
it('does not leak the internal drop key as a title', () => {
// post_title is NULL on purpose (inventing one would put words in a
// creator's mouth), so the untitled fallback must not print
// `fc-drop:<message id>` the way it does for a real untitled post.
const w = mountComponent(PostCard, {
props: { post: { ...SYNTH, external_post_id: 'fc-drop:99887766' } },
pinia: freshPinia(),
})
expect(w.text()).not.toContain('fc-drop:')
})
it('degrades to unmarked when the field is absent entirely', () => {
// A post dict composed before the field existed must read as "not
// synthetic" rather than throw on `synthesis.message_count`.
const { synthesized_by: _drop, synthesis: _also, ...legacy } = SYNTH
const w = mountComponent(PostCard, { props: { post: legacy }, pinia: freshPinia() })
expect(w.find('.fc-post-card__synthetic').exists()).toBe(false)
})
it('singularises a one-message drop', () => {
const w = mountComponent(PostCard, {
props: { post: { ...SYNTH, synthesis: { message_count: 1 } } },
pinia: freshPinia(),
})
expect(w.text()).toContain('Grouped from 1 Discord message')
expect(w.text()).not.toContain('1 Discord messages')
})
})
const thumbs = (n) =>
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))