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:
512
backend/alerts_agent/processing/rule_engine.py
Normal file
512
backend/alerts_agent/processing/rule_engine.py
Normal file
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
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": {},
|
||||
}
|
||||
Reference in New Issue
Block a user