diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 7b83f3c..2f94ffc 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -2,11 +2,13 @@ import type { FastifyInstance } from 'fastify'; import * as settingsDb from '../storage/db/settings.js'; import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; +import * as articlesDb from '../storage/db/articles.js'; import * as categoriesDb from '../storage/db/categories.js'; import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; import { clearSourceContent, reissueSourceContent, reissueArticle, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; +import { publishEventRecap } from '../pipeline/publish.js'; import { pollSourceNow } from '../ingestion/poller.js'; import { logger, listLogs } from '../storage/db/logs.js'; import * as backlogStats from '../queue/backlogStats.js'; @@ -187,6 +189,39 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(204).send(); }); + // Forces one tracked item's recap to run right now, ignoring its recapIntervalHours + // cadence entirely (even if recaps are turned off for it) — for "I want a wrap-up + // right now" rather than waiting out the timer. Still summarizes the same real + // window eventsRecap.ts would (everything published since lastRecapAt, or the last + // 24h if it's never recapped) rather than some arbitrary admin-chosen range, and + // still requires that window to actually contain something — an AI call with zero + // source material to summarize would just hallucinate content it wasn't given. + app.post('/api/admin/events/:id/recap-now', async (req, reply) => { + const { id } = req.params as { id: string }; + const event = eventsDb.getEvent(id); + if (!event) return reply.code(404).send({ error: 'not found' }); + if (event.sourceIds.length === 0) { + return { published: false, reason: 'No sources assigned to this item yet.' }; + } + + const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString(); + const constituents = articlesDb.articlesForEventSince(event.id, since); + if (constituents.length === 0) { + return { published: false, reason: 'Nothing new published under this item since its last recap.' }; + } + + const settings = settingsDb.getSettings(); + const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort); + try { + const article = await publishEventRecap(provider, settings, event, constituents); + eventsDb.markRecapped(event.id); + logger.info('events', `Manually forced recap for "${event.name}" from ${constituents.length} article(s)`); + return { published: true, title: article.title }; + } catch (err) { + return reply.code(502).send({ error: `Recap failed: ${(err as Error).message}` }); + } + }); + // --- Models / AI service (fetched live from the configured Ollama host) --- app.get('/api/admin/models', async (_req, reply) => { const settings = settingsDb.getSettings(); diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index a77d98d..6043012 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -18,7 +18,8 @@ import type { InstalledWidget, WidgetUploadManifest, PipelineStats, - ModelContextInfo + ModelContextInfo, + ForceRecapResult } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { @@ -136,6 +137,12 @@ export const updateEvent = (id: string, patch: Partial, fetch export const deleteEvent = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/events/${id}`, { method: 'DELETE' }, fetchFn); +// Runs this item's recap immediately, ignoring its recapIntervalHours cadence — still +// summarizes the same real window (everything since lastRecapAt) and still requires +// there to actually be something new to summarize (see the backend route). +export const forceRecap = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/events/${id}/recap-now`, { method: 'POST' }, fetchFn); + // Models / AI service export const getModels = (fetchFn?: typeof fetch) => request('/api/admin/models', {}, fetchFn); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index a94956b..5c905e5 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -199,6 +199,15 @@ export interface AdminTrackedEvent { recapCustomInstructions: string; } +/** Response from POST /api/admin/events/:id/recap-now. */ +export interface ForceRecapResult { + published: boolean; + /** Set when published is true. */ + title?: string; + /** Set when published is false — why nothing was generated (no sources assigned, nothing new since last recap). */ + reason?: string; +} + export interface ModelCatalog { embedding: string[]; image: string[]; diff --git a/frontend/src/lib/components/admin/EventsTab.svelte b/frontend/src/lib/components/admin/EventsTab.svelte index 44a6a6a..80d5c91 100644 --- a/frontend/src/lib/components/admin/EventsTab.svelte +++ b/frontend/src/lib/components/admin/EventsTab.svelte @@ -1,6 +1,6 @@