This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

637 lines
22 KiB
Python

"""
SQLAlchemy Database Models für Alerts Agent.
Persistiert Topics, Alerts, Rules und Profile in PostgreSQL.
Nutzt die gleiche Base wie classroom_engine für konsistente Migrationen.
"""
from datetime import datetime
from sqlalchemy import (
Column, String, Integer, Float, DateTime, JSON,
Boolean, Text, Enum as SQLEnum, ForeignKey, Index
)
from sqlalchemy.orm import relationship
import enum
import uuid
# Import Base from classroom_engine for shared metadata
from classroom_engine.database import Base
class AlertSourceEnum(str, enum.Enum):
"""Quelle des Alerts."""
GOOGLE_ALERTS_RSS = "google_alerts_rss"
GOOGLE_ALERTS_EMAIL = "google_alerts_email"
RSS_FEED = "rss_feed"
WEBHOOK = "webhook"
MANUAL = "manual"
class AlertStatusEnum(str, enum.Enum):
"""Verarbeitungsstatus des Alerts."""
NEW = "new"
PROCESSED = "processed"
DUPLICATE = "duplicate"
SCORED = "scored"
REVIEWED = "reviewed"
ARCHIVED = "archived"
class RelevanceDecisionEnum(str, enum.Enum):
"""Relevanz-Entscheidung."""
KEEP = "KEEP"
DROP = "DROP"
REVIEW = "REVIEW"
class FeedTypeEnum(str, enum.Enum):
"""Typ der Feed-Quelle."""
RSS = "rss"
EMAIL = "email"
WEBHOOK = "webhook"
class RuleActionEnum(str, enum.Enum):
"""Aktionen für Regeln."""
KEEP = "keep"
DROP = "drop"
TAG = "tag"
EMAIL = "email"
WEBHOOK = "webhook"
SLACK = "slack"
class ImportanceLevelEnum(str, enum.Enum):
"""5-stufige Wichtigkeitsskala für Guided Mode."""
INFO = "info" # 0.0-0.4 - Informativ
PRUEFEN = "pruefen" # 0.4-0.6 - Zu prüfen
WICHTIG = "wichtig" # 0.6-0.75 - Wichtig
DRINGEND = "dringend" # 0.75-0.9 - Dringend
KRITISCH = "kritisch" # 0.9-1.0 - Kritisch
class AlertModeEnum(str, enum.Enum):
"""Modus für Alert-Nutzung."""
GUIDED = "guided" # Geführter Modus für Lehrer/Schulleitungen
EXPERT = "expert" # Experten-Modus für IT-affine Nutzer
class MigrationModeEnum(str, enum.Enum):
"""Wie wurden die Alerts migriert."""
FORWARD = "forward" # E-Mail-Weiterleitung
IMPORT = "import" # RSS-Import
RECONSTRUCTED = "reconstructed" # Automatisch rekonstruiert
class DigestStatusEnum(str, enum.Enum):
"""Status der Digest-Generierung."""
PENDING = "pending"
GENERATING = "generating"
SENT = "sent"
FAILED = "failed"
class UserRoleEnum(str, enum.Enum):
"""Rolle des Nutzers für Template-Empfehlungen."""
LEHRKRAFT = "lehrkraft"
SCHULLEITUNG = "schulleitung"
IT_BEAUFTRAGTE = "it_beauftragte"
class AlertTopicDB(Base):
"""
Alert Topic / Feed-Quelle.
Repräsentiert eine Google Alert-Konfiguration oder einen RSS-Feed.
"""
__tablename__ = 'alert_topics'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), nullable=True, index=True) # Optional: Multi-User
# Topic-Details
name = Column(String(255), nullable=False)
description = Column(Text, default="")
# Feed-Konfiguration
feed_url = Column(String(2000), nullable=True)
feed_type = Column(
SQLEnum(FeedTypeEnum),
default=FeedTypeEnum.RSS,
nullable=False
)
# Scheduling
is_active = Column(Boolean, default=True, index=True)
fetch_interval_minutes = Column(Integer, default=60)
last_fetched_at = Column(DateTime, nullable=True)
last_fetch_error = Column(Text, nullable=True)
# Statistiken
total_items_fetched = Column(Integer, default=0)
items_kept = Column(Integer, default=0)
items_dropped = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
alerts = relationship("AlertItemDB", back_populates="topic", cascade="all, delete-orphan")
rules = relationship("AlertRuleDB", back_populates="topic", cascade="all, delete-orphan")
def __repr__(self):
return f"<AlertTopic {self.name} ({self.feed_type.value})>"
class AlertItemDB(Base):
"""
Einzelner Alert-Eintrag.
Entspricht einem Artikel/Link aus Google Alerts oder RSS.
"""
__tablename__ = 'alert_items'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=False, index=True)
# Content
title = Column(Text, nullable=False)
url = Column(String(2000), nullable=False)
snippet = Column(Text, default="")
article_text = Column(Text, nullable=True) # Volltext (optional)
# Metadaten
lang = Column(String(10), default="de")
published_at = Column(DateTime, nullable=True, index=True)
fetched_at = Column(DateTime, default=datetime.utcnow, index=True)
processed_at = Column(DateTime, nullable=True)
# Source
source = Column(
SQLEnum(AlertSourceEnum),
default=AlertSourceEnum.GOOGLE_ALERTS_RSS,
nullable=False
)
# Deduplication
url_hash = Column(String(64), unique=True, nullable=False, index=True)
content_hash = Column(String(64), nullable=True) # SimHash für Fuzzy-Matching
canonical_url = Column(String(2000), nullable=True)
# Status
status = Column(
SQLEnum(AlertStatusEnum),
default=AlertStatusEnum.NEW,
nullable=False,
index=True
)
cluster_id = Column(String(36), nullable=True) # Gruppierung ähnlicher Alerts
# Relevanz-Scoring
relevance_score = Column(Float, nullable=True)
relevance_decision = Column(
SQLEnum(RelevanceDecisionEnum),
nullable=True,
index=True
)
relevance_reasons = Column(JSON, default=list) # ["matches_priority", ...]
relevance_summary = Column(Text, nullable=True)
scored_by_model = Column(String(100), nullable=True) # "llama3.1:8b"
scored_at = Column(DateTime, nullable=True)
# User Actions
user_marked_relevant = Column(Boolean, nullable=True) # Explizites Feedback
user_tags = Column(JSON, default=list) # ["wichtig", "später lesen"]
user_notes = Column(Text, nullable=True)
# Guided Mode Fields (NEU)
importance_level = Column(
SQLEnum(ImportanceLevelEnum),
nullable=True,
index=True
)
why_relevant = Column(Text, nullable=True) # "Warum relevant?" Erklärung
next_steps = Column(JSON, default=list) # ["Schulleitung informieren", "Frist beachten"]
action_deadline = Column(DateTime, nullable=True) # Falls es eine Frist gibt
source_name = Column(String(255), nullable=True) # "Kultusministerium NRW"
source_credibility = Column(String(50), default="official") # official, news, blog
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
topic = relationship("AlertTopicDB", back_populates="alerts")
# Composite Index für häufige Queries
__table_args__ = (
Index('ix_alert_items_topic_status', 'topic_id', 'status'),
Index('ix_alert_items_topic_decision', 'topic_id', 'relevance_decision'),
)
def __repr__(self):
return f"<AlertItem {self.id[:8]}: {self.title[:50]}... ({self.status.value})>"
class AlertRuleDB(Base):
"""
Filterregel für Alerts.
Definiert Bedingungen und Aktionen für automatische Verarbeitung.
"""
__tablename__ = 'alert_rules'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=True, index=True)
user_id = Column(String(36), nullable=True, index=True)
# Rule-Details
name = Column(String(255), nullable=False)
description = Column(Text, default="")
# Bedingungen (als JSON)
# Format: [{"field": "title", "op": "contains", "value": "..."}]
conditions = Column(JSON, nullable=False, default=list)
# Aktion
action_type = Column(
SQLEnum(RuleActionEnum),
default=RuleActionEnum.KEEP,
nullable=False
)
action_config = Column(JSON, default=dict) # {"email": "x@y.z", "tags": [...]}
# Priorisierung (höher = wird zuerst ausgeführt)
priority = Column(Integer, default=0, index=True)
is_active = Column(Boolean, default=True, index=True)
# Statistiken
match_count = Column(Integer, default=0)
last_matched_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
topic = relationship("AlertTopicDB", back_populates="rules")
def __repr__(self):
return f"<AlertRule {self.name} ({self.action_type.value})>"
class AlertProfileDB(Base):
"""
Nutzer-Profil für Relevanz-Scoring.
Speichert Prioritäten, Ausschlüsse und Lern-Beispiele.
"""
__tablename__ = 'alert_profiles'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), unique=True, nullable=True, index=True)
# Name für Anzeige (falls mehrere Profile pro User)
name = Column(String(255), default="Default")
# Relevanz-Kriterien
# Format: [{"label": "Inklusion", "weight": 0.9, "keywords": [...], "description": "..."}]
priorities = Column(JSON, default=list)
# Ausschluss-Keywords
exclusions = Column(JSON, default=list) # ["Stellenanzeige", "Werbung"]
# Few-Shot Beispiele für LLM
# Format: [{"title": "...", "url": "...", "reason": "...", "added_at": "..."}]
positive_examples = Column(JSON, default=list)
negative_examples = Column(JSON, default=list)
# Policies
# Format: {"prefer_german_sources": true, "max_age_days": 30}
policies = Column(JSON, default=dict)
# Statistiken
total_scored = Column(Integer, default=0)
total_kept = Column(Integer, default=0)
total_dropped = Column(Integer, default=0)
accuracy_estimate = Column(Float, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<AlertProfile {self.name} (user={self.user_id})>"
def get_prompt_context(self) -> str:
"""
Generiere Kontext für LLM-Prompt.
Dieser Text wird in den System-Prompt des Relevanz-Scorers eingefügt.
"""
lines = ["## Relevanzprofil des Nutzers\n"]
# Prioritäten
if self.priorities:
lines.append("### Prioritäten (Themen von Interesse):")
for p in self.priorities:
weight = p.get("weight", 0.5)
weight_label = "Sehr wichtig" if weight > 0.7 else "Wichtig" if weight > 0.4 else "Interessant"
lines.append(f"- **{p.get('label', 'Unbenannt')}** ({weight_label})")
if p.get("description"):
lines.append(f" {p['description']}")
if p.get("keywords"):
lines.append(f" Keywords: {', '.join(p['keywords'])}")
lines.append("")
# Ausschlüsse
if self.exclusions:
lines.append("### Ausschlüsse (ignorieren):")
lines.append(f"Themen mit diesen Keywords: {', '.join(self.exclusions)}")
lines.append("")
# Positive Beispiele (letzte 5)
if self.positive_examples:
lines.append("### Beispiele für relevante Alerts:")
for ex in self.positive_examples[-5:]:
lines.append(f"- \"{ex.get('title', '')}\"")
if ex.get("reason"):
lines.append(f" Grund: {ex['reason']}")
lines.append("")
# Negative Beispiele (letzte 5)
if self.negative_examples:
lines.append("### Beispiele für irrelevante Alerts:")
for ex in self.negative_examples[-5:]:
lines.append(f"- \"{ex.get('title', '')}\"")
if ex.get("reason"):
lines.append(f" Grund: {ex['reason']}")
lines.append("")
# Policies
if self.policies:
lines.append("### Zusätzliche Regeln:")
for key, value in self.policies.items():
lines.append(f"- {key}: {value}")
return "\n".join(lines)
@classmethod
def create_default_education_profile(cls) -> "AlertProfileDB":
"""
Erstelle ein Standard-Profil für Bildungsthemen.
"""
return cls(
name="Bildung Default",
priorities=[
{
"label": "Inklusion",
"weight": 0.9,
"keywords": ["inklusiv", "Förderbedarf", "Behinderung", "Barrierefreiheit"],
"description": "Inklusive Bildung, Förderschulen, Nachteilsausgleich"
},
{
"label": "Datenschutz Schule",
"weight": 0.85,
"keywords": ["DSGVO", "Schülerfotos", "Einwilligung", "personenbezogene Daten"],
"description": "DSGVO in Schulen, Datenschutz bei Klassenfotos"
},
{
"label": "Schulrecht Bayern",
"weight": 0.8,
"keywords": ["BayEUG", "Schulordnung", "Kultusministerium", "Bayern"],
"description": "Bayerisches Schulrecht, Verordnungen"
},
{
"label": "Digitalisierung Schule",
"weight": 0.7,
"keywords": ["DigitalPakt", "Tablet-Klasse", "Lernplattform"],
"description": "Digitale Medien im Unterricht"
},
],
exclusions=["Stellenanzeige", "Praktikum gesucht", "Werbung", "Pressemitteilung"],
policies={
"prefer_german_sources": True,
"max_age_days": 30,
"min_content_length": 100,
}
)
# ============================================================================
# DUAL-MODE SYSTEM: Templates, Subscriptions, Sources, Digests
# ============================================================================
class AlertTemplateDB(Base):
"""
Vorkonfigurierte Alert-Templates (Playbooks).
Für Guided Mode: Lehrer wählen 1-3 Templates statt RSS-Feeds zu konfigurieren.
"""
__tablename__ = 'alert_templates'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Template-Identität
slug = Column(String(100), unique=True, nullable=False) # "foerderprogramme", "abitur-updates"
name = Column(String(255), nullable=False) # "Förderprogramme & Fristen"
description = Column(Text, default="") # B1/B2 Deutsch, 1-2 Sätze
icon = Column(String(50), default="") # Emoji: "💰", "📝", "⚖️"
category = Column(String(100), default="") # "administration", "teaching", "it"
# Zielgruppen (welche Rollen profitieren)
target_roles = Column(JSON, default=list) # ["schulleitung", "lehrkraft"]
# Template-Konfiguration
topics_config = Column(JSON, default=list) # Vorkonfigurierte RSS-Feeds
rules_config = Column(JSON, default=list) # Vorkonfigurierte Regeln
profile_config = Column(JSON, default=dict) # Prioritäten/Ausschlüsse
# Importance-Mapping (Score → 5 Stufen)
importance_config = Column(JSON, default=dict) # {"critical": 0.90, "urgent": 0.75, ...}
# Ausgabe-Einstellungen
max_cards_per_day = Column(Integer, default=10)
digest_enabled = Column(Boolean, default=True)
digest_day = Column(String(20), default="monday") # Tag für wöchentlichen Digest
# Lokalisierung
language = Column(String(10), default="de")
# Metadata
is_active = Column(Boolean, default=True)
is_premium = Column(Boolean, default=False) # Für kostenpflichtige Templates
sort_order = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
subscriptions = relationship("UserAlertSubscriptionDB", back_populates="template")
def __repr__(self):
return f"<AlertTemplate {self.slug}: {self.name}>"
class AlertSourceDB(Base):
"""
Alert-Quelle für Migration bestehender Alerts.
Unterstützt: E-Mail-Weiterleitung, RSS-Import, Rekonstruktion.
"""
__tablename__ = 'alert_sources'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id = Column(String(36), nullable=True, index=True) # Für Multi-Tenant
user_id = Column(String(36), nullable=True, index=True)
# Quellen-Typ
source_type = Column(
SQLEnum(FeedTypeEnum),
default=FeedTypeEnum.RSS,
nullable=False
)
# Original-Bezeichnung (vom Kunden)
original_label = Column(String(255), nullable=True) # "EU IT Ausschreibungen"
# E-Mail-Weiterleitung
inbound_address = Column(String(255), nullable=True, unique=True) # alerts+tenant123@breakpilot.app
# RSS-Import
rss_url = Column(String(2000), nullable=True)
# Migration-Modus
migration_mode = Column(
SQLEnum(MigrationModeEnum),
default=MigrationModeEnum.IMPORT,
nullable=False
)
# Verknüpfung zu erstelltem Topic
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='SET NULL'), nullable=True)
# Status
is_active = Column(Boolean, default=True)
items_received = Column(Integer, default=0)
last_item_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<AlertSource {self.source_type.value}: {self.original_label}>"
class UserAlertSubscriptionDB(Base):
"""
User-Subscription für Alert-Templates oder Expert-Profile.
Speichert Modus-Wahl, Template-Verknüpfung und Wizard-Zustand.
"""
__tablename__ = 'user_alert_subscriptions'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), nullable=False, index=True)
school_id = Column(String(36), nullable=True, index=True) # Optional: Schulkontext
# Modus-Auswahl
mode = Column(
SQLEnum(AlertModeEnum),
default=AlertModeEnum.GUIDED,
nullable=False
)
# Nutzer-Rolle (für Guided Mode)
user_role = Column(
SQLEnum(UserRoleEnum),
nullable=True
)
# Template-Verknüpfung (Guided Mode) - kann mehrere sein
template_id = Column(String(36), ForeignKey('alert_templates.id', ondelete='SET NULL'), nullable=True)
selected_template_ids = Column(JSON, default=list) # Bis zu 3 Templates
# Profil-Verknüpfung (Expert Mode)
profile_id = Column(String(36), ForeignKey('alert_profiles.id', ondelete='SET NULL'), nullable=True)
# Subscription-Einstellungen
is_active = Column(Boolean, default=True)
notification_email = Column(String(255), nullable=True)
# Digest-Präferenzen
digest_enabled = Column(Boolean, default=True)
digest_frequency = Column(String(20), default="weekly") # weekly, daily
digest_day = Column(String(20), default="monday")
last_digest_sent_at = Column(DateTime, nullable=True)
# Wizard-Zustand (für unvollständige Setups)
wizard_step = Column(Integer, default=0)
wizard_completed = Column(Boolean, default=False)
wizard_state = Column(JSON, default=dict) # Zwischenspeicher für Wizard-Daten
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
template = relationship("AlertTemplateDB", back_populates="subscriptions")
profile = relationship("AlertProfileDB")
digests = relationship("AlertDigestDB", back_populates="subscription", cascade="all, delete-orphan")
def __repr__(self):
return f"<UserAlertSubscription {self.user_id} ({self.mode.value})>"
class AlertDigestDB(Base):
"""
Wöchentliche Digest-Zusammenfassung.
Enthält gerenderte Zusammenfassung + Statistiken.
"""
__tablename__ = 'alert_digests'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
subscription_id = Column(String(36), ForeignKey('user_alert_subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(String(36), nullable=False, index=True)
# Zeitraum
period_start = Column(DateTime, nullable=False)
period_end = Column(DateTime, nullable=False)
# Content
title = Column(String(255), default="") # "KW 3/2026 - Ihre Bildungs-Alerts"
summary_html = Column(Text, default="") # Gerenderte HTML-Zusammenfassung
summary_pdf_url = Column(String(500), nullable=True) # Link zum PDF-Export
# Statistiken
total_alerts = Column(Integer, default=0)
kritisch_count = Column(Integer, default=0)
dringend_count = Column(Integer, default=0)
wichtig_count = Column(Integer, default=0)
pruefen_count = Column(Integer, default=0)
info_count = Column(Integer, default=0)
# Enthaltene Alert-IDs
alert_ids = Column(JSON, default=list)
# Status
status = Column(
SQLEnum(DigestStatusEnum),
default=DigestStatusEnum.PENDING,
nullable=False
)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
subscription = relationship("UserAlertSubscriptionDB", back_populates="digests")
def __repr__(self):
return f"<AlertDigest {self.title} ({self.status.value})>"