Rearchitect tracked events: a real displayed category, recap is additive

Previously a tracked event withheld all matching items from ever
publishing individually — they sat unclustered until the recap job fired,
which then bundled them into one article and discarded the raw items.
Visitors saw nothing from an event until a recap happened, and never saw
the underlying pieces at all.

Matching items now publish through the exact same pipeline as everything
else (individually or merged with same-story coverage via the normal
clustering pipeline), just tagged with the event's id via a new
publishDirect/publishCluster opts.eventId. An item whose source is
assigned to an event but doesn't match its keyword filter still
publishes normally, just without the tag, instead of being dropped.

The recap is now a periodic *additional* AI-written summary of everything
already published under the event since the last recap (new
synthesizeRecap prompt + publishEventRecap, reading MergedArticle bodies
rather than raw feed items) — never a replacement. Added a new
MergedArticle.isRecap flag so the two are visually distinguishable.

Frontend: a tracked event is now a real displayed category — new
/event/[id] page (mirrors /category/[name]), active events appended to
the site nav, and a "🧵 AI Recap" marker on recap articles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 23:25:36 +00:00
parent 97d3ed366c
commit fbb879effe
14 changed files with 256 additions and 74 deletions
+67 -6
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { InferenceProvider } from '../inference/provider.js'; import type { InferenceProvider } from '../inference/provider.js';
import type { Cluster } from './clustering.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 { selectBestImage, faviconUrlFor } from './image-selection.js';
import { downloadAndStore, promoteToPublished, storeMediaBuffer } from '../storage/media/index.js'; import { downloadAndStore, promoteToPublished, storeMediaBuffer } from '../storage/media/index.js';
import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js'; import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js';
@@ -16,7 +16,8 @@ import type {
TweetMediaItem, TweetMediaItem,
QuotedTweet, QuotedTweet,
TelegramMediaItem, TelegramMediaItem,
TelegramMediaRef TelegramMediaRef,
TrackedEvent
} from '../storage/db/types.js'; } from '../storage/db/types.js';
const FOLLOW_UP_LOOKBACK_DAYS = 3; const FOLLOW_UP_LOOKBACK_DAYS = 3;
@@ -233,7 +234,11 @@ async function resolveQuotedTweet(
* tag-based thread detection — but these earlier articles aren't retroactively * tag-based thread detection — but these earlier articles aren't retroactively
* rewritten or merged with anything after the fact. * rewritten or merged with anything after the fact.
*/ */
export async function publishDirect(item: ContentItem, settings: GlobalSettings): Promise<MergedArticle> { export async function publishDirect(
item: ContentItem,
settings: GlobalSettings,
opts: { eventId?: string } = {}
): Promise<MergedArticle> {
const category = uniqueCategories([item]); const category = uniqueCategories([item]);
const storedMediaIds: string[] = []; const storedMediaIds: string[] = [];
@@ -315,7 +320,7 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
telegramMessage, telegramMessage,
category, category,
geo: item.geo, geo: item.geo,
eventId: item.eventId, eventId: opts.eventId ?? item.eventId,
sourceCount: 1, sourceCount: 1,
sources: [ sources: [
{ {
@@ -334,7 +339,8 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
threadId: randomUUID(), threadId: randomUUID(),
previousArticleId: null, previousArticleId: null,
nextArticleId: null, nextArticleId: null,
topStories: anyPushesToTopStories([item]) topStories: anyPushesToTopStories([item]),
isRecap: false
}); });
for (const id of storedMediaIds) promoteToPublished(id, article.id); for (const id of storedMediaIds) promoteToPublished(id, article.id);
@@ -428,7 +434,8 @@ export async function publishCluster(
threadId, threadId,
previousArticleId, previousArticleId,
nextArticleId: null, nextArticleId: null,
topStories: anyPushesToTopStories(items) topStories: anyPushesToTopStories(items),
isRecap: false
}); });
if (storedMediaId) { if (storedMediaId) {
@@ -437,3 +444,57 @@ export async function publishCluster(
return article; 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<MergedArticle> {
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
});
}
+46 -9
View File
@@ -1,8 +1,16 @@
import type { InferenceProvider } from '../inference/provider.js'; 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 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: 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...") - Attributes specific claims to the outlet that reported them (e.g. "Reuters reported...", "AP notes...")
- Does not copy phrasing verbatim from any source - Does not copy phrasing verbatim from any source
@@ -24,14 +32,7 @@ function buildPrompt(items: ContentItem[]): string {
.join('\n\n'); .join('\n\n');
} }
export async function synthesizeArticle( function parseResult(raw: string): SynthesisResult {
provider: InferenceProvider,
model: string,
items: ContentItem[]
): Promise<SynthesisResult> {
const prompt = buildPrompt(items);
const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT });
const [body, tagSection] = raw.split(TAG_DELIMITER); const [body, tagSection] = raw.split(TAG_DELIMITER);
const tagLabels = (tagSection ?? '') const tagLabels = (tagSection ?? '')
.split(',') .split(',')
@@ -40,3 +41,39 @@ export async function synthesizeArticle(
return { body: body.trim(), tagLabels }; return { body: body.trim(), tagLabels };
} }
export async function synthesizeArticle(
provider: InferenceProvider,
model: string,
items: ContentItem[]
): Promise<SynthesisResult> {
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<SynthesisResult> {
const prompt = buildRecapPrompt(eventName, articles);
const raw = await provider.generate(prompt, { model, system: RECAP_SYSTEM_PROMPT });
return parseResult(raw);
}
+18 -31
View File
@@ -1,19 +1,20 @@
import type { InferenceProvider } from '../inference/provider.js'; import type { InferenceProvider } from '../inference/provider.js';
import * as eventsDb from '../storage/db/events.js'; import * as eventsDb from '../storage/db/events.js';
import * as contentItemsDb from '../storage/db/contentItems.js'; import * as articlesDb from '../storage/db/articles.js';
import { embedPendingItems } from '../pipeline/embedding.js'; import { publishEventRecap } from '../pipeline/publish.js';
import { publishCluster } from '../pipeline/publish.js';
import { randomUUID } from 'node:crypto';
import type { GlobalSettings } from '../storage/db/types.js'; import type { GlobalSettings } from '../storage/db/types.js';
import { logger } from '../storage/db/logs.js'; import { logger } from '../storage/db/logs.js';
function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boolean { function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boolean {
if (event.cadence === 'continuous') return true; // handled every cycle like normal clustering, just scoped to its sources
const now = new Date(); const now = new Date();
const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null; 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; return !last || now.getTime() - last.getTime() >= 3600_000;
} }
@@ -29,6 +30,12 @@ function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boo
return false; 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<number> { export async function runEventRecaps(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
const events = eventsDb.listActiveEvents(); const events = eventsDb.listActiveEvents();
let published = 0; let published = 0;
@@ -37,34 +44,14 @@ export async function runEventRecaps(provider: InferenceProvider, settings: Glob
if (event.sourceIds.length === 0 || !isDue(event)) continue; if (event.sourceIds.length === 0 || !isDue(event)) continue;
const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString(); const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString();
const items = contentItemsDb const constituents = articlesDb.articlesForEventSince(event.id, since);
.unclusteredItemsForSources(event.sourceIds, since) if (constituents.length === 0) continue;
.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;
try { try {
const article = await publishCluster( const article = await publishEventRecap(provider, settings, event, constituents);
provider,
settings,
{
id: randomUUID(),
items: withEmbeddings,
centroid: withEmbeddings[0].embedding!
},
{ eventId: event.id }
);
contentItemsDb.assignCluster(
withEmbeddings.map((i) => i.id),
article.id
);
eventsDb.markRecapped(event.id); eventsDb.markRecapped(event.id);
published++; 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) { } catch (err) {
logger.error('events', `Recap failed for "${event.name}": ${(err as Error).message}`); logger.error('events', `Recap failed for "${event.name}": ${(err as Error).message}`);
} }
+27 -16
View File
@@ -17,15 +17,18 @@ function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
} }
/** /**
* An item is "claimed" by a tracked event — and so left for eventsRecap.ts to handle * A tracked event is a displayed category like any other — matching items publish
* instead of normal synthesis — only if it belongs to one of the event's sources AND * normally (individually or merged with same-story coverage, exactly like regular
* matches its keyword filter. An item from an event-linked source that doesn't match * 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 * (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 * item doesn't mention Iran, just isn't part of that event — it still publishes, only
* silently dropped — it just isn't part of that event's recap. * without the tag).
*/ */
function isClaimedByEvent(item: ContentItem, events: TrackedEvent[]): boolean { function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | null {
return events.some((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords)); 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<string, number>): number { function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
@@ -52,7 +55,7 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
*/ */
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> { export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
const activeEvents = eventsDb.listActiveEvents(); const activeEvents = eventsDb.listActiveEvents();
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents)); const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0; if (items.length === 0) return 0;
const categories = categoriesDb.listCategories(); const categories = categoriesDb.listCategories();
@@ -66,7 +69,8 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
for (const item of ranked) { for (const item of ranked) {
try { 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); contentItemsDb.assignCluster([item.id], article.id);
published++; published++;
logger.info('synthesis', `Published "${article.title}" directly (no AI available)`); logger.info('synthesis', `Published "${article.title}" directly (no AI available)`);
@@ -79,15 +83,17 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
} }
/** /**
* One pass of the synthesis queue: cluster whatever's unclustered (excluding items * One pass of the synthesis queue: cluster whatever's unclustered, ordered by
* claimed by a tracked event — belonging to one of its sources AND matching its
* keyword filter, if any — which are handled by eventsRecap.ts instead), ordered by
* admin-defined category priority, and publish clusters that have cleared the * admin-defined category priority, and publish clusters that have cleared the
* hold-before-publish window. * hold-before-publish window. Items claimed by an active tracked event (belonging to
* one of its sources and matching its keyword filter, if any) publish exactly like
* everything else — individually or merged with same-story coverage — just tagged with
* the event's id so they're browsable under it and eligible for eventsRecap.ts's
* periodic AI wrap-up.
*/ */
export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise<number> { export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
const activeEvents = eventsDb.listActiveEvents(); const activeEvents = eventsDb.listActiveEvents();
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents)); const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0; if (items.length === 0) return 0;
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with // 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; let publishedDirect = 0;
for (const item of directItems) { for (const item of directItems) {
try { 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); contentItemsDb.assignCluster([item.id], article.id);
publishedDirect++; publishedDirect++;
const source = sourcesDb.getSource(item.sourceId); const source = sourcesDb.getSource(item.sourceId);
@@ -140,7 +147,11 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
} }
try { 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( contentItemsDb.assignCluster(
cluster.items.map((i) => i.id), cluster.items.map((i) => i.id),
cluster.id cluster.id
+14 -4
View File
@@ -24,7 +24,8 @@ function rowToArticle(row: any): MergedArticle {
nextArticleId: row.next_article_id, nextArticleId: row.next_article_id,
topStories: !!row.top_stories, topStories: !!row.top_stories,
tweet: row.tweet ? JSON.parse(row.tweet) : null, 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, 'id'>): MergedArticle
const id = `art-${randomUUID()}`; const id = `art-${randomUUID()}`;
db.prepare( db.prepare(
`INSERT INTO merged_articles `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) (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
id, id,
article.title, article.title,
@@ -54,7 +55,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
article.nextArticleId, article.nextArticleId,
article.topStories ? 1 : 0, article.topStories ? 1 : 0,
article.tweet ? JSON.stringify(article.tweet) : null, 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) { if (article.previousArticleId) {
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, 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); 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 { export function latestArticleInThread(threadId: string): MergedArticle | null {
const row = db const row = db
.prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1') .prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1')
+5 -1
View File
@@ -86,7 +86,8 @@ export function migrate() {
next_article_id TEXT, next_article_id TEXT,
top_stories INTEGER NOT NULL DEFAULT 0, -- true if any contributing source opted into "Push to Top Stories?" 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 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_published ON merged_articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id); 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')) { if (!hasColumn('tracked_events', 'keywords')) {
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'"); 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" — // 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 // general news sources belong here, not on "Top stories" itself, which isn't a real
+2
View File
@@ -155,6 +155,8 @@ export interface MergedArticle {
nextArticleId: string | null; nextArticleId: string | null;
/** True if any contributing source opted into "Push to Top Stories?" — gates the homepage feed, see articles.queryFeed. */ /** True if any contributing source opted into "Push to Top Stories?" — gates the homepage feed, see articles.queryFeed. */
topStories: boolean; 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 { export interface Tag {
@@ -28,6 +28,10 @@
<div class="content"> <div class="content">
<div class="meta"> <div class="meta">
{#if article.isRecap}
<span>🧵 AI Recap</span>
<span>&middot;</span>
{/if}
<span>{article.category[0] ?? ''}</span> <span>{article.category[0] ?? ''}</span>
<span>&middot;</span> <span>&middot;</span>
<span>{sourceLabel}</span> <span>{sourceLabel}</span>
+2
View File
@@ -64,6 +64,8 @@ export interface MergedArticle {
threadId: string; threadId: string;
previousArticleId: string | null; previousArticleId: string | null;
nextArticleId: 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 { export interface Tag {
+10 -4
View File
@@ -30,12 +30,18 @@
// isn't itself a filterable category — it always means "everything, chronological", // 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 // 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. // 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, label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(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 { function isActive(href: string): boolean {
if (href === '/') return $page.url.pathname === '/'; if (href === '/') return $page.url.pathname === '/';
+9 -3
View File
@@ -1,8 +1,14 @@
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { getCategories } from '$lib/api'; import { getCategories, getEvents } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess'; import { getPrivateAccessStatus } from '$lib/privateAccess';
export const load: LayoutLoad = async ({ fetch, data }) => { export const load: LayoutLoad = async ({ fetch, data }) => {
const [categories, privateAccess] = await Promise.all([getCategories(fetch), getPrivateAccessStatus(fetch)]); const [categories, events, privateAccess] = await Promise.all([
return { ...data, categories, privateAccess }; 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 };
}; };
@@ -17,6 +17,10 @@
<article> <article>
<div class="meta"> <div class="meta">
{#if a.isRecap}
<span>🧵 AI Recap</span>
<span>&middot;</span>
{/if}
{#if a.sourceCount > 1} {#if a.sourceCount > 1}
<span>⇄ Merged from {a.sourceCount} sources</span> <span>⇄ Merged from {a.sourceCount} sources</span>
<span>&middot;</span> <span>&middot;</span>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageData } from './$types';
import InfiniteFeed from '$lib/components/InfiniteFeed.svelte';
let { data }: { data: PageData } = $props();
</script>
<div class="head">
<span class="title">{data.name}</span>
<span class="sub">Tracked event — periodically recapped by AI</span>
</div>
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
<style>
.head {
margin: 24px 0 8px;
}
.title {
font-family: var(--font-voice);
font-size: 26px;
font-weight: 500;
}
.sub {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
</style>
+18
View File
@@ -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 };
};