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}` }))