Phase 1: Manual iCal feeds + Discovery tab - 5 new DB tables in event_discovery schema (regions, interests, sources, discovered_events, user_actions) - iCal parser (node-ical) with deduplication (SHA-256 hash) - Crawl scheduler (15-min interval, auto-deactivate after 5 errors) - CRUD routes for regions, interests, sources + paginated feed endpoint - Frontend: "Meine Events" / "Entdecken" tab navigation in ListView - Discovery setup wizard (regions via mana-geocoding + interests) - DiscoveredEventCard with save/dismiss, SourceManager for iCal feeds - "Merken" creates a local socialEvent from discovered event Phase 2: Auto source discovery + LLM extraction + relevance scoring - Source discoverer: web search via mana-research to auto-find iCal feeds and venue websites for a region - Website extractor: crawl via mana-research /extract, then LLM-based event extraction via mana-llm with structured JSON output - Flexible date parsing (ISO, DD.MM.YYYY), markdown fence stripping - Relevance scorer: category match, freetext match, haversine distance, time proximity, weekend bonus (0-100 clamped) - Routes: POST regions/:id/discover-sources, PUT/DELETE sources/:id/activate|reject - Frontend: "Automatisch finden" button, suggested vs active sources UI 107 tests (all passing), no regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25 KiB
Event Discovery — Implementierungsplan
Status (2026-04-17)
Planung, noch kein Code.
Ziel
Eine KI im Events-Modul, die automatisch öffentliche Veranstaltungen in den Regionen des Nutzers findet, strukturiert und als kuratierten Feed vorschlägt. Der Nutzer konfiguriert Städte/Gebiete + Interessen; das System scannt Event-Kalender, Venue-Websites und Vereinsseiten, dedupliziert und rankt nach Relevanz.
Abgrenzung
- Eigene Events (socialEvents, RSVP, Bring-Liste) bleiben unberührt — Discovery ist ein paralleler Read-only-Feed
- mana-research wird als Provider-Schicht genutzt (Web-Suche, Extraktion), aber Discovery-Logik lebt in mana-events
- mana-crawler wird NICHT direkt genutzt — Firecrawl/Jina über mana-research reichen für Event-Extraktion
- mana-ai Missions kommen erst in Phase 3 — Phase 1-2 läuft als dedizierter Cron/API ohne Mission-Runner
Architektur
Nutzer (Events-Modul, Tab "Entdecken")
│
▼
apps/mana web ──→ mana-events (3065)
│
┌────────────┼────────────────┐
▼ ▼ ▼
Discovery API Source Manager Crawl Scheduler
│ │ │
▼ ▼ ▼
PostgreSQL mana-research mana-geocoding
(event_discovery (Web-Suche, (Region →
Schema) Extraktion) BoundingBox)
Neue DB-Tabellen (PostgreSQL, Schema event_discovery in mana_platform)
-- Quellen, die regelmäßig gescannt werden
discovery_sources
id uuid PK
user_id text NOT NULL -- Besitzer
type text NOT NULL -- 'ical' | 'website' | 'api' | 'search_query'
url text -- Feed-URL oder Website-URL
name text NOT NULL -- "Jazzhaus Freiburg", "VHS Konstanz"
region_id uuid FK → discovery_regions
crawl_interval_hours int DEFAULT 24
last_crawled_at timestamptz
last_success_at timestamptz
error_count int DEFAULT 0
last_error text
is_active boolean DEFAULT true
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now()
-- Regionen des Nutzers
discovery_regions
id uuid PK
user_id text NOT NULL
label text NOT NULL -- "Freiburg", "Basel"
lat double precision
lon double precision
radius_km int DEFAULT 25
is_active boolean DEFAULT true
created_at timestamptz DEFAULT now()
-- Nutzer-Interessen für Relevanz-Scoring
discovery_interests
id uuid PK
user_id text NOT NULL
category text NOT NULL -- 'music' | 'tech' | 'sport' | 'art' | ...
freetext text -- "Impro-Theater", "Rust Meetups"
weight real DEFAULT 1.0 -- Nutzer kann priorisieren
created_at timestamptz DEFAULT now()
-- Gefundene Events (dedupliziert, normalisiert)
discovered_events
id uuid PK
source_id uuid FK → discovery_sources (CASCADE)
external_id text -- Dedupe-Anker (URL oder Hash)
dedupe_hash text NOT NULL -- sha256(lower(title) + date + location)
title text NOT NULL
description text
location text
lat double precision
lon double precision
start_at timestamptz NOT NULL
end_at timestamptz
all_day boolean DEFAULT false
image_url text
source_url text NOT NULL -- Link zur Original-Seite
source_name text -- "Jazzhaus Freiburg"
category text -- LLM-klassifiziert
price_info text -- "Frei", "12 €", "VVK 15 / AK 18"
raw_extracted jsonb -- Rohdaten der LLM-Extraktion
crawled_at timestamptz DEFAULT now()
expires_at timestamptz -- start_at + 1 Tag (für Cleanup)
UNIQUE(dedupe_hash) -- Idempotenz
-- Nutzer-Interaktion mit entdeckten Events
discovery_user_actions
id uuid PK
user_id text NOT NULL
event_id uuid FK → discovered_events (CASCADE)
action text NOT NULL -- 'save' | 'dismiss' | 'hide_source'
acted_at timestamptz DEFAULT now()
UNIQUE(user_id, event_id)
-- Indizes
CREATE INDEX idx_discovered_events_start ON discovered_events(start_at);
CREATE INDEX idx_discovered_events_source ON discovered_events(source_id);
CREATE INDEX idx_discovery_sources_user ON discovery_sources(user_id, is_active);
CREATE INDEX idx_discovery_regions_user ON discovery_regions(user_id);
CREATE INDEX idx_discovery_actions_user ON discovery_user_actions(user_id);
Lokale Tabellen (Dexie) — nur Cache + Offline
Discovery-Daten sind nicht local-first (sie entstehen auf dem Server). Dexie dient nur als Offline-Cache:
discoveryRegions — id, label, lat, lon, radiusKm, isActive
discoveryInterests — id, category, freetext, weight
discoveredEvents — id, title, description, location, lat, lon,
startAt, endAt, sourceUrl, sourceName,
imageUrl, category, priceInfo, relevanceScore,
userAction (null|save|dismiss), crawledAt
Keine Verschlüsselung nötig — das sind öffentliche Event-Daten, keine User-Inhalte.
Kein Sync über mana-sync — der Server ist die Source of Truth, Client pollt/cached.
Phase 1 — Regionen, iCal-Feeds, Discovery-Tab
Ziel: Nutzer kann Regionen + iCal-Feeds manuell konfigurieren. Events werden geparst und im "Entdecken"-Tab angezeigt.
1.1 Backend: DB-Schema + CRUD-Routen (mana-events)
Dateien:
services/mana-events/src/db/schema/
discovery.ts ← NEU: Drizzle-Schema für alle 5 Tabellen
services/mana-events/src/routes/
discovery.ts ← NEU: CRUD für regions, interests, sources
discovery-feed.ts ← NEU: Feed-Endpoint (paginiert, gefiltert)
services/mana-events/src/app.ts
→ Neue Routen registrieren unter /api/v1/discovery/*
API-Endpunkte (alle JWT-authentifiziert):
# Regionen
GET /api/v1/discovery/regions → [{id, label, lat, lon, radiusKm}]
POST /api/v1/discovery/regions ← {label, lat, lon, radiusKm}
PUT /api/v1/discovery/regions/:id ← {label?, radiusKm?, isActive?}
DELETE /api/v1/discovery/regions/:id
# Interessen
GET /api/v1/discovery/interests → [{id, category, freetext, weight}]
POST /api/v1/discovery/interests ← {category, freetext?, weight?}
DELETE /api/v1/discovery/interests/:id
# Quellen (Phase 1: nur iCal)
GET /api/v1/discovery/sources → [{id, type, url, name, region, status}]
POST /api/v1/discovery/sources ← {type: 'ical', url, name, regionId}
DELETE /api/v1/discovery/sources/:id
POST /api/v1/discovery/sources/:id/crawl → Sofort-Crawl auslösen
# Feed
GET /api/v1/discovery/feed → {events: [...], total, hasMore}
?from=ISO&to=ISO&category=music&limit=20&offset=0
POST /api/v1/discovery/feed/:eventId/action ← {action: 'save' | 'dismiss'}
1.2 Backend: iCal-Parser + Crawl-Loop
Dateien:
services/mana-events/src/discovery/
ical-parser.ts ← iCal → discovered_events (ical.js oder node-ical)
crawl-scheduler.ts ← Interval-basierter Crawl-Loop (wie rateBucketSweeper)
deduplicator.ts ← sha256(lower(title) + startAt.toISODate() + lower(location))
types.ts ← NormalizedEvent, CrawlResult, SourceStatus
Ablauf eines Crawl-Zyklus:
crawl-scheduler.ts (runs every 15 min)
│
├─ SELECT sources WHERE is_active AND last_crawled_at < now() - interval_hours
│
├─ Für jede fällige Source:
│ ├─ fetch(source.url) mit 10s Timeout
│ ├─ ical-parser.ts: VEVENT → NormalizedEvent[]
│ ├─ deduplicator.ts: dedupe_hash berechnen
│ ├─ UPSERT INTO discovered_events ON CONFLICT(dedupe_hash)
│ │ → Bestehende: title/description/location updaten falls geändert
│ │ → Neue: INSERT
│ ├─ UPDATE source SET last_crawled_at, last_success_at, error_count=0
│ └─ Bei Fehler: error_count++, last_error setzen
│ → Nach 5 Fehlern: is_active = false (Nutzer wird informiert)
│
└─ DELETE FROM discovered_events WHERE expires_at < now()
(Cleanup abgelaufener Events)
Dependency: node-ical (Bun-kompatibel, ~50KB, parst VEVENT/VTODO/VFREEBUSY)
1.3 Frontend: Discovery-Tab + Regionen-Setup
Dateien:
apps/mana/apps/web/src/lib/modules/events/
discovery/
api.ts ← HTTP-Client (fetchWithAuth, analog events/api.ts)
types.ts ← DiscoveredEvent, DiscoveryRegion, DiscoveryInterest, etc.
queries.svelte.ts ← Reactive state ($state) für Feed, Regionen, Interessen
stores.svelte.ts ← Mutationen (addRegion, addSource, saveEvent, dismissEvent)
components/
DiscoveryTab.svelte ← Der neue Tab-Inhalt
DiscoverySetup.svelte ← Onboarding: Regionen + Interessen konfigurieren
DiscoveredEventCard.svelte ← Karte mit Titel, Datum, Ort, Quelle, Aktionen
SourceManager.svelte ← iCal-Feed-URLs verwalten (hinzufügen, Status, löschen)
RegionPicker.svelte ← Stadt-Suche via mana-geocoding + Radius-Slider
ListView.svelte ← Tab-Navigation ergänzen: "Meine Events" | "Entdecken"
ListView.svelte — Tab-Erweiterung:
<script>
let activeTab = $state<'mine' | 'discover'>('mine');
</script>
<div class="tab-bar">
<button class:active={activeTab === 'mine'} onclick={() => activeTab = 'mine'}>
Meine Events
</button>
<button class:active={activeTab === 'discover'} onclick={() => activeTab = 'discover'}>
Entdecken
</button>
</div>
{#if activeTab === 'mine'}
<!-- bestehender Inhalt (upcoming/past/create) -->
{:else}
<DiscoveryTab {navigate} />
{/if}
DiscoveryTab.svelte — Aufbau:
{#if !hasRegions}
<DiscoverySetup onComplete={reload} />
{:else}
<div class="discovery-controls">
<RegionPicker regions={regions.value} />
<CategoryFilter interests={interests.value} />
<button onclick={refreshFeed}>Aktualisieren</button>
</div>
{#if feed.value.length === 0}
<p class="empty">Noch keine Events gefunden. Füge iCal-Feeds hinzu oder warte auf den nächsten Scan.</p>
{:else}
{#each feed.value as event (event.id)}
<DiscoveredEventCard
{event}
onSave={() => actions.save(event.id)}
onDismiss={() => actions.dismiss(event.id)}
onOpen={() => window.open(event.sourceUrl, '_blank')}
/>
{/each}
{/if}
<SourceManager sources={sources.value} regionId={activeRegionId} />
{/if}
DiscoveredEventCard.svelte — Felder:
- Titel (fett)
- Datum + Uhrzeit (formatiert, relativ: "Morgen, 19:00" / "Sa 26. Apr, 20:00")
- Ort + Entfernung zum nächsten Region-Zentrum
- Quelle (Link zur Original-Seite)
- Kategorie-Badge (Musik, Tech, Sport, ...)
- Preis-Info falls vorhanden
- Aktionen: "Merken" (→ eigenes socialEvent anlegen), "Nicht interessant", "Zur Quelle"
"Merken"-Flow:
Nutzer klickt "Merken"
→ discoveryStore.saveEvent(discoveredEvent)
→ eventsStore.createEvent({
title: event.title,
startTime: event.startAt,
endTime: event.endAt,
location: event.location,
description: `${event.description}\n\nQuelle: ${event.sourceUrl}`,
locationLat: event.lat,
locationLon: event.lon,
})
→ POST /api/v1/discovery/feed/:eventId/action {action: 'save'}
→ Karte zeigt "Gespeichert ✓"
1.4 Module-Integration
module.config.ts — Neue Dexie-Tabellen registrieren (Cache-only, kein Sync):
// events/module.config.ts — erweitert
tables: [
{ name: 'socialEvents', syncName: 'events' },
{ name: 'eventGuests' },
{ name: 'eventInvitations' },
{ name: 'eventItems' },
// NEU: Discovery-Cache (nicht gesynct, rein lokal)
{ name: 'discoveryRegions' },
{ name: 'discoveryInterests' },
{ name: 'discoveredEvents' },
],
database.ts — Indizes:
discoveryRegions: 'id, isActive',
discoveryInterests: 'id, category',
discoveredEvents: 'id, startAt, category, userAction, [startAt+category]',
Keine Encryption-Registry — öffentliche Daten.
1.5 Deliverables Phase 1
- Drizzle-Schema
event_discovery+bun run db:push - CRUD-Routen für Regionen, Interessen, Quellen
- iCal-Parser mit Dedup + Cleanup
- Crawl-Scheduler (15-Min-Intervall)
- Feed-Endpoint (paginiert, nach Datum gefiltert)
- Frontend: Tab-Navigation in ListView
- Frontend: DiscoverySetup (Regionen + Interessen)
- Frontend: RegionPicker mit mana-geocoding Autocomplete
- Frontend: SourceManager (iCal-URLs CRUD)
- Frontend: DiscoveredEventCard + Feed-Ansicht
- Frontend: "Merken" → socialEvent anlegen
- Tests: iCal-Parser Unit-Tests, Feed-Route Integration-Tests
Phase 2 — Automatische Quellen-Entdeckung + LLM-Extraktion
Ziel: Die KI findet selbst Event-Quellen für die Regionen des Nutzers und extrahiert Events von unstrukturierten Websites.
2.1 Quellen-Entdeckung (Meta-Crawl)
Neue Datei: services/mana-events/src/discovery/source-discoverer.ts
Ablauf:
Nutzer fügt Region "Freiburg" hinzu
│
├─ Trigger: source-discoverer.discoverForRegion(region)
│
├─ Schritt 1: Web-Suche via mana-research
│ Queries (parallel, via mana-research POST /api/v1/search):
│ "Veranstaltungskalender Freiburg ical"
│ "Events Freiburg 2026"
│ "Kulturzentren Freiburg Programm"
│ "Vereine Freiburg Veranstaltungen"
│ "Konzerte Theater Freiburg Termine"
│
├─ Schritt 2: Ergebnisse filtern
│ → URLs die auf .ics enden → Typ 'ical'
│ → URLs mit /kalender, /programm, /events, /veranstaltungen → Typ 'website'
│ → Bekannte Plattformen (eventbrite.*/freiburg, meetup.com/*freiburg) → Typ 'api'
│
├─ Schritt 3: LLM-Klassifikation (optional, via mana-llm)
│ Prompt: "Ist diese URL eine Event-Quelle? Wenn ja: Name, Typ, Region."
│ → Filtert Noise (Nachrichtenartikel über Events, generische Stadtseiten)
│
├─ Schritt 4: Vorschläge speichern
│ → INSERT INTO discovery_sources (status: 'suggested')
│ → Nutzer sieht Vorschläge im SourceManager und kann aktivieren/ablehnen
│
└─ Schritt 5: Sofort-Crawl für aktivierte Quellen
API-Erweiterung:
POST /api/v1/discovery/regions/:id/discover-sources
→ Triggert Meta-Crawl, returns {suggestedCount}
GET /api/v1/discovery/sources?status=suggested
→ Vorgeschlagene Quellen die der Nutzer noch bestätigen muss
PUT /api/v1/discovery/sources/:id/activate
PUT /api/v1/discovery/sources/:id/reject
2.2 Website-Extraktion (LLM-basiert)
Neue Datei: services/mana-events/src/discovery/website-extractor.ts
Ablauf für Typ website:
Source: { type: 'website', url: 'https://jazzhaus.de/programm' }
│
├─ Schritt 1: Seite crawlen via mana-research
│ POST mana-research /api/v1/extract
│ { url: source.url, provider: 'jina' } (oder 'firecrawl')
│ → Markdown-Text der Seite
│
├─ Schritt 2: LLM-Extraktion via mana-llm
│ System-Prompt:
│ "Du bist ein Event-Extractor. Extrahiere ALLE kommenden
│ Veranstaltungen von dieser Seite. Pro Event:
│ - title (string, required)
│ - date (ISO 8601, required)
│ - endDate (ISO 8601, optional)
│ - location (string, optional — Venue-Name + Adresse)
│ - description (string, max 300 Zeichen)
│ - category (music|theater|art|tech|sport|food|family|other)
│ - priceInfo (string, optional — z.B. 'VVK 15€ / AK 18€')
│ - imageUrl (string, optional)
│ Antwort als JSON-Array. Ignoriere vergangene Events.
│ Heutiges Datum: {today}"
│ User-Prompt: <Markdown der gecrawlten Seite>
│ → JSON-Array von NormalizedEvents
│
├─ Schritt 3: Validierung + Normalisierung
│ → Datum parsen (LLMs machen manchmal "25. April 2026" statt ISO)
│ → Geocoding via mana-geocoding falls location vorhanden
│ → dedupe_hash berechnen
│
└─ Schritt 4: UPSERT INTO discovered_events
LLM-Kosten: ~500-2000 Input-Tokens pro Seite + ~200-500 Output-Tokens. Bei Haiku-Klasse: ~0.001-0.003 $ pro Seite. Bei täglichem Crawl von 50 Quellen: ~$0.05-0.15/Tag.
2.3 Relevanz-Scoring
Neue Datei: services/mana-events/src/discovery/scorer.ts
function scoreEvent(
event: DiscoveredEvent,
interests: DiscoveryInterest[],
regions: DiscoveryRegion[],
userActions: Map<string, 'save' | 'dismiss'>
): number {
let score = 50; // Basis
// Kategorie-Match: +20 pro Match mit Nutzer-Interesse (gewichtet)
for (const interest of interests) {
if (event.category === interest.category) score += 20 * interest.weight;
if (interest.freetext && event.title.toLowerCase().includes(interest.freetext.toLowerCase()))
score += 15 * interest.weight;
}
// Entfernung: -1 pro km über 5km (nah = besser)
const nearestRegion = findNearestRegion(event, regions);
if (nearestRegion) {
const distKm = haversine(event.lat, event.lon, nearestRegion.lat, nearestRegion.lon);
score -= Math.max(0, distKm - 5);
}
// Zeitnähe: +10 wenn innerhalb 7 Tagen, +5 wenn innerhalb 14 Tagen
const daysUntil = (new Date(event.startAt).getTime() - Date.now()) / 86400000;
if (daysUntil <= 7) score += 10;
else if (daysUntil <= 14) score += 5;
// Wochenende-Bonus: +5 wenn Sa/So (die meisten Nutzer sind freier)
const dow = new Date(event.startAt).getDay();
if (dow === 0 || dow === 6) score += 5;
// Source-Qualität: +5 wenn Source hohe Erfolgsquote hat
// (Phase 3: implizites Feedback aus save/dismiss-Ratio)
return Math.max(0, Math.min(100, score));
}
Feed-Endpoint erweitert: ORDER BY relevance_score DESC, start_at ASC
2.4 Frontend-Erweiterungen
SourceManager.svelte — erweitert:
<!-- Bestehend: manuelle iCal-Eingabe -->
<!-- NEU: "Quellen automatisch finden" Button -->
<button onclick={discoverSources}>
Quellen automatisch finden für {activeRegion.label}
</button>
{#if suggestedSources.length > 0}
<h3>Vorgeschlagene Quellen</h3>
{#each suggestedSources as source}
<SourceSuggestionCard
{source}
onActivate={() => activateSource(source.id)}
onReject={() => rejectSource(source.id)}
/>
{/each}
{/if}
DiscoveredEventCard.svelte — erweitert:
- Relevanz-Indikator (farbiger Dot: grün >70, gelb >40, grau <40)
- "Warum vorgeschlagen?"-Tooltip (Kategorie-Match, Nähe, Zeitnähe)
- Kategorie-Badge prominenter
2.5 Deliverables Phase 2
- Source-Discoverer: Web-Suche → iCal/Website-URLs → Vorschläge
- Website-Extractor: Crawl → LLM-Extraktion → normalisierte Events
- Relevanz-Scorer mit Kategorie/Distanz/Zeit-Gewichtung
- API:
/discover-sources,/activate,/reject - Frontend: "Quellen automatisch finden" + Vorschlags-UI
- Frontend: Relevanz-Indikator + "Warum vorgeschlagen?"
- Crawl-Scheduler erweitert: Website-Typ + Fehlerhandling
- Tests: Website-Extractor mit Mock-HTML, Scorer Unit-Tests
Phase 3 — mana-ai Integration + Proaktive Vorschläge
Ziel: Discovery wird zu einem AI-Tool. Mana-AI-Missions können proaktiv Events finden und vorschlagen.
3.1 AI-Tool: discover_events
In @mana/shared-ai:
{
name: 'discover_events',
description: 'Suche öffentliche Veranstaltungen in den konfigurierten Regionen des Nutzers',
parameters: {
query: { type: 'string', description: 'Optionaler Suchtext (z.B. "Jazz Konzerte")' },
category: { type: 'string', description: 'Kategorie-Filter' },
days_ahead: { type: 'number', description: 'Wie viele Tage voraus (default 14)' },
},
defaultPolicy: 'auto', // Read-only, kann im Reasoning-Loop laufen
}
Server-side (mana-ai): Ruft mana-events /api/v1/discovery/feed auf, injiziert Ergebnisse als ResolvedInput.
3.2 AI-Tool: suggest_event
{
name: 'suggest_event',
description: 'Schlage dem Nutzer ein entdecktes Event vor (erscheint als Proposal)',
parameters: {
discovered_event_id: { type: 'string', required: true },
reason: { type: 'string', description: 'Warum dieses Event relevant ist' },
},
defaultPolicy: 'propose', // Nutzer muss bestätigen
}
Approve-Handler: Führt den "Merken"-Flow aus (discoveredEvent → socialEvent).
3.3 Proaktive Mission: "Event-Scout"
Als Agent-Template (analog Recherche-Agent):
{
name: 'Event-Scout',
description: 'Findet regelmäßig Events in deinen Regionen und schlägt passende vor',
defaultMissions: [
{
objective: 'Prüfe neue Events in meinen Regionen. Schlage die 3-5 relevantesten vor, die ich noch nicht gesehen habe.',
cadence: 'daily',
isPaused: false,
}
],
policy: {
discover_events: 'auto',
suggest_event: 'propose',
}
}
3.4 Feedback-Loop
Implizites Profil aus Nutzer-Aktionen:
save_count(category=music) / total_music_shown → music_affinity
dismiss_count(source=X) / total_from_X → source_quality
→ Gewichtung in scorer.ts anpassen:
- Kategorien mit hoher Affinity: weight * 1.5
- Quellen mit niedriger Qualität: weight * 0.5
- Quellen mit >80% dismiss: deaktivieren + Nutzer informieren
3.5 Notifications
Via mana-notify:
- Täglicher Digest (optional): "5 neue Events in Freiburg diese Woche"
- Highlight-Alert (optional): Push bei Events mit Score >90
- Source-Status: "iCal-Feed von Jazzhaus ist seit 3 Tagen nicht erreichbar"
3.6 Deliverables Phase 3
- AI-Tools
discover_events+suggest_eventin shared-ai + mana-ai - Agent-Template "Event-Scout"
- Feedback-Loop: implizites Profil → Scorer-Gewichtung
- Notification-Integration (täglicher Digest, Highlight-Alert)
- Tests: AI-Tool Unit-Tests, Feedback-Aggregation
Phase 4 — Event-Plattform-APIs + Social
Ziel: Strukturierte APIs von Event-Plattformen anbinden für höhere Datenqualität.
4.1 Provider-Adapter
services/mana-events/src/discovery/providers/
base.ts ← Interface: fetchEvents(region, dateRange) → NormalizedEvent[]
ical.ts ← Bestehender iCal-Parser (refactored)
website.ts ← Bestehender Website-Extractor (refactored)
eventbrite.ts ← Eventbrite API (OAuth, kostenlos für Reads)
meetup.ts ← Meetup GraphQL API
facebook-events.ts ← Meta Graph API (eingeschränkt, braucht App Review)
4.2 Stadt-Portale
Viele Städte haben halbstrukturierte Event-Kalender:
- freiburg.de/veranstaltungen → RSS/Atom wo vorhanden, sonst Website-Extractor
- basel.ch/events → Ähnlich
- Tourismus-Seiten (Schwarzwald-Tourismus, Basel-Tourismus)
→ Diese werden als type: 'website' Quellen mit spezifischen Crawl-Hinweisen angelegt.
4.3 Deliverables Phase 4
- Provider-Adapter-Interface + Refactoring bestehender Parser
- Eventbrite-Provider
- Meetup-Provider
- Stadt-Portal-Unterstützung (optimierte Extraktion)
Abhängigkeiten
| Service | Rolle | Schon vorhanden? |
|---|---|---|
| mana-events (3065) | Hosting der Discovery-Logik + DB | Ja, wird erweitert |
| mana-research (3068) | Web-Suche + Extraktion | Ja |
| mana-geocoding (3018) | Region-Geocoding + Distanzberechnung | Ja |
| mana-llm | LLM-Aufrufe für Extraktion + Klassifikation | Ja |
| mana-credits | Kosten-Tracking für LLM + Research-Calls | Ja |
| mana-notify (3024) | Push-Notifications für Digests | Ja |
| mana-ai (3067) | Mission-Runner für proaktive Vorschläge | Ja, Phase 3 |
Neue npm-Dependencies:
node-ical— iCal-Parsing (Phase 1)- Keine weiteren — alles andere ist über bestehende Services abgedeckt
Risiken + Mitigationen
| Risiko | Mitigation |
|---|---|
| iCal-Feeds kaputt / nicht-standard | Robuster Parser + error_count + Auto-Deaktivierung nach 5 Fehlern |
| LLM-Extraktion unzuverlässig | Structured Output (JSON-Mode), Validierung, Fallback auf Regex-Extraktion für bekannte Formate |
| Zu viele irrelevante Events | Relevanz-Scoring + Dismiss-Feedback + Source-Qualitäts-Tracking |
| Hohe LLM-Kosten bei vielen Quellen | Haiku-Klasse nutzen, Caching (gleiche Seite → kein Re-Extract wenn unverändert), Rate-Limits pro User |
| Geocoding-Ungenauigkeit | Fallback: Events ohne Koordinaten bekommen Region-Zentrum + maximalen Radius |
| DSGVO: öffentliche Events speichern | Events sind öffentlich publiziert, kein personenbezogener Inhalt. User-Actions (save/dismiss) sind personal data → Löschung bei Account-Delete |
Empfehlung
Phase 1 zuerst bauen. Das allein ist schon wertvoll — ein Nutzer, der 10 iCal-Feeds seiner Lieblings-Venues einträgt, bekommt einen aggregierten Event-Feed ohne dass je eine KI laufen muss. Phase 2 macht es dann intelligent (automatische Quellen-Entdeckung + unstrukturierte Seiten). Phase 3 macht es proaktiv (KI schlägt Events vor). Phase 4 ist nice-to-have.
Geschätzter Aufwand Phase 1: Backend ~1.5 Tage, Frontend ~1.5 Tage, Tests ~0.5 Tage = ~3.5 Tage.