Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
368 lines
11 KiB
Python
368 lines
11 KiB
Python
"""
|
|
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)
|