[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:
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal file
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal file
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal 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`
|
||||
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal file
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal file
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal file
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal 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} "{rule.conditions[0]?.value}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal file
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal file
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal 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>
|
||||
`,
|
||||
}
|
||||
68
website/app/admin/alerts/_components/types.ts
Normal file
68
website/app/admin/alerts/_components/types.ts
Normal 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'
|
||||
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal file
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal 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
Reference in New Issue
Block a user