[split-required] Split final 43 files (500-668 LOC) to complete refactoring

klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 09:41:42 +02:00
parent 451365a312
commit bd4b956e3c
113 changed files with 13790 additions and 14148 deletions

View File

@@ -7,21 +7,12 @@ Verwaltet den 3-Schritt Setup-Wizard:
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 typing import List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
@@ -29,77 +20,22 @@ from ..db.models import (
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
)
from .wizard_models import (
WizardState,
Step1Data,
Step2Data,
Step3Data,
StepResponse,
MigrateEmailRequest,
MigrateEmailResponse,
MigrateRssRequest,
MigrateRssResponse,
)
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
# ============================================================================
@@ -144,13 +80,9 @@ def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]
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,
"id": t.id, "slug": t.slug, "name": t.name,
"description": t.description, "icon": t.icon,
"category": t.category, "recommended": True,
})
return result
@@ -167,14 +99,8 @@ def _generate_inbound_address(user_id: str, source_id: str) -> str:
# ============================================================================
@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.
"""
async def get_wizard_state(db: DBSession = Depends(get_db)):
"""Hole aktuellen Wizard-Status."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -182,15 +108,8 @@ async def get_wizard_state(
).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=[],
)
return WizardState()
# 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 []
@@ -204,61 +123,37 @@ async def get_wizard_state(
@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.
"""
async def save_step_1(data: Step1Data, db: DBSession = Depends(get_db)):
"""Schritt 1: Rolle speichern."""
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'"
)
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,
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).
"""
async def save_step_2(data: Step2Data, db: DBSession = Depends(get_db)):
"""Schritt 2: Templates auswählen."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -269,46 +164,28 @@ async def save_step_2(
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()
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],
}
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,
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.
"""
async def save_step_3(data: Step3Data, db: DBSession = Depends(get_db)):
"""Schritt 3: Digest-Einstellungen und Bestätigung."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -318,16 +195,13 @@ async def save_step_3(
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,
@@ -336,27 +210,17 @@ async def save_step_3(
}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
return StepResponse(
status="success",
current_step=3,
next_step=4,
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.
"""
async def complete_wizard(db: DBSession = Depends(get_db)):
"""Wizard abschließen und Templates aktivieren."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -366,18 +230,14 @@ async def complete_wizard(
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 {
@@ -390,9 +250,7 @@ async def complete_wizard(
@router.post("/reset")
async def reset_wizard(
db: DBSession = Depends(get_db)
):
async def reset_wizard(db: DBSession = Depends(get_db)):
"""Wizard zurücksetzen (für Neustart)."""
user_id = get_user_id_from_request()
@@ -405,10 +263,7 @@ async def reset_wizard(
db.delete(subscription)
db.commit()
return {
"status": "success",
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
}
return {"status": "success", "message": "Wizard zurückgesetzt. Sie können neu beginnen."}
# ============================================================================
@@ -416,29 +271,16 @@ async def reset_wizard(
# ============================================================================
@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.
"""
async def start_email_migration(request: MigrateEmailRequest = None, db: DBSession = Depends(get_db)):
"""Starte E-Mail-Migration für bestehende Google Alerts."""
user_id = get_user_id_from_request()
# Erstelle AlertSource
source = AlertSourceDB(
id=str(uuid.uuid4()),
user_id=user_id,
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,
migration_mode=MigrationModeEnum.FORWARD, is_active=True,
)
# Generiere Inbound-Adresse
source.inbound_address = _generate_inbound_address(user_id, source.id)
db.add(source)
@@ -446,9 +288,7 @@ async def start_email_migration(
db.refresh(source)
return MigrateEmailResponse(
status="success",
inbound_address=source.inbound_address,
source_id=source.id,
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",
@@ -460,74 +300,49 @@ async def start_email_migration(
@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.
"""
async def import_rss_feeds(request: MigrateRssRequest, db: DBSession = Depends(get_db)):
"""Importiere bestehende Google Alert RSS-Feeds."""
user_id = get_user_id_from_request()
from ..db.models import AlertTopicDB
sources_created = 0
topics_created = 0
sources_created, topics_created = 0, 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,
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,
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,
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)
):
async def list_migration_sources(db: DBSession = Depends(get_db)):
"""Liste alle Migration-Quellen des Users."""
user_id = get_user_id_from_request()