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>
513 lines
14 KiB
Python
513 lines
14 KiB
Python
"""
|
|
Rule Engine für Alerts Agent.
|
|
|
|
Evaluiert Regeln gegen Alert-Items und führt Aktionen aus.
|
|
|
|
Regel-Struktur:
|
|
- Bedingungen: [{field, operator, value}, ...] (AND-verknüpft)
|
|
- Aktion: keep, drop, tag, email, webhook, slack
|
|
- Priorität: Höhere Priorität wird zuerst evaluiert
|
|
"""
|
|
import re
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import List, Dict, Any, Optional, Callable
|
|
from enum import Enum
|
|
|
|
from alerts_agent.db.models import AlertItemDB, AlertRuleDB, RuleActionEnum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConditionOperator(str, Enum):
|
|
"""Operatoren für Regel-Bedingungen."""
|
|
CONTAINS = "contains"
|
|
NOT_CONTAINS = "not_contains"
|
|
EQUALS = "equals"
|
|
NOT_EQUALS = "not_equals"
|
|
STARTS_WITH = "starts_with"
|
|
ENDS_WITH = "ends_with"
|
|
REGEX = "regex"
|
|
GREATER_THAN = "gt"
|
|
LESS_THAN = "lt"
|
|
GREATER_EQUAL = "gte"
|
|
LESS_EQUAL = "lte"
|
|
IN_LIST = "in"
|
|
NOT_IN_LIST = "not_in"
|
|
|
|
|
|
@dataclass
|
|
class RuleCondition:
|
|
"""Eine einzelne Regel-Bedingung."""
|
|
field: str # "title", "snippet", "url", "source", "relevance_score"
|
|
operator: ConditionOperator
|
|
value: Any # str, float, list
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict) -> "RuleCondition":
|
|
"""Erstellt eine Bedingung aus einem Dict."""
|
|
return cls(
|
|
field=data.get("field", ""),
|
|
operator=ConditionOperator(data.get("operator", data.get("op", "contains"))),
|
|
value=data.get("value", ""),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class RuleMatch:
|
|
"""Ergebnis einer Regel-Evaluierung."""
|
|
rule_id: str
|
|
rule_name: str
|
|
matched: bool
|
|
action: RuleActionEnum
|
|
action_config: Dict[str, Any]
|
|
conditions_met: List[str] # Welche Bedingungen haben gematched
|
|
|
|
|
|
def get_field_value(alert: AlertItemDB, field: str) -> Any:
|
|
"""
|
|
Extrahiert einen Feldwert aus einem Alert.
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
field: Feldname
|
|
|
|
Returns:
|
|
Feldwert oder None
|
|
"""
|
|
field_map = {
|
|
"title": alert.title,
|
|
"snippet": alert.snippet,
|
|
"url": alert.url,
|
|
"source": alert.source.value if alert.source else "",
|
|
"status": alert.status.value if alert.status else "",
|
|
"relevance_score": alert.relevance_score,
|
|
"relevance_decision": alert.relevance_decision.value if alert.relevance_decision else "",
|
|
"lang": alert.lang,
|
|
"topic_id": alert.topic_id,
|
|
}
|
|
|
|
return field_map.get(field)
|
|
|
|
|
|
def evaluate_condition(
|
|
alert: AlertItemDB,
|
|
condition: RuleCondition,
|
|
) -> bool:
|
|
"""
|
|
Evaluiert eine einzelne Bedingung gegen einen Alert.
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
condition: Zu evaluierende Bedingung
|
|
|
|
Returns:
|
|
True wenn Bedingung erfüllt
|
|
"""
|
|
field_value = get_field_value(alert, condition.field)
|
|
|
|
if field_value is None:
|
|
return False
|
|
|
|
op = condition.operator
|
|
target = condition.value
|
|
|
|
try:
|
|
# String-Operationen (case-insensitive)
|
|
if isinstance(field_value, str):
|
|
field_lower = field_value.lower()
|
|
target_lower = str(target).lower() if isinstance(target, str) else target
|
|
|
|
if op == ConditionOperator.CONTAINS:
|
|
return target_lower in field_lower
|
|
|
|
elif op == ConditionOperator.NOT_CONTAINS:
|
|
return target_lower not in field_lower
|
|
|
|
elif op == ConditionOperator.EQUALS:
|
|
return field_lower == target_lower
|
|
|
|
elif op == ConditionOperator.NOT_EQUALS:
|
|
return field_lower != target_lower
|
|
|
|
elif op == ConditionOperator.STARTS_WITH:
|
|
return field_lower.startswith(target_lower)
|
|
|
|
elif op == ConditionOperator.ENDS_WITH:
|
|
return field_lower.endswith(target_lower)
|
|
|
|
elif op == ConditionOperator.REGEX:
|
|
try:
|
|
return bool(re.search(str(target), field_value, re.IGNORECASE))
|
|
except re.error:
|
|
logger.warning(f"Invalid regex pattern: {target}")
|
|
return False
|
|
|
|
elif op == ConditionOperator.IN_LIST:
|
|
if isinstance(target, list):
|
|
return any(t.lower() in field_lower for t in target if isinstance(t, str))
|
|
return False
|
|
|
|
elif op == ConditionOperator.NOT_IN_LIST:
|
|
if isinstance(target, list):
|
|
return not any(t.lower() in field_lower for t in target if isinstance(t, str))
|
|
return True
|
|
|
|
# Numerische Operationen
|
|
elif isinstance(field_value, (int, float)):
|
|
target_num = float(target) if target else 0
|
|
|
|
if op == ConditionOperator.EQUALS:
|
|
return field_value == target_num
|
|
|
|
elif op == ConditionOperator.NOT_EQUALS:
|
|
return field_value != target_num
|
|
|
|
elif op == ConditionOperator.GREATER_THAN:
|
|
return field_value > target_num
|
|
|
|
elif op == ConditionOperator.LESS_THAN:
|
|
return field_value < target_num
|
|
|
|
elif op == ConditionOperator.GREATER_EQUAL:
|
|
return field_value >= target_num
|
|
|
|
elif op == ConditionOperator.LESS_EQUAL:
|
|
return field_value <= target_num
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error evaluating condition: {e}")
|
|
return False
|
|
|
|
return False
|
|
|
|
|
|
def evaluate_rule(
|
|
alert: AlertItemDB,
|
|
rule: AlertRuleDB,
|
|
) -> RuleMatch:
|
|
"""
|
|
Evaluiert eine Regel gegen einen Alert.
|
|
|
|
Alle Bedingungen müssen erfüllt sein (AND-Verknüpfung).
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
rule: Zu evaluierende Regel
|
|
|
|
Returns:
|
|
RuleMatch-Ergebnis
|
|
"""
|
|
conditions = rule.conditions or []
|
|
conditions_met = []
|
|
all_matched = True
|
|
|
|
for cond_dict in conditions:
|
|
condition = RuleCondition.from_dict(cond_dict)
|
|
if evaluate_condition(alert, condition):
|
|
conditions_met.append(f"{condition.field} {condition.operator.value} {condition.value}")
|
|
else:
|
|
all_matched = False
|
|
|
|
# Wenn keine Bedingungen definiert sind, matcht die Regel immer
|
|
if not conditions:
|
|
all_matched = True
|
|
|
|
return RuleMatch(
|
|
rule_id=rule.id,
|
|
rule_name=rule.name,
|
|
matched=all_matched,
|
|
action=rule.action_type,
|
|
action_config=rule.action_config or {},
|
|
conditions_met=conditions_met,
|
|
)
|
|
|
|
|
|
def evaluate_rules_for_alert(
|
|
alert: AlertItemDB,
|
|
rules: List[AlertRuleDB],
|
|
) -> Optional[RuleMatch]:
|
|
"""
|
|
Evaluiert alle Regeln gegen einen Alert und gibt den ersten Match zurück.
|
|
|
|
Regeln werden nach Priorität (absteigend) evaluiert.
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
rules: Liste von Regeln (sollte bereits nach Priorität sortiert sein)
|
|
|
|
Returns:
|
|
Erster RuleMatch oder None
|
|
"""
|
|
for rule in rules:
|
|
if not rule.is_active:
|
|
continue
|
|
|
|
# Topic-Filter: Regel gilt nur für bestimmtes Topic
|
|
if rule.topic_id and rule.topic_id != alert.topic_id:
|
|
continue
|
|
|
|
match = evaluate_rule(alert, rule)
|
|
|
|
if match.matched:
|
|
logger.debug(
|
|
f"Rule '{rule.name}' matched alert '{alert.id[:8]}': "
|
|
f"{match.conditions_met}"
|
|
)
|
|
return match
|
|
|
|
return None
|
|
|
|
|
|
class RuleEngine:
|
|
"""
|
|
Rule Engine für Batch-Verarbeitung von Alerts.
|
|
|
|
Verwendet für das Scoring von mehreren Alerts gleichzeitig.
|
|
"""
|
|
|
|
def __init__(self, db_session):
|
|
"""
|
|
Initialisiert die Rule Engine.
|
|
|
|
Args:
|
|
db_session: SQLAlchemy Session
|
|
"""
|
|
self.db = db_session
|
|
self._rules_cache: Optional[List[AlertRuleDB]] = None
|
|
|
|
def _get_active_rules(self) -> List[AlertRuleDB]:
|
|
"""Lädt aktive Regeln aus der Datenbank (cached)."""
|
|
if self._rules_cache is None:
|
|
from alerts_agent.db.repository import RuleRepository
|
|
repo = RuleRepository(self.db)
|
|
self._rules_cache = repo.get_active()
|
|
|
|
return self._rules_cache
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Leert den Regel-Cache."""
|
|
self._rules_cache = None
|
|
|
|
def process_alert(
|
|
self,
|
|
alert: AlertItemDB,
|
|
) -> Optional[RuleMatch]:
|
|
"""
|
|
Verarbeitet einen Alert mit allen aktiven Regeln.
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
|
|
Returns:
|
|
RuleMatch wenn eine Regel matcht, sonst None
|
|
"""
|
|
rules = self._get_active_rules()
|
|
return evaluate_rules_for_alert(alert, rules)
|
|
|
|
def process_alerts(
|
|
self,
|
|
alerts: List[AlertItemDB],
|
|
) -> Dict[str, RuleMatch]:
|
|
"""
|
|
Verarbeitet mehrere Alerts mit allen aktiven Regeln.
|
|
|
|
Args:
|
|
alerts: Liste von Alert-Items
|
|
|
|
Returns:
|
|
Dict von alert_id -> RuleMatch (nur für gematschte Alerts)
|
|
"""
|
|
rules = self._get_active_rules()
|
|
results = {}
|
|
|
|
for alert in alerts:
|
|
match = evaluate_rules_for_alert(alert, rules)
|
|
if match:
|
|
results[alert.id] = match
|
|
|
|
return results
|
|
|
|
def apply_rule_actions(
|
|
self,
|
|
alert: AlertItemDB,
|
|
match: RuleMatch,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Wendet die Regel-Aktion auf einen Alert an.
|
|
|
|
Args:
|
|
alert: Alert-Item
|
|
match: RuleMatch mit Aktionsinformationen
|
|
|
|
Returns:
|
|
Dict mit Ergebnis der Aktion
|
|
"""
|
|
from alerts_agent.db.repository import AlertItemRepository, RuleRepository
|
|
|
|
alert_repo = AlertItemRepository(self.db)
|
|
rule_repo = RuleRepository(self.db)
|
|
|
|
action = match.action
|
|
config = match.action_config
|
|
|
|
result = {"action": action.value, "success": False}
|
|
|
|
try:
|
|
if action == RuleActionEnum.KEEP:
|
|
# Alert als KEEP markieren
|
|
alert_repo.update_scoring(
|
|
alert_id=alert.id,
|
|
score=1.0,
|
|
decision="KEEP",
|
|
reasons=["rule_match"],
|
|
summary=f"Matched rule: {match.rule_name}",
|
|
model="rule_engine",
|
|
)
|
|
result["success"] = True
|
|
|
|
elif action == RuleActionEnum.DROP:
|
|
# Alert als DROP markieren
|
|
alert_repo.update_scoring(
|
|
alert_id=alert.id,
|
|
score=0.0,
|
|
decision="DROP",
|
|
reasons=["rule_match"],
|
|
summary=f"Dropped by rule: {match.rule_name}",
|
|
model="rule_engine",
|
|
)
|
|
result["success"] = True
|
|
|
|
elif action == RuleActionEnum.TAG:
|
|
# Tags hinzufügen
|
|
tags = config.get("tags", [])
|
|
if tags:
|
|
existing_tags = alert.user_tags or []
|
|
new_tags = list(set(existing_tags + tags))
|
|
alert_repo.update(alert.id, user_tags=new_tags)
|
|
result["tags_added"] = tags
|
|
result["success"] = True
|
|
|
|
elif action == RuleActionEnum.EMAIL:
|
|
# E-Mail-Benachrichtigung senden
|
|
# Wird von Actions-Modul behandelt
|
|
result["email_config"] = config
|
|
result["success"] = True
|
|
result["deferred"] = True # Wird später gesendet
|
|
|
|
elif action == RuleActionEnum.WEBHOOK:
|
|
# Webhook aufrufen
|
|
# Wird von Actions-Modul behandelt
|
|
result["webhook_config"] = config
|
|
result["success"] = True
|
|
result["deferred"] = True
|
|
|
|
elif action == RuleActionEnum.SLACK:
|
|
# Slack-Nachricht senden
|
|
# Wird von Actions-Modul behandelt
|
|
result["slack_config"] = config
|
|
result["success"] = True
|
|
result["deferred"] = True
|
|
|
|
# Match-Count erhöhen
|
|
rule_repo.increment_match_count(match.rule_id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error applying rule action: {e}")
|
|
result["error"] = str(e)
|
|
|
|
return result
|
|
|
|
|
|
# Convenience-Funktionen für einfache Nutzung
|
|
def create_keyword_rule(
|
|
name: str,
|
|
keywords: List[str],
|
|
action: str = "keep",
|
|
field: str = "title",
|
|
) -> Dict:
|
|
"""
|
|
Erstellt eine Keyword-basierte Regel.
|
|
|
|
Args:
|
|
name: Regelname
|
|
keywords: Liste von Keywords (OR-verknüpft über IN_LIST)
|
|
action: Aktion (keep, drop, tag)
|
|
field: Feld zum Prüfen (title, snippet, url)
|
|
|
|
Returns:
|
|
Regel-Definition als Dict
|
|
"""
|
|
return {
|
|
"name": name,
|
|
"conditions": [
|
|
{
|
|
"field": field,
|
|
"operator": "in",
|
|
"value": keywords,
|
|
}
|
|
],
|
|
"action_type": action,
|
|
"action_config": {},
|
|
}
|
|
|
|
|
|
def create_exclusion_rule(
|
|
name: str,
|
|
excluded_terms: List[str],
|
|
field: str = "title",
|
|
) -> Dict:
|
|
"""
|
|
Erstellt eine Ausschluss-Regel.
|
|
|
|
Args:
|
|
name: Regelname
|
|
excluded_terms: Liste von auszuschließenden Begriffen
|
|
field: Feld zum Prüfen
|
|
|
|
Returns:
|
|
Regel-Definition als Dict
|
|
"""
|
|
return {
|
|
"name": name,
|
|
"conditions": [
|
|
{
|
|
"field": field,
|
|
"operator": "in",
|
|
"value": excluded_terms,
|
|
}
|
|
],
|
|
"action_type": "drop",
|
|
"action_config": {},
|
|
}
|
|
|
|
|
|
def create_score_threshold_rule(
|
|
name: str,
|
|
min_score: float,
|
|
action: str = "keep",
|
|
) -> Dict:
|
|
"""
|
|
Erstellt eine Score-basierte Regel.
|
|
|
|
Args:
|
|
name: Regelname
|
|
min_score: Mindest-Score
|
|
action: Aktion bei Erreichen des Scores
|
|
|
|
Returns:
|
|
Regel-Definition als Dict
|
|
"""
|
|
return {
|
|
"name": name,
|
|
"conditions": [
|
|
{
|
|
"field": "relevance_score",
|
|
"operator": "gte",
|
|
"value": min_score,
|
|
}
|
|
],
|
|
"action_type": action,
|
|
"action_config": {},
|
|
}
|