diff --git a/apps/mana/apps/web/src/lib/data/ai/policy.ts b/apps/mana/apps/web/src/lib/data/ai/policy.ts index e3292b5e1..2e3748fb5 100644 --- a/apps/mana/apps/web/src/lib/data/ai/policy.ts +++ b/apps/mana/apps/web/src/lib/data/ai/policy.ts @@ -48,6 +48,7 @@ const AUTO_TOOLS: Record = { 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', diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index e3ea6b062..690aec151 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -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} diff --git a/packages/shared-ai/src/policy/proposable-tools.ts b/packages/shared-ai/src/policy/proposable-tools.ts index d40974a4e..7b0052201 100644 --- a/packages/shared-ai/src/policy/proposable-tools.ts +++ b/packages/shared-ai/src/policy/proposable-tools.ts @@ -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]; diff --git a/services/mana-ai/src/config.ts b/services/mana-ai/src/config.ts index df7f78594..e4ee3830a 100644 --- a/services/mana-ai/src/config.ts +++ b/services/mana-ai/src/config.ts @@ -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', diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts index bf679c442..7540d1020 100644 --- a/services/mana-ai/src/cron/tick.ts +++ b/services/mana-ai/src/cron/tick.ts @@ -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 { } 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 { 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, diff --git a/services/mana-ai/src/planner/news-research-client.ts b/services/mana-ai/src/planner/news-research-client.ts new file mode 100644 index 000000000..44c36a490 --- /dev/null +++ b/services/mana-ai/src/planner/news-research-client.ts @@ -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 { + 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 { + 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 { + 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'); +} diff --git a/services/mana-ai/src/planner/tools.ts b/services/mana-ai/src/planner/tools.ts index 291e81497..30698b266 100644 --- a/services/mana-ai/src/planner/tools.ts +++ b/services/mana-ai/src/planner/tools.ts @@ -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(AI_AVAILABLE_TOOLS.map((t) => t.name));