mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
4c8034f9d0
commit
fdb8e60d07
6 changed files with 182 additions and 5 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const AI_PROPOSABLE_TOOL_NAMES = [
|
|||
'create_place',
|
||||
'visit_place',
|
||||
'undo_drink',
|
||||
'save_news_article',
|
||||
] as const;
|
||||
|
||||
export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -83,6 +83,27 @@ export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [
|
|||
description: 'Macht den letzten Drink-Eintrag rückgängig',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const AI_AVAILABLE_TOOL_NAMES = new Set<string>(AI_AVAILABLE_TOOLS.map((t) => t.name));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue