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:
20
backend/alerts_agent/actions/__init__.py
Normal file
20
backend/alerts_agent/actions/__init__.py
Normal 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",
|
||||
]
|
||||
123
backend/alerts_agent/actions/base.py
Normal file
123
backend/alerts_agent/actions/base.py
Normal 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 []
|
||||
232
backend/alerts_agent/actions/dispatcher.py
Normal file
232
backend/alerts_agent/actions/dispatcher.py
Normal 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,
|
||||
)
|
||||
251
backend/alerts_agent/actions/email_action.py
Normal file
251
backend/alerts_agent/actions/email_action.py
Normal 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
|
||||
198
backend/alerts_agent/actions/slack_action.py
Normal file
198
backend/alerts_agent/actions/slack_action.py
Normal 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
|
||||
135
backend/alerts_agent/actions/webhook_action.py
Normal file
135
backend/alerts_agent/actions/webhook_action.py
Normal 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"
|
||||
Reference in New Issue
Block a user