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:
367
backend/state_engine/engine.py
Normal file
367
backend/state_engine/engine.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
State Engine - Antizipations-Engine und Phasen-Service.
|
||||
|
||||
Komponenten:
|
||||
- AnticipationEngine: Evaluiert Regeln und generiert Vorschläge
|
||||
- PhaseService: Verwaltet Phasen-Übergänge
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import (
|
||||
SchoolYearPhase,
|
||||
TeacherContext,
|
||||
PhaseInfo,
|
||||
get_phase_info,
|
||||
PHASE_INFO
|
||||
)
|
||||
from .rules import Rule, Suggestion, SuggestionPriority, RULES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phasen-Übergänge
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class PhaseTransition:
|
||||
"""Definition eines erlaubten Phasen-Übergangs."""
|
||||
from_phase: SchoolYearPhase
|
||||
to_phase: SchoolYearPhase
|
||||
condition: Callable[[TeacherContext], bool]
|
||||
auto_trigger: bool = False # Automatisch wenn Condition erfüllt
|
||||
|
||||
|
||||
# Vordefinierte Übergänge
|
||||
VALID_TRANSITIONS: List[PhaseTransition] = [
|
||||
# Onboarding → SchoolYearStart
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.ONBOARDING,
|
||||
to_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
||||
condition=lambda ctx: (
|
||||
ctx.has_completed_milestone("school_select") and
|
||||
ctx.has_completed_milestone("consent_accept") and
|
||||
ctx.has_completed_milestone("profile_complete")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
|
||||
# SchoolYearStart → TeachingSetup
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
||||
to_phase=SchoolYearPhase.TEACHING_SETUP,
|
||||
condition=lambda ctx: (
|
||||
len(ctx.classes) > 0 and
|
||||
ctx.has_completed_milestone("add_students")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
|
||||
# TeachingSetup → Performance1
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.TEACHING_SETUP,
|
||||
to_phase=SchoolYearPhase.PERFORMANCE_1,
|
||||
condition=lambda ctx: (
|
||||
ctx.weeks_since_start >= 6 and
|
||||
ctx.has_learning_units()
|
||||
),
|
||||
auto_trigger=False, # Manueller Übergang
|
||||
),
|
||||
|
||||
# Performance1 → SemesterEnd
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.PERFORMANCE_1,
|
||||
to_phase=SchoolYearPhase.SEMESTER_END,
|
||||
condition=lambda ctx: (
|
||||
(ctx.is_in_month(1) or ctx.is_in_month(2)) and
|
||||
ctx.has_completed_milestone("enter_grades")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
|
||||
# SemesterEnd → Teaching2
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.SEMESTER_END,
|
||||
to_phase=SchoolYearPhase.TEACHING_2,
|
||||
condition=lambda ctx: (
|
||||
ctx.has_completed_milestone("generate_certificates")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
|
||||
# Teaching2 → Performance2
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.TEACHING_2,
|
||||
to_phase=SchoolYearPhase.PERFORMANCE_2,
|
||||
condition=lambda ctx: (
|
||||
ctx.weeks_since_start >= 30 or
|
||||
ctx.is_in_month(4) or ctx.is_in_month(5)
|
||||
),
|
||||
auto_trigger=False,
|
||||
),
|
||||
|
||||
# Performance2 → YearEnd
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.PERFORMANCE_2,
|
||||
to_phase=SchoolYearPhase.YEAR_END,
|
||||
condition=lambda ctx: (
|
||||
(ctx.is_in_month(6) or ctx.is_in_month(7)) and
|
||||
ctx.has_completed_milestone("enter_final_grades")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
|
||||
# YearEnd → Archived
|
||||
PhaseTransition(
|
||||
from_phase=SchoolYearPhase.YEAR_END,
|
||||
to_phase=SchoolYearPhase.ARCHIVED,
|
||||
condition=lambda ctx: (
|
||||
ctx.has_completed_milestone("generate_final_certificates") and
|
||||
ctx.has_completed_milestone("archive_year")
|
||||
),
|
||||
auto_trigger=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PhaseService:
|
||||
"""
|
||||
Verwaltet Schuljahres-Phasen und deren Übergänge.
|
||||
|
||||
Funktionen:
|
||||
- Phasen-Status abrufen
|
||||
- Automatische Übergänge prüfen
|
||||
- Manuelle Übergänge durchführen
|
||||
"""
|
||||
|
||||
def __init__(self, transitions: List[PhaseTransition] = None):
|
||||
self.transitions = transitions or VALID_TRANSITIONS
|
||||
|
||||
def get_current_phase_info(self, phase: SchoolYearPhase) -> PhaseInfo:
|
||||
"""Gibt Metadaten zur aktuellen Phase zurück."""
|
||||
return get_phase_info(phase)
|
||||
|
||||
def get_all_phases(self) -> List[Dict[str, Any]]:
|
||||
"""Gibt alle Phasen mit Metadaten zurück."""
|
||||
return [
|
||||
{
|
||||
"phase": info.phase.value,
|
||||
"display_name": info.display_name,
|
||||
"description": info.description,
|
||||
"typical_months": info.typical_months,
|
||||
}
|
||||
for info in PHASE_INFO.values()
|
||||
]
|
||||
|
||||
def check_and_transition(self, ctx: TeacherContext) -> Optional[SchoolYearPhase]:
|
||||
"""
|
||||
Prüft ob ein automatischer Phasen-Übergang möglich ist.
|
||||
|
||||
Args:
|
||||
ctx: Aktueller TeacherContext
|
||||
|
||||
Returns:
|
||||
Neue Phase wenn Übergang stattfand, sonst None
|
||||
"""
|
||||
current_phase = ctx.current_phase
|
||||
|
||||
for transition in self.transitions:
|
||||
if (transition.from_phase == current_phase and
|
||||
transition.auto_trigger and
|
||||
transition.condition(ctx)):
|
||||
|
||||
logger.info(
|
||||
f"Auto-transition from {current_phase} to {transition.to_phase} "
|
||||
f"for teacher {ctx.teacher_id}"
|
||||
)
|
||||
return transition.to_phase
|
||||
|
||||
return None
|
||||
|
||||
def can_transition_to(self, ctx: TeacherContext, target_phase: SchoolYearPhase) -> bool:
|
||||
"""
|
||||
Prüft ob ein Übergang zu einer bestimmten Phase möglich ist.
|
||||
|
||||
Args:
|
||||
ctx: Aktueller TeacherContext
|
||||
target_phase: Zielphase
|
||||
|
||||
Returns:
|
||||
True wenn Übergang erlaubt
|
||||
"""
|
||||
for transition in self.transitions:
|
||||
if (transition.from_phase == ctx.current_phase and
|
||||
transition.to_phase == target_phase):
|
||||
return transition.condition(ctx)
|
||||
return False
|
||||
|
||||
def get_next_phase(self, current: SchoolYearPhase) -> Optional[SchoolYearPhase]:
|
||||
"""Gibt die nächste Phase in der Sequenz zurück."""
|
||||
phase_order = [
|
||||
SchoolYearPhase.ONBOARDING,
|
||||
SchoolYearPhase.SCHOOL_YEAR_START,
|
||||
SchoolYearPhase.TEACHING_SETUP,
|
||||
SchoolYearPhase.PERFORMANCE_1,
|
||||
SchoolYearPhase.SEMESTER_END,
|
||||
SchoolYearPhase.TEACHING_2,
|
||||
SchoolYearPhase.PERFORMANCE_2,
|
||||
SchoolYearPhase.YEAR_END,
|
||||
SchoolYearPhase.ARCHIVED,
|
||||
]
|
||||
|
||||
try:
|
||||
idx = phase_order.index(current)
|
||||
if idx < len(phase_order) - 1:
|
||||
return phase_order[idx + 1]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_progress_percentage(self, ctx: TeacherContext) -> float:
|
||||
"""
|
||||
Berechnet Fortschritt in der aktuellen Phase.
|
||||
|
||||
Returns:
|
||||
Prozent (0-100)
|
||||
"""
|
||||
phase_info = get_phase_info(ctx.current_phase)
|
||||
required = set(phase_info.required_actions)
|
||||
completed = set(ctx.completed_milestones)
|
||||
|
||||
if not required:
|
||||
return 100.0
|
||||
|
||||
done = len(required.intersection(completed))
|
||||
return (done / len(required)) * 100
|
||||
|
||||
|
||||
class AnticipationEngine:
|
||||
"""
|
||||
Evaluiert Antizipations-Regeln und generiert priorisierte Vorschläge.
|
||||
|
||||
Die Engine:
|
||||
- Wendet alle aktiven Regeln auf den TeacherContext an
|
||||
- Priorisiert Vorschläge nach Dringlichkeit
|
||||
- Limitiert auf max. 5 Vorschläge
|
||||
"""
|
||||
|
||||
def __init__(self, rules: List[Rule] = None, max_suggestions: int = 5):
|
||||
self.rules = rules or RULES
|
||||
self.max_suggestions = max_suggestions
|
||||
|
||||
def get_suggestions(self, ctx: TeacherContext) -> List[Suggestion]:
|
||||
"""
|
||||
Evaluiert alle Regeln und gibt priorisierte Vorschläge zurück.
|
||||
|
||||
Args:
|
||||
ctx: TeacherContext mit allen relevanten Informationen
|
||||
|
||||
Returns:
|
||||
Liste von max. 5 Vorschlägen, sortiert nach Priorität
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
for rule in self.rules:
|
||||
try:
|
||||
suggestion = rule.evaluate(ctx)
|
||||
if suggestion:
|
||||
suggestions.append(suggestion)
|
||||
except Exception as e:
|
||||
logger.warning(f"Rule {rule.id} evaluation failed: {e}")
|
||||
|
||||
return self._prioritize(suggestions)
|
||||
|
||||
def _prioritize(self, suggestions: List[Suggestion]) -> List[Suggestion]:
|
||||
"""
|
||||
Sortiert Vorschläge nach Priorität und limitiert.
|
||||
|
||||
Reihenfolge:
|
||||
1. URGENT (1)
|
||||
2. HIGH (2)
|
||||
3. MEDIUM (3)
|
||||
4. LOW (4)
|
||||
"""
|
||||
sorted_suggestions = sorted(
|
||||
suggestions,
|
||||
key=lambda s: s.priority.value
|
||||
)
|
||||
return sorted_suggestions[:self.max_suggestions]
|
||||
|
||||
def get_top_suggestion(self, ctx: TeacherContext) -> Optional[Suggestion]:
|
||||
"""
|
||||
Gibt den wichtigsten Vorschlag zurück.
|
||||
|
||||
Args:
|
||||
ctx: TeacherContext
|
||||
|
||||
Returns:
|
||||
Wichtigster Vorschlag oder None
|
||||
"""
|
||||
suggestions = self.get_suggestions(ctx)
|
||||
return suggestions[0] if suggestions else None
|
||||
|
||||
def get_suggestions_by_category(
|
||||
self,
|
||||
ctx: TeacherContext
|
||||
) -> Dict[str, List[Suggestion]]:
|
||||
"""
|
||||
Gruppiert Vorschläge nach Kategorie.
|
||||
|
||||
Returns:
|
||||
Dict mit Kategorien als Keys und Listen von Vorschlägen
|
||||
"""
|
||||
suggestions = self.get_suggestions(ctx)
|
||||
by_category: Dict[str, List[Suggestion]] = {}
|
||||
|
||||
for suggestion in suggestions:
|
||||
if suggestion.category not in by_category:
|
||||
by_category[suggestion.category] = []
|
||||
by_category[suggestion.category].append(suggestion)
|
||||
|
||||
return by_category
|
||||
|
||||
def count_by_priority(self, ctx: TeacherContext) -> Dict[str, int]:
|
||||
"""
|
||||
Zählt Vorschläge nach Priorität.
|
||||
|
||||
Returns:
|
||||
Dict mit Prioritäten und Anzahlen
|
||||
"""
|
||||
suggestions = self.get_suggestions(ctx)
|
||||
counts = {
|
||||
"urgent": 0,
|
||||
"high": 0,
|
||||
"medium": 0,
|
||||
"low": 0,
|
||||
}
|
||||
|
||||
for s in suggestions:
|
||||
if s.priority == SuggestionPriority.URGENT:
|
||||
counts["urgent"] += 1
|
||||
elif s.priority == SuggestionPriority.HIGH:
|
||||
counts["high"] += 1
|
||||
elif s.priority == SuggestionPriority.MEDIUM:
|
||||
counts["medium"] += 1
|
||||
else:
|
||||
counts["low"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Factory Functions
|
||||
# ============================================================================
|
||||
|
||||
def create_anticipation_engine(custom_rules: List[Rule] = None) -> AnticipationEngine:
|
||||
"""Erstellt eine neue AnticipationEngine."""
|
||||
return AnticipationEngine(rules=custom_rules)
|
||||
|
||||
|
||||
def create_phase_service(custom_transitions: List[PhaseTransition] = None) -> PhaseService:
|
||||
"""Erstellt einen neuen PhaseService."""
|
||||
return PhaseService(transitions=custom_transitions)
|
||||
Reference in New Issue
Block a user