mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(ai-tools): server-side web-research + contacts for agents
Two major tool expansions — the Recherche-Agent and Today-Agent can now research the web autonomously (no browser needed), and a future Meeting-Prep agent can read + create contacts. === research_news (server-side execution) === The biggest addition: mana-ai can now call mana-api's news-research endpoints (POST /discover + /search) directly, without a browser. Infrastructure: - services/mana-ai/src/planner/news-research-client.ts — full HTTP client with discover→search pipeline. 15s/30s timeouts. Graceful null on any failure (network, mana-api down, bad response) so the tick never crashes from research errors. - config.manaApiUrl added (default http://localhost:3060); wired in docker-compose.macmini.yml as http://mana-api:3060 + depends_on mana-api with service_healthy condition. Pre-planning research step (cron/tick.ts): - Before the planner prompt is built, the tick checks if the mission's objective or conceptMarkdown matches research keywords (same RESEARCH_TRIGGER regex the webapp uses). When it matches: * NewsResearchClient.research(objective) runs discovery + search * Results are injected as a synthetic ResolvedInput with id '__web-research__' and a formatted markdown context block * The Planner then sees real article URLs/titles/excerpts and can reference them in create_note / save_news_article steps * Log line: "pre-research: N feeds, M articles" Tool registration: - research_news added to AI_PROPOSABLE_TOOL_NAMES + mana-ai tools.ts with params (query, language?, limit?). This lets the planner also explicitly propose a research step as a PlanStep (in addition to the pre-planning auto-injection). === create_contact === - Added to AI_PROPOSABLE_TOOL_NAMES + mana-ai tools.ts with params (firstName required, lastName/email/phone/company/notes optional). - Contacts are encrypted at rest; server planner can plan the step but execution stays on the webapp (same as all propose tools). Full server-side contact resolution via Key-Grant is a future enhancement. - get_contacts added to webapp AUTO_TOOLS so agents can inspect existing contacts without nagging (read-only, auto-policy). Module coverage now: ✅ todo (5) ✅ calendar (2) ✅ notes (5) ✅ places (4) ✅ drink (3) ✅ food (2) ✅ news (1) ✅ journal (1) ✅ habits (3) ✅ news-research (1) ✅ contacts (1) 11 modules, 28 tools total (17 propose, 11 auto). Tests: mana-ai 41/41 (drift-guard passes), shared-ai type-check clean, webapp svelte-check 0 errors, 0 warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae53e93b9a
commit
23b8cc13fb
7 changed files with 253 additions and 2 deletions
|
|
@ -48,6 +48,7 @@ const AUTO_TOOLS: Record<string, 'auto'> = {
|
|||
get_places: 'auto',
|
||||
location_log: 'auto',
|
||||
get_habits: 'auto',
|
||||
get_contacts: 'auto',
|
||||
// Append-only self-state logs: AI proposing "did you drink water?" +
|
||||
// user confirming + AI logging it should not require a second approval.
|
||||
log_drink: 'auto',
|
||||
|
|
|
|||
|
|
@ -316,12 +316,15 @@ services:
|
|||
condition: service_healthy
|
||||
mana-llm:
|
||||
condition: service_started
|
||||
mana-api:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
NODE_ENV: production
|
||||
PORT: 3067
|
||||
SYNC_DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_sync
|
||||
MANA_LLM_URL: http://mana-llm:3020
|
||||
MANA_API_URL: http://mana-api:3060
|
||||
MANA_SERVICE_KEY: ${MANA_SERVICE_KEY}
|
||||
TICK_INTERVAL_MS: ${MANA_AI_TICK_INTERVAL_MS:-60000}
|
||||
TICK_ENABLED: ${MANA_AI_TICK_ENABLED:-true}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export const AI_PROPOSABLE_TOOL_NAMES = [
|
|||
// ── Habits ────────────────────────────────
|
||||
'create_habit',
|
||||
'log_habit',
|
||||
// ── News-Research ─────────────────────────
|
||||
'research_news',
|
||||
// ── Contacts ──────────────────────────────
|
||||
'create_contact',
|
||||
] as const;
|
||||
|
||||
export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export interface Config {
|
|||
syncDatabaseUrl: string;
|
||||
/** mana-llm HTTP endpoint (OpenAI-compatible). */
|
||||
manaLlmUrl: string;
|
||||
/** Unified mana-api (Hono/Bun, port 3060). Hosts module-specific
|
||||
* compute endpoints including news-research. Used by the pre-planning
|
||||
* research step to feed web-research context into the planner prompt
|
||||
* before it produces plan steps. */
|
||||
manaApiUrl: string;
|
||||
/** Shared key for service-to-service calls. */
|
||||
serviceKey: string;
|
||||
/** How often the background tick scans for due Missions, in ms. */
|
||||
|
|
@ -49,6 +54,7 @@ export function loadConfig(): Config {
|
|||
'postgresql://mana:devpassword@localhost:5432/mana_sync'
|
||||
),
|
||||
manaLlmUrl: requireEnv('MANA_LLM_URL', 'http://localhost:3020'),
|
||||
manaApiUrl: requireEnv('MANA_API_URL', 'http://localhost:3060'),
|
||||
serviceKey: requireEnv('MANA_SERVICE_KEY', 'dev-service-key'),
|
||||
tickIntervalMs: parseInt(process.env.TICK_INTERVAL_MS ?? '60000', 10),
|
||||
tickEnabled: process.env.TICK_ENABLED !== 'false',
|
||||
|
|
|
|||
|
|
@ -43,11 +43,18 @@ import {
|
|||
agentDecisionsTotal,
|
||||
} from '../metrics';
|
||||
import { unwrapMissionGrant } from '../crypto/unwrap-grant';
|
||||
import { NewsResearchClient } from '../planner/news-research-client';
|
||||
import type { ResolverContext } from '../db/resolvers/types';
|
||||
import type { Config } from '../config';
|
||||
|
||||
const ENC_PREFIX = 'enc:1:';
|
||||
|
||||
/** Heuristic: mission objectives that should trigger a pre-planning
|
||||
* web-research step. Same regex the webapp uses in its reasoning loop
|
||||
* (`data/ai/missions/runner.ts`). */
|
||||
const RESEARCH_TRIGGER =
|
||||
/\b(recherchier|research|news|finde|suche|aktuelle|neueste|today|history|historisch|on this day)/i;
|
||||
|
||||
/** True when the value looks like the webapp's AES-GCM wire format. */
|
||||
function isCiphertext(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
|
||||
|
|
@ -163,7 +170,7 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
|
|||
}
|
||||
|
||||
try {
|
||||
const plan = await planOneMission(m, planner, sql, agent);
|
||||
const plan = await planOneMission(m, planner, sql, agent, config);
|
||||
if (plan === null) {
|
||||
parseFailures++;
|
||||
parseFailuresTotal.inc();
|
||||
|
|
@ -225,7 +232,8 @@ async function planOneMission(
|
|||
m: ServerMission,
|
||||
planner: PlannerClient,
|
||||
sql: Sql,
|
||||
agent: ServerAgent | null
|
||||
agent: ServerAgent | null,
|
||||
config: Config
|
||||
): Promise<AiPlanOutput | null> {
|
||||
const mission = serverMissionToSharedMission(m);
|
||||
// Resolve the mission's Key-Grant (if any) once per tick. An absent
|
||||
|
|
@ -237,6 +245,29 @@ async function planOneMission(
|
|||
// reference goes out of scope and gets GC'd.
|
||||
const context = await buildResolverContext(m);
|
||||
const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId, context);
|
||||
|
||||
// Pre-planning research step: when the mission objective matches
|
||||
// research keywords, run RSS discovery + search against mana-api and
|
||||
// inject the results as a synthetic ResolvedInput. This gives the
|
||||
// Planner real sources to reference instead of hallucinating URLs.
|
||||
// Mirrors the webapp's auto-kontext + research pre-step.
|
||||
if (RESEARCH_TRIGGER.test(m.objective) || RESEARCH_TRIGGER.test(m.conceptMarkdown)) {
|
||||
const nrc = new NewsResearchClient(config.manaApiUrl);
|
||||
const research = await nrc.research(m.objective, { language: 'de', limit: 8 });
|
||||
if (research) {
|
||||
resolvedInputs.push({
|
||||
id: '__web-research__',
|
||||
module: 'news-research',
|
||||
table: 'web',
|
||||
title: `Web-Research: "${m.objective.slice(0, 60)}"`,
|
||||
content: research.contextMarkdown,
|
||||
});
|
||||
console.log(
|
||||
`[mana-ai tick] mission=${m.id} pre-research: ${research.feedCount} feeds, ${research.articles.length} articles`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const input: AiPlanInput = {
|
||||
mission,
|
||||
resolvedInputs,
|
||||
|
|
|
|||
158
services/mana-ai/src/planner/news-research-client.ts
Normal file
158
services/mana-ai/src/planner/news-research-client.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* HTTP client for the mana-api news-research endpoints.
|
||||
*
|
||||
* Called by the pre-planning research step in the tick loop: when a
|
||||
* mission's objective matches research keywords, we discover RSS feeds
|
||||
* + search them for relevant articles BEFORE the Planner prompt is
|
||||
* built. The results go into the Planner's resolved-inputs so the LLM
|
||||
* can reference real sources in its plan — instead of hallucinating
|
||||
* URLs.
|
||||
*
|
||||
* Endpoints called:
|
||||
* POST /api/v1/news-research/discover — find RSS feeds for a query
|
||||
* POST /api/v1/news-research/search — rank articles from those feeds
|
||||
*
|
||||
* Both live on mana-api (Hono/Bun, port 3060 internally). Service-to-
|
||||
* service auth is not required on this internal path (same trust
|
||||
* boundary as mana-llm calls).
|
||||
*/
|
||||
|
||||
export interface DiscoveredFeed {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ScoredArticle {
|
||||
url: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
publishedAt: string | null;
|
||||
feedUrl: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface NewsResearchResult {
|
||||
articles: ScoredArticle[];
|
||||
feedCount: number;
|
||||
/** Markdown-formatted context string ready for injection into the
|
||||
* Planner prompt as a ResolvedInput. */
|
||||
contextMarkdown: string;
|
||||
}
|
||||
|
||||
export class NewsResearchClient {
|
||||
constructor(private manaApiUrl: string) {}
|
||||
|
||||
/**
|
||||
* Full pipeline: discover feeds for the query, then search + rank
|
||||
* articles. Returns a ready-to-inject context blob.
|
||||
*
|
||||
* Gracefully returns null on any error (network, parse, timeout) so
|
||||
* a failing mana-api doesn't crash the tick.
|
||||
*/
|
||||
async research(
|
||||
query: string,
|
||||
opts: { language?: string; limit?: number } = {}
|
||||
): Promise<NewsResearchResult | null> {
|
||||
try {
|
||||
const feeds = await this.discover(query, opts.language ?? 'de');
|
||||
if (!feeds || feeds.length === 0) return null;
|
||||
|
||||
const feedUrls = feeds.map((f) => f.url);
|
||||
const articles = await this.search(feedUrls, query, opts.limit ?? 10);
|
||||
if (!articles || articles.length === 0) return null;
|
||||
|
||||
const contextMarkdown = formatContext(query, feeds, articles);
|
||||
return {
|
||||
articles,
|
||||
feedCount: feeds.length,
|
||||
contextMarkdown,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[news-research-client] research failed:',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async discover(query: string, language: string): Promise<DiscoveredFeed[] | null> {
|
||||
try {
|
||||
const res = await fetch(`${this.manaApiUrl}/api/v1/news-research/discover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, language, limit: 5 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[news-research-client] discover ${res.status}: ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json()) as { feeds: DiscoveredFeed[] };
|
||||
return body.feeds;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[news-research-client] discover error:',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async search(
|
||||
feedUrls: string[],
|
||||
query: string,
|
||||
limit: number
|
||||
): Promise<ScoredArticle[] | null> {
|
||||
try {
|
||||
const res = await fetch(`${this.manaApiUrl}/api/v1/news-research/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feeds: feedUrls, query, limit }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[news-research-client] search ${res.status}: ${res.statusText}`);
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json()) as { articles: ScoredArticle[] };
|
||||
return body.articles;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[news-research-client] search error:',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn discover + search results into a markdown block the Planner
|
||||
* prompt can reference. Mirrors the webapp's
|
||||
* `modules/news-research/tools.ts` formatting.
|
||||
*/
|
||||
function formatContext(query: string, feeds: DiscoveredFeed[], articles: ScoredArticle[]): string {
|
||||
const lines: string[] = [
|
||||
`# Web-Research: "${query}"`,
|
||||
'',
|
||||
`${feeds.length} Quellen durchsucht, ${articles.length} relevante Artikel gefunden:`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const a of articles) {
|
||||
lines.push(`## ${a.title}`);
|
||||
if (a.excerpt) lines.push(`> ${a.excerpt}`);
|
||||
lines.push(`URL: ${a.url}`);
|
||||
if (a.publishedAt) lines.push(`Datum: ${a.publishedAt}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'---',
|
||||
'Nutze diese Quellen fuer deinen Plan. Verwende nur URLs die oben stehen; erfinde keine.'
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -208,6 +208,54 @@ export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [
|
|||
{ name: 'note', type: 'string', description: 'Optionale Notiz zum Log', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ── News-Research ────────────────────────────────────────
|
||||
{
|
||||
name: 'research_news',
|
||||
module: 'news-research',
|
||||
description:
|
||||
'Durchsucht RSS-Feeds und Web-Quellen nach relevanten Artikeln zu einem Thema. Gibt gefundene Artikel-URLs + Titel + Zusammenfassung zurueck. Nuetzlich als Vorstufe zu save_news_article.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Suchbegriff / Thema (z.B. "TypeScript 5.8 release")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
description: 'Sprache (z.B. "de" oder "en")',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Max. Anzahl Ergebnisse (Standard: 10)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Contacts ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'create_contact',
|
||||
module: 'contacts',
|
||||
description: 'Erstellt einen neuen Kontakt. Felder die nicht bekannt sind einfach weglassen.',
|
||||
parameters: [
|
||||
{ name: 'firstName', type: 'string', description: 'Vorname', required: true },
|
||||
{ name: 'lastName', type: 'string', description: 'Nachname', required: false },
|
||||
{ name: 'email', type: 'string', description: 'E-Mail-Adresse', required: false },
|
||||
{ name: 'phone', type: 'string', description: 'Telefonnummer', required: false },
|
||||
{ name: 'company', type: 'string', description: 'Firma / Organisation', required: false },
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'string',
|
||||
description: 'Freitext-Notizen zum Kontakt',
|
||||
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