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:
@@ -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<MergedArticle> {
|
||||
export async function publishDirect(
|
||||
item: ContentItem,
|
||||
settings: GlobalSettings,
|
||||
opts: { eventId?: string } = {}
|
||||
): Promise<MergedArticle> {
|
||||
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<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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<SynthesisResult> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
@@ -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<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 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<typeof eventsDb.listActiveEvents>[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<number> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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<string, number>): number {
|
||||
@@ -52,7 +55,7 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
|
||||
*/
|
||||
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
|
||||
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<num
|
||||
|
||||
for (const item of ranked) {
|
||||
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);
|
||||
published++;
|
||||
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
|
||||
* 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
|
||||
* One pass of the synthesis queue: cluster whatever's unclustered, ordered by
|
||||
* 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> {
|
||||
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
|
||||
|
||||
@@ -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, 'id'>): 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, 'id'>): 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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
{#if article.isRecap}
|
||||
<span>🧵 AI Recap</span>
|
||||
<span>·</span>
|
||||
{/if}
|
||||
<span>{article.category[0] ?? ''}</span>
|
||||
<span>·</span>
|
||||
<span>{sourceLabel}</span>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 === '/';
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
<article>
|
||||
<div class="meta">
|
||||
{#if a.isRecap}
|
||||
<span>🧵 AI Recap</span>
|
||||
<span>·</span>
|
||||
{/if}
|
||||
{#if a.sourceCount > 1}
|
||||
<span>⇄ Merged from {a.sourceCount} sources</span>
|
||||
<span>·</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>
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user