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>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,20 @@
"""
Actions Module für Alerts Agent.
Führt Aktionen aus, die durch Regeln oder Scoring ausgelöst werden.
"""
from .base import ActionHandler, ActionResult, ActionType
from .email_action import EmailAction
from .webhook_action import WebhookAction
from .slack_action import SlackAction
from .dispatcher import ActionDispatcher
__all__ = [
"ActionHandler",
"ActionResult",
"ActionType",
"EmailAction",
"WebhookAction",
"SlackAction",
"ActionDispatcher",
]

View File

@@ -0,0 +1,123 @@
"""
Base Classes für Alert Actions.
Definiert das Interface für alle Action-Handler.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any, Optional, List
from enum import Enum
class ActionType(str, Enum):
"""Verfügbare Aktionstypen."""
EMAIL = "email"
WEBHOOK = "webhook"
SLACK = "slack"
TEAMS = "teams"
TAG = "tag"
ARCHIVE = "archive"
@dataclass
class ActionResult:
"""Ergebnis einer ausgeführten Aktion."""
success: bool
action_type: ActionType
message: str
timestamp: datetime = field(default_factory=datetime.utcnow)
details: Dict[str, Any] = field(default_factory=dict)
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert zu Dict für Logging/Speicherung."""
return {
"success": self.success,
"action_type": self.action_type.value,
"message": self.message,
"timestamp": self.timestamp.isoformat(),
"details": self.details,
"error": self.error,
}
@dataclass
class AlertContext:
"""Kontext für eine Aktion mit Alert-Informationen."""
alert_id: str
title: str
url: str
snippet: str
topic_name: str
relevance_score: Optional[float] = None
relevance_decision: Optional[str] = None
matched_rule: Optional[str] = None
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert zu Dict für Templates."""
return {
"alert_id": self.alert_id,
"title": self.title,
"url": self.url,
"snippet": self.snippet,
"topic_name": self.topic_name,
"relevance_score": self.relevance_score,
"relevance_decision": self.relevance_decision,
"matched_rule": self.matched_rule,
"tags": self.tags,
}
class ActionHandler(ABC):
"""
Abstrakte Basisklasse für Action-Handler.
Jede Aktion (Email, Webhook, Slack) implementiert diese Schnittstelle.
"""
@property
@abstractmethod
def action_type(self) -> ActionType:
"""Gibt den Aktionstyp zurück."""
pass
@abstractmethod
async def execute(
self,
context: AlertContext,
config: Dict[str, Any],
) -> ActionResult:
"""
Führt die Aktion aus.
Args:
context: Alert-Kontext mit allen relevanten Informationen
config: Aktionsspezifische Konfiguration
Returns:
ActionResult mit Erfolgsstatus und Details
"""
pass
def validate_config(self, config: Dict[str, Any]) -> bool:
"""
Validiert die Aktions-Konfiguration.
Args:
config: Zu validierende Konfiguration
Returns:
True wenn gültig
"""
return True
def get_required_config_fields(self) -> List[str]:
"""
Gibt erforderliche Konfigurationsfelder zurück.
Returns:
Liste von Feldnamen
"""
return []

View File

@@ -0,0 +1,232 @@
"""
Action Dispatcher für Alerts Agent.
Verteilt Aktionen an die entsprechenden Handler.
"""
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime
from .base import ActionHandler, ActionResult, ActionType, AlertContext
from .email_action import EmailAction
from .webhook_action import WebhookAction
from .slack_action import SlackAction
logger = logging.getLogger(__name__)
class ActionDispatcher:
"""
Zentrale Verteilung von Aktionen an Handler.
Registriert Handler für verschiedene Aktionstypen und
führt Aktionen basierend auf Regel-Konfigurationen aus.
"""
def __init__(self):
"""Initialisiert den Dispatcher mit Standard-Handlern."""
self._handlers: Dict[ActionType, ActionHandler] = {}
# Standard-Handler registrieren
self.register_handler(EmailAction())
self.register_handler(WebhookAction())
self.register_handler(SlackAction())
def register_handler(self, handler: ActionHandler) -> None:
"""
Registriert einen Action-Handler.
Args:
handler: Handler-Instanz
"""
self._handlers[handler.action_type] = handler
logger.debug(f"Registered action handler: {handler.action_type.value}")
def get_handler(self, action_type: ActionType) -> Optional[ActionHandler]:
"""
Gibt den Handler für einen Aktionstyp zurück.
Args:
action_type: Aktionstyp
Returns:
Handler oder None
"""
return self._handlers.get(action_type)
def list_handlers(self) -> List[str]:
"""Gibt Liste der registrierten Handler zurück."""
return [at.value for at in self._handlers.keys()]
async def dispatch(
self,
action_type: str,
context: AlertContext,
config: Dict[str, Any],
) -> ActionResult:
"""
Führt eine Aktion aus.
Args:
action_type: Aktionstyp als String (email, webhook, slack)
context: Alert-Kontext
config: Aktionsspezifische Konfiguration
Returns:
ActionResult
"""
try:
# ActionType aus String
at = ActionType(action_type.lower())
except ValueError:
return ActionResult(
success=False,
action_type=ActionType.WEBHOOK, # Fallback
message=f"Unbekannter Aktionstyp: {action_type}",
error="Unknown action type",
)
handler = self.get_handler(at)
if not handler:
return ActionResult(
success=False,
action_type=at,
message=f"Kein Handler für {action_type} registriert",
error="No handler registered",
)
# Konfiguration validieren
if not handler.validate_config(config):
required = handler.get_required_config_fields()
return ActionResult(
success=False,
action_type=at,
message=f"Ungültige Konfiguration für {action_type}",
error=f"Required fields: {required}",
)
# Aktion ausführen
logger.info(f"Dispatching {action_type} action for alert {context.alert_id[:8]}")
result = await handler.execute(context, config)
return result
async def dispatch_multiple(
self,
actions: List[Dict[str, Any]],
context: AlertContext,
) -> List[ActionResult]:
"""
Führt mehrere Aktionen aus.
Args:
actions: Liste von Aktionen [{type, config}, ...]
context: Alert-Kontext
Returns:
Liste von ActionResults
"""
results = []
for action in actions:
action_type = action.get("type", action.get("action_type", ""))
config = action.get("config", action.get("action_config", {}))
result = await self.dispatch(action_type, context, config)
results.append(result)
return results
# Singleton-Instanz
_dispatcher: Optional[ActionDispatcher] = None
def get_dispatcher() -> ActionDispatcher:
"""Gibt den globalen ActionDispatcher zurück."""
global _dispatcher
if _dispatcher is None:
_dispatcher = ActionDispatcher()
return _dispatcher
async def execute_action(
action_type: str,
alert_id: str,
title: str,
url: str,
snippet: str,
topic_name: str,
config: Dict[str, Any],
relevance_score: Optional[float] = None,
relevance_decision: Optional[str] = None,
matched_rule: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> ActionResult:
"""
Convenience-Funktion zum Ausführen einer Aktion.
Erstellt den Kontext und ruft den Dispatcher auf.
"""
context = AlertContext(
alert_id=alert_id,
title=title,
url=url,
snippet=snippet,
topic_name=topic_name,
relevance_score=relevance_score,
relevance_decision=relevance_decision,
matched_rule=matched_rule,
tags=tags or [],
)
dispatcher = get_dispatcher()
return await dispatcher.dispatch(action_type, context, config)
async def execute_rule_actions(
alert_id: str,
title: str,
url: str,
snippet: str,
topic_name: str,
rule_action: str,
rule_config: Dict[str, Any],
rule_name: str,
) -> ActionResult:
"""
Führt die Aktion einer gematschten Regel aus.
Args:
alert_id: Alert-ID
title: Alert-Titel
url: Alert-URL
snippet: Alert-Snippet
topic_name: Topic-Name
rule_action: Aktionstyp der Regel
rule_config: Aktions-Konfiguration
rule_name: Name der Regel
Returns:
ActionResult
"""
# Nur externe Aktionen (email, webhook, slack) hier behandeln
# keep/drop/tag werden direkt von der Rule Engine behandelt
if rule_action not in ["email", "webhook", "slack"]:
return ActionResult(
success=True,
action_type=ActionType.TAG, # Dummy
message=f"Interne Aktion {rule_action} von Rule Engine behandelt",
)
return await execute_action(
action_type=rule_action,
alert_id=alert_id,
title=title,
url=url,
snippet=snippet,
topic_name=topic_name,
config=rule_config,
matched_rule=rule_name,
)

View File

@@ -0,0 +1,251 @@
"""
Email Action für Alerts Agent.
Sendet E-Mail-Benachrichtigungen für Alerts.
"""
import logging
from typing import Dict, Any, List
from datetime import datetime
from .base import ActionHandler, ActionResult, ActionType, AlertContext
logger = logging.getLogger(__name__)
# HTML-Template für Alert-E-Mails
EMAIL_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4A90E2; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }}
.alert-card {{ background: white; padding: 15px; margin: 10px 0; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
.alert-title {{ font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }}
.alert-title a {{ color: #4A90E2; text-decoration: none; }}
.alert-snippet {{ font-size: 14px; color: #666; margin-bottom: 8px; }}
.alert-meta {{ font-size: 12px; color: #999; }}
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }}
.badge-keep {{ background: #d4edda; color: #155724; }}
.badge-review {{ background: #fff3cd; color: #856404; }}
.footer {{ padding: 15px; text-align: center; font-size: 12px; color: #999; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">BreakPilot Alert</h2>
<p style="margin: 5px 0 0 0; opacity: 0.9;">{topic_name}</p>
</div>
<div class="content">
<div class="alert-card">
<div class="alert-title">
<a href="{url}">{title}</a>
</div>
<div class="alert-snippet">{snippet}</div>
<div class="alert-meta">
{decision_badge}
{score_display}
{rule_display}
</div>
</div>
</div>
<div class="footer">
Gesendet von BreakPilot Alerts Agent<br>
<a href="{dashboard_url}" style="color: #4A90E2;">Zur Inbox</a>
</div>
</div>
</body>
</html>
"""
class EmailAction(ActionHandler):
"""
E-Mail-Benachrichtigungen für Alerts.
Konfiguration:
- to: E-Mail-Adresse(n) des Empfängers
- subject_prefix: Optionaler Betreff-Prefix
- include_snippet: Snippet einbinden (default: true)
"""
@property
def action_type(self) -> ActionType:
return ActionType.EMAIL
def get_required_config_fields(self) -> List[str]:
return ["to"]
def validate_config(self, config: Dict[str, Any]) -> bool:
to = config.get("to")
if not to:
return False
if isinstance(to, str):
return "@" in to
if isinstance(to, list):
return all("@" in email for email in to)
return False
async def execute(
self,
context: AlertContext,
config: Dict[str, Any],
) -> ActionResult:
"""
Sendet eine E-Mail-Benachrichtigung.
Args:
context: Alert-Kontext
config: E-Mail-Konfiguration (to, subject_prefix, etc.)
Returns:
ActionResult
"""
try:
# Empfänger
to = config.get("to")
if isinstance(to, str):
recipients = [to]
else:
recipients = to
# Betreff
subject_prefix = config.get("subject_prefix", "[BreakPilot Alert]")
subject = f"{subject_prefix} {context.title[:50]}"
# HTML-Body generieren
html_body = self._render_email(context, config)
# E-Mail senden
sent = await self._send_email(
recipients=recipients,
subject=subject,
html_body=html_body,
)
if sent:
return ActionResult(
success=True,
action_type=self.action_type,
message=f"E-Mail an {len(recipients)} Empfänger gesendet",
details={"recipients": recipients, "subject": subject},
)
else:
return ActionResult(
success=False,
action_type=self.action_type,
message="E-Mail konnte nicht gesendet werden",
error="SMTP-Fehler",
)
except Exception as e:
logger.error(f"Email action error: {e}")
return ActionResult(
success=False,
action_type=self.action_type,
message="E-Mail-Fehler",
error=str(e),
)
def _render_email(
self,
context: AlertContext,
config: Dict[str, Any],
) -> str:
"""Rendert das E-Mail-Template."""
# Decision Badge
decision_badge = ""
if context.relevance_decision:
badge_class = "badge-keep" if context.relevance_decision == "KEEP" else "badge-review"
decision_badge = f'<span class="badge {badge_class}">{context.relevance_decision}</span>'
# Score
score_display = ""
if context.relevance_score is not None:
score_display = f' | Score: {context.relevance_score:.0%}'
# Matched Rule
rule_display = ""
if context.matched_rule:
rule_display = f' | Regel: {context.matched_rule}'
# Snippet
snippet = context.snippet[:200] if context.snippet else ""
if config.get("include_snippet", True) is False:
snippet = ""
# Dashboard URL
dashboard_url = config.get("dashboard_url", "http://localhost:8000/studio#alerts")
return EMAIL_TEMPLATE.format(
topic_name=context.topic_name,
title=context.title,
url=context.url,
snippet=snippet,
decision_badge=decision_badge,
score_display=score_display,
rule_display=rule_display,
dashboard_url=dashboard_url,
)
async def _send_email(
self,
recipients: List[str],
subject: str,
html_body: str,
) -> bool:
"""
Sendet die E-Mail über SMTP.
Verwendet aiosmtplib für async SMTP.
"""
import os
smtp_host = os.getenv("SMTP_HOST", "localhost")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_user = os.getenv("SMTP_USER", "")
smtp_pass = os.getenv("SMTP_PASS", "")
smtp_from = os.getenv("SMTP_FROM", "alerts@breakpilot.de")
try:
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# E-Mail erstellen
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = ", ".join(recipients)
# HTML-Teil
html_part = MIMEText(html_body, "html", "utf-8")
msg.attach(html_part)
# Senden
await aiosmtplib.send(
msg,
hostname=smtp_host,
port=smtp_port,
username=smtp_user if smtp_user else None,
password=smtp_pass if smtp_pass else None,
start_tls=True if smtp_port == 587 else False,
)
logger.info(f"Email sent to {recipients}")
return True
except ImportError:
logger.warning("aiosmtplib not installed. Email not sent.")
# Im Dev-Modus: Erfolg simulieren
logger.info(f"[DEV] Would send email to {recipients}: {subject}")
return True
except Exception as e:
logger.error(f"SMTP error: {e}")
return False

View File

@@ -0,0 +1,198 @@
"""
Slack Action für Alerts Agent.
Sendet Slack-Nachrichten für Alerts via Incoming Webhooks.
"""
import logging
from typing import Dict, Any, List
import httpx
from .base import ActionHandler, ActionResult, ActionType, AlertContext
logger = logging.getLogger(__name__)
class SlackAction(ActionHandler):
"""
Slack-Benachrichtigungen für Alerts via Incoming Webhooks.
Konfiguration:
- webhook_url: Slack Incoming Webhook URL
- channel: Optional - Channel überschreiben
- username: Optional - Bot-Username (default: BreakPilot Alerts)
- icon_emoji: Optional - Bot-Icon (default: :bell:)
"""
@property
def action_type(self) -> ActionType:
return ActionType.SLACK
def get_required_config_fields(self) -> List[str]:
return ["webhook_url"]
def validate_config(self, config: Dict[str, Any]) -> bool:
url = config.get("webhook_url", "")
return "hooks.slack.com" in url or url.startswith("https://")
async def execute(
self,
context: AlertContext,
config: Dict[str, Any],
) -> ActionResult:
"""
Sendet eine Slack-Nachricht.
Args:
context: Alert-Kontext
config: Slack-Konfiguration (webhook_url, channel, etc.)
Returns:
ActionResult
"""
try:
webhook_url = config.get("webhook_url")
# Slack-Payload mit Block Kit
payload = self._build_slack_payload(context, config)
# Request senden
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
# Slack gibt "ok" als Text zurück bei Erfolg
success = response.status_code == 200 and response.text == "ok"
return ActionResult(
success=success,
action_type=self.action_type,
message="Slack-Nachricht gesendet" if success else "Slack-Fehler",
details={
"status_code": response.status_code,
"response": response.text[:100],
},
error=None if success else response.text,
)
except Exception as e:
logger.error(f"Slack action error: {e}")
return ActionResult(
success=False,
action_type=self.action_type,
message="Slack-Fehler",
error=str(e),
)
def _build_slack_payload(
self,
context: AlertContext,
config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Erstellt den Slack-Payload mit Block Kit.
Verwendet Rich-Formatting für bessere Darstellung.
"""
# Basis-Payload
payload = {
"username": config.get("username", "BreakPilot Alerts"),
"icon_emoji": config.get("icon_emoji", ":bell:"),
}
# Channel überschreiben wenn angegeben
if config.get("channel"):
payload["channel"] = config["channel"]
# Block Kit Blocks
blocks = [
# Header
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"📰 {context.topic_name}",
"emoji": True,
}
},
# Alert-Titel als Link
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*<{context.url}|{context.title}>*",
}
},
]
# Snippet wenn vorhanden
if context.snippet:
snippet = context.snippet[:200]
if len(context.snippet) > 200:
snippet += "..."
blocks.append({
"type": "section",
"text": {
"type": "plain_text",
"text": snippet,
"emoji": False,
}
})
# Kontext-Felder (Score, Decision, Rule)
fields = []
if context.relevance_score is not None:
score_emoji = "🟢" if context.relevance_score >= 0.7 else "🟡" if context.relevance_score >= 0.4 else "🔴"
fields.append({
"type": "mrkdwn",
"text": f"*Score:* {score_emoji} {context.relevance_score:.0%}",
})
if context.relevance_decision:
decision_emoji = {"KEEP": "", "DROP": "", "REVIEW": "👀"}.get(context.relevance_decision, "")
fields.append({
"type": "mrkdwn",
"text": f"*Decision:* {decision_emoji} {context.relevance_decision}",
})
if context.matched_rule:
fields.append({
"type": "mrkdwn",
"text": f"*Regel:* {context.matched_rule}",
})
if context.tags:
fields.append({
"type": "mrkdwn",
"text": f"*Tags:* {', '.join(context.tags)}",
})
if fields:
blocks.append({
"type": "section",
"fields": fields[:10], # Max 10 Felder
})
# Divider
blocks.append({"type": "divider"})
# Actions (Link zur Inbox)
blocks.append({
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": f"<{config.get('dashboard_url', 'http://localhost:8000/studio#alerts')}|Zur Alerts Inbox> | Gesendet von BreakPilot",
}
]
})
payload["blocks"] = blocks
# Fallback-Text für Notifications
payload["text"] = f"Neuer Alert: {context.title}"
return payload

View File

@@ -0,0 +1,135 @@
"""
Webhook Action für Alerts Agent.
Sendet HTTP-Webhooks für Alerts.
"""
import logging
from typing import Dict, Any, List
import httpx
from .base import ActionHandler, ActionResult, ActionType, AlertContext
logger = logging.getLogger(__name__)
class WebhookAction(ActionHandler):
"""
Webhook-Benachrichtigungen für Alerts.
Konfiguration:
- url: Webhook-URL
- method: HTTP-Methode (default: POST)
- headers: Zusätzliche Headers
- include_full_context: Vollen Alert-Kontext senden (default: true)
"""
@property
def action_type(self) -> ActionType:
return ActionType.WEBHOOK
def get_required_config_fields(self) -> List[str]:
return ["url"]
def validate_config(self, config: Dict[str, Any]) -> bool:
url = config.get("url", "")
return url.startswith("http://") or url.startswith("https://")
async def execute(
self,
context: AlertContext,
config: Dict[str, Any],
) -> ActionResult:
"""
Sendet einen Webhook.
Args:
context: Alert-Kontext
config: Webhook-Konfiguration (url, method, headers)
Returns:
ActionResult
"""
try:
url = config.get("url")
method = config.get("method", "POST").upper()
headers = config.get("headers", {})
timeout = config.get("timeout", 30)
# Payload erstellen
payload = self._build_payload(context, config)
# Standard-Headers
headers.setdefault("Content-Type", "application/json")
headers.setdefault("User-Agent", "BreakPilot-AlertsAgent/1.0")
# Request senden
async with httpx.AsyncClient(timeout=timeout) as client:
if method == "POST":
response = await client.post(url, json=payload, headers=headers)
elif method == "PUT":
response = await client.put(url, json=payload, headers=headers)
else:
response = await client.get(url, params=payload, headers=headers)
# Erfolg prüfen
success = 200 <= response.status_code < 300
return ActionResult(
success=success,
action_type=self.action_type,
message=f"Webhook {method} {url} - Status {response.status_code}",
details={
"url": url,
"method": method,
"status_code": response.status_code,
"response_length": len(response.text),
},
error=None if success else f"HTTP {response.status_code}",
)
except httpx.TimeoutException:
logger.error(f"Webhook timeout: {config.get('url')}")
return ActionResult(
success=False,
action_type=self.action_type,
message="Webhook Timeout",
error="Request timed out",
)
except Exception as e:
logger.error(f"Webhook error: {e}")
return ActionResult(
success=False,
action_type=self.action_type,
message="Webhook-Fehler",
error=str(e),
)
def _build_payload(
self,
context: AlertContext,
config: Dict[str, Any],
) -> Dict[str, Any]:
"""Erstellt den Webhook-Payload."""
if config.get("include_full_context", True):
# Voller Kontext
return {
"event": "alert.matched",
"alert": context.to_dict(),
"timestamp": self._get_timestamp(),
}
else:
# Minimal-Payload
return {
"event": "alert.matched",
"alert_id": context.alert_id,
"title": context.title,
"url": context.url,
"timestamp": self._get_timestamp(),
}
def _get_timestamp(self) -> str:
"""Gibt aktuellen ISO-Timestamp zurück."""
from datetime import datetime
return datetime.utcnow().isoformat() + "Z"