From 2c0d866287c9e330c03dd52679b177aa521927e3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 18 Apr 2026 15:37:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(events):=20Phase=203=20=E2=80=94=20AI=20to?= =?UTF-8?q?ols,=20Event-Scout=20template,=20feedback=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add discover_events (auto) and suggest_event (propose) to shared-ai tool catalog. discover_events reads the discovery feed, suggest_event creates a proposal to save a discovered event to the user's calendar. - Add Event-Scout agent template with daily "Events der Woche" mission. Policy: discover_events=auto, suggest_event=propose, all else denied. - Add frontend tool implementations in events/tools.ts — discover_events calls the feed API, suggest_event delegates to discoveryStore.saveEvent. - Add feedback.ts — computes implicit user profile from save/dismiss history (category affinity + source quality as 0–2x weight multipliers). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/modules/events/tools.ts | 118 ++++++++++++++++ .../src/agents/templates/event-scout.ts | 92 +++++++++++++ .../shared-ai/src/agents/templates/index.ts | 3 + packages/shared-ai/src/tools/schemas.ts | 65 +++++++++ .../mana-events/src/discovery/feedback.ts | 128 ++++++++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 packages/shared-ai/src/agents/templates/event-scout.ts create mode 100644 services/mana-events/src/discovery/feedback.ts diff --git a/apps/mana/apps/web/src/lib/modules/events/tools.ts b/apps/mana/apps/web/src/lib/modules/events/tools.ts index ae7be50b1..db7f9d08f 100644 --- a/apps/mana/apps/web/src/lib/modules/events/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/events/tools.ts @@ -1,5 +1,7 @@ import type { ModuleTool } from '$lib/data/tools/types'; import { eventsStore } from './stores/events.svelte'; +import { discoveryStore } from './discovery/store.svelte'; +import * as discoveryApi from './discovery/api'; export const socialEventsTools: ModuleTool[] = [ { @@ -26,4 +28,120 @@ export const socialEventsTools: ModuleTool[] = [ : { success: false, message: result.error ?? 'Fehler' }; }, }, + + // ── Event Discovery (Phase 3) ─────────────────────────────── + { + name: 'discover_events', + module: 'events', + description: + 'Sucht oeffentliche Veranstaltungen in den konfigurierten Regionen des Nutzers. Gibt Events mit Titel, Datum, Ort, Kategorie und Quelle zurueck.', + parameters: [ + { + name: 'query', + type: 'string', + description: 'Optionaler Suchtext (z.B. "Jazz Konzerte")', + required: false, + }, + { + name: 'category', + type: 'string', + description: 'Kategorie-Filter', + required: false, + }, + { + name: 'days_ahead', + type: 'number', + description: 'Wie viele Tage voraus suchen (Standard: 14)', + required: false, + }, + ], + async execute(params) { + const daysAhead = (params.days_ahead as number) ?? 14; + const to = new Date(Date.now() + daysAhead * 86_400_000).toISOString(); + const feedParams: discoveryApi.FeedParams = { + to, + hideDismissed: true, + limit: 20, + }; + if (params.category) feedParams.category = params.category as string; + + const result = await discoveryApi.getFeed(feedParams); + const events = result.events.map((e) => ({ + id: e.id, + title: e.title, + date: e.startAt, + location: e.location, + category: e.category, + source: e.sourceName, + sourceUrl: e.sourceUrl, + priceInfo: e.priceInfo, + })); + + if (events.length === 0) { + return { + success: true, + data: { events: [] }, + message: 'Keine Events in den konfigurierten Regionen gefunden.', + }; + } + + const summary = events + .slice(0, 10) + .map( + (e) => + `- ${e.title} (${new Date(e.date).toLocaleDateString('de-DE')}${e.location ? `, ${e.location}` : ''})` + ) + .join('\n'); + + return { + success: true, + data: { events, total: result.total }, + message: `${events.length} Events gefunden:\n${summary}`, + }; + }, + }, + { + name: 'suggest_event', + module: 'events', + description: + 'Schlaegt dem Nutzer ein entdecktes Event vor. Erstellt ein Proposal das der Nutzer bestaetigen muss, um das Event in seinen Kalender zu uebernehmen.', + parameters: [ + { + name: 'discovered_event_id', + type: 'string', + description: 'ID des entdeckten Events', + required: true, + }, + { + name: 'reason', + type: 'string', + description: 'Begruendung warum dieses Event relevant ist', + required: false, + }, + ], + async execute(params) { + const eventId = params.discovered_event_id as string; + const reason = params.reason as string | undefined; + + // Load the event from the feed to get its details + const result = await discoveryApi.getFeed({ limit: 100 }); + const event = result.events.find((e) => e.id === eventId); + if (!event) { + return { success: false, message: `Event ${eventId} nicht gefunden` }; + } + + // Save the event (creates a local socialEvent) + await discoveryStore.saveEvent(eventId); + + const msg = reason + ? `Event "${event.title}" vorgeschlagen: ${reason}` + : `Event "${event.title}" vorgeschlagen`; + + return { + success: true, + data: { eventId, title: event.title, date: event.startAt }, + message: msg, + }; + }, + }, ]; diff --git a/packages/shared-ai/src/agents/templates/event-scout.ts b/packages/shared-ai/src/agents/templates/event-scout.ts new file mode 100644 index 000000000..ce0a1cbe6 --- /dev/null +++ b/packages/shared-ai/src/agents/templates/event-scout.ts @@ -0,0 +1,92 @@ +import { AI_PROPOSABLE_TOOL_NAMES } from '../../policy/proposable-tools'; +import type { AgentTemplate } from './types'; +import type { AiPolicy } from '../../policy/types'; + +/** + * Event-Scout agent — discovers public events in the user's configured + * regions and suggests the most relevant ones. Runs daily or on-demand. + * + * discover_events is auto (read-only, feeds context to the planner). + * suggest_event is propose (creates a proposal the user must approve). + */ + +const EVENT_SCOUT_POLICY: AiPolicy = { + tools: { + ...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'deny' as const])), + discover_events: 'auto', + suggest_event: 'propose', + }, + defaultsByModule: { + events: 'propose', + }, + defaultForAi: 'deny', +}; + +export const eventScoutTemplate: AgentTemplate = { + id: 'event-scout', + version: '1', + icon: '🎪', + label: 'Event-Scout', + tagline: 'Findet Events in deiner Region und schlaegt passende vor', + description: `Der Event-Scout durchsucht deine konfigurierten Regionen nach oeffentlichen Veranstaltungen und schlaegt dir die relevantesten vor. + +Voraussetzung: Richte mindestens eine Region und Interessen im Events-Modul unter "Entdecken" ein. + +Der Agent: +1. Liest deine Event-Discovery-Feeds (automatisch, ohne Nachfrage) +2. Waehlt die 3-5 relevantesten Events aus +3. Schlaegt sie dir als Proposals vor — du entscheidest was in deinen Kalender kommt`, + category: 'ai', + color: '#8B5CF6', + agent: { + name: 'Event-Scout', + avatar: '🎪', + role: 'Findet oeffentliche Events in deinen Regionen und schlaegt passende vor', + systemPrompt: `Du bist ein Event-Scout. Deine Aufgabe ist es, relevante oeffentliche Veranstaltungen in den Regionen des Nutzers zu finden und die besten vorzuschlagen. + +Vorgehen: +1. Nutze discover_events um die aktuellen Events in den Regionen des Nutzers abzurufen. +2. Bewerte die Events nach Relevanz: Passen sie zu den Interessen? Sind sie zeitlich nah? Besonders interessant? +3. Waehle die 3-5 besten Events aus. +4. Nutze suggest_event fuer jedes ausgewaehlte Event. Gib eine kurze Begruendung warum es relevant ist. + +Qualitaetskriterien: +- Bevorzuge Events die zeitlich nah sind (naechste 7 Tage > naechste 14 Tage) +- Bevorzuge Events die zu den Interessen des Nutzers passen +- Variiere die Kategorien — nicht 5x das gleiche Genre +- Schreib die Begruendung auf Deutsch, kurz und konkret`, + memory: `# Event-Scout Einstellungen + +(Hier kannst du festhalten welche Art von Events dich besonders interessiert, +welche du lieber nicht vorgeschlagen bekommst, bevorzugte Wochentage etc.) +`, + policy: EVENT_SCOUT_POLICY, + maxConcurrentMissions: 1, + }, + scene: { + name: 'Event-Entdeckung', + description: 'Events finden und vorschlagen', + openApps: [ + { appId: 'events', widthPx: 540 }, + { appId: 'ai-missions', widthPx: 440 }, + { appId: 'calendar', widthPx: 440 }, + ], + }, + missions: [ + { + title: 'Events der Woche', + objective: + 'Pruefe neue Events in meinen Regionen. Schlage die 3-5 relevantesten vor, die ich noch nicht gesehen habe. Begruende warum jedes Event fuer mich interessant sein koennte.', + conceptMarkdown: `# Woechentlicher Event-Check + +Der Event-Scout prueft die konfigurierten Regionen auf neue Veranstaltungen +und schlaegt die relevantesten vor. Jeder Vorschlag erscheint als Proposal — +du entscheidest was in deinen Kalender kommt. + +**Voraussetzung:** Mindestens eine Region und Interessen muessen im Events-Modul +unter dem Tab "Entdecken" eingerichtet sein.`, + cadence: { kind: 'daily' }, + startPaused: true, + }, + ], +}; diff --git a/packages/shared-ai/src/agents/templates/index.ts b/packages/shared-ai/src/agents/templates/index.ts index a5205cae1..ab88ceb5f 100644 --- a/packages/shared-ai/src/agents/templates/index.ts +++ b/packages/shared-ai/src/agents/templates/index.ts @@ -13,6 +13,7 @@ import { todayTemplate } from './today'; import { calmnessTemplate } from './calmness'; import { fitnessTemplate } from './fitness'; import { deepWorkTemplate } from './deep-work'; +import { eventScoutTemplate } from './event-scout'; export type { // Generalised names (T1 of workbench-templates plan): @@ -38,6 +39,7 @@ export const ALL_TEMPLATES = [ calmnessTemplate, fitnessTemplate, deepWorkTemplate, + eventScoutTemplate, ] as const; export { @@ -47,6 +49,7 @@ export { calmnessTemplate, fitnessTemplate, deepWorkTemplate, + eventScoutTemplate, }; /** Lookup helper — returns the template matching the given id, or diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index cdb42dd51..4cee09491 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -886,6 +886,71 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + + // ── Event Discovery ───────────────────────────────────────── + { + name: 'discover_events', + module: 'events', + description: + 'Sucht oeffentliche Veranstaltungen in den konfigurierten Regionen des Nutzers. Gibt Events mit Titel, Datum, Ort, Kategorie und Quelle zurueck.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'query', + type: 'string', + description: 'Optionaler Suchtext (z.B. "Jazz Konzerte")', + required: false, + }, + { + name: 'category', + type: 'string', + description: 'Kategorie-Filter', + required: false, + enum: [ + 'music', + 'theater', + 'art', + 'tech', + 'sport', + 'food', + 'family', + 'nature', + 'education', + 'community', + 'nightlife', + 'market', + 'other', + ], + }, + { + name: 'days_ahead', + type: 'number', + description: 'Wie viele Tage voraus suchen (Standard: 14)', + required: false, + }, + ], + }, + { + name: 'suggest_event', + module: 'events', + description: + 'Schlaegt dem Nutzer ein entdecktes Event vor. Erstellt ein Proposal das der Nutzer bestaetigen muss, um das Event in seinen Kalender zu uebernehmen.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'discovered_event_id', + type: 'string', + description: 'ID des entdeckten Events', + required: true, + }, + { + name: 'reason', + type: 'string', + description: 'Begruendung warum dieses Event relevant ist', + required: false, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════ diff --git a/services/mana-events/src/discovery/feedback.ts b/services/mana-events/src/discovery/feedback.ts new file mode 100644 index 000000000..1ee523c0b --- /dev/null +++ b/services/mana-events/src/discovery/feedback.ts @@ -0,0 +1,128 @@ +/** + * Feedback loop — aggregate save/dismiss ratios per category and source + * to build an implicit user profile for better relevance scoring. + * + * The profile is computed on-demand from discovery_user_actions joined + * with discovered_events. No separate table needed — the action history + * IS the profile. + */ + +import { eq, sql } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { discoveryUserActions, discoveredEvents } from '../db/schema/discovery'; + +/** Per-category affinity based on save/dismiss ratio. */ +export interface CategoryAffinity { + category: string; + saves: number; + dismisses: number; + /** 0.0–2.0 multiplier: >1 means user likes this category, <1 means dislikes. */ + weight: number; +} + +/** Per-source quality based on save/dismiss ratio. */ +export interface SourceQuality { + sourceId: string; + sourceName: string | null; + saves: number; + dismisses: number; + /** 0.0–2.0 multiplier. Sources with high dismiss rate get penalized. */ + weight: number; +} + +export interface UserProfile { + categoryAffinities: CategoryAffinity[]; + sourceQualities: SourceQuality[]; + totalActions: number; +} + +/** + * Compute an implicit user profile from their save/dismiss history. + * Returns category affinities and source quality metrics. + * + * Requires at least 5 actions to be meaningful — returns empty profile + * if insufficient data. + */ +export async function computeUserProfile(db: Database, userId: string): Promise { + // Count total actions to check if we have enough data + const countResult = await db + .select({ count: sql`count(*)::int` }) + .from(discoveryUserActions) + .where(eq(discoveryUserActions.userId, userId)); + + const totalActions = countResult[0]?.count ?? 0; + if (totalActions < 5) { + return { categoryAffinities: [], sourceQualities: [], totalActions }; + } + + // Category affinities: group by category, count saves/dismisses + const categoryRows = await db.execute(sql` + SELECT + de.category, + COUNT(*) FILTER (WHERE dua.action = 'save') AS saves, + COUNT(*) FILTER (WHERE dua.action = 'dismiss') AS dismisses + FROM event_discovery.discovery_user_actions dua + JOIN event_discovery.discovered_events de ON de.id = dua.event_id + WHERE dua.user_id = ${userId} AND de.category IS NOT NULL + GROUP BY de.category + ORDER BY saves DESC + `); + + const categoryAffinities: CategoryAffinity[] = ( + categoryRows as unknown as Array<{ + category: string; + saves: string; + dismisses: string; + }> + ).map((row) => { + const saves = parseInt(row.saves, 10); + const dismisses = parseInt(row.dismisses, 10); + const total = saves + dismisses; + // Weight: ratio of saves to total, scaled to 0–2 range + // 100% saves → 2.0, 50% → 1.0, 0% → 0.2 (floor) + const ratio = total > 0 ? saves / total : 0.5; + return { + category: row.category, + saves, + dismisses, + weight: Math.max(0.2, ratio * 2), + }; + }); + + // Source quality: group by source, count saves/dismisses + const sourceRows = await db.execute(sql` + SELECT + de.source_id, + de.source_name, + COUNT(*) FILTER (WHERE dua.action = 'save') AS saves, + COUNT(*) FILTER (WHERE dua.action = 'dismiss') AS dismisses + FROM event_discovery.discovery_user_actions dua + JOIN event_discovery.discovered_events de ON de.id = dua.event_id + WHERE dua.user_id = ${userId} + GROUP BY de.source_id, de.source_name + ORDER BY saves DESC + `); + + const sourceQualities: SourceQuality[] = ( + sourceRows as unknown as Array<{ + source_id: string; + source_name: string | null; + saves: string; + dismisses: string; + }> + ).map((row) => { + const saves = parseInt(row.saves, 10); + const dismisses = parseInt(row.dismisses, 10); + const total = saves + dismisses; + const ratio = total > 0 ? saves / total : 0.5; + return { + sourceId: row.source_id, + sourceName: row.source_name, + saves, + dismisses, + weight: Math.max(0.2, ratio * 2), + }; + }); + + return { categoryAffinities, sourceQualities, totalActions }; +}