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,17 @@
"""Alert Agent API."""
from fastapi import APIRouter
from .routes import router as main_router
from .topics import router as topics_router
from .rules import router as rules_router
# Erstelle einen kombinierten Router
router = APIRouter(prefix="/alerts", tags=["Alerts Agent"])
# Include alle Sub-Router
router.include_router(main_router)
router.include_router(topics_router)
router.include_router(rules_router)
__all__ = ["router"]

View File

@@ -0,0 +1,551 @@
"""
API Routes fuer Alert Digests (Wochenzusammenfassungen).
Endpoints:
- GET /digests - Liste aller Digests fuer den User
- GET /digests/{id} - Digest-Details
- GET /digests/{id}/pdf - PDF-Download
- POST /digests/generate - Digest manuell generieren
- POST /digests/{id}/send-email - Digest per E-Mail versenden
"""
import uuid
import io
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
from ..db.models import (
AlertDigestDB, UserAlertSubscriptionDB, DigestStatusEnum
)
from ..processing.digest_generator import DigestGenerator
router = APIRouter(prefix="/digests", tags=["digests"])
# ============================================================================
# Request/Response Models
# ============================================================================
class DigestListItem(BaseModel):
"""Kurze Digest-Info fuer Liste."""
id: str
period_start: datetime
period_end: datetime
total_alerts: int
critical_count: int
urgent_count: int
status: str
created_at: datetime
class DigestDetail(BaseModel):
"""Vollstaendige Digest-Details."""
id: str
subscription_id: Optional[str]
user_id: str
period_start: datetime
period_end: datetime
summary_html: str
summary_pdf_url: Optional[str]
total_alerts: int
critical_count: int
urgent_count: int
important_count: int
review_count: int
info_count: int
status: str
sent_at: Optional[datetime]
created_at: datetime
class DigestListResponse(BaseModel):
"""Response fuer Digest-Liste."""
digests: List[DigestListItem]
total: int
class GenerateDigestRequest(BaseModel):
"""Request fuer manuelle Digest-Generierung."""
weeks_back: int = Field(default=1, ge=1, le=4, description="Wochen zurueck")
force_regenerate: bool = Field(default=False, description="Vorhandenen Digest ueberschreiben")
class GenerateDigestResponse(BaseModel):
"""Response fuer Digest-Generierung."""
status: str
digest_id: Optional[str]
message: str
class SendEmailRequest(BaseModel):
"""Request fuer E-Mail-Versand."""
email: Optional[str] = Field(default=None, description="E-Mail-Adresse (optional, sonst aus Subscription)")
class SendEmailResponse(BaseModel):
"""Response fuer E-Mail-Versand."""
status: str
sent_to: str
message: str
# ============================================================================
# Helper Functions
# ============================================================================
def get_user_id_from_request() -> str:
"""
Extrahiert User-ID aus Request.
TODO: JWT-Token auswerten, aktuell Dummy.
"""
return "demo-user"
def _digest_to_list_item(digest: AlertDigestDB) -> DigestListItem:
"""Konvertiere DB-Model zu List-Item."""
return DigestListItem(
id=digest.id,
period_start=digest.period_start,
period_end=digest.period_end,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
status=digest.status.value if digest.status else "pending",
created_at=digest.created_at
)
def _digest_to_detail(digest: AlertDigestDB) -> DigestDetail:
"""Konvertiere DB-Model zu Detail."""
return DigestDetail(
id=digest.id,
subscription_id=digest.subscription_id,
user_id=digest.user_id,
period_start=digest.period_start,
period_end=digest.period_end,
summary_html=digest.summary_html or "",
summary_pdf_url=digest.summary_pdf_url,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
important_count=digest.important_count or 0,
review_count=digest.review_count or 0,
info_count=digest.info_count or 0,
status=digest.status.value if digest.status else "pending",
sent_at=digest.sent_at,
created_at=digest.created_at
)
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=DigestListResponse)
async def list_digests(
limit: int = Query(10, ge=1, le=50),
offset: int = Query(0, ge=0),
db: DBSession = Depends(get_db)
):
"""
Liste alle Digests des aktuellen Users.
Sortiert nach Erstellungsdatum (neueste zuerst).
"""
user_id = get_user_id_from_request()
query = db.query(AlertDigestDB).filter(
AlertDigestDB.user_id == user_id
).order_by(AlertDigestDB.created_at.desc())
total = query.count()
digests = query.offset(offset).limit(limit).all()
return DigestListResponse(
digests=[_digest_to_list_item(d) for d in digests],
total=total
)
@router.get("/latest", response_model=DigestDetail)
async def get_latest_digest(
db: DBSession = Depends(get_db)
):
"""
Hole den neuesten Digest des Users.
"""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
AlertDigestDB.user_id == user_id
).order_by(AlertDigestDB.created_at.desc()).first()
if not digest:
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
return _digest_to_detail(digest)
@router.get("/{digest_id}", response_model=DigestDetail)
async def get_digest(
digest_id: str,
db: DBSession = Depends(get_db)
):
"""
Hole Details eines spezifischen Digests.
"""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
AlertDigestDB.id == digest_id,
AlertDigestDB.user_id == user_id
).first()
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
return _digest_to_detail(digest)
@router.get("/{digest_id}/pdf")
async def get_digest_pdf(
digest_id: str,
db: DBSession = Depends(get_db)
):
"""
Generiere und lade PDF-Version des Digests herunter.
"""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
AlertDigestDB.id == digest_id,
AlertDigestDB.user_id == user_id
).first()
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
if not digest.summary_html:
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
# PDF generieren
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {str(e)}")
# Dateiname
filename = f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}_{digest.period_end.strftime('%Y%m%d')}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
@router.get("/latest/pdf")
async def get_latest_digest_pdf(
db: DBSession = Depends(get_db)
):
"""
PDF des neuesten Digests herunterladen.
"""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
AlertDigestDB.user_id == user_id
).order_by(AlertDigestDB.created_at.desc()).first()
if not digest:
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
if not digest.summary_html:
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
# PDF generieren
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {str(e)}")
filename = f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}_{digest.period_end.strftime('%Y%m%d')}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
@router.post("/generate", response_model=GenerateDigestResponse)
async def generate_digest(
request: GenerateDigestRequest = None,
db: DBSession = Depends(get_db)
):
"""
Generiere einen neuen Digest manuell.
Normalerweise werden Digests automatisch woechentlich generiert.
Diese Route erlaubt manuelle Generierung fuer Tests oder On-Demand.
"""
user_id = get_user_id_from_request()
weeks_back = request.weeks_back if request else 1
# Pruefe ob bereits ein Digest fuer diesen Zeitraum existiert
now = datetime.utcnow()
period_end = now - timedelta(days=now.weekday())
period_start = period_end - timedelta(weeks=weeks_back)
existing = db.query(AlertDigestDB).filter(
AlertDigestDB.user_id == user_id,
AlertDigestDB.period_start >= period_start - timedelta(days=1),
AlertDigestDB.period_end <= period_end + timedelta(days=1)
).first()
if existing and not (request and request.force_regenerate):
return GenerateDigestResponse(
status="exists",
digest_id=existing.id,
message="Digest fuer diesen Zeitraum existiert bereits"
)
# Generiere neuen Digest
generator = DigestGenerator(db)
try:
digest = await generator.generate_weekly_digest(user_id, weeks_back)
if digest:
return GenerateDigestResponse(
status="success",
digest_id=digest.id,
message="Digest erfolgreich generiert"
)
else:
return GenerateDigestResponse(
status="empty",
digest_id=None,
message="Keine Alerts fuer diesen Zeitraum vorhanden"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler bei Digest-Generierung: {str(e)}")
@router.post("/{digest_id}/send-email", response_model=SendEmailResponse)
async def send_digest_email(
digest_id: str,
request: SendEmailRequest = None,
db: DBSession = Depends(get_db)
):
"""
Versende Digest per E-Mail.
"""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
AlertDigestDB.id == digest_id,
AlertDigestDB.user_id == user_id
).first()
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
# E-Mail-Adresse ermitteln
email = None
if request and request.email:
email = request.email
else:
# Aus Subscription holen
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == digest.subscription_id
).first()
if subscription:
email = subscription.notification_email
if not email:
raise HTTPException(status_code=400, detail="Keine E-Mail-Adresse angegeben")
# E-Mail versenden
try:
await send_digest_by_email(digest, email)
# Status aktualisieren
digest.status = DigestStatusEnum.SENT
digest.sent_at = datetime.utcnow()
db.commit()
return SendEmailResponse(
status="success",
sent_to=email,
message="E-Mail erfolgreich versendet"
)
except Exception as e:
digest.status = DigestStatusEnum.FAILED
db.commit()
raise HTTPException(status_code=500, detail=f"E-Mail-Versand fehlgeschlagen: {str(e)}")
# ============================================================================
# PDF Generation
# ============================================================================
async def generate_pdf_from_html(html_content: str) -> bytes:
"""
Generiere PDF aus HTML.
Verwendet WeasyPrint oder wkhtmltopdf als Fallback.
"""
try:
# Versuche WeasyPrint (bevorzugt)
from weasyprint import HTML
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
except ImportError:
pass
try:
# Fallback: wkhtmltopdf via pdfkit
import pdfkit
pdf_bytes = pdfkit.from_string(html_content, False)
return pdf_bytes
except ImportError:
pass
try:
# Fallback: xhtml2pdf
from xhtml2pdf import pisa
result = io.BytesIO()
pisa.CreatePDF(io.StringIO(html_content), dest=result)
return result.getvalue()
except ImportError:
pass
# Letzter Fallback: Einfache Text-Konvertierung
raise ImportError(
"Keine PDF-Bibliothek verfuegbar. "
"Installieren Sie: pip install weasyprint oder pip install pdfkit oder pip install xhtml2pdf"
)
# ============================================================================
# Email Sending
# ============================================================================
async def send_digest_by_email(digest: AlertDigestDB, recipient_email: str):
"""
Versende Digest per E-Mail.
Verwendet:
- Lokalen SMTP-Server (Postfix/Sendmail)
- SMTP-Relay (z.B. SES, Mailgun)
- SendGrid API
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
# E-Mail zusammenstellen
msg = MIMEMultipart('alternative')
msg['Subject'] = f"Wochenbericht: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}"
msg['From'] = os.getenv('SMTP_FROM', 'alerts@breakpilot.app')
msg['To'] = recipient_email
# Text-Version
text_content = f"""
BreakPilot Alerts - Wochenbericht
Zeitraum: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}
Gesamt: {digest.total_alerts} Meldungen
Kritisch: {digest.critical_count}
Dringend: {digest.urgent_count}
Oeffnen Sie die HTML-Version fuer die vollstaendige Uebersicht.
---
Diese E-Mail wurde automatisch von BreakPilot Alerts generiert.
"""
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
# HTML-Version
if digest.summary_html:
msg.attach(MIMEText(digest.summary_html, 'html', 'utf-8'))
# PDF-Anhang (optional)
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
pdf_attachment = MIMEApplication(pdf_bytes, _subtype='pdf')
pdf_attachment.add_header(
'Content-Disposition', 'attachment',
filename=f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}.pdf"
)
msg.attach(pdf_attachment)
except Exception:
pass # PDF-Anhang ist optional
# Senden
smtp_host = os.getenv('SMTP_HOST', 'localhost')
smtp_port = int(os.getenv('SMTP_PORT', '25'))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
try:
if smtp_port == 465:
# SSL
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
else:
server = smtplib.SMTP(smtp_host, smtp_port)
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
except Exception as e:
# Fallback: SendGrid API
sendgrid_key = os.getenv('SENDGRID_API_KEY')
if sendgrid_key:
await send_via_sendgrid(msg, sendgrid_key)
else:
raise e
async def send_via_sendgrid(msg, api_key: str):
"""Fallback: SendGrid API."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"personalizations": [{"to": [{"email": msg['To']}]}],
"from": {"email": msg['From']},
"subject": msg['Subject'],
"content": [
{"type": "text/plain", "value": msg.get_payload(0).get_payload()},
{"type": "text/html", "value": msg.get_payload(1).get_payload() if len(msg.get_payload()) > 1 else ""}
]
}
)
if response.status_code >= 400:
raise Exception(f"SendGrid error: {response.status_code}")

View File

@@ -0,0 +1,510 @@
"""
API Routes für Alerts Agent.
Endpoints:
- POST /alerts/ingest - Manuell Alerts importieren
- POST /alerts/run - Scoring Pipeline starten
- GET /alerts/inbox - Inbox Items abrufen
- POST /alerts/feedback - Relevanz-Feedback geben
- GET /alerts/profile - User Relevance Profile
- PUT /alerts/profile - Profile aktualisieren
"""
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from ..models.alert_item import AlertItem, AlertStatus
from ..models.relevance_profile import RelevanceProfile, PriorityItem
from ..processing.relevance_scorer import RelevanceDecision, RelevanceScorer
router = APIRouter(prefix="/alerts", tags=["alerts"])
# LLM Scorer Konfiguration aus Umgebungsvariablen
LLM_GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "http://localhost:8000/llm")
LLM_API_KEY = os.getenv("LLM_API_KEYS", "").split(",")[0] if os.getenv("LLM_API_KEYS") else ""
ALERTS_USE_LLM = os.getenv("ALERTS_USE_LLM", "false").lower() == "true"
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# ============================================================================
_alerts_store: dict[str, AlertItem] = {}
_profile_store: dict[str, RelevanceProfile] = {}
# ============================================================================
# Request/Response Models
# ============================================================================
class AlertIngestRequest(BaseModel):
"""Request für manuelles Alert-Import."""
title: str = Field(..., min_length=1, max_length=500)
url: str = Field(..., min_length=1)
snippet: Optional[str] = Field(default=None, max_length=2000)
topic_label: str = Field(default="Manual Import")
published_at: Optional[datetime] = None
class AlertIngestResponse(BaseModel):
"""Response für Alert-Import."""
id: str
status: str
message: str
class AlertRunRequest(BaseModel):
"""Request für Scoring-Pipeline."""
limit: int = Field(default=50, ge=1, le=200)
skip_scored: bool = Field(default=True)
class AlertRunResponse(BaseModel):
"""Response für Scoring-Pipeline."""
processed: int
keep: int
drop: int
review: int
errors: int
duration_ms: int
class InboxItem(BaseModel):
"""Ein Item in der Inbox."""
id: str
title: str
url: str
snippet: Optional[str]
topic_label: str
published_at: Optional[datetime]
relevance_score: Optional[float]
relevance_decision: Optional[str]
relevance_summary: Optional[str]
status: str
class InboxResponse(BaseModel):
"""Response für Inbox-Abfrage."""
items: list[InboxItem]
total: int
page: int
page_size: int
class FeedbackRequest(BaseModel):
"""Request für Relevanz-Feedback."""
alert_id: str
is_relevant: bool
reason: Optional[str] = None
tags: list[str] = Field(default_factory=list)
class FeedbackResponse(BaseModel):
"""Response für Feedback."""
success: bool
message: str
profile_updated: bool
class ProfilePriorityRequest(BaseModel):
"""Priority für Profile-Update."""
label: str
weight: float = Field(default=0.5, ge=0.0, le=1.0)
keywords: list[str] = Field(default_factory=list)
description: Optional[str] = None
class ProfileUpdateRequest(BaseModel):
"""Request für Profile-Update."""
priorities: Optional[list[ProfilePriorityRequest]] = None
exclusions: Optional[list[str]] = None
policies: Optional[dict] = None
class ProfileResponse(BaseModel):
"""Response für Profile."""
id: str
priorities: list[dict]
exclusions: list[str]
policies: dict
total_scored: int
total_kept: int
total_dropped: int
accuracy_estimate: Optional[float]
# ============================================================================
# Endpoints
# ============================================================================
@router.post("/ingest", response_model=AlertIngestResponse)
async def ingest_alert(request: AlertIngestRequest):
"""
Manuell einen Alert importieren.
Nützlich für Tests oder manuelles Hinzufügen von Artikeln.
"""
alert = AlertItem(
title=request.title,
url=request.url,
snippet=request.snippet or "",
topic_label=request.topic_label,
published_at=request.published_at,
)
_alerts_store[alert.id] = alert
return AlertIngestResponse(
id=alert.id,
status="created",
message=f"Alert '{alert.title[:50]}...' importiert"
)
@router.post("/run", response_model=AlertRunResponse)
async def run_scoring_pipeline(request: AlertRunRequest):
"""
Scoring-Pipeline für neue Alerts starten.
Bewertet alle unbewerteten Alerts und klassifiziert sie
in KEEP, DROP oder REVIEW.
Wenn ALERTS_USE_LLM=true, wird das LLM Gateway für Scoring verwendet.
Sonst wird ein schnelles Keyword-basiertes Scoring durchgeführt.
"""
import time
start = time.time()
# Alle unbewerteten Alerts holen
alerts_to_score = [
a for a in _alerts_store.values()
if a.status == AlertStatus.NEW or (not request.skip_scored and a.status == AlertStatus.SCORED)
][:request.limit]
if not alerts_to_score:
return AlertRunResponse(
processed=0, keep=0, drop=0, review=0, errors=0,
duration_ms=int((time.time() - start) * 1000)
)
keep = drop = review = errors = 0
# Profil für Scoring laden
profile = _profile_store.get("default")
if not profile:
profile = RelevanceProfile.create_default_education_profile()
profile.id = "default"
_profile_store["default"] = profile
if ALERTS_USE_LLM and LLM_API_KEY:
# LLM-basiertes Scoring über Gateway
scorer = RelevanceScorer(
gateway_url=LLM_GATEWAY_URL,
api_key=LLM_API_KEY,
model="breakpilot-teacher-8b",
)
try:
results = await scorer.score_batch(alerts_to_score, profile=profile)
for result in results:
if result.error:
errors += 1
elif result.decision == RelevanceDecision.KEEP:
keep += 1
elif result.decision == RelevanceDecision.DROP:
drop += 1
else:
review += 1
finally:
await scorer.close()
else:
# Fallback: Keyword-basiertes Scoring (schnell, ohne LLM)
for alert in alerts_to_score:
title_lower = alert.title.lower()
snippet_lower = (alert.snippet or "").lower()
combined = title_lower + " " + snippet_lower
# Ausschlüsse aus Profil prüfen
if any(excl.lower() in combined for excl in profile.exclusions):
alert.relevance_score = 0.15
alert.relevance_decision = RelevanceDecision.DROP.value
drop += 1
# Prioritäten aus Profil prüfen
elif any(
p.label.lower() in combined or
any(kw.lower() in combined for kw in (p.keywords if hasattr(p, 'keywords') else []))
for p in profile.priorities
):
alert.relevance_score = 0.85
alert.relevance_decision = RelevanceDecision.KEEP.value
keep += 1
else:
alert.relevance_score = 0.55
alert.relevance_decision = RelevanceDecision.REVIEW.value
review += 1
alert.status = AlertStatus.SCORED
duration_ms = int((time.time() - start) * 1000)
return AlertRunResponse(
processed=len(alerts_to_score),
keep=keep,
drop=drop,
review=review,
errors=errors,
duration_ms=duration_ms,
)
@router.get("/inbox", response_model=InboxResponse)
async def get_inbox(
decision: Optional[str] = Query(default=None, description="Filter: KEEP, DROP, REVIEW"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
):
"""
Inbox Items abrufen.
Filtert nach Relevanz-Entscheidung. Standard zeigt KEEP und REVIEW.
"""
# Filter Alerts
alerts = list(_alerts_store.values())
if decision:
alerts = [a for a in alerts if a.relevance_decision == decision.upper()]
else:
# Standard: KEEP und REVIEW zeigen
alerts = [a for a in alerts if a.relevance_decision in ["KEEP", "REVIEW"]]
# Sortieren nach Score (absteigend)
alerts.sort(key=lambda a: a.relevance_score or 0, reverse=True)
# Pagination
total = len(alerts)
start = (page - 1) * page_size
end = start + page_size
page_alerts = alerts[start:end]
items = [
InboxItem(
id=a.id,
title=a.title,
url=a.url,
snippet=a.snippet,
topic_label=a.topic_label,
published_at=a.published_at,
relevance_score=a.relevance_score,
relevance_decision=a.relevance_decision,
relevance_summary=a.relevance_summary,
status=a.status.value,
)
for a in page_alerts
]
return InboxResponse(
items=items,
total=total,
page=page,
page_size=page_size,
)
@router.post("/feedback", response_model=FeedbackResponse)
async def submit_feedback(request: FeedbackRequest):
"""
Feedback zu einem Alert geben.
Das Feedback wird verwendet um das Relevanzprofil zu verbessern.
"""
alert = _alerts_store.get(request.alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert nicht gefunden")
# Alert Status aktualisieren
alert.status = AlertStatus.REVIEWED
# Profile aktualisieren (Default-Profile für Demo)
profile = _profile_store.get("default")
if not profile:
profile = RelevanceProfile.create_default_education_profile()
profile.id = "default"
_profile_store["default"] = profile
profile.update_from_feedback(
alert_title=alert.title,
alert_url=alert.url,
is_relevant=request.is_relevant,
reason=request.reason or "",
)
return FeedbackResponse(
success=True,
message="Feedback gespeichert",
profile_updated=True,
)
@router.get("/profile", response_model=ProfileResponse)
async def get_profile(user_id: Optional[str] = Query(default=None)):
"""
Relevanz-Profil abrufen.
Ohne user_id wird das Default-Profil zurückgegeben.
"""
profile_id = user_id or "default"
profile = _profile_store.get(profile_id)
if not profile:
# Default-Profile erstellen
profile = RelevanceProfile.create_default_education_profile()
profile.id = profile_id
_profile_store[profile_id] = profile
return ProfileResponse(
id=profile.id,
priorities=[p.to_dict() if isinstance(p, PriorityItem) else p
for p in profile.priorities],
exclusions=profile.exclusions,
policies=profile.policies,
total_scored=profile.total_scored,
total_kept=profile.total_kept,
total_dropped=profile.total_dropped,
accuracy_estimate=profile.accuracy_estimate,
)
@router.put("/profile", response_model=ProfileResponse)
async def update_profile(
request: ProfileUpdateRequest,
user_id: Optional[str] = Query(default=None),
):
"""
Relevanz-Profil aktualisieren.
Erlaubt Anpassung von Prioritäten, Ausschlüssen und Policies.
"""
profile_id = user_id or "default"
profile = _profile_store.get(profile_id)
if not profile:
profile = RelevanceProfile()
profile.id = profile_id
# Updates anwenden
if request.priorities is not None:
profile.priorities = [
PriorityItem(
label=p.label,
weight=p.weight,
keywords=p.keywords,
description=p.description,
)
for p in request.priorities
]
if request.exclusions is not None:
profile.exclusions = request.exclusions
if request.policies is not None:
profile.policies = request.policies
profile.updated_at = datetime.utcnow()
_profile_store[profile_id] = profile
return ProfileResponse(
id=profile.id,
priorities=[p.to_dict() if isinstance(p, PriorityItem) else p
for p in profile.priorities],
exclusions=profile.exclusions,
policies=profile.policies,
total_scored=profile.total_scored,
total_kept=profile.total_kept,
total_dropped=profile.total_dropped,
accuracy_estimate=profile.accuracy_estimate,
)
@router.get("/stats")
async def get_stats():
"""
Statistiken über Alerts und Scoring.
Gibt Statistiken im Format zurück, das das Frontend erwartet:
- total_alerts, new_alerts, kept_alerts, review_alerts, dropped_alerts
- total_topics, active_topics, total_rules
"""
alerts = list(_alerts_store.values())
total = len(alerts)
# Zähle nach Status und Decision
new_alerts = sum(1 for a in alerts if a.status == AlertStatus.NEW)
kept_alerts = sum(1 for a in alerts if a.relevance_decision == "KEEP")
review_alerts = sum(1 for a in alerts if a.relevance_decision == "REVIEW")
dropped_alerts = sum(1 for a in alerts if a.relevance_decision == "DROP")
# Topics und Rules (In-Memory hat diese nicht, aber wir geben 0 zurück)
# Bei DB-Implementierung würden wir hier die Repositories nutzen
total_topics = 0
active_topics = 0
total_rules = 0
# Versuche DB-Statistiken zu laden wenn verfügbar
try:
from alerts_agent.db import get_db
from alerts_agent.db.repository import TopicRepository, RuleRepository
from contextlib import contextmanager
# Versuche eine DB-Session zu bekommen
db_gen = get_db()
db = next(db_gen, None)
if db:
try:
topic_repo = TopicRepository(db)
rule_repo = RuleRepository(db)
all_topics = topic_repo.get_all()
total_topics = len(all_topics)
active_topics = len([t for t in all_topics if t.is_active])
all_rules = rule_repo.get_all()
total_rules = len(all_rules)
finally:
try:
next(db_gen, None)
except StopIteration:
pass
except Exception:
# DB nicht verfügbar, nutze In-Memory Defaults
pass
# Berechne Durchschnittsscore
scored_alerts = [a for a in alerts if a.relevance_score is not None]
avg_score = sum(a.relevance_score for a in scored_alerts) / len(scored_alerts) if scored_alerts else 0.0
return {
# Frontend-kompatibles Format
"total_alerts": total,
"new_alerts": new_alerts,
"kept_alerts": kept_alerts,
"review_alerts": review_alerts,
"dropped_alerts": dropped_alerts,
"total_topics": total_topics,
"active_topics": active_topics,
"total_rules": total_rules,
"avg_score": avg_score,
# Zusätzliche Details (Abwärtskompatibilität)
"by_status": {
"new": new_alerts,
"scored": sum(1 for a in alerts if a.status == AlertStatus.SCORED),
"reviewed": sum(1 for a in alerts if a.status == AlertStatus.REVIEWED),
},
"by_decision": {
"KEEP": kept_alerts,
"REVIEW": review_alerts,
"DROP": dropped_alerts,
},
}

View File

@@ -0,0 +1,473 @@
"""
Rules API Routes für Alerts Agent.
CRUD-Operationen für Alert-Regeln.
"""
from typing import List, Optional, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from alerts_agent.db import get_db
from alerts_agent.db.repository import RuleRepository
from alerts_agent.db.models import RuleActionEnum
router = APIRouter(prefix="/rules", tags=["alerts"])
# =============================================================================
# PYDANTIC MODELS
# =============================================================================
class RuleConditionModel(BaseModel):
"""Model für eine Regel-Bedingung."""
field: str = Field(..., description="Feld zum Prüfen (title, snippet, url, source, relevance_score)")
operator: str = Field(..., alias="op", description="Operator (contains, not_contains, equals, regex, gt, lt, in)")
value: Any = Field(..., description="Vergleichswert (String, Zahl, oder Liste)")
class Config:
populate_by_name = True
class RuleCreate(BaseModel):
"""Request-Model für Regel-Erstellung."""
name: str = Field(..., min_length=1, max_length=255)
description: str = Field(default="", max_length=2000)
conditions: List[RuleConditionModel] = Field(default_factory=list)
action_type: str = Field(default="keep", description="Aktion: keep, drop, tag, email, webhook, slack")
action_config: Dict[str, Any] = Field(default_factory=dict)
topic_id: Optional[str] = Field(default=None, description="Optional: Nur für bestimmtes Topic")
priority: int = Field(default=0, ge=0, le=1000, description="Priorität (höher = wird zuerst evaluiert)")
is_active: bool = Field(default=True)
class RuleUpdate(BaseModel):
"""Request-Model für Regel-Update."""
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
conditions: Optional[List[RuleConditionModel]] = None
action_type: Optional[str] = None
action_config: Optional[Dict[str, Any]] = None
topic_id: Optional[str] = None
priority: Optional[int] = Field(default=None, ge=0, le=1000)
is_active: Optional[bool] = None
class RuleResponse(BaseModel):
"""Response-Model für Regel."""
id: str
name: str
description: str
conditions: List[Dict[str, Any]]
action_type: str
action_config: Dict[str, Any]
topic_id: Optional[str]
priority: int
is_active: bool
match_count: int
last_matched_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class RuleListResponse(BaseModel):
"""Response-Model für Regel-Liste."""
rules: List[RuleResponse]
total: int
class RuleTestRequest(BaseModel):
"""Request-Model für Regel-Test."""
title: str = Field(default="Test Title")
snippet: str = Field(default="Test snippet content")
url: str = Field(default="https://example.com/test")
source: str = Field(default="rss_feed")
relevance_score: Optional[float] = Field(default=None)
class RuleTestResponse(BaseModel):
"""Response-Model für Regel-Test."""
rule_id: str
rule_name: str
matched: bool
action: str
conditions_met: List[str]
# =============================================================================
# API ENDPOINTS
# =============================================================================
@router.post("", response_model=RuleResponse, status_code=201)
async def create_rule(
rule: RuleCreate,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Erstellt eine neue Regel.
Regeln werden nach Priorität evaluiert. Höhere Priorität = wird zuerst geprüft.
"""
repo = RuleRepository(db)
# Conditions zu Dict konvertieren
conditions = [
{"field": c.field, "op": c.operator, "value": c.value}
for c in rule.conditions
]
created = repo.create(
name=rule.name,
description=rule.description,
conditions=conditions,
action_type=rule.action_type,
action_config=rule.action_config,
topic_id=rule.topic_id,
priority=rule.priority,
)
if not rule.is_active:
repo.update(created.id, is_active=False)
created = repo.get_by_id(created.id)
return _to_rule_response(created)
@router.get("", response_model=RuleListResponse)
async def list_rules(
is_active: Optional[bool] = None,
topic_id: Optional[str] = None,
db: DBSession = Depends(get_db),
) -> RuleListResponse:
"""
Listet alle Regeln auf.
Regeln sind nach Priorität sortiert (höchste zuerst).
"""
repo = RuleRepository(db)
if is_active is True:
rules = repo.get_active()
else:
rules = repo.get_all()
# Topic-Filter
if topic_id:
rules = [r for r in rules if r.topic_id == topic_id or r.topic_id is None]
return RuleListResponse(
rules=[_to_rule_response(r) for r in rules],
total=len(rules),
)
@router.get("/{rule_id}", response_model=RuleResponse)
async def get_rule(
rule_id: str,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Ruft eine Regel nach ID ab.
"""
repo = RuleRepository(db)
rule = repo.get_by_id(rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
return _to_rule_response(rule)
@router.put("/{rule_id}", response_model=RuleResponse)
async def update_rule(
rule_id: str,
updates: RuleUpdate,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Aktualisiert eine Regel.
"""
repo = RuleRepository(db)
# Nur übergebene Werte updaten
update_dict = {}
if updates.name is not None:
update_dict["name"] = updates.name
if updates.description is not None:
update_dict["description"] = updates.description
if updates.conditions is not None:
update_dict["conditions"] = [
{"field": c.field, "op": c.operator, "value": c.value}
for c in updates.conditions
]
if updates.action_type is not None:
update_dict["action_type"] = updates.action_type
if updates.action_config is not None:
update_dict["action_config"] = updates.action_config
if updates.topic_id is not None:
update_dict["topic_id"] = updates.topic_id
if updates.priority is not None:
update_dict["priority"] = updates.priority
if updates.is_active is not None:
update_dict["is_active"] = updates.is_active
if not update_dict:
raise HTTPException(status_code=400, detail="Keine Updates angegeben")
updated = repo.update(rule_id, **update_dict)
if not updated:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
return _to_rule_response(updated)
@router.delete("/{rule_id}", status_code=204)
async def delete_rule(
rule_id: str,
db: DBSession = Depends(get_db),
):
"""
Löscht eine Regel.
"""
repo = RuleRepository(db)
success = repo.delete(rule_id)
if not success:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
return None
@router.post("/{rule_id}/activate", response_model=RuleResponse)
async def activate_rule(
rule_id: str,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Aktiviert eine Regel.
"""
repo = RuleRepository(db)
updated = repo.update(rule_id, is_active=True)
if not updated:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
return _to_rule_response(updated)
@router.post("/{rule_id}/deactivate", response_model=RuleResponse)
async def deactivate_rule(
rule_id: str,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Deaktiviert eine Regel.
"""
repo = RuleRepository(db)
updated = repo.update(rule_id, is_active=False)
if not updated:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
return _to_rule_response(updated)
@router.post("/{rule_id}/test", response_model=RuleTestResponse)
async def test_rule(
rule_id: str,
test_data: RuleTestRequest,
db: DBSession = Depends(get_db),
) -> RuleTestResponse:
"""
Testet eine Regel gegen Testdaten.
Nützlich um Regeln vor der Aktivierung zu testen.
"""
from alerts_agent.processing.rule_engine import evaluate_rule
from alerts_agent.db.models import AlertItemDB, AlertSourceEnum, AlertStatusEnum
repo = RuleRepository(db)
rule = repo.get_by_id(rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
# Mock-Alert für Test erstellen
mock_alert = AlertItemDB(
id="test-alert",
topic_id="test-topic",
title=test_data.title,
snippet=test_data.snippet,
url=test_data.url,
url_hash="test-hash",
source=AlertSourceEnum(test_data.source) if test_data.source else AlertSourceEnum.RSS_FEED,
status=AlertStatusEnum.NEW,
relevance_score=test_data.relevance_score,
)
# Regel evaluieren
match = evaluate_rule(mock_alert, rule)
return RuleTestResponse(
rule_id=match.rule_id,
rule_name=match.rule_name,
matched=match.matched,
action=match.action.value,
conditions_met=match.conditions_met,
)
@router.post("/test-all", response_model=List[RuleTestResponse])
async def test_all_rules(
test_data: RuleTestRequest,
db: DBSession = Depends(get_db),
) -> List[RuleTestResponse]:
"""
Testet alle aktiven Regeln gegen Testdaten.
Zeigt welche Regeln matchen würden.
"""
from alerts_agent.processing.rule_engine import evaluate_rules_for_alert, evaluate_rule
from alerts_agent.db.models import AlertItemDB, AlertSourceEnum, AlertStatusEnum
repo = RuleRepository(db)
rules = repo.get_active()
# Mock-Alert für Test erstellen
mock_alert = AlertItemDB(
id="test-alert",
topic_id="test-topic",
title=test_data.title,
snippet=test_data.snippet,
url=test_data.url,
url_hash="test-hash",
source=AlertSourceEnum(test_data.source) if test_data.source else AlertSourceEnum.RSS_FEED,
status=AlertStatusEnum.NEW,
relevance_score=test_data.relevance_score,
)
results = []
for rule in rules:
match = evaluate_rule(mock_alert, rule)
results.append(RuleTestResponse(
rule_id=match.rule_id,
rule_name=match.rule_name,
matched=match.matched,
action=match.action.value,
conditions_met=match.conditions_met,
))
return results
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _to_rule_response(rule) -> RuleResponse:
"""Konvertiert ein Rule-DB-Objekt zu RuleResponse."""
return RuleResponse(
id=rule.id,
name=rule.name,
description=rule.description or "",
conditions=rule.conditions or [],
action_type=rule.action_type.value if rule.action_type else "keep",
action_config=rule.action_config or {},
topic_id=rule.topic_id,
priority=rule.priority,
is_active=rule.is_active,
match_count=rule.match_count,
last_matched_at=rule.last_matched_at,
created_at=rule.created_at,
updated_at=rule.updated_at,
)
# =============================================================================
# PRESET RULES
# =============================================================================
PRESET_RULES = {
"exclude_jobs": {
"name": "Stellenanzeigen ausschließen",
"description": "Filtert Stellenanzeigen und Job-Postings",
"conditions": [
{"field": "title", "op": "in", "value": ["Stellenanzeige", "Job", "Karriere", "Praktikum", "Werkstudent", "Ausbildung", "Referendariat"]}
],
"action_type": "drop",
"priority": 100,
},
"exclude_ads": {
"name": "Werbung ausschließen",
"description": "Filtert Werbung und Pressemitteilungen",
"conditions": [
{"field": "title", "op": "in", "value": ["Werbung", "Anzeige", "Pressemitteilung", "PR:", "Sponsored"]}
],
"action_type": "drop",
"priority": 100,
},
"keep_inklusion": {
"name": "Inklusion behalten",
"description": "Behält Artikel zum Thema Inklusion",
"conditions": [
{"field": "title", "op": "in", "value": ["Inklusion", "inklusiv", "Förderbedarf", "Förderschule", "Nachteilsausgleich"]}
],
"action_type": "keep",
"priority": 50,
},
"keep_datenschutz": {
"name": "Datenschutz behalten",
"description": "Behält Artikel zum Thema Datenschutz in Schulen",
"conditions": [
{"field": "title", "op": "in", "value": ["DSGVO", "Datenschutz", "Schülerfotos", "personenbezogen"]}
],
"action_type": "keep",
"priority": 50,
},
}
@router.get("/presets/list")
async def list_preset_rules() -> Dict[str, Any]:
"""
Listet verfügbare Regel-Vorlagen auf.
"""
return {
"presets": [
{"id": key, **value}
for key, value in PRESET_RULES.items()
]
}
@router.post("/presets/{preset_id}/apply", response_model=RuleResponse)
async def apply_preset_rule(
preset_id: str,
db: DBSession = Depends(get_db),
) -> RuleResponse:
"""
Wendet eine Regel-Vorlage an (erstellt die Regel).
"""
if preset_id not in PRESET_RULES:
raise HTTPException(status_code=404, detail="Preset nicht gefunden")
preset = PRESET_RULES[preset_id]
repo = RuleRepository(db)
created = repo.create(
name=preset["name"],
description=preset.get("description", ""),
conditions=preset["conditions"],
action_type=preset["action_type"],
priority=preset.get("priority", 0),
)
return _to_rule_response(created)

View File

@@ -0,0 +1,421 @@
"""
API Routes für User Alert Subscriptions.
Verwaltet Nutzer-Abonnements für Templates und Digest-Einstellungen.
Endpoints:
- POST /subscriptions - Neue Subscription erstellen
- GET /subscriptions - User-Subscriptions auflisten
- GET /subscriptions/{id} - Subscription-Details
- PUT /subscriptions/{id} - Subscription aktualisieren
- DELETE /subscriptions/{id} - Subscription deaktivieren
- POST /subscriptions/{id}/activate-template - Template aktivieren
"""
import uuid
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
from ..db.models import (
UserAlertSubscriptionDB, AlertTemplateDB, AlertProfileDB,
AlertTopicDB, AlertRuleDB, AlertModeEnum, UserRoleEnum
)
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
# ============================================================================
# Request/Response Models
# ============================================================================
class SubscriptionCreate(BaseModel):
"""Request für neue Subscription."""
mode: str = Field(default="guided", description="'guided' oder 'expert'")
user_role: Optional[str] = Field(default=None, description="lehrkraft, schulleitung, it_beauftragte")
template_ids: List[str] = Field(default=[], description="Ausgewählte Template-IDs (max. 3)")
notification_email: Optional[str] = Field(default=None)
digest_enabled: bool = Field(default=True)
digest_frequency: str = Field(default="weekly")
class SubscriptionUpdate(BaseModel):
"""Request für Subscription-Update."""
template_ids: Optional[List[str]] = None
notification_email: Optional[str] = None
digest_enabled: Optional[bool] = None
digest_frequency: Optional[str] = None
is_active: Optional[bool] = None
class SubscriptionResponse(BaseModel):
"""Response für eine Subscription."""
id: str
user_id: str
mode: str
user_role: Optional[str]
selected_template_ids: List[str]
template_names: List[str]
notification_email: Optional[str]
digest_enabled: bool
digest_frequency: str
wizard_completed: bool
is_active: bool
created_at: datetime
updated_at: datetime
class SubscriptionListResponse(BaseModel):
"""Response für Subscription-Liste."""
subscriptions: List[SubscriptionResponse]
total: int
class ActivateTemplateRequest(BaseModel):
"""Request für Template-Aktivierung."""
create_topics: bool = Field(default=True, description="Topics aus Template-Config erstellen")
create_rules: bool = Field(default=True, description="Rules aus Template-Config erstellen")
class ActivateTemplateResponse(BaseModel):
"""Response für Template-Aktivierung."""
status: str
topics_created: int
rules_created: int
profile_updated: bool
message: str
# ============================================================================
# Helper Functions
# ============================================================================
def get_user_id_from_request() -> str:
"""
Extrahiert User-ID aus Request.
TODO: JWT-Token auswerten, aktuell Dummy.
"""
return "demo-user"
def _get_template_names(db: DBSession, template_ids: List[str]) -> List[str]:
"""Hole Template-Namen für IDs."""
if not template_ids:
return []
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id.in_(template_ids)
).all()
return [t.name for t in templates]
def _subscription_to_response(sub: UserAlertSubscriptionDB, db: DBSession) -> SubscriptionResponse:
"""Konvertiere DB-Model zu Response."""
template_ids = sub.selected_template_ids or []
return SubscriptionResponse(
id=sub.id,
user_id=sub.user_id,
mode=sub.mode.value if sub.mode else "guided",
user_role=sub.user_role.value if sub.user_role else None,
selected_template_ids=template_ids,
template_names=_get_template_names(db, template_ids),
notification_email=sub.notification_email,
digest_enabled=sub.digest_enabled if sub.digest_enabled is not None else True,
digest_frequency=sub.digest_frequency or "weekly",
wizard_completed=sub.wizard_completed if sub.wizard_completed is not None else False,
is_active=sub.is_active if sub.is_active is not None else True,
created_at=sub.created_at,
updated_at=sub.updated_at,
)
# ============================================================================
# Endpoints
# ============================================================================
@router.post("", response_model=SubscriptionResponse)
async def create_subscription(
request: SubscriptionCreate,
db: DBSession = Depends(get_db)
):
"""
Erstelle eine neue Alert-Subscription.
Im Guided Mode werden 1-3 Templates ausgewählt.
Im Expert Mode wird ein eigenes Profil konfiguriert.
"""
user_id = get_user_id_from_request()
# Validiere Modus
try:
mode = AlertModeEnum(request.mode)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültiger Modus. Erlaubt: 'guided', 'expert'")
# Validiere Rolle
user_role = None
if request.user_role:
try:
user_role = UserRoleEnum(request.user_role)
except ValueError:
raise HTTPException(
status_code=400,
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
)
# Validiere Template-IDs
if request.template_ids:
if len(request.template_ids) > 3:
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
# Prüfe ob Templates existieren
existing = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id.in_(request.template_ids)
).count()
if existing != len(request.template_ids):
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
# Erstelle Subscription
subscription = UserAlertSubscriptionDB(
id=str(uuid.uuid4()),
user_id=user_id,
mode=mode,
user_role=user_role,
selected_template_ids=request.template_ids,
notification_email=request.notification_email,
digest_enabled=request.digest_enabled,
digest_frequency=request.digest_frequency,
wizard_completed=len(request.template_ids) > 0, # Abgeschlossen wenn Templates gewählt
is_active=True,
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return _subscription_to_response(subscription, db)
@router.get("", response_model=SubscriptionListResponse)
async def list_subscriptions(
active_only: bool = Query(True, description="Nur aktive Subscriptions"),
db: DBSession = Depends(get_db)
):
"""Liste alle Subscriptions des aktuellen Users."""
user_id = get_user_id_from_request()
query = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id
)
if active_only:
query = query.filter(UserAlertSubscriptionDB.is_active == True)
subscriptions = query.order_by(UserAlertSubscriptionDB.created_at.desc()).all()
return SubscriptionListResponse(
subscriptions=[_subscription_to_response(s, db) for s in subscriptions],
total=len(subscriptions)
)
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
async def get_subscription(
subscription_id: str,
db: DBSession = Depends(get_db)
):
"""Hole Details einer Subscription."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == subscription_id,
UserAlertSubscriptionDB.user_id == user_id
).first()
if not subscription:
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
return _subscription_to_response(subscription, db)
@router.put("/{subscription_id}", response_model=SubscriptionResponse)
async def update_subscription(
subscription_id: str,
request: SubscriptionUpdate,
db: DBSession = Depends(get_db)
):
"""Aktualisiere eine Subscription."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == subscription_id,
UserAlertSubscriptionDB.user_id == user_id
).first()
if not subscription:
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
# Update Felder
if request.template_ids is not None:
if len(request.template_ids) > 3:
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
subscription.selected_template_ids = request.template_ids
if request.notification_email is not None:
subscription.notification_email = request.notification_email
if request.digest_enabled is not None:
subscription.digest_enabled = request.digest_enabled
if request.digest_frequency is not None:
subscription.digest_frequency = request.digest_frequency
if request.is_active is not None:
subscription.is_active = request.is_active
subscription.updated_at = datetime.utcnow()
db.commit()
db.refresh(subscription)
return _subscription_to_response(subscription, db)
@router.delete("/{subscription_id}")
async def deactivate_subscription(
subscription_id: str,
db: DBSession = Depends(get_db)
):
"""Deaktiviere eine Subscription (Soft-Delete)."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == subscription_id,
UserAlertSubscriptionDB.user_id == user_id
).first()
if not subscription:
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
subscription.is_active = False
subscription.updated_at = datetime.utcnow()
db.commit()
return {"status": "success", "message": "Subscription deaktiviert"}
@router.post("/{subscription_id}/activate-template", response_model=ActivateTemplateResponse)
async def activate_template(
subscription_id: str,
request: ActivateTemplateRequest = None,
db: DBSession = Depends(get_db)
):
"""
Aktiviere die gewählten Templates für eine Subscription.
Erstellt:
- Topics aus Template.topics_config (RSS-Feeds)
- Rules aus Template.rules_config (Filter-Regeln)
- Aktualisiert das User-Profil mit Template.profile_config
"""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == subscription_id,
UserAlertSubscriptionDB.user_id == user_id
).first()
if not subscription:
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
if not subscription.selected_template_ids:
raise HTTPException(status_code=400, detail="Keine Templates ausgewählt")
# Lade Templates
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id.in_(subscription.selected_template_ids)
).all()
topics_created = 0
rules_created = 0
profile_updated = False
for template in templates:
# Topics erstellen
if request is None or request.create_topics:
for topic_config in (template.topics_config or []):
topic = AlertTopicDB(
id=str(uuid.uuid4()),
user_id=user_id,
name=topic_config.get("name", f"Topic from {template.name}"),
description=f"Automatisch erstellt aus Template: {template.name}",
is_active=True,
fetch_interval_minutes=60,
)
db.add(topic)
topics_created += 1
# Rules erstellen
if request is None or request.create_rules:
for rule_config in (template.rules_config or []):
rule = AlertRuleDB(
id=str(uuid.uuid4()),
user_id=user_id,
name=rule_config.get("name", f"Rule from {template.name}"),
description=f"Automatisch erstellt aus Template: {template.name}",
conditions=rule_config.get("conditions", []),
action_type=rule_config.get("action_type", "keep"),
action_config=rule_config.get("action_config", {}),
priority=rule_config.get("priority", 50),
is_active=True,
)
db.add(rule)
rules_created += 1
# Profil aktualisieren
if template.profile_config:
profile = db.query(AlertProfileDB).filter(
AlertProfileDB.user_id == user_id
).first()
if not profile:
profile = AlertProfileDB(
id=str(uuid.uuid4()),
user_id=user_id,
name=f"Profil für {user_id}",
)
db.add(profile)
# Merge priorities
existing_priorities = profile.priorities or []
new_priorities = template.profile_config.get("priorities", [])
for p in new_priorities:
if p not in existing_priorities:
existing_priorities.append(p)
profile.priorities = existing_priorities
# Merge exclusions
existing_exclusions = profile.exclusions or []
new_exclusions = template.profile_config.get("exclusions", [])
for e in new_exclusions:
if e not in existing_exclusions:
existing_exclusions.append(e)
profile.exclusions = existing_exclusions
profile_updated = True
# Markiere Wizard als abgeschlossen
subscription.wizard_completed = True
subscription.updated_at = datetime.utcnow()
db.commit()
return ActivateTemplateResponse(
status="success",
topics_created=topics_created,
rules_created=rules_created,
profile_updated=profile_updated,
message=f"Templates aktiviert: {topics_created} Topics, {rules_created} Rules erstellt."
)

View File

@@ -0,0 +1,410 @@
"""
API Routes für Alert-Templates (Playbooks).
Endpoints für Guided Mode:
- GET /templates - Liste aller verfügbaren Templates
- GET /templates/{template_id} - Template-Details
- POST /templates/{template_id}/preview - Vorschau generieren
- GET /templates/by-role/{role} - Templates für eine Rolle
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
from ..db.models import AlertTemplateDB, UserRoleEnum
router = APIRouter(prefix="/templates", tags=["templates"])
# ============================================================================
# Request/Response Models
# ============================================================================
class TemplateListItem(BaseModel):
"""Kurzinfo für Template-Liste."""
id: str
slug: str
name: str
description: str
icon: str
category: str
target_roles: List[str]
is_premium: bool
max_cards_per_day: int
sort_order: int
class Config:
from_attributes = True
class TemplateDetail(BaseModel):
"""Vollständige Template-Details."""
id: str
slug: str
name: str
description: str
icon: str
category: str
target_roles: List[str]
topics_config: List[dict]
rules_config: List[dict]
profile_config: dict
importance_config: dict
max_cards_per_day: int
digest_enabled: bool
digest_day: str
is_premium: bool
is_active: bool
class Config:
from_attributes = True
class TemplateListResponse(BaseModel):
"""Response für Template-Liste."""
templates: List[TemplateListItem]
total: int
class PreviewRequest(BaseModel):
"""Request für Template-Vorschau."""
sample_count: int = Field(default=3, ge=1, le=5)
class PreviewItem(BaseModel):
"""Ein Vorschau-Item."""
title: str
snippet: str
importance_level: str
why_relevant: str
source_name: str
class PreviewResponse(BaseModel):
"""Response für Template-Vorschau."""
template_name: str
sample_items: List[PreviewItem]
estimated_daily_count: str
message: str
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=TemplateListResponse)
async def list_templates(
category: Optional[str] = Query(None, description="Filter nach Kategorie"),
role: Optional[str] = Query(None, description="Filter nach Zielrolle"),
include_premium: bool = Query(True, description="Premium-Templates einschließen"),
db: DBSession = Depends(get_db)
):
"""
Liste alle verfügbaren Alert-Templates.
Templates sind vorkonfigurierte Playbooks für bestimmte Themen
(Förderprogramme, Datenschutz, IT-Security, etc.).
"""
query = db.query(AlertTemplateDB).filter(AlertTemplateDB.is_active == True)
if category:
query = query.filter(AlertTemplateDB.category == category)
if not include_premium:
query = query.filter(AlertTemplateDB.is_premium == False)
templates = query.order_by(AlertTemplateDB.sort_order).all()
# Filter nach Rolle (JSON-Feld)
if role:
templates = [t for t in templates if role in (t.target_roles or [])]
return TemplateListResponse(
templates=[
TemplateListItem(
id=t.id,
slug=t.slug,
name=t.name,
description=t.description,
icon=t.icon or "",
category=t.category or "",
target_roles=t.target_roles or [],
is_premium=t.is_premium or False,
max_cards_per_day=t.max_cards_per_day or 10,
sort_order=t.sort_order or 0,
)
for t in templates
],
total=len(templates)
)
@router.get("/by-role/{role}", response_model=TemplateListResponse)
async def get_templates_by_role(
role: str,
db: DBSession = Depends(get_db)
):
"""
Empfohlene Templates für eine bestimmte Rolle.
Rollen:
- lehrkraft: Fokus auf Unterricht, Fortbildungen, Wettbewerbe
- schulleitung: Fokus auf Administration, Fördermittel, Recht
- it_beauftragte: Fokus auf IT-Security, Datenschutz
"""
# Validiere Rolle
valid_roles = ["lehrkraft", "schulleitung", "it_beauftragte"]
if role not in valid_roles:
raise HTTPException(
status_code=400,
detail=f"Ungültige Rolle. Erlaubt: {', '.join(valid_roles)}"
)
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.is_active == True,
AlertTemplateDB.is_premium == False # Nur kostenlose für Empfehlungen
).order_by(AlertTemplateDB.sort_order).all()
# Filter nach Rolle
filtered = [t for t in templates if role in (t.target_roles or [])]
return TemplateListResponse(
templates=[
TemplateListItem(
id=t.id,
slug=t.slug,
name=t.name,
description=t.description,
icon=t.icon or "",
category=t.category or "",
target_roles=t.target_roles or [],
is_premium=t.is_premium or False,
max_cards_per_day=t.max_cards_per_day or 10,
sort_order=t.sort_order or 0,
)
for t in filtered
],
total=len(filtered)
)
@router.get("/{template_id}", response_model=TemplateDetail)
async def get_template(
template_id: str,
db: DBSession = Depends(get_db)
):
"""
Vollständige Details eines Templates abrufen.
Enthält alle Konfigurationen (Topics, Rules, Profile).
"""
template = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id == template_id
).first()
if not template:
# Versuche nach Slug zu suchen
template = db.query(AlertTemplateDB).filter(
AlertTemplateDB.slug == template_id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template nicht gefunden")
return TemplateDetail(
id=template.id,
slug=template.slug,
name=template.name,
description=template.description,
icon=template.icon or "",
category=template.category or "",
target_roles=template.target_roles or [],
topics_config=template.topics_config or [],
rules_config=template.rules_config or [],
profile_config=template.profile_config or {},
importance_config=template.importance_config or {},
max_cards_per_day=template.max_cards_per_day or 10,
digest_enabled=template.digest_enabled if template.digest_enabled is not None else True,
digest_day=template.digest_day or "monday",
is_premium=template.is_premium or False,
is_active=template.is_active if template.is_active is not None else True,
)
@router.post("/{template_id}/preview", response_model=PreviewResponse)
async def preview_template(
template_id: str,
request: PreviewRequest = None,
db: DBSession = Depends(get_db)
):
"""
Generiere eine Vorschau, wie Alerts für dieses Template aussehen würden.
Zeigt Beispiel-Alerts mit Wichtigkeitsstufen und "Warum relevant?"-Erklärungen.
"""
template = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id == template_id
).first()
if not template:
template = db.query(AlertTemplateDB).filter(
AlertTemplateDB.slug == template_id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template nicht gefunden")
# Generiere Beispiel-Alerts basierend auf Template-Konfiguration
sample_items = _generate_preview_items(template)
return PreviewResponse(
template_name=template.name,
sample_items=sample_items[:request.sample_count if request else 3],
estimated_daily_count=f"Ca. {template.max_cards_per_day} Meldungen pro Tag",
message=f"Diese Vorschau zeigt, wie Alerts für '{template.name}' aussehen würden."
)
@router.post("/seed")
async def seed_templates(
force_update: bool = Query(False, description="Bestehende Templates aktualisieren"),
db: DBSession = Depends(get_db)
):
"""
Fügt die vordefinierten Templates in die Datenbank ein.
Nur für Entwicklung/Setup.
"""
from ..data.templates import seed_templates as do_seed
count = do_seed(db, force_update=force_update)
return {
"status": "success",
"templates_created": count,
"message": f"{count} Templates wurden eingefügt/aktualisiert."
}
# ============================================================================
# Helper Functions
# ============================================================================
def _generate_preview_items(template: AlertTemplateDB) -> List[PreviewItem]:
"""
Generiere Beispiel-Alerts für Template-Vorschau.
Diese sind statisch/exemplarisch, nicht aus echten Daten.
"""
# Template-spezifische Beispiele
examples = {
"foerderprogramme": [
PreviewItem(
title="DigitalPakt 2.0: Neue Antragsphase startet am 1. April",
snippet="Das BMBF hat die zweite Phase des DigitalPakt Schule angekündigt...",
importance_level="DRINGEND",
why_relevant="Frist endet in 45 Tagen. Betrifft alle Schulen mit Förderbedarf.",
source_name="Bundesministerium für Bildung"
),
PreviewItem(
title="Landesförderung: 50.000€ für innovative Schulprojekte",
snippet="Das Kultusministerium fördert Schulen, die digitale Lernkonzepte...",
importance_level="WICHTIG",
why_relevant="Passende Förderung für Ihr Bundesland. Keine Eigenbeteiligung erforderlich.",
source_name="Kultusministerium"
),
PreviewItem(
title="Erasmus+ Schulpartnerschaften: Jetzt bewerben",
snippet="Für das Schuljahr 2026/27 können Schulen EU-Förderung beantragen...",
importance_level="PRUEFEN",
why_relevant="EU-Programm mit hoher Fördersumme. Bewerbungsfrist in 3 Monaten.",
source_name="EU-Kommission"
),
],
"abitur-updates": [
PreviewItem(
title="Neue Operatoren für Abitur Deutsch ab 2027",
snippet="Die KMK hat überarbeitete Operatoren für das Fach Deutsch beschlossen...",
importance_level="WICHTIG",
why_relevant="Betrifft die Oberstufenplanung. Anpassung der Klausuren erforderlich.",
source_name="KMK"
),
PreviewItem(
title="Abiturtermine 2026: Prüfungsplan veröffentlicht",
snippet="Das Kultusministerium hat die Termine für das Abitur 2026 bekannt gegeben...",
importance_level="INFO",
why_relevant="Planungsgrundlage für Schuljahreskalender.",
source_name="Kultusministerium"
),
],
"datenschutz-recht": [
PreviewItem(
title="LfDI: Neue Handreichung zu Schülerfotos",
snippet="Der Landesbeauftragte für Datenschutz hat eine aktualisierte...",
importance_level="DRINGEND",
why_relevant="Handlungsbedarf: Bestehende Einwilligungen müssen geprüft werden.",
source_name="Datenschutzbeauftragter"
),
PreviewItem(
title="Microsoft 365 an Schulen: Neue Bewertung",
snippet="Die Datenschutzkonferenz hat ihre Position zu Microsoft 365 aktualisiert...",
importance_level="WICHTIG",
why_relevant="Betrifft Schulen mit Microsoft-Lizenzen. Dokumentationspflicht.",
source_name="DSK"
),
],
"it-security": [
PreviewItem(
title="CVE-2026-1234: Kritische Lücke in Moodle",
snippet="Eine schwerwiegende Sicherheitslücke wurde in Moodle 4.x gefunden...",
importance_level="KRITISCH",
why_relevant="Sofortiges Update erforderlich. Exploit bereits aktiv.",
source_name="BSI CERT-Bund"
),
PreviewItem(
title="Phishing-Welle: Gefälschte Schulportal-Mails",
snippet="Aktuell werden vermehrt Phishing-Mails an Lehrkräfte versendet...",
importance_level="DRINGEND",
why_relevant="Warnung an Kollegium empfohlen. Erkennungsmerkmale beachten.",
source_name="BSI"
),
],
"fortbildungen": [
PreviewItem(
title="Kostenlose Fortbildung: KI im Unterricht",
snippet="Das Landesinstitut bietet eine Online-Fortbildung zu KI-Tools...",
importance_level="PRUEFEN",
why_relevant="Passt zu Ihrem Interessenprofil. Online-Format, 4 Stunden.",
source_name="Landesinstitut"
),
],
"wettbewerbe-projekte": [
PreviewItem(
title="Jugend forscht: Anmeldung bis 30. November",
snippet="Der größte deutsche MINT-Wettbewerb sucht wieder junge Forscher...",
importance_level="WICHTIG",
why_relevant="Frist in 60 Tagen. Für Schüler ab Klasse 4.",
source_name="Jugend forscht e.V."
),
],
}
# Hole Beispiele für dieses Template oder generische
slug = template.slug
if slug in examples:
return examples[slug]
# Generische Beispiele
return [
PreviewItem(
title=f"Beispiel-Meldung für {template.name}",
snippet=f"Dies ist eine Vorschau, wie Alerts für das Thema '{template.name}' aussehen würden.",
importance_level="INFO",
why_relevant="Passend zu Ihren ausgewählten Themen.",
source_name="Beispielquelle"
)
]

View File

@@ -0,0 +1,405 @@
"""
Topic API Routes für Alerts Agent.
CRUD-Operationen für Alert-Topics (Feed-Quellen).
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field, HttpUrl
from sqlalchemy.orm import Session as DBSession
from alerts_agent.db import get_db
from alerts_agent.db.repository import TopicRepository, AlertItemRepository
from alerts_agent.db.models import FeedTypeEnum
router = APIRouter(prefix="/topics", tags=["alerts"])
# =============================================================================
# PYDANTIC MODELS
# =============================================================================
class TopicCreate(BaseModel):
"""Request-Model für Topic-Erstellung."""
name: str = Field(..., min_length=1, max_length=255)
description: str = Field(default="", max_length=2000)
feed_url: Optional[str] = Field(default=None, max_length=2000)
feed_type: str = Field(default="rss") # rss, email, webhook
fetch_interval_minutes: int = Field(default=60, ge=5, le=1440)
is_active: bool = Field(default=True)
class TopicUpdate(BaseModel):
"""Request-Model für Topic-Update."""
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
feed_url: Optional[str] = Field(default=None, max_length=2000)
feed_type: Optional[str] = None
fetch_interval_minutes: Optional[int] = Field(default=None, ge=5, le=1440)
is_active: Optional[bool] = None
class TopicResponse(BaseModel):
"""Response-Model für Topic."""
id: str
name: str
description: str
feed_url: Optional[str]
feed_type: str
is_active: bool
fetch_interval_minutes: int
last_fetched_at: Optional[datetime]
last_fetch_error: Optional[str]
total_items_fetched: int
items_kept: int
items_dropped: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TopicListResponse(BaseModel):
"""Response-Model für Topic-Liste."""
topics: List[TopicResponse]
total: int
class TopicStatsResponse(BaseModel):
"""Response-Model für Topic-Statistiken."""
topic_id: str
name: str
total_alerts: int
by_status: dict
by_decision: dict
keep_rate: Optional[float]
class FetchResultResponse(BaseModel):
"""Response-Model für manuellen Fetch."""
success: bool
topic_id: str
new_items: int
duplicates_skipped: int
error: Optional[str] = None
# =============================================================================
# API ENDPOINTS
# =============================================================================
@router.post("", response_model=TopicResponse, status_code=201)
async def create_topic(
topic: TopicCreate,
db: DBSession = Depends(get_db),
) -> TopicResponse:
"""
Erstellt ein neues Topic (Feed-Quelle).
Topics repräsentieren Google Alerts RSS-Feeds oder andere Feed-Quellen.
"""
repo = TopicRepository(db)
created = repo.create(
name=topic.name,
description=topic.description,
feed_url=topic.feed_url,
feed_type=topic.feed_type,
fetch_interval_minutes=topic.fetch_interval_minutes,
is_active=topic.is_active,
)
return TopicResponse(
id=created.id,
name=created.name,
description=created.description or "",
feed_url=created.feed_url,
feed_type=created.feed_type.value if created.feed_type else "rss",
is_active=created.is_active,
fetch_interval_minutes=created.fetch_interval_minutes,
last_fetched_at=created.last_fetched_at,
last_fetch_error=created.last_fetch_error,
total_items_fetched=created.total_items_fetched,
items_kept=created.items_kept,
items_dropped=created.items_dropped,
created_at=created.created_at,
updated_at=created.updated_at,
)
@router.get("", response_model=TopicListResponse)
async def list_topics(
is_active: Optional[bool] = None,
db: DBSession = Depends(get_db),
) -> TopicListResponse:
"""
Listet alle Topics auf.
Optional nach aktivem Status filterbar.
"""
repo = TopicRepository(db)
topics = repo.get_all(is_active=is_active)
return TopicListResponse(
topics=[
TopicResponse(
id=t.id,
name=t.name,
description=t.description or "",
feed_url=t.feed_url,
feed_type=t.feed_type.value if t.feed_type else "rss",
is_active=t.is_active,
fetch_interval_minutes=t.fetch_interval_minutes,
last_fetched_at=t.last_fetched_at,
last_fetch_error=t.last_fetch_error,
total_items_fetched=t.total_items_fetched,
items_kept=t.items_kept,
items_dropped=t.items_dropped,
created_at=t.created_at,
updated_at=t.updated_at,
)
for t in topics
],
total=len(topics),
)
@router.get("/{topic_id}", response_model=TopicResponse)
async def get_topic(
topic_id: str,
db: DBSession = Depends(get_db),
) -> TopicResponse:
"""
Ruft ein Topic nach ID ab.
"""
repo = TopicRepository(db)
topic = repo.get_by_id(topic_id)
if not topic:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
return TopicResponse(
id=topic.id,
name=topic.name,
description=topic.description or "",
feed_url=topic.feed_url,
feed_type=topic.feed_type.value if topic.feed_type else "rss",
is_active=topic.is_active,
fetch_interval_minutes=topic.fetch_interval_minutes,
last_fetched_at=topic.last_fetched_at,
last_fetch_error=topic.last_fetch_error,
total_items_fetched=topic.total_items_fetched,
items_kept=topic.items_kept,
items_dropped=topic.items_dropped,
created_at=topic.created_at,
updated_at=topic.updated_at,
)
@router.put("/{topic_id}", response_model=TopicResponse)
async def update_topic(
topic_id: str,
updates: TopicUpdate,
db: DBSession = Depends(get_db),
) -> TopicResponse:
"""
Aktualisiert ein Topic.
"""
repo = TopicRepository(db)
# Nur übergebene Werte updaten
update_dict = {k: v for k, v in updates.model_dump().items() if v is not None}
if not update_dict:
raise HTTPException(status_code=400, detail="Keine Updates angegeben")
updated = repo.update(topic_id, **update_dict)
if not updated:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
return TopicResponse(
id=updated.id,
name=updated.name,
description=updated.description or "",
feed_url=updated.feed_url,
feed_type=updated.feed_type.value if updated.feed_type else "rss",
is_active=updated.is_active,
fetch_interval_minutes=updated.fetch_interval_minutes,
last_fetched_at=updated.last_fetched_at,
last_fetch_error=updated.last_fetch_error,
total_items_fetched=updated.total_items_fetched,
items_kept=updated.items_kept,
items_dropped=updated.items_dropped,
created_at=updated.created_at,
updated_at=updated.updated_at,
)
@router.delete("/{topic_id}", status_code=204)
async def delete_topic(
topic_id: str,
db: DBSession = Depends(get_db),
):
"""
Löscht ein Topic und alle zugehörigen Alerts (CASCADE).
"""
repo = TopicRepository(db)
success = repo.delete(topic_id)
if not success:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
return None
@router.get("/{topic_id}/stats", response_model=TopicStatsResponse)
async def get_topic_stats(
topic_id: str,
db: DBSession = Depends(get_db),
) -> TopicStatsResponse:
"""
Ruft Statistiken für ein Topic ab.
"""
topic_repo = TopicRepository(db)
alert_repo = AlertItemRepository(db)
topic = topic_repo.get_by_id(topic_id)
if not topic:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
by_status = alert_repo.count_by_status(topic_id)
by_decision = alert_repo.count_by_decision(topic_id)
total = sum(by_status.values())
keep_count = by_decision.get("KEEP", 0)
return TopicStatsResponse(
topic_id=topic_id,
name=topic.name,
total_alerts=total,
by_status=by_status,
by_decision=by_decision,
keep_rate=keep_count / total if total > 0 else None,
)
@router.post("/{topic_id}/fetch", response_model=FetchResultResponse)
async def fetch_topic(
topic_id: str,
background_tasks: BackgroundTasks,
db: DBSession = Depends(get_db),
) -> FetchResultResponse:
"""
Löst einen manuellen Fetch für ein Topic aus.
Der Fetch wird im Hintergrund ausgeführt. Das Ergebnis zeigt
die Anzahl neuer Items und übersprungener Duplikate.
"""
topic_repo = TopicRepository(db)
topic = topic_repo.get_by_id(topic_id)
if not topic:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
if not topic.feed_url:
raise HTTPException(
status_code=400,
detail="Topic hat keine Feed-URL konfiguriert"
)
# Import hier um zirkuläre Imports zu vermeiden
from alerts_agent.ingestion.rss_fetcher import fetch_and_store_feed
try:
result = await fetch_and_store_feed(
topic_id=topic_id,
feed_url=topic.feed_url,
db=db,
)
return FetchResultResponse(
success=True,
topic_id=topic_id,
new_items=result.get("new_items", 0),
duplicates_skipped=result.get("duplicates_skipped", 0),
)
except Exception as e:
# Fehler im Topic speichern
topic_repo.update(topic_id, last_fetch_error=str(e))
return FetchResultResponse(
success=False,
topic_id=topic_id,
new_items=0,
duplicates_skipped=0,
error=str(e),
)
@router.post("/{topic_id}/activate", response_model=TopicResponse)
async def activate_topic(
topic_id: str,
db: DBSession = Depends(get_db),
) -> TopicResponse:
"""
Aktiviert ein Topic für automatisches Fetching.
"""
repo = TopicRepository(db)
updated = repo.update(topic_id, is_active=True)
if not updated:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
return TopicResponse(
id=updated.id,
name=updated.name,
description=updated.description or "",
feed_url=updated.feed_url,
feed_type=updated.feed_type.value if updated.feed_type else "rss",
is_active=updated.is_active,
fetch_interval_minutes=updated.fetch_interval_minutes,
last_fetched_at=updated.last_fetched_at,
last_fetch_error=updated.last_fetch_error,
total_items_fetched=updated.total_items_fetched,
items_kept=updated.items_kept,
items_dropped=updated.items_dropped,
created_at=updated.created_at,
updated_at=updated.updated_at,
)
@router.post("/{topic_id}/deactivate", response_model=TopicResponse)
async def deactivate_topic(
topic_id: str,
db: DBSession = Depends(get_db),
) -> TopicResponse:
"""
Deaktiviert ein Topic (stoppt automatisches Fetching).
"""
repo = TopicRepository(db)
updated = repo.update(topic_id, is_active=False)
if not updated:
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
return TopicResponse(
id=updated.id,
name=updated.name,
description=updated.description or "",
feed_url=updated.feed_url,
feed_type=updated.feed_type.value if updated.feed_type else "rss",
is_active=updated.is_active,
fetch_interval_minutes=updated.fetch_interval_minutes,
last_fetched_at=updated.last_fetched_at,
last_fetch_error=updated.last_fetch_error,
total_items_fetched=updated.total_items_fetched,
items_kept=updated.items_kept,
items_dropped=updated.items_dropped,
created_at=updated.created_at,
updated_at=updated.updated_at,
)

View File

@@ -0,0 +1,554 @@
"""
API Routes für den Guided Mode Wizard.
Verwaltet den 3-Schritt Setup-Wizard:
1. Rolle wählen (Lehrkraft, Schulleitung, IT-Beauftragte)
2. Templates auswählen (max. 3)
3. Bestätigung und Aktivierung
Zusätzlich: Migration-Wizard für bestehende Google Alerts.
Endpoints:
- GET /wizard/state - Aktuellen Wizard-Status abrufen
- PUT /wizard/step/{step} - Schritt speichern
- POST /wizard/complete - Wizard abschließen
- POST /wizard/reset - Wizard zurücksetzen
- POST /wizard/migrate/email - E-Mail-Migration starten
- POST /wizard/migrate/rss - RSS-Import
"""
import uuid
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
from ..db.models import (
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
)
router = APIRouter(prefix="/wizard", tags=["wizard"])
# ============================================================================
# Request/Response Models
# ============================================================================
class WizardState(BaseModel):
"""Aktueller Wizard-Status."""
subscription_id: Optional[str] = None
current_step: int = 0 # 0=nicht gestartet, 1-3=Schritte, 4=abgeschlossen
is_completed: bool = False
step_data: Dict[str, Any] = {}
recommended_templates: List[Dict[str, Any]] = []
class Step1Data(BaseModel):
"""Daten für Schritt 1: Rollenwahl."""
role: str = Field(..., description="lehrkraft, schulleitung, it_beauftragte")
class Step2Data(BaseModel):
"""Daten für Schritt 2: Template-Auswahl."""
template_ids: List[str] = Field(..., min_length=1, max_length=3)
class Step3Data(BaseModel):
"""Daten für Schritt 3: Bestätigung."""
notification_email: Optional[str] = None
digest_enabled: bool = True
digest_frequency: str = "weekly"
class StepResponse(BaseModel):
"""Response für Schritt-Update."""
status: str
current_step: int
next_step: int
message: str
recommended_templates: List[Dict[str, Any]] = []
class MigrateEmailRequest(BaseModel):
"""Request für E-Mail-Migration."""
original_label: Optional[str] = Field(default=None, description="Beschreibung des Alerts")
class MigrateEmailResponse(BaseModel):
"""Response für E-Mail-Migration."""
status: str
inbound_address: str
instructions: List[str]
source_id: str
class MigrateRssRequest(BaseModel):
"""Request für RSS-Import."""
rss_urls: List[str] = Field(..., min_length=1, max_length=20)
labels: Optional[List[str]] = None
class MigrateRssResponse(BaseModel):
"""Response für RSS-Import."""
status: str
sources_created: int
topics_created: int
message: str
# ============================================================================
# Helper Functions
# ============================================================================
def get_user_id_from_request() -> str:
"""Extrahiert User-ID aus Request."""
return "demo-user"
def _get_or_create_subscription(db: DBSession, user_id: str) -> UserAlertSubscriptionDB:
"""Hole oder erstelle Subscription für Wizard."""
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id,
UserAlertSubscriptionDB.wizard_completed == False
).first()
if not subscription:
subscription = UserAlertSubscriptionDB(
id=str(uuid.uuid4()),
user_id=user_id,
mode=AlertModeEnum.GUIDED,
wizard_step=0,
wizard_completed=False,
wizard_state={},
is_active=True,
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]:
"""Hole empfohlene Templates für eine Rolle."""
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.is_active == True,
AlertTemplateDB.is_premium == False
).order_by(AlertTemplateDB.sort_order).all()
result = []
for t in templates:
if role in (t.target_roles or []):
result.append({
"id": t.id,
"slug": t.slug,
"name": t.name,
"description": t.description,
"icon": t.icon,
"category": t.category,
"recommended": True,
})
return result
def _generate_inbound_address(user_id: str, source_id: str) -> str:
"""Generiere eindeutige Inbound-E-Mail-Adresse."""
short_id = source_id[:8]
return f"alerts+{short_id}@breakpilot.app"
# ============================================================================
# Wizard Endpoints
# ============================================================================
@router.get("/state", response_model=WizardState)
async def get_wizard_state(
db: DBSession = Depends(get_db)
):
"""
Hole aktuellen Wizard-Status.
Gibt Schritt, gespeicherte Daten und empfohlene Templates zurück.
"""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id
).order_by(UserAlertSubscriptionDB.created_at.desc()).first()
if not subscription:
return WizardState(
subscription_id=None,
current_step=0,
is_completed=False,
step_data={},
recommended_templates=[],
)
# Empfohlene Templates basierend auf Rolle
role = subscription.user_role.value if subscription.user_role else None
recommended = _get_recommended_templates(db, role) if role else []
return WizardState(
subscription_id=subscription.id,
current_step=subscription.wizard_step or 0,
is_completed=subscription.wizard_completed or False,
step_data=subscription.wizard_state or {},
recommended_templates=recommended,
)
@router.put("/step/1", response_model=StepResponse)
async def save_step_1(
data: Step1Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 1: Rolle speichern.
Wählt die Rolle des Nutzers und gibt passende Template-Empfehlungen.
"""
user_id = get_user_id_from_request()
# Validiere Rolle
try:
role = UserRoleEnum(data.role)
except ValueError:
raise HTTPException(
status_code=400,
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
)
subscription = _get_or_create_subscription(db, user_id)
# Update
subscription.user_role = role
subscription.wizard_step = 1
wizard_state = subscription.wizard_state or {}
wizard_state["step1"] = {"role": data.role}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
db.refresh(subscription)
# Empfohlene Templates
recommended = _get_recommended_templates(db, data.role)
return StepResponse(
status="success",
current_step=1,
next_step=2,
message=f"Rolle '{data.role}' gespeichert. Bitte wählen Sie jetzt Ihre Themen.",
recommended_templates=recommended,
)
@router.put("/step/2", response_model=StepResponse)
async def save_step_2(
data: Step2Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 2: Templates auswählen.
Speichert die ausgewählten Templates (1-3).
"""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id,
UserAlertSubscriptionDB.wizard_completed == False
).first()
if not subscription:
raise HTTPException(status_code=400, detail="Bitte zuerst Schritt 1 abschließen")
# Validiere Template-IDs
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id.in_(data.template_ids)
).all()
if len(templates) != len(data.template_ids):
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
# Update
subscription.selected_template_ids = data.template_ids
subscription.wizard_step = 2
wizard_state = subscription.wizard_state or {}
wizard_state["step2"] = {
"template_ids": data.template_ids,
"template_names": [t.name for t in templates],
}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
return StepResponse(
status="success",
current_step=2,
next_step=3,
message=f"{len(templates)} Themen ausgewählt. Bitte bestätigen Sie Ihre Auswahl.",
recommended_templates=[],
)
@router.put("/step/3", response_model=StepResponse)
async def save_step_3(
data: Step3Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 3: Digest-Einstellungen und Bestätigung.
Speichert E-Mail und Digest-Präferenzen.
"""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id,
UserAlertSubscriptionDB.wizard_completed == False
).first()
if not subscription:
raise HTTPException(status_code=400, detail="Bitte zuerst Schritte 1 und 2 abschließen")
if not subscription.selected_template_ids:
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen (Schritt 2)")
# Update
subscription.notification_email = data.notification_email
subscription.digest_enabled = data.digest_enabled
subscription.digest_frequency = data.digest_frequency
subscription.wizard_step = 3
wizard_state = subscription.wizard_state or {}
wizard_state["step3"] = {
"notification_email": data.notification_email,
"digest_enabled": data.digest_enabled,
"digest_frequency": data.digest_frequency,
}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
return StepResponse(
status="success",
current_step=3,
next_step=4,
message="Einstellungen gespeichert. Klicken Sie auf 'Jetzt starten' um den Wizard abzuschließen.",
recommended_templates=[],
)
@router.post("/complete")
async def complete_wizard(
db: DBSession = Depends(get_db)
):
"""
Wizard abschließen und Templates aktivieren.
Erstellt Topics, Rules und Profile basierend auf den gewählten Templates.
"""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id,
UserAlertSubscriptionDB.wizard_completed == False
).first()
if not subscription:
raise HTTPException(status_code=400, detail="Kein aktiver Wizard gefunden")
if not subscription.selected_template_ids:
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen")
# Aktiviere Templates (über Subscription-Endpoint)
from .subscriptions import activate_template, ActivateTemplateRequest
# Markiere als abgeschlossen
subscription.wizard_completed = True
subscription.wizard_step = 4
subscription.updated_at = datetime.utcnow()
db.commit()
return {
"status": "success",
"message": "Wizard abgeschlossen! Ihre Alerts werden ab jetzt gesammelt.",
"subscription_id": subscription.id,
"selected_templates": subscription.selected_template_ids,
"next_action": "Besuchen Sie die Inbox, um Ihre ersten Alerts zu sehen.",
}
@router.post("/reset")
async def reset_wizard(
db: DBSession = Depends(get_db)
):
"""Wizard zurücksetzen (für Neustart)."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.user_id == user_id,
UserAlertSubscriptionDB.wizard_completed == False
).first()
if subscription:
db.delete(subscription)
db.commit()
return {
"status": "success",
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
}
# ============================================================================
# Migration Endpoints (für bestehende Google Alerts)
# ============================================================================
@router.post("/migrate/email", response_model=MigrateEmailResponse)
async def start_email_migration(
request: MigrateEmailRequest = None,
db: DBSession = Depends(get_db)
):
"""
Starte E-Mail-Migration für bestehende Google Alerts.
Generiert eine eindeutige Inbound-E-Mail-Adresse, an die der Nutzer
seine Google Alerts weiterleiten kann.
"""
user_id = get_user_id_from_request()
# Erstelle AlertSource
source = AlertSourceDB(
id=str(uuid.uuid4()),
user_id=user_id,
source_type=FeedTypeEnum.EMAIL,
original_label=request.original_label if request else "Google Alert Migration",
migration_mode=MigrationModeEnum.FORWARD,
is_active=True,
)
# Generiere Inbound-Adresse
source.inbound_address = _generate_inbound_address(user_id, source.id)
db.add(source)
db.commit()
db.refresh(source)
return MigrateEmailResponse(
status="success",
inbound_address=source.inbound_address,
source_id=source.id,
instructions=[
"1. Öffnen Sie Google Alerts (google.com/alerts)",
"2. Klicken Sie auf das Bearbeiten-Symbol bei Ihrem Alert",
f"3. Ändern Sie die E-Mail-Adresse zu: {source.inbound_address}",
"4. Speichern Sie die Änderung",
"5. Ihre Alerts werden automatisch importiert und gefiltert",
],
)
@router.post("/migrate/rss", response_model=MigrateRssResponse)
async def import_rss_feeds(
request: MigrateRssRequest,
db: DBSession = Depends(get_db)
):
"""
Importiere bestehende Google Alert RSS-Feeds.
Erstellt für jede RSS-URL einen AlertSource und Topic.
"""
user_id = get_user_id_from_request()
from ..db.models import AlertTopicDB
sources_created = 0
topics_created = 0
for i, url in enumerate(request.rss_urls):
# Label aus Request oder generieren
label = None
if request.labels and i < len(request.labels):
label = request.labels[i]
if not label:
label = f"RSS Feed {i + 1}"
# Erstelle AlertSource
source = AlertSourceDB(
id=str(uuid.uuid4()),
user_id=user_id,
source_type=FeedTypeEnum.RSS,
original_label=label,
rss_url=url,
migration_mode=MigrationModeEnum.IMPORT,
is_active=True,
)
db.add(source)
sources_created += 1
# Erstelle Topic
topic = AlertTopicDB(
id=str(uuid.uuid4()),
user_id=user_id,
name=label,
description=f"Importiert aus RSS: {url[:50]}...",
feed_url=url,
feed_type=FeedTypeEnum.RSS,
is_active=True,
fetch_interval_minutes=60,
)
db.add(topic)
# Verknüpfe Source mit Topic
source.topic_id = topic.id
topics_created += 1
db.commit()
return MigrateRssResponse(
status="success",
sources_created=sources_created,
topics_created=topics_created,
message=f"{sources_created} RSS-Feeds importiert. Die Alerts werden automatisch abgerufen.",
)
@router.get("/migrate/sources")
async def list_migration_sources(
db: DBSession = Depends(get_db)
):
"""Liste alle Migration-Quellen des Users."""
user_id = get_user_id_from_request()
sources = db.query(AlertSourceDB).filter(
AlertSourceDB.user_id == user_id
).order_by(AlertSourceDB.created_at.desc()).all()
return {
"sources": [
{
"id": s.id,
"type": s.source_type.value if s.source_type else "unknown",
"label": s.original_label,
"inbound_address": s.inbound_address,
"rss_url": s.rss_url,
"migration_mode": s.migration_mode.value if s.migration_mode else "unknown",
"items_received": s.items_received,
"is_active": s.is_active,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in sources
],
"total": len(sources),
}