Add "Force recap now" button to tracked items

Lets an admin trigger a tracked item's recap immediately instead of
waiting out its recapIntervalHours cadence. Still summarizes only the
real window of new constituent articles since the last recap (or the
last 24h if never recapped), returning a friendly not-published reason
when there's nothing new rather than fabricating content.
This commit is contained in:
Claude
2026-07-28 14:28:45 +00:00
parent a312e0a8d3
commit ef59b2e91a
4 changed files with 106 additions and 2 deletions
+35
View File
@@ -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();
+8 -1
View File
@@ -18,7 +18,8 @@ import type {
InstalledWidget,
WidgetUploadManifest,
PipelineStats,
ModelContextInfo
ModelContextInfo,
ForceRecapResult
} from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -136,6 +137,12 @@ export const updateEvent = (id: string, patch: Partial<AdminTrackedEvent>, fetch
export const deleteEvent = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/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<ForceRecapResult>(`/api/admin/events/${id}/recap-now`, { method: 'POST' }, fetchFn);
// Models / AI service
export const getModels = (fetchFn?: typeof fetch) =>
request<ModelCatalog>('/api/admin/models', {}, fetchFn);
+9
View File
@@ -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[];
@@ -1,6 +1,6 @@
<script lang="ts">
import type { AdminTrackedEvent, AdminSource } from '$lib/adminTypes';
import { addEvent, updateEvent, deleteEvent } from '$lib/adminApi';
import { addEvent, updateEvent, deleteEvent, forceRecap } from '$lib/adminApi';
import CollapsibleSection from './CollapsibleSection.svelte';
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
@@ -26,6 +26,30 @@
}
let editForm = $state(emptyEditForm());
let recappingId = $state<string | null>(null);
let recapMessage = $state<{ id: string; text: string; isError: boolean } | null>(null);
// Runs this item's recap right now instead of waiting out its cadence timer — still
// summarizes only whatever's genuinely new since the last recap (see the backend
// route), so it can come back saying there was nothing to recap rather than always
// producing one.
async function handleForceRecap(id: string) {
recappingId = id;
recapMessage = null;
try {
const result = await forceRecap(id);
recapMessage = {
id,
text: result.published ? `Recap published: "${result.title}"` : (result.reason ?? 'Nothing to recap.'),
isError: false
};
} catch (err) {
recapMessage = { id, text: (err as Error).message, isError: true };
} finally {
recappingId = null;
}
}
async function handleAdd() {
if (!newEvent.name) return;
const created = await addEvent({
@@ -182,6 +206,17 @@
it off for something you're just organizing under its own nav entry (a commit feed,
a torrent feed) with nothing that needs summarizing.
</p>
<div class="force-recap-row">
<button
onclick={() => handleForceRecap(event.id)}
disabled={recappingId === event.id}
>
{recappingId === event.id ? 'Recapping…' : 'Force recap now'}
</button>
{#if recapMessage && recapMessage.id === event.id}
<span class="recap-message" class:error={recapMessage.isError}>{recapMessage.text}</span>
{/if}
</div>
</div>
<div class="more-section">
@@ -370,6 +405,24 @@
.cadence-block .hint {
margin-top: 6px;
}
.force-recap-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
}
.force-recap-row button {
font-size: 12px;
padding: 6px 12px;
}
.recap-message {
font-size: 11px;
color: var(--text-secondary);
}
.recap-message.error {
color: var(--text-danger);
}
.more-section {
margin-top: 12px;
}