From 7071c87cd68f74c7d6e5a0a351b05dbb5e65d7d9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 10 Sep 2026 11:17:56 -0400 Subject: [PATCH] feat: a grouped post says so, and the operator can tune the grouping (388 E2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 --- backend/app/services/discord_grouping.py | 6 +- frontend/src/components/posts/PostCard.vue | 38 +++++++++ .../settings/DiscordGroupingCard.vue | 82 +++++++++++++++++++ .../components/settings/MaintenancePanel.vue | 2 + frontend/test/components/postCard.spec.js | 54 ++++++++++++ 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/settings/DiscordGroupingCard.vue diff --git a/backend/app/services/discord_grouping.py b/backend/app/services/discord_grouping.py index 3eb0b56..b224e78 100644 --- a/backend/app/services/discord_grouping.py +++ b/backend/app/services/discord_grouping.py @@ -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 diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index f595a70..8ec2d5e 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -6,6 +6,19 @@ {{ post.source?.platform ?? 'filesystem import' }} + + + + grouped by FabledCurator +

{{ displayTitle }}

+ +

{{ synthesisTitle }}

Post {{ post.external_post_id }}

@@ -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; } diff --git a/frontend/src/components/settings/DiscordGroupingCard.vue b/frontend/src/components/settings/DiscordGroupingCard.vue new file mode 100644 index 0000000..6055c0d --- /dev/null +++ b/frontend/src/components/settings/DiscordGroupingCard.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 372f920..6a51458 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -14,6 +14,7 @@
+
@@ -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' diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js index 7e65b9b..68c259b 100644 --- a/frontend/test/components/postCard.spec.js +++ b/frontend/test/components/postCard.spec.js @@ -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:` 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}` }))