Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
'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.topics || data.items || [])
|
|
}
|
|
if (rulesRes.ok) {
|
|
const data = await rulesRes.json()
|
|
setRules(data.rules || data.items || [])
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
// --- Helper functions (pure, no hooks) ---
|
|
|
|
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 getScoreBadgeClass(score: number | null): { pct: number; cls: string } | 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 { pct, cls }
|
|
}
|
|
|
|
export function getDecisionBadgeClass(decision: string | null): { decision: string; cls: 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 { decision, cls: styles[decision] || 'bg-slate-100' }
|
|
}
|