website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""
|
|
SQLAlchemy Database Models fuer Alerts Agent.
|
|
|
|
Split into:
|
|
- enums.py: All enum definitions
|
|
- models.py (this file): Core ORM models (Topic, Item, Rule, Profile)
|
|
- models_dual_mode.py: Dual-mode system (Template, Source, Subscription, Digest)
|
|
|
|
All symbols are re-exported here for backward compatibility.
|
|
"""
|
|
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 uuid
|
|
|
|
from classroom_engine.database import Base
|
|
|
|
# Re-export all enums
|
|
from .enums import (
|
|
AlertSourceEnum,
|
|
AlertStatusEnum,
|
|
RelevanceDecisionEnum,
|
|
FeedTypeEnum,
|
|
RuleActionEnum,
|
|
ImportanceLevelEnum,
|
|
AlertModeEnum,
|
|
MigrationModeEnum,
|
|
DigestStatusEnum,
|
|
UserRoleEnum,
|
|
)
|
|
|
|
# Re-export dual-mode models
|
|
from .models_dual_mode import (
|
|
AlertTemplateDB,
|
|
AlertSourceDB,
|
|
UserAlertSubscriptionDB,
|
|
AlertDigestDB,
|
|
)
|
|
|
|
|
|
class AlertTopicDB(Base):
|
|
"""
|
|
Alert Topic / Feed-Quelle.
|
|
Repraesentiert 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)
|
|
name = Column(String(255), nullable=False)
|
|
description = Column(Text, default="")
|
|
feed_url = Column(String(2000), nullable=True)
|
|
feed_type = Column(SQLEnum(FeedTypeEnum), default=FeedTypeEnum.RSS, nullable=False)
|
|
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)
|
|
total_items_fetched = Column(Integer, default=0)
|
|
items_kept = Column(Integer, default=0)
|
|
items_dropped = Column(Integer, default=0)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
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)
|
|
title = Column(Text, nullable=False)
|
|
url = Column(String(2000), nullable=False)
|
|
snippet = Column(Text, default="")
|
|
article_text = Column(Text, nullable=True)
|
|
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 = Column(SQLEnum(AlertSourceEnum), default=AlertSourceEnum.GOOGLE_ALERTS_RSS, nullable=False)
|
|
url_hash = Column(String(64), unique=True, nullable=False, index=True)
|
|
content_hash = Column(String(64), nullable=True)
|
|
canonical_url = Column(String(2000), nullable=True)
|
|
status = Column(SQLEnum(AlertStatusEnum), default=AlertStatusEnum.NEW, nullable=False, index=True)
|
|
cluster_id = Column(String(36), nullable=True)
|
|
relevance_score = Column(Float, nullable=True)
|
|
relevance_decision = Column(SQLEnum(RelevanceDecisionEnum), nullable=True, index=True)
|
|
relevance_reasons = Column(JSON, default=list)
|
|
relevance_summary = Column(Text, nullable=True)
|
|
scored_by_model = Column(String(100), nullable=True)
|
|
scored_at = Column(DateTime, nullable=True)
|
|
user_marked_relevant = Column(Boolean, nullable=True)
|
|
user_tags = Column(JSON, default=list)
|
|
user_notes = Column(Text, nullable=True)
|
|
# Guided Mode Fields
|
|
importance_level = Column(SQLEnum(ImportanceLevelEnum), nullable=True, index=True)
|
|
why_relevant = Column(Text, nullable=True)
|
|
next_steps = Column(JSON, default=list)
|
|
action_deadline = Column(DateTime, nullable=True)
|
|
source_name = Column(String(255), nullable=True)
|
|
source_credibility = Column(String(50), default="official")
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
topic = relationship("AlertTopicDB", back_populates="alerts")
|
|
|
|
__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 fuer Alerts."""
|
|
__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)
|
|
name = Column(String(255), nullable=False)
|
|
description = Column(Text, default="")
|
|
conditions = Column(JSON, nullable=False, default=list)
|
|
action_type = Column(SQLEnum(RuleActionEnum), default=RuleActionEnum.KEEP, nullable=False)
|
|
action_config = Column(JSON, default=dict)
|
|
priority = Column(Integer, default=0, index=True)
|
|
is_active = Column(Boolean, default=True, index=True)
|
|
match_count = Column(Integer, default=0)
|
|
last_matched_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
topic = relationship("AlertTopicDB", back_populates="rules")
|
|
|
|
def __repr__(self):
|
|
return f"<AlertRule {self.name} ({self.action_type.value})>"
|
|
|
|
|
|
class AlertProfileDB(Base):
|
|
"""Nutzer-Profil fuer Relevanz-Scoring."""
|
|
__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 = Column(String(255), default="Default")
|
|
priorities = Column(JSON, default=list)
|
|
exclusions = Column(JSON, default=list)
|
|
positive_examples = Column(JSON, default=list)
|
|
negative_examples = Column(JSON, default=list)
|
|
policies = Column(JSON, default=dict)
|
|
total_scored = Column(Integer, default=0)
|
|
total_kept = Column(Integer, default=0)
|
|
total_dropped = Column(Integer, default=0)
|
|
accuracy_estimate = Column(Float, nullable=True)
|
|
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 fuer LLM-Prompt."""
|
|
lines = ["## Relevanzprofil des Nutzers\n"]
|
|
|
|
if self.priorities:
|
|
lines.append("### Prioritaeten (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("")
|
|
|
|
if self.exclusions:
|
|
lines.append("### Ausschluesse (ignorieren):")
|
|
lines.append(f"Themen mit diesen Keywords: {', '.join(self.exclusions)}")
|
|
lines.append("")
|
|
|
|
if self.positive_examples:
|
|
lines.append("### Beispiele fuer 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("")
|
|
|
|
if self.negative_examples:
|
|
lines.append("### Beispiele fuer 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("")
|
|
|
|
if self.policies:
|
|
lines.append("### Zusaetzliche 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 fuer Bildungsthemen."""
|
|
return cls(
|
|
name="Bildung Default",
|
|
priorities=[
|
|
{
|
|
"label": "Inklusion", "weight": 0.9,
|
|
"keywords": ["inklusiv", "Foerderbedarf", "Behinderung", "Barrierefreiheit"],
|
|
"description": "Inklusive Bildung, Foerderschulen, Nachteilsausgleich"
|
|
},
|
|
{
|
|
"label": "Datenschutz Schule", "weight": 0.85,
|
|
"keywords": ["DSGVO", "Schuelerfotos", "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,
|
|
}
|
|
)
|