mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 03:36:42 +02:00
feat(events): Phase 3 — AI tools, Event-Scout template, feedback loop
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
2f226a93aa
commit
2c0d866287
5 changed files with 406 additions and 0 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
import type { ModuleTool } from '$lib/data/tools/types';
|
import type { ModuleTool } from '$lib/data/tools/types';
|
||||||
import { eventsStore } from './stores/events.svelte';
|
import { eventsStore } from './stores/events.svelte';
|
||||||
|
import { discoveryStore } from './discovery/store.svelte';
|
||||||
|
import * as discoveryApi from './discovery/api';
|
||||||
|
|
||||||
export const socialEventsTools: ModuleTool[] = [
|
export const socialEventsTools: ModuleTool[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -26,4 +28,120 @@ export const socialEventsTools: ModuleTool[] = [
|
||||||
: { success: false, message: result.error ?? 'Fehler' };
|
: { 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
92
packages/shared-ai/src/agents/templates/event-scout.ts
Normal file
92
packages/shared-ai/src/agents/templates/event-scout.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -13,6 +13,7 @@ import { todayTemplate } from './today';
|
||||||
import { calmnessTemplate } from './calmness';
|
import { calmnessTemplate } from './calmness';
|
||||||
import { fitnessTemplate } from './fitness';
|
import { fitnessTemplate } from './fitness';
|
||||||
import { deepWorkTemplate } from './deep-work';
|
import { deepWorkTemplate } from './deep-work';
|
||||||
|
import { eventScoutTemplate } from './event-scout';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
// Generalised names (T1 of workbench-templates plan):
|
// Generalised names (T1 of workbench-templates plan):
|
||||||
|
|
@ -38,6 +39,7 @@ export const ALL_TEMPLATES = [
|
||||||
calmnessTemplate,
|
calmnessTemplate,
|
||||||
fitnessTemplate,
|
fitnessTemplate,
|
||||||
deepWorkTemplate,
|
deepWorkTemplate,
|
||||||
|
eventScoutTemplate,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -47,6 +49,7 @@ export {
|
||||||
calmnessTemplate,
|
calmnessTemplate,
|
||||||
fitnessTemplate,
|
fitnessTemplate,
|
||||||
deepWorkTemplate,
|
deepWorkTemplate,
|
||||||
|
eventScoutTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lookup helper — returns the template matching the given id, or
|
/** Lookup helper — returns the template matching the given id, or
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
128
services/mana-events/src/discovery/feedback.ts
Normal file
128
services/mana-events/src/discovery/feedback.ts
Normal file
|
|
@ -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<UserProfile> {
|
||||||
|
// Count total actions to check if we have enough data
|
||||||
|
const countResult = await db
|
||||||
|
.select({ count: sql<number>`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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue