diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index a158ac7..ab19350 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { InferenceProvider } from '../inference/provider.js'; import type { Cluster } from './clustering.js'; -import { synthesizeArticle } from './synthesis.js'; +import { synthesizeArticle, synthesizeRecap } from './synthesis.js'; import { selectBestImage, faviconUrlFor } from './image-selection.js'; import { downloadAndStore, promoteToPublished, storeMediaBuffer } from '../storage/media/index.js'; import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js'; @@ -16,7 +16,8 @@ import type { TweetMediaItem, QuotedTweet, TelegramMediaItem, - TelegramMediaRef + TelegramMediaRef, + TrackedEvent } from '../storage/db/types.js'; const FOLLOW_UP_LOOKBACK_DAYS = 3; @@ -233,7 +234,11 @@ async function resolveQuotedTweet( * tag-based thread detection — but these earlier articles aren't retroactively * rewritten or merged with anything after the fact. */ -export async function publishDirect(item: ContentItem, settings: GlobalSettings): Promise { +export async function publishDirect( + item: ContentItem, + settings: GlobalSettings, + opts: { eventId?: string } = {} +): Promise { const category = uniqueCategories([item]); const storedMediaIds: string[] = []; @@ -315,7 +320,7 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) telegramMessage, category, geo: item.geo, - eventId: item.eventId, + eventId: opts.eventId ?? item.eventId, sourceCount: 1, sources: [ { @@ -334,7 +339,8 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) threadId: randomUUID(), previousArticleId: null, nextArticleId: null, - topStories: anyPushesToTopStories([item]) + topStories: anyPushesToTopStories([item]), + isRecap: false }); for (const id of storedMediaIds) promoteToPublished(id, article.id); @@ -428,7 +434,8 @@ export async function publishCluster( threadId, previousArticleId, nextArticleId: null, - topStories: anyPushesToTopStories(items) + topStories: anyPushesToTopStories(items), + isRecap: false }); if (storedMediaId) { @@ -437,3 +444,57 @@ export async function publishCluster( return article; } + +/** + * Builds the periodic AI recap for a tracked event — a standalone summary article of + * everything published under this event since the last recap, additive alongside those + * individual articles rather than replacing or consuming them (see eventsRecap.ts). + * Deliberately much lighter than publishCluster: no embedding/clustering, no follow-up + * thread detection (each recap stands alone), hero image and sources are just carried + * over from the constituent articles rather than re-resolved from raw content items. + */ +export async function publishEventRecap( + provider: InferenceProvider, + settings: GlobalSettings, + event: TrackedEvent, + constituents: MergedArticle[] +): Promise { + const { body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event.name, constituents); + + const resolvedTags = []; + for (const label of tagLabels) { + try { + const embedding = await provider.embed(label, { model: settings.selectedModels.embedding }); + resolvedTags.push(tags.resolveOrCreateTag(label, embedding, settings.tagDedupThreshold)); + } catch (err) { + logger.error('events', `Tag embedding failed for "${label}": ${(err as Error).message}`); + } + } + + const category = [...new Set(constituents.flatMap((a) => a.category))]; + const heroImage = constituents.find((a) => a.heroImage)?.heroImage ?? null; + const now = new Date().toISOString(); + + return articles.insertArticle({ + title: `${event.name}: recap`, + body, + heroImage, + video: null, + tweet: null, + telegramMessage: null, + category, + geo: constituents.find((a) => a.geo)?.geo ?? null, + eventId: event.id, + sourceCount: constituents.flatMap((a) => a.sources).length, + sources: constituents.flatMap((a) => a.sources), + publishedAt: now, + updatedAt: now, + mergeConfidence: 1.0, + tags: resolvedTags.map((t) => t.id), + threadId: randomUUID(), + previousArticleId: null, + nextArticleId: null, + topStories: constituents.some((a) => a.topStories), + isRecap: true + }); +} diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts index 0480fb0..d52a539 100644 --- a/backend/src/pipeline/synthesis.ts +++ b/backend/src/pipeline/synthesis.ts @@ -1,8 +1,16 @@ import type { InferenceProvider } from '../inference/provider.js'; -import type { ContentItem } from '../storage/db/types.js'; +import type { ContentItem, MergedArticle } from '../storage/db/types.js'; const TAG_DELIMITER = '---TAGS---'; +const RECAP_SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given a chronological list of articles already published about an ongoing tracked event, write a single recap article that: +- Summarizes what has happened across the period covered, in chronological order +- Highlights the most significant developments rather than restating every article +- Stays neutral and factual, without editorializing +- Is 3-5 short paragraphs + +After the recap, on a new line, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this recap is about. If nothing salient qualifies, leave the tag line empty.`; + const SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write a single original article that: - Attributes specific claims to the outlet that reported them (e.g. "Reuters reported...", "AP notes...") - Does not copy phrasing verbatim from any source @@ -24,14 +32,7 @@ function buildPrompt(items: ContentItem[]): string { .join('\n\n'); } -export async function synthesizeArticle( - provider: InferenceProvider, - model: string, - items: ContentItem[] -): Promise { - const prompt = buildPrompt(items); - const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT }); - +function parseResult(raw: string): SynthesisResult { const [body, tagSection] = raw.split(TAG_DELIMITER); const tagLabels = (tagSection ?? '') .split(',') @@ -40,3 +41,39 @@ export async function synthesizeArticle( return { body: body.trim(), tagLabels }; } + +export async function synthesizeArticle( + provider: InferenceProvider, + model: string, + items: ContentItem[] +): Promise { + const prompt = buildPrompt(items); + const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT }); + return parseResult(raw); +} + +function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string { + const entries = articles + .map((article, i) => `Article ${i + 1} (published ${article.publishedAt}):\nTitle: ${article.title}\n${article.body}`) + .join('\n\n'); + return `Tracked event: ${eventName}\n\n${entries}`; +} + +/** + * Recaps a period's worth of already-published articles under one tracked event — a + * different job from synthesizeArticle's same-story dedup (which merges multiple + * outlets' coverage of ONE story into one article): this summarizes many already- + * distinct articles about an ONGOING situation into a rolling wrap-up, so it gets its + * own prompt and reads from already-synthesized article bodies rather than raw feed + * summaries. + */ +export async function synthesizeRecap( + provider: InferenceProvider, + model: string, + eventName: string, + articles: MergedArticle[] +): Promise { + const prompt = buildRecapPrompt(eventName, articles); + const raw = await provider.generate(prompt, { model, system: RECAP_SYSTEM_PROMPT }); + return parseResult(raw); +} diff --git a/backend/src/queue/eventsRecap.ts b/backend/src/queue/eventsRecap.ts index 329e581..3541e37 100644 --- a/backend/src/queue/eventsRecap.ts +++ b/backend/src/queue/eventsRecap.ts @@ -1,19 +1,20 @@ import type { InferenceProvider } from '../inference/provider.js'; import * as eventsDb from '../storage/db/events.js'; -import * as contentItemsDb from '../storage/db/contentItems.js'; -import { embedPendingItems } from '../pipeline/embedding.js'; -import { publishCluster } from '../pipeline/publish.js'; -import { randomUUID } from 'node:crypto'; +import * as articlesDb from '../storage/db/articles.js'; +import { publishEventRecap } from '../pipeline/publish.js'; import type { GlobalSettings } from '../storage/db/types.js'; import { logger } from '../storage/db/logs.js'; function isDue(event: ReturnType[number]): boolean { - if (event.cadence === 'continuous') return true; // handled every cycle like normal clustering, just scoped to its sources - const now = new Date(); const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null; - if (event.cadence === 'hourly') { + // "Continuous" no longer means "recap every tick" — individual items matching this + // event now publish immediately regardless of cadence (see priorityQueue.ts), so the + // recap job's only remaining purpose is the periodic AI wrap-up. Treated the same as + // hourly so an ongoing event still gets occasional recaps without spamming a + // near-duplicate one on every synthesis tick. + if (event.cadence === 'continuous' || event.cadence === 'hourly') { return !last || now.getTime() - last.getTime() >= 3600_000; } @@ -29,6 +30,12 @@ function isDue(event: ReturnType[number]): boo return false; } +/** + * Periodically writes an AI recap summarizing everything published under a tracked + * event since its last recap — additive alongside those individual articles (which + * publish immediately via the normal pipeline, see priorityQueue.ts), not a replacement + * for them. + */ export async function runEventRecaps(provider: InferenceProvider, settings: GlobalSettings): Promise { const events = eventsDb.listActiveEvents(); let published = 0; @@ -37,34 +44,14 @@ export async function runEventRecaps(provider: InferenceProvider, settings: Glob if (event.sourceIds.length === 0 || !isDue(event)) continue; const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString(); - const items = contentItemsDb - .unclusteredItemsForSources(event.sourceIds, since) - .filter((item) => eventsDb.itemMatchesEventKeywords(item, event.keywords)); - if (items.length === 0) continue; - - const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, items); - const withEmbeddings = embedded.filter((i) => i.embedding); - if (withEmbeddings.length === 0) continue; + const constituents = articlesDb.articlesForEventSince(event.id, since); + if (constituents.length === 0) continue; try { - const article = await publishCluster( - provider, - settings, - { - id: randomUUID(), - items: withEmbeddings, - centroid: withEmbeddings[0].embedding! - }, - { eventId: event.id } - ); - - contentItemsDb.assignCluster( - withEmbeddings.map((i) => i.id), - article.id - ); + const article = await publishEventRecap(provider, settings, event, constituents); eventsDb.markRecapped(event.id); published++; - logger.info('events', `Published recap for "${event.name}" from ${withEmbeddings.length} item(s)`); + logger.info('events', `Published recap for "${event.name}" from ${constituents.length} article(s)`); } catch (err) { logger.error('events', `Recap failed for "${event.name}": ${(err as Error).message}`); } diff --git a/backend/src/queue/priorityQueue.ts b/backend/src/queue/priorityQueue.ts index 68b2355..c6234fa 100644 --- a/backend/src/queue/priorityQueue.ts +++ b/backend/src/queue/priorityQueue.ts @@ -17,15 +17,18 @@ function partition(items: T[], predicate: (item: T) => boolean): [T[], T[]] { } /** - * An item is "claimed" by a tracked event — and so left for eventsRecap.ts to handle - * instead of normal synthesis — only if it belongs to one of the event's sources AND - * matches its keyword filter. An item from an event-linked source that doesn't match + * A tracked event is a displayed category like any other — matching items publish + * normally (individually or merged with same-story coverage, exactly like regular + * news), just tagged with the event's id so they're browsable under it and so + * eventsRecap.ts can periodically write an AI wrap-up from them. An item only counts as + * "claimed" if it belongs to one of the event's sources AND matches its keyword filter * (e.g. a general Middle-East feed assigned to an "Iran war" event, but this particular - * item doesn't mention Iran) falls through to normal synthesis rather than being - * silently dropped — it just isn't part of that event's recap. + * item doesn't mention Iran, just isn't part of that event — it still publishes, only + * without the tag). */ -function isClaimedByEvent(item: ContentItem, events: TrackedEvent[]): boolean { - return events.some((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords)); +function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | null { + const match = events.find((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords)); + return match?.id ?? null; } function primaryCategoryRank(item: ContentItem, rankByName: Map): number { @@ -52,7 +55,7 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map) */ export async function runPassthroughCycle(settings: GlobalSettings): Promise { const activeEvents = eventsDb.listActiveEvents(); - const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents)); + const items = contentItemsDb.unclusteredItemsExcludingSources([]); if (items.length === 0) return 0; const categories = categoriesDb.listCategories(); @@ -66,7 +69,8 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise { const activeEvents = eventsDb.listActiveEvents(); - const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents)); + const items = contentItemsDb.unclusteredItemsExcludingSources([]); if (items.length === 0) return 0; // YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with @@ -104,7 +110,8 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G let publishedDirect = 0; for (const item of directItems) { try { - const article = await publishDirect(item, settings); + const eventId = claimedEventId(item, activeEvents) ?? undefined; + const article = await publishDirect(item, settings, { eventId }); contentItemsDb.assignCluster([item.id], article.id); publishedDirect++; const source = sourcesDb.getSource(item.sourceId); @@ -140,7 +147,11 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G } try { - const article = await publishCluster(provider, settings, cluster); + // A cluster's event tag comes from whichever of its items (if any) is claimed — + // in practice a cluster's items are all near-duplicate coverage of the same + // story, so they'd all match the same event's filter anyway when they match at all. + const eventId = cluster.items.map((i) => claimedEventId(i, activeEvents)).find((id) => id !== null) ?? undefined; + const article = await publishCluster(provider, settings, cluster, { eventId }); contentItemsDb.assignCluster( cluster.items.map((i) => i.id), cluster.id diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts index 62bbffe..85ea331 100644 --- a/backend/src/storage/db/articles.ts +++ b/backend/src/storage/db/articles.ts @@ -24,7 +24,8 @@ function rowToArticle(row: any): MergedArticle { nextArticleId: row.next_article_id, topStories: !!row.top_stories, tweet: row.tweet ? JSON.parse(row.tweet) : null, - telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null + telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null, + isRecap: !!row.is_recap }; } @@ -32,8 +33,8 @@ export function insertArticle(article: Omit): MergedArticle const id = `art-${randomUUID()}`; db.prepare( `INSERT INTO merged_articles - (id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message, is_recap) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, article.title, @@ -54,7 +55,8 @@ export function insertArticle(article: Omit): MergedArticle article.nextArticleId, article.topStories ? 1 : 0, article.tweet ? JSON.stringify(article.tweet) : null, - article.telegramMessage ? JSON.stringify(article.telegramMessage) : null + article.telegramMessage ? JSON.stringify(article.telegramMessage) : null, + article.isRecap ? 1 : 0 ); if (article.previousArticleId) { db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId); @@ -133,6 +135,14 @@ export function queryFeed( return rows.map(rowToArticle); } +/** Individual (non-recap) articles published under a tracked event since a timestamp — the recap job's input, see eventsRecap.ts. */ +export function articlesForEventSince(eventId: string, since: string): MergedArticle[] { + const rows = db + .prepare('SELECT * FROM merged_articles WHERE event_id = ? AND is_recap = 0 AND published_at > ? ORDER BY published_at') + .all(eventId, since); + return rows.map(rowToArticle); +} + export function latestArticleInThread(threadId: string): MergedArticle | null { const row = db .prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1') diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index e05746f..1dd2108 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -86,7 +86,8 @@ export function migrate() { next_article_id TEXT, top_stories INTEGER NOT NULL DEFAULT 0, -- true if any contributing source opted into "Push to Top Stories?" tweet TEXT, -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only - telegram_message TEXT -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only + telegram_message TEXT, -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only + is_recap INTEGER NOT NULL DEFAULT 0 -- true only for the AI-written periodic summary of a tracked event, see eventsRecap.ts ); CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at); CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id); @@ -240,6 +241,9 @@ export function migrate() { if (!hasColumn('tracked_events', 'keywords')) { db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'"); } + if (!hasColumn('merged_articles', 'is_recap')) { + db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0'); + } // Seed default categories if none exist yet. "News" sits right under "Top stories" — // general news sources belong here, not on "Top stories" itself, which isn't a real diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 152c3f2..d8e73d1 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -155,6 +155,8 @@ export interface MergedArticle { nextArticleId: string | null; /** True if any contributing source opted into "Push to Top Stories?" — gates the homepage feed, see articles.queryFeed. */ topStories: boolean; + /** True only for the AI-written periodic summary of a tracked event (see eventsRecap.ts) — distinguishes it from the individual articles published under the same eventId. */ + isRecap: boolean; } export interface Tag { diff --git a/frontend/src/lib/components/ArticleListRow.svelte b/frontend/src/lib/components/ArticleListRow.svelte index 07b3d69..6c44483 100644 --- a/frontend/src/lib/components/ArticleListRow.svelte +++ b/frontend/src/lib/components/ArticleListRow.svelte @@ -28,6 +28,10 @@
+ {#if article.isRecap} + 🧵 AI Recap + · + {/if} {article.category[0] ?? ''} · {sourceLabel} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 10d3128..0a3e773 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -64,6 +64,8 @@ export interface MergedArticle { threadId: string; previousArticleId: string | null; nextArticleId: string | null; + /** True only for the AI-written periodic summary of a tracked event — see the /event/[id] page. */ + isRecap: boolean; } export interface Tag { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index a87439b..36dadec 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -30,12 +30,18 @@ // isn't itself a filterable category — it always means "everything, chronological", // i.e. the homepage. Every other admin-defined category gets its own /category/:slug // page. See MergeTab's category priority list for where these are managed. - const navItems = $derived( - data.categories.map((cat) => ({ + // + // A tracked event is a displayed category too, just backed by a source+keyword + // filter instead of manual per-source category checkboxes, and periodically + // AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab, + // appended after the regular categories. + const navItems = $derived([ + ...data.categories.map((cat) => ({ label: cat.name, href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}` - })) - ); + })), + ...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })) + ]); function isActive(href: string): boolean { if (href === '/') return $page.url.pathname === '/'; diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index cc08a1f..3f91bbc 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,8 +1,14 @@ import type { LayoutLoad } from './$types'; -import { getCategories } from '$lib/api'; +import { getCategories, getEvents } from '$lib/api'; import { getPrivateAccessStatus } from '$lib/privateAccess'; export const load: LayoutLoad = async ({ fetch, data }) => { - const [categories, privateAccess] = await Promise.all([getCategories(fetch), getPrivateAccessStatus(fetch)]); - return { ...data, categories, privateAccess }; + const [categories, events, privateAccess] = await Promise.all([ + getCategories(fetch), + getEvents(fetch), + getPrivateAccessStatus(fetch) + ]); + // Tracked events are a displayed category like any other (see MergeTab/EventsTab) — + // only active ones show up as browsable, same as a paused/disabled category wouldn't. + return { ...data, categories, events: events.filter((e) => e.active), privateAccess }; }; diff --git a/frontend/src/routes/article/[id]/+page.svelte b/frontend/src/routes/article/[id]/+page.svelte index c9308a3..5bf579a 100644 --- a/frontend/src/routes/article/[id]/+page.svelte +++ b/frontend/src/routes/article/[id]/+page.svelte @@ -17,6 +17,10 @@
+ {#if a.isRecap} + 🧵 AI Recap + · + {/if} {#if a.sourceCount > 1} ⇄ Merged from {a.sourceCount} sources · diff --git a/frontend/src/routes/event/[id]/+page.svelte b/frontend/src/routes/event/[id]/+page.svelte new file mode 100644 index 0000000..ce674b9 --- /dev/null +++ b/frontend/src/routes/event/[id]/+page.svelte @@ -0,0 +1,30 @@ + + +
+ {data.name} + Tracked event — periodically recapped by AI +
+ + + + diff --git a/frontend/src/routes/event/[id]/+page.ts b/frontend/src/routes/event/[id]/+page.ts new file mode 100644 index 0000000..29bac4b --- /dev/null +++ b/frontend/src/routes/event/[id]/+page.ts @@ -0,0 +1,18 @@ +import type { PageLoad } from './$types'; +import { getFeed } from '$lib/api'; + +const PAGE_SIZE = 15; + +// Mirrors /category/[name] — a tracked event is a displayed category too, just backed +// by a source+keyword filter instead of manual per-source category checkboxes (see +// EventsTab.svelte). The event's own name comes from the root layout's already-loaded +// active-events list (same pattern category pages use to resolve a slug back to a +// real category name) rather than a second fetch. +export const load: PageLoad = async ({ params, fetch, parent }) => { + const { events } = await parent(); + const match = events.find((e) => e.id === params.id); + + const filters = { eventId: params.id }; + const initial = await getFeed({ ...filters, limit: PAGE_SIZE }, fetch); + return { initial, filters, name: match?.name ?? 'Tracked event', pageSize: PAGE_SIZE }; +};