[split-required] Split website + studio-v2 monoliths (Phase 3 continued)

Website (14 monoliths split):
- compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20)
- quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11)
- i18n.ts (1,173 → 8 language files)
- unity-bridge (1,094 → 12), backlog (1,087 → 6)
- training (1,066 → 8), rag (1,063 → 8)
- Deleted index_original.ts (4,899 LOC dead backup)

Studio-v2 (5 monoliths split):
- meet/page.tsx (1,481 → 9), messages (1,166 → 9)
- AlertsB2BContext.tsx (1,165 → 5 modules)
- alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6)

All existing imports preserved. Zero new TypeScript errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

View File

@@ -0,0 +1,80 @@
'use client'
import type { AlertItem, Topic, Stats } from './types'
import { getScoreBadge } from './useAlertsData'
interface DashboardTabProps {
stats: Stats | null
topics: Topic[]
alerts: AlertItem[]
error: string | null
}
export default function DashboardTab({ stats, topics, alerts, error }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
<div className="text-sm text-slate-500">Alerts gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
<div className="text-sm text-slate-500">Neue Alerts</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
<div className="text-sm text-slate-500">Relevant</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
<div className="text-sm text-slate-500">Zur Pruefung</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
<div className="space-y-3">
{topics.slice(0, 5).map((topic) => (
<div key={topic.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<div className="font-medium text-slate-900">{topic.name}</div>
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
<div className="space-y-3">
{alerts.slice(0, 5).map((alert) => (
<div key={alert.id} className="p-3 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{alert.topic_name}</span>
{getScoreBadge(alert.relevance_score)}
</div>
</div>
))}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,430 @@
'use client'
export default function DocumentationTab() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-200px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-pre:bg-slate-900 prose-pre:text-slate-100">
{/* Header */}
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
</div>
{/* Audit Box */}
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
<p className="text-sm text-blue-800">
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
</p>
</div>
{/* Ziel des Systems */}
<h2>Ziel des Alert-Systems</h2>
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
<ul>
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
</ul>
{/* Datenschutz Compliance */}
<h2>Datenschutz-Compliance</h2>
<DocTable
headers={['Massnahme', 'Umsetzung', 'Wirkung']}
rows={[
['100% Self-Hosted', 'Alle Dienste auf eigenen Servern', 'Keine Cloud-Abhaengigkeit'],
['Lokale KI', 'Ollama/vLLM on-premise', 'Keine Daten an OpenAI etc.'],
['URL-Deduplizierung', 'SHA256-Hash, Tracking entfernt', 'Minimale Datenspeicherung'],
['Soft-Delete', 'Archivierung statt Loeschung', 'Audit-Trail erhalten'],
['RBAC', 'Rollenbasierte Zugriffskontrolle', 'Nur autorisierter Zugriff'],
]}
/>
{/* Architektur */}
<h2>1. Systemarchitektur</h2>
<h3>Gesamtarchitektur</h3>
<pre className="text-xs leading-tight overflow-x-auto">{ARCHITECTURE_DIAGRAM}</pre>
{/* Datenfluss */}
<h3>Datenfluss bei Alert-Verarbeitung</h3>
<pre className="text-xs leading-tight overflow-x-auto">{DATAFLOW_DIAGRAM}</pre>
{/* Feed Ingestion */}
<h2>2. Feed Ingestion</h2>
<h3>RSS Fetcher</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Parser', 'feedparser 6.x', 'Standard RSS/Atom Parser'],
['HTTP Client', 'httpx (async)', 'Non-blocking Fetches'],
['Timeout', '30 Sekunden', 'Konfigurierbar'],
['Parallelitaet', 'Ja (asyncio.gather)', 'Mehrere Feeds gleichzeitig'],
]}
/>
<h3>Deduplizierung</h3>
<p>Die Deduplizierung verhindert doppelte Alerts durch:</p>
<ol>
<li><strong>URL-Normalisierung</strong>: Tracking-Parameter entfernen (utm_*, fbclid, gclid), Hostname lowercase, Trailing Slash entfernen</li>
<li><strong>URL-Hash</strong>: SHA256 der normalisierten URL, erste 16 Zeichen als Index</li>
</ol>
{/* Rule Engine */}
<h2>3. Rule Engine</h2>
<h3>Unterstuetzte Operatoren</h3>
<DocTable
headers={['Operator', 'Beschreibung', 'Beispiel']}
rows={[
['contains', 'Text enthaelt', 'title contains "Inklusion"'],
['not_contains', 'Text enthaelt nicht', 'title not_contains "Werbung"'],
['equals', 'Exakte Uebereinstimmung', 'status equals "new"'],
['regex', 'Regulaerer Ausdruck', 'title regex "\\d{4}"'],
['gt / lt', 'Groesser/Kleiner', 'relevance_score gt 0.8'],
['in', 'In Liste enthalten', 'title in ["KI", "AI"]'],
]}
monoFirstCol
/>
<h3>Verfuegbare Felder</h3>
<DocTable
headers={['Feld', 'Typ', 'Beschreibung']}
rows={[
['title', 'String', 'Alert-Titel'],
['snippet', 'String', 'Textausschnitt'],
['url', 'String', 'Artikel-URL'],
['source', 'Enum', 'google_alerts_rss, rss_feed, manual'],
['relevance_score', 'Float', '0.0 - 1.0'],
['relevance_decision', 'Enum', 'KEEP, DROP, REVIEW'],
]}
monoFirstCol
/>
<h3>Aktionen</h3>
<DocTable
headers={['Aktion', 'Beschreibung', 'Konfiguration']}
rows={[
['keep', 'Als relevant markieren', '-'],
['drop', 'Archivieren', '-'],
['tag', 'Tags hinzufuegen', '{"tags": ["wichtig"]}'],
['email', 'E-Mail senden', '{"to": "x@y.de"}'],
['webhook', 'HTTP POST', '{"url": "https://..."}'],
['slack', 'Slack-Nachricht', '{"webhook_url": "..."}'],
]}
monoFirstCol
/>
{/* KI Relevanzpruefung */}
<h2>4. KI-Relevanzpruefung</h2>
<h3>LLM Scorer</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Gateway URL', 'http://localhost:8000/llm', 'LLM Gateway Endpoint'],
['Modell', 'breakpilot-teacher-8b', 'Fein-getuntes Llama 3.1'],
['Temperatur', '0.3', 'Niedrig fuer Konsistenz'],
['Max Tokens', '500', 'Response-Limit'],
]}
/>
<h3>Bewertungskriterien</h3>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
</tbody>
</table>
</div>
<h3>Few-Shot Learning</h3>
<p>Das Profil verbessert sich durch Nutzerfeedback:</p>
<ol>
<li>Nutzer markiert Alert als relevant/irrelevant</li>
<li>Alert wird als positives/negatives Beispiel gespeichert</li>
<li>Beispiele werden in den Prompt eingefuegt (max. 5 pro Kategorie)</li>
<li>LLM lernt aus konkreten Beispielen des Nutzers</li>
</ol>
{/* Relevanz Profile */}
<h2>5. Relevanz-Profile</h2>
<h3>Standard-Bildungsprofil</h3>
<DocTable
headers={['Prioritaet', 'Gewicht', 'Keywords']}
rows={[
['Inklusion', '0.9', 'inklusiv, Foerderbedarf, Behinderung'],
['Datenschutz Schule', '0.85', 'DSGVO, Schuelerfotos, Einwilligung'],
['Schulrecht Bayern', '0.8', 'BayEUG, Schulordnung, KM'],
['Digitalisierung Schule', '0.7', 'DigitalPakt, Tablet-Klasse'],
]}
/>
<h3>Standard-Ausschluesse</h3>
<ul>
<li>Stellenanzeige</li>
<li>Praktikum gesucht</li>
<li>Werbung</li>
<li>Pressemitteilung</li>
</ul>
{/* API Endpoints */}
<h2>6. API Endpoints</h2>
<h3>Alerts API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/inbox', 'GET', 'Inbox Items abrufen'],
['/api/alerts/ingest', 'POST', 'Manuell Alert importieren'],
['/api/alerts/run', 'POST', 'Scoring-Pipeline starten'],
['/api/alerts/feedback', 'POST', 'Relevanz-Feedback geben'],
['/api/alerts/stats', 'GET', 'Statistiken abrufen'],
]}
monoFirstCol
/>
<h3>Topics API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/topics', 'GET', 'Topics auflisten'],
['/api/alerts/topics', 'POST', 'Neues Topic erstellen'],
['/api/alerts/topics/{id}', 'PUT', 'Topic aktualisieren'],
['/api/alerts/topics/{id}', 'DELETE', 'Topic loeschen (CASCADE)'],
['/api/alerts/topics/{id}/fetch', 'POST', 'Manueller Feed-Abruf'],
]}
monoFirstCol
/>
<h3>Rules & Profile API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/rules', 'GET/POST', 'Regeln verwalten'],
['/api/alerts/rules/{id}', 'PUT/DELETE', 'Regel bearbeiten/loeschen'],
['/api/alerts/profile', 'GET', 'Profil abrufen'],
['/api/alerts/profile', 'PUT', 'Profil aktualisieren'],
['/api/alerts/scheduler/status', 'GET', 'Scheduler-Status'],
]}
monoFirstCol
/>
{/* Datenbank Schema */}
<h2>7. Datenbank-Schema</h2>
<h3>Tabellen</h3>
<DocTable
headers={['Tabelle', 'Beschreibung', 'Wichtige Felder']}
rows={[
['alert_topics', 'Feed-Quellen', 'name, feed_url, feed_type, is_active, fetch_interval'],
['alert_items', 'Einzelne Alerts', 'title, url, url_hash, relevance_score, relevance_decision'],
['alert_rules', 'Filterregeln', 'name, conditions (JSON), action_type, priority'],
['alert_profiles', 'Nutzer-Profile', 'priorities, exclusions, positive/negative_examples'],
]}
monoFirstCol
/>
{/* DSGVO */}
<h2>8. DSGVO-Konformitaet</h2>
<h3>Rechtsgrundlage (Art. 6 DSGVO)</h3>
<DocTable
headers={['Verarbeitung', 'Rechtsgrundlage', 'Umsetzung']}
rows={[
['Feed-Abruf', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Informationsbeschaffung'],
['Alert-Speicherung', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Nur oeffentliche Informationen'],
['LLM-Scoring', 'Art. 6(1)(f) - Berechtigtes Interesse', 'On-Premise, keine PII'],
['Profil-Learning', 'Art. 6(1)(a) - Einwilligung', 'Opt-in durch Nutzung'],
]}
/>
<h3>Technische Datenschutz-Massnahmen</h3>
<ul>
<li><strong>Datenminimierung</strong>: Nur Titel, URL, Snippet - keine personenbezogenen Daten</li>
<li><strong>Lokale Verarbeitung</strong>: Ollama/vLLM on-premise - kein Datenabfluss an Cloud</li>
<li><strong>Pseudonymisierung</strong>: UUIDs statt Namen</li>
<li><strong>Automatische Loeschung</strong>: 90 Tage Retention fuer archivierte Alerts</li>
<li><strong>Audit-Logging</strong>: Stats und Match-Counts fuer Nachvollziehbarkeit</li>
</ul>
{/* Open Source */}
<h2>9. Open Source Lizenzen (SBOM)</h2>
<h3>Python Dependencies</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['FastAPI', 'MIT', 'Ja'],
['SQLAlchemy', 'MIT', 'Ja'],
['httpx', 'BSD-3-Clause', 'Ja'],
['feedparser', 'BSD-2-Clause', 'Ja'],
['APScheduler', 'MIT', 'Ja'],
]}
greenLastCol
/>
<h3>KI-Komponenten</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['Ollama', 'MIT', 'Ja'],
['Llama 3.1', 'Meta Llama 3', 'Ja*'],
['vLLM', 'Apache-2.0', 'Ja'],
]}
greenLastCol
/>
{/* Kontakt */}
<h2>10. Kontakt & Support</h2>
<DocTable
headers={['Kontakt', 'Adresse']}
rows={[
['Technischer Support', 'support@breakpilot.de'],
['Datenschutzbeauftragter', 'dsb@breakpilot.de'],
['Dokumentation', 'docs.breakpilot.de'],
['GitHub', 'github.com/breakpilot'],
]}
/>
{/* Footer */}
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Helper component for repetitive doc tables */
/* ------------------------------------------------------------------ */
interface DocTableProps {
headers: string[]
rows: string[][]
monoFirstCol?: boolean
greenLastCol?: boolean
}
function DocTable({ headers, rows, monoFirstCol, greenLastCol }: DocTableProps) {
return (
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{headers.map((h) => (
<th key={h} className="px-4 py-2 text-left font-semibold">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={`px-4 py-2${ci === 0 && monoFirstCol ? ' font-mono text-xs' : ''}${ci === row.length - 1 && greenLastCol ? ' text-green-600' : ''}`}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ------------------------------------------------------------------ */
/* ASCII diagrams kept as constants to avoid JSX noise */
/* ------------------------------------------------------------------ */
const ARCHITECTURE_DIAGRAM = `\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 BreakPilot Alerts Frontend \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Dashboard \u2502 \u2502 Inbox \u2502 \u2502 Topics \u2502 \u2502 Profile \u2502 \u2502
\u2502 \u2502 (Stats) \u2502 \u2502 (Review) \u2502 \u2502 (Feeds) \u2502 \u2502 (Learning) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Ingestion Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 RSS Fetcher \u2502 \u2502 Email Parser \u2502 \u2502 APScheduler \u2502 \u2502
\u2502 \u2502 (feedparser) \u2502 \u2502 (geplant) \u2502 \u2502 (AsyncIO) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Deduplication (URL-Hash + SimHash) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Processing Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Rule Engine \u2502 \u2502
\u2502 \u2502 Operatoren: contains, regex, gt/lt, in, starts_with \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 LLM Relevance Scorer \u2502 \u2502
\u2502 \u2502 Output: { score, decision: KEEP/DROP/REVIEW, summary } \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Action Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Email Action \u2502 \u2502 Webhook Action \u2502 \u2502 Slack Action \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Storage Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 PostgreSQL \u2502 \u2502 Valkey \u2502 \u2502 LLM Gateway \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518`
const DATAFLOW_DIAGRAM = `1. APScheduler triggert Fetch (alle 60 Min. default)
\u2502
v
2. RSS Fetcher holt Feed von Google Alerts
\u2502
v
3. Deduplizierung prueft URL-Hash
\u2502
\u251c\u2500\u2500 URL bekannt \u2500\u2500> Uebersprungen
\u2514\u2500\u2500 URL neu \u2500\u2500> Weiter
\u2502
v
4. Alert in Datenbank gespeichert (Status: NEW)
\u2502
v
5. Rule Engine evaluiert aktive Regeln
\u2502
\u251c\u2500\u2500 Regel matcht \u2500\u2500> Aktion ausfuehren
\u2514\u2500\u2500 Keine Regel \u2500\u2500> LLM Scoring
\u2502
v
6. LLM Relevance Scorer
\u2502
\u251c\u2500\u2500 KEEP (>= 0.7) \u2500\u2500> Inbox
\u251c\u2500\u2500 REVIEW (0.4-0.7) \u2500\u2500> Inbox (Pruefung)
\u2514\u2500\u2500 DROP (< 0.4) \u2500\u2500> Archiviert
\u2502
v
7. Nutzer-Feedback \u2500\u2500> Profile aktualisieren`

View File

@@ -0,0 +1,67 @@
'use client'
import type { AlertItem } from './types'
import { formatTimeAgo, getScoreBadge, getDecisionBadge } from './useAlertsData'
interface InboxTabProps {
inboxFilter: string
setInboxFilter: (filter: string) => void
filteredAlerts: AlertItem[]
}
export default function InboxTab({ inboxFilter, setInboxFilter, filteredAlerts }: InboxTabProps) {
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'new', 'keep', 'review'].map((filter) => (
<button
key={filter}
onClick={() => setInboxFilter(filter)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
inboxFilter === filter
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' && 'Alle'}
{filter === 'new' && 'Neu'}
{filter === 'keep' && 'Relevant'}
{filter === 'review' && 'Pruefung'}
</button>
))}
</div>
{/* Alerts Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-slate-50">
<td className="p-4">
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-primary-600">
{alert.title}
</a>
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
</td>
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { Profile } from './types'
interface ProfileTabProps {
profile: Profile | null
}
export default function ProfileTab({ profile }: ProfileTabProps) {
return (
<div className="max-w-2xl space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Prioritaeten (wichtige Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.priorities?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ausschluesse (unerwuenschte Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.exclusions?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert KEEP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.keep_threshold || 0.7}
>
<option value={0.8}>80% (sehr streng)</option>
<option value={0.7}>70% (empfohlen)</option>
<option value={0.6}>60% (weniger streng)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert DROP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.drop_threshold || 0.3}
>
<option value={0.4}>40% (strenger)</option>
<option value={0.3}>30% (empfohlen)</option>
<option value={0.2}>20% (lockerer)</option>
</select>
</div>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
Profil speichern
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { Rule } from './types'
interface RulesTabProps {
rules: Rule[]
}
export default function RulesTab({ rules }: RulesTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
/>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import type { Topic } from './types'
import { formatTimeAgo } from './useAlertsData'
interface TopicsTabProps {
topics: Topic[]
}
export default function TopicsTab({ topics }: TopicsTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Topic hinzufuegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex justify-between items-start mb-3">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
<div className="text-sm">
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
<span className="text-slate-500"> Alerts</span>
</div>
<div className="text-xs text-slate-500">
{formatTimeAgo(topic.last_fetched_at)}
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
/**
* System Info configuration for Audit tab
*/
export const alertsSystemConfig = {
title: 'Alerts Agent System',
description: 'Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung',
version: '1.0.0',
architecture: {
layers: [
{
title: 'Ingestion Layer',
components: ['RSS Fetcher', 'Email Parser', 'Webhook Receiver', 'APScheduler'],
color: '#3b82f6',
},
{
title: 'Processing Layer',
components: ['Deduplication', 'Rule Engine', 'LLM Relevance Scorer'],
color: '#8b5cf6',
},
{
title: 'Action Layer',
components: ['Email Actions', 'Webhook Actions', 'Slack Actions'],
color: '#22c55e',
},
{
title: 'Storage Layer',
components: ['PostgreSQL', 'Valkey Cache'],
color: '#f59e0b',
},
],
},
features: [
{ name: 'RSS Feed Parsing', status: 'active' as const, description: 'Google Alerts und andere RSS/Atom Feeds' },
{ name: 'LLM Relevance Scoring', status: 'active' as const, description: 'KI-basierte Relevanzpruefung mit Few-Shot Learning' },
{ name: 'Rule Engine', status: 'active' as const, description: 'Regelbasierte Filterung mit Conditions' },
{ name: 'Email Actions', status: 'active' as const, description: 'E-Mail-Benachrichtigungen bei Matches' },
{ name: 'Webhook Actions', status: 'active' as const, description: 'HTTP Webhooks fuer Integrationen' },
{ name: 'Slack Actions', status: 'active' as const, description: 'Slack Block Kit Nachrichten' },
{ name: 'Email Parsing', status: 'planned' as const, description: 'Google Alerts per E-Mail empfangen' },
{ name: 'Microsoft Teams', status: 'planned' as const, description: 'Teams Adaptive Cards' },
],
roadmap: [
{
phase: 'Phase 1 (Completed)',
priority: 'high' as const,
items: ['PostgreSQL Persistenz', 'Repository Pattern', 'Alembic Migrations'],
},
{
phase: 'Phase 2 (Completed)',
priority: 'high' as const,
items: ['Topic CRUD API', 'APScheduler Integration', 'Email Parser'],
},
{
phase: 'Phase 3 (Completed)',
priority: 'medium' as const,
items: ['Rule Engine', 'Condition Operators', 'Rule API'],
},
{
phase: 'Phase 4 (Completed)',
priority: 'medium' as const,
items: ['Action Dispatcher', 'Email/Webhook/Slack Actions'],
},
{
phase: 'Phase 5 (Current)',
priority: 'high' as const,
items: ['Studio Frontend', 'Admin Frontend', 'Audit & Documentation'],
},
],
technicalDetails: [
{ component: 'Backend', technology: 'FastAPI', version: '0.100+', description: 'Async REST API' },
{ component: 'ORM', technology: 'SQLAlchemy', version: '2.0', description: 'Async ORM mit PostgreSQL' },
{ component: 'Scheduler', technology: 'APScheduler', version: '3.x', description: 'AsyncIO Scheduler' },
{ component: 'HTTP Client', technology: 'httpx', description: 'Async HTTP fuer Webhooks' },
{ component: 'Feed Parser', technology: 'feedparser', version: '6.x', description: 'RSS/Atom Parsing' },
{ component: 'LLM Gateway', technology: 'Ollama/vLLM/Claude', description: 'Multi-Provider LLM' },
],
privacyNotes: [
'Alle Daten werden in Deutschland gespeichert (PostgreSQL)',
'Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)',
'LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen',
'DSGVO-konforme Datenverarbeitung',
],
auditInfo: [
{
category: 'Datenbank',
items: [
{ label: 'Tabellen', value: '4 (topics, items, rules, profiles)', status: 'ok' as const },
{ label: 'Indizes', value: 'URL-Hash, Topic-ID, Status', status: 'ok' as const },
{ label: 'Backups', value: 'PostgreSQL pg_dump', status: 'ok' as const },
],
},
{
category: 'API Sicherheit',
items: [
{ label: 'Authentifizierung', value: 'Bearer Token (geplant)', status: 'warning' as const },
{ label: 'Rate Limiting', value: 'Nicht implementiert', status: 'warning' as const },
{ label: 'Input Validation', value: 'Pydantic Models', status: 'ok' as const },
],
},
{
category: 'Logging & Monitoring',
items: [
{ label: 'Structured Logging', value: 'Python logging', status: 'ok' as const },
{ label: 'Metriken', value: 'Stats Endpoint', status: 'ok' as const },
{ label: 'Health Checks', value: '/api/alerts/health', status: 'ok' as const },
],
},
],
fullDocumentation: `
<h3>Alerts Agent - Entwicklerdokumentation</h3>
<h4>API Endpoints</h4>
<ul>
<li><code>GET /api/alerts/inbox</code> - Alerts auflisten</li>
<li><code>POST /api/alerts/ingest</code> - Alert hinzufuegen</li>
<li><code>GET /api/alerts/topics</code> - Topics auflisten</li>
<li><code>POST /api/alerts/topics</code> - Topic erstellen</li>
<li><code>GET /api/alerts/rules</code> - Regeln auflisten</li>
<li><code>POST /api/alerts/rules</code> - Regel erstellen</li>
<li><code>GET /api/alerts/profile</code> - Profil abrufen</li>
<li><code>PUT /api/alerts/profile</code> - Profil aktualisieren</li>
</ul>
<h4>Architektur</h4>
<p>Der Alerts Agent verwendet ein Pipeline-basiertes Design:</p>
<ol>
<li><strong>Ingestion</strong>: RSS Feeds werden periodisch abgerufen</li>
<li><strong>Deduplication</strong>: SimHash-basierte Duplikaterkennung</li>
<li><strong>Scoring</strong>: LLM-basierte Relevanzpruefung</li>
<li><strong>Rules</strong>: Regelbasierte Filterung und Aktionen</li>
<li><strong>Actions</strong>: Email/Webhook/Slack Benachrichtigungen</li>
</ol>
`,
}

View File

@@ -0,0 +1,68 @@
/**
* Types for Alerts Monitoring Admin Page
*/
export interface AlertItem {
id: string
title: string
url: string
snippet: string
topic_name: string
relevance_score: number | null
relevance_decision: string | null
status: string
fetched_at: string
published_at: string | null
matched_rule: string | null
tags: string[]
}
export interface Topic {
id: string
name: string
feed_url: string
feed_type: string
is_active: boolean
fetch_interval_minutes: number
last_fetched_at: string | null
alert_count: number
}
export interface Rule {
id: string
name: string
topic_id: string | null
conditions: Array<{
field: string
operator: string
value: string | number
}>
action_type: string
action_config: Record<string, unknown>
priority: number
is_active: boolean
}
export interface Profile {
priorities: string[]
exclusions: string[]
positive_examples: Array<{ title: string; url: string }>
negative_examples: Array<{ title: string; url: string }>
policies: {
keep_threshold: number
drop_threshold: number
}
}
export interface Stats {
total_alerts: number
new_alerts: number
kept_alerts: number
review_alerts: number
dropped_alerts: number
total_topics: number
active_topics: number
total_rules: number
}
export type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'

View File

@@ -0,0 +1,193 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { AlertItem, Topic, Rule, Profile, Stats } from './types'
const API_BASE = '/api/alerts'
export function useAlertsData() {
const [stats, setStats] = useState<Stats | null>(null)
const [alerts, setAlerts] = useState<AlertItem[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inboxFilter, setInboxFilter] = useState<string>('all')
const fetchData = useCallback(async () => {
try {
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
fetch(`${API_BASE}/stats`),
fetch(`${API_BASE}/inbox?limit=50`),
fetch(`${API_BASE}/topics`),
fetch(`${API_BASE}/rules`),
fetch(`${API_BASE}/profile`),
])
if (statsRes.ok) setStats(await statsRes.json())
if (alertsRes.ok) {
const data = await alertsRes.json()
setAlerts(data.items || [])
}
if (topicsRes.ok) {
const data = await topicsRes.json()
setTopics(data.items || data || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.items || data || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStats({
total_alerts: 147,
new_alerts: 23,
kept_alerts: 89,
review_alerts: 12,
dropped_alerts: 23,
total_topics: 5,
active_topics: 4,
total_rules: 8,
})
setAlerts([
{
id: 'demo_1',
title: 'Neue Studie zur digitalen Bildung an Schulen',
url: 'https://example.com/artikel1',
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
topic_name: 'Digitale Bildung',
relevance_score: 0.85,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date().toISOString(),
published_at: null,
matched_rule: null,
tags: ['bildung', 'digital'],
},
{
id: 'demo_2',
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
url: 'https://example.com/artikel2',
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
topic_name: 'Inklusion',
relevance_score: 0.72,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date(Date.now() - 3600000).toISOString(),
published_at: null,
matched_rule: null,
tags: ['inklusion'],
},
])
setTopics([
{
id: 'topic_1',
name: 'Digitale Bildung',
feed_url: 'https://google.com/alerts/feeds/123',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date().toISOString(),
alert_count: 47,
},
{
id: 'topic_2',
name: 'Inklusion',
feed_url: 'https://google.com/alerts/feeds/456',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
alert_count: 32,
},
])
setRules([
{
id: 'rule_1',
name: 'Stellenanzeigen ausschliessen',
topic_id: null,
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
action_type: 'drop',
action_config: {},
priority: 10,
is_active: true,
},
])
setProfile({
priorities: ['Inklusion', 'digitale Bildung'],
exclusions: ['Stellenanzeigen', 'Werbung'],
positive_examples: [],
negative_examples: [],
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const filteredAlerts = alerts.filter((alert) => {
if (inboxFilter === 'all') return true
if (inboxFilter === 'new') return alert.status === 'new'
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
return true
})
return {
stats,
alerts,
topics,
rules,
profile,
loading,
error,
inboxFilter,
setInboxFilter,
filteredAlerts,
}
}
export function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
export function getScoreBadge(score: number | null) {
if (score === null) return null
const pct = Math.round(score * 100)
let cls = 'bg-slate-100 text-slate-600'
if (pct >= 70) cls = 'bg-green-100 text-green-800'
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
else cls = 'bg-red-100 text-red-800'
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
}
export function getDecisionBadge(decision: string | null) {
if (!decision) return null
const styles: Record<string, string> = {
KEEP: 'bg-green-100 text-green-800',
REVIEW: 'bg-amber-100 text-amber-800',
DROP: 'bg-red-100 text-red-800',
}
return (
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
{decision}
</span>
)
}

File diff suppressed because it is too large Load Diff