feat(ai): web-research pre-step + auto-kontext + save_news_article tool

Mission objectives matching /recherch|research|news|finde|suche|aktuelle|neueste/i
trigger a synchronous deep-research call (mana-search + mana-llm via the
existing /api/v1/research/start-sync pipeline) before the planner runs;
the summary plus top-8 source URLs are injected as a synthetic ResolvedInput
so the planner can stage save_news_article proposals against real URLs.

The kontext singleton is auto-attached to every mission's planner input
(decrypted client-side, gated on non-empty content + not already linked).

save_news_article is a new proposable tool routed through articlesStore
.saveFromUrl (Readability via /api/v1/news/extract/save). AiProposalInbox
mounted on /news so the user can approve/reject inline. mana-ai planner
tool list mirrors the new tool to keep the boot-time drift guard happy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 19:10:13 +02:00
parent 4c8034f9d0
commit fdb8e60d07
6 changed files with 182 additions and 5 deletions

View file

@ -146,6 +146,18 @@ export const researchApi = {
});
},
/**
* Synchronous variant blocks until the pipeline is done. Used by the
* AI Mission runner for its web-research pre-step where we need the
* sources synchronously before handing off to the planner.
*/
async startSync(input: StartResearchInput): Promise<ResearchResult> {
return jsonRequest('/api/v1/research/start-sync', {
method: 'POST',
body: JSON.stringify(input),
});
},
/** Fetch a single research result row by id. */
async get(researchResultId: string): Promise<ResearchResult> {
return jsonRequest(`/api/v1/research/${researchResultId}`);

View file

@ -29,9 +29,20 @@ import {
import { resolveMissionInputs } from './input-resolvers';
import { getAvailableToolsForAi } from './available-tools';
import { executeTool } from '../../tools/executor';
import { db } from '../../database';
import { decryptRecords } from '../../crypto';
import { researchApi } from '$lib/api/research';
import type { Actor } from '../../events/actor';
import type { Mission, MissionIteration, PlanStep } from './types';
import type { AiPlanInput, AiPlanOutput, PlannedStep } from './planner/types';
import type { AiPlanInput, AiPlanOutput, PlannedStep, ResolvedInput } from './planner/types';
/** Heuristic: mission objective text that should trigger a pre-step
* web-research call. Keeps the trigger explicit so unrelated missions
* don't burn credits accidentally. */
const RESEARCH_TRIGGER = /\b(recherchier|research|news|finde|suche|aktuelle|neueste)/i;
/** Singleton row id of the kontext doc kept in sync with
* `modules/kontext/types.ts` (KONTEXT_SINGLETON_ID). */
const KONTEXT_SINGLETON_ID = 'singleton';
/** Hard timeout for one mission run. Cancels the in-flight planner call
* and finalises the iteration as failed. 90 s is comfortable for a
@ -144,7 +155,34 @@ export async function runMission(
'resolving-inputs',
mission!.inputs.length > 0 ? `${mission!.inputs.length} Input(s)` : 'keine Inputs'
);
const resolvedInputs = await resolveMissionInputs(mission!.inputs);
const baseInputs = await resolveMissionInputs(mission!.inputs);
const resolvedInputs: ResolvedInput[] = [...baseInputs];
// Auto-inject the kontext singleton (if non-empty and not already
// linked) so every mission has the user's standing context as
// background. Decrypted client-side; never reaches the server.
const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext');
if (!alreadyHasKontext) {
const kontextEntry = await loadKontextAsResolvedInput();
if (kontextEntry) resolvedInputs.push(kontextEntry);
}
// Pre-step web research: if the objective looks like research,
// run the deep-research pipeline (mana-search + mana-llm) and
// attach the summary + sources so the planner can decide which
// to save via save_news_article. Failures are non-fatal — the
// planner still runs with whatever inputs we have.
if (RESEARCH_TRIGGER.test(mission!.objective)) {
await enterPhase('resolving-inputs', 'Web-Recherche…');
try {
const researchEntry = await runWebResearch(mission!);
if (researchEntry) resolvedInputs.push(researchEntry);
} catch (err) {
console.warn('[MissionRunner] web-research pre-step failed:', err);
}
await checkCancel();
}
const availableTools = getAvailableToolsForAi(aiActor);
await checkCancel();
@ -275,6 +313,67 @@ function emptyResult(
};
}
/** Read the kontext singleton + decrypt; returns null if empty/missing. */
async function loadKontextAsResolvedInput(): Promise<ResolvedInput | null> {
try {
const local = await db
.table<{ id: string; content?: string; deletedAt?: string }>('kontextDoc')
.get(KONTEXT_SINGLETON_ID);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('kontextDoc', [local]);
const content = decrypted?.content?.trim();
if (!content) return null;
return {
id: KONTEXT_SINGLETON_ID,
module: 'kontext',
table: 'kontextDoc',
title: 'Kontext (Standing)',
content,
};
} catch (err) {
console.warn('[MissionRunner] kontext auto-inject failed:', err);
return null;
}
}
/** Run the deep-research pipeline against the mission objective and
* collapse its summary + sources into one ResolvedInput formatted so
* the planner can copy URLs into save_news_article calls. */
async function runWebResearch(mission: Mission): Promise<ResolvedInput | null> {
const result = await researchApi.startSync({
// Tag the run with the mission id so backend logs can correlate.
questionId: `mission:${mission.id}`,
title: mission.objective.slice(0, 500),
description: mission.conceptMarkdown?.slice(0, 4000),
depth: 'quick',
});
if (result.status === 'error' || !result.summary) return null;
const sources = await researchApi.listSources(result.id);
const sourcesBlock = sources
.slice(0, 8)
.map((s, i) =>
`[${i + 1}] ${s.title || s.url}\n URL: ${s.url}\n ${s.snippet ?? ''}`.trim()
)
.join('\n\n');
const content = [
`Zusammenfassung (Tiefe: ${result.depth}):`,
result.summary,
'',
'Quellen (kopiere die URL beim Aufruf von save_news_article):',
sourcesBlock || '(keine Quellen)',
].join('\n');
return {
id: result.id,
module: 'research',
table: 'researchResults',
title: 'Web-Recherche zu diesem Auftrag',
content,
};
}
/**
* Scan all active missions whose `nextRunAt` has passed and run them once
* each. Used by the foreground tick that wires this into `+layout.svelte`.

View file

@ -1,4 +1,46 @@
/**
* News Tools LLM-accessible operations for the news module.
*
* `save_news_article` is the agent's path into the user's reading list.
* On approve, the executor calls `articlesStore.saveFromUrl(url)` which
* routes through `apps/api /api/v1/news/extract/save` (Readability) and
* stores the encrypted result in `newsArticles`. `title` and `summary`
* are display hints the canonical title/excerpt come back from the
* extractor so the AI can't lie about content.
*/
import type { ModuleTool } from '$lib/data/tools/types';
// News tools are limited — saveFromCurated requires a full LocalCachedArticle
// which is complex for LLM tool calling. Read-only for now.
export const newsTools: ModuleTool[] = [];
import { articlesStore } from './stores/articles.svelte';
export const newsTools: ModuleTool[] = [
{
name: 'save_news_article',
module: 'news',
description:
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
parameters: [
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
{
name: 'title',
type: 'string',
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
required: false,
},
{
name: 'summary',
type: 'string',
description: 'Kurze Begründung warum dieser Artikel relevant ist',
required: false,
},
],
async execute(params) {
const url = params.url as string;
const article = await articlesStore.saveFromUrl(url);
return {
success: true,
message: `Artikel gespeichert: ${article.title}`,
data: { articleId: article.id, title: article.title },
};
},
},
];

View file

@ -18,6 +18,7 @@
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte';
import {
ALL_TOPICS,
type Topic,
@ -146,6 +147,7 @@
</svelte:head>
<div class="news-page">
<AiProposalInbox module="news" />
{#if !isOnboarded}
<!-- ─── Onboarding ───────────────────────────────────── -->
<header class="hero">