[split-required] Split remaining 500-680 LOC files (final batch)

website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -5,16 +5,29 @@ Stellt PostgreSQL-Anbindung für Alert-Persistenz bereit.
Nutzt die gleiche Base wie classroom_engine für konsistente Migrationen.
"""
from .database import Base, SessionLocal, get_db, engine
from .models import (
AlertTopicDB,
AlertItemDB,
AlertRuleDB,
AlertProfileDB,
from .enums import (
AlertSourceEnum,
AlertStatusEnum,
RelevanceDecisionEnum,
FeedTypeEnum,
RuleActionEnum,
ImportanceLevelEnum,
AlertModeEnum,
MigrationModeEnum,
DigestStatusEnum,
UserRoleEnum,
)
from .models import (
AlertTopicDB,
AlertItemDB,
AlertRuleDB,
AlertProfileDB,
)
from .models_dual_mode import (
AlertTemplateDB,
AlertSourceDB,
UserAlertSubscriptionDB,
AlertDigestDB,
)
__all__ = [
@@ -31,4 +44,13 @@ __all__ = [
"RelevanceDecisionEnum",
"FeedTypeEnum",
"RuleActionEnum",
"ImportanceLevelEnum",
"AlertModeEnum",
"MigrationModeEnum",
"DigestStatusEnum",
"UserRoleEnum",
"AlertTemplateDB",
"AlertSourceDB",
"UserAlertSubscriptionDB",
"AlertDigestDB",
]

View File

@@ -0,0 +1,84 @@
"""
Enum definitions for Alerts Agent database models.
"""
import enum
class AlertSourceEnum(str, enum.Enum):
"""Quelle des Alerts."""
GOOGLE_ALERTS_RSS = "google_alerts_rss"
GOOGLE_ALERTS_EMAIL = "google_alerts_email"
RSS_FEED = "rss_feed"
WEBHOOK = "webhook"
MANUAL = "manual"
class AlertStatusEnum(str, enum.Enum):
"""Verarbeitungsstatus des Alerts."""
NEW = "new"
PROCESSED = "processed"
DUPLICATE = "duplicate"
SCORED = "scored"
REVIEWED = "reviewed"
ARCHIVED = "archived"
class RelevanceDecisionEnum(str, enum.Enum):
"""Relevanz-Entscheidung."""
KEEP = "KEEP"
DROP = "DROP"
REVIEW = "REVIEW"
class FeedTypeEnum(str, enum.Enum):
"""Typ der Feed-Quelle."""
RSS = "rss"
EMAIL = "email"
WEBHOOK = "webhook"
class RuleActionEnum(str, enum.Enum):
"""Aktionen fuer Regeln."""
KEEP = "keep"
DROP = "drop"
TAG = "tag"
EMAIL = "email"
WEBHOOK = "webhook"
SLACK = "slack"
class ImportanceLevelEnum(str, enum.Enum):
"""5-stufige Wichtigkeitsskala fuer Guided Mode."""
INFO = "info"
PRUEFEN = "pruefen"
WICHTIG = "wichtig"
DRINGEND = "dringend"
KRITISCH = "kritisch"
class AlertModeEnum(str, enum.Enum):
"""Modus fuer Alert-Nutzung."""
GUIDED = "guided"
EXPERT = "expert"
class MigrationModeEnum(str, enum.Enum):
"""Wie wurden die Alerts migriert."""
FORWARD = "forward"
IMPORT = "import"
RECONSTRUCTED = "reconstructed"
class DigestStatusEnum(str, enum.Enum):
"""Status der Digest-Generierung."""
PENDING = "pending"
GENERATING = "generating"
SENT = "sent"
FAILED = "failed"
class UserRoleEnum(str, enum.Enum):
"""Rolle des Nutzers fuer Template-Empfehlungen."""
LEHRKRAFT = "lehrkraft"
SCHULLEITUNG = "schulleitung"
IT_BEAUFTRAGTE = "it_beauftragte"

View File

@@ -1,8 +1,12 @@
"""
SQLAlchemy Database Models für Alerts Agent.
SQLAlchemy Database Models fuer Alerts Agent.
Persistiert Topics, Alerts, Rules und Profile in PostgreSQL.
Nutzt die gleiche Base wie classroom_engine für konsistente Migrationen.
Split into:
- enums.py: All enum definitions
- models.py (this file): Core ORM models (Topic, Item, Rule, Profile)
- models_dual_mode.py: Dual-mode system (Template, Source, Subscription, Digest)
All symbols are re-exported here for backward compatibility.
"""
from datetime import datetime
from sqlalchemy import (
@@ -10,132 +14,56 @@ from sqlalchemy import (
Boolean, Text, Enum as SQLEnum, ForeignKey, Index
)
from sqlalchemy.orm import relationship
import enum
import uuid
# Import Base from classroom_engine for shared metadata
from classroom_engine.database import Base
# Re-export all enums
from .enums import (
AlertSourceEnum,
AlertStatusEnum,
RelevanceDecisionEnum,
FeedTypeEnum,
RuleActionEnum,
ImportanceLevelEnum,
AlertModeEnum,
MigrationModeEnum,
DigestStatusEnum,
UserRoleEnum,
)
class AlertSourceEnum(str, enum.Enum):
"""Quelle des Alerts."""
GOOGLE_ALERTS_RSS = "google_alerts_rss"
GOOGLE_ALERTS_EMAIL = "google_alerts_email"
RSS_FEED = "rss_feed"
WEBHOOK = "webhook"
MANUAL = "manual"
class AlertStatusEnum(str, enum.Enum):
"""Verarbeitungsstatus des Alerts."""
NEW = "new"
PROCESSED = "processed"
DUPLICATE = "duplicate"
SCORED = "scored"
REVIEWED = "reviewed"
ARCHIVED = "archived"
class RelevanceDecisionEnum(str, enum.Enum):
"""Relevanz-Entscheidung."""
KEEP = "KEEP"
DROP = "DROP"
REVIEW = "REVIEW"
class FeedTypeEnum(str, enum.Enum):
"""Typ der Feed-Quelle."""
RSS = "rss"
EMAIL = "email"
WEBHOOK = "webhook"
class RuleActionEnum(str, enum.Enum):
"""Aktionen für Regeln."""
KEEP = "keep"
DROP = "drop"
TAG = "tag"
EMAIL = "email"
WEBHOOK = "webhook"
SLACK = "slack"
class ImportanceLevelEnum(str, enum.Enum):
"""5-stufige Wichtigkeitsskala für Guided Mode."""
INFO = "info" # 0.0-0.4 - Informativ
PRUEFEN = "pruefen" # 0.4-0.6 - Zu prüfen
WICHTIG = "wichtig" # 0.6-0.75 - Wichtig
DRINGEND = "dringend" # 0.75-0.9 - Dringend
KRITISCH = "kritisch" # 0.9-1.0 - Kritisch
class AlertModeEnum(str, enum.Enum):
"""Modus für Alert-Nutzung."""
GUIDED = "guided" # Geführter Modus für Lehrer/Schulleitungen
EXPERT = "expert" # Experten-Modus für IT-affine Nutzer
class MigrationModeEnum(str, enum.Enum):
"""Wie wurden die Alerts migriert."""
FORWARD = "forward" # E-Mail-Weiterleitung
IMPORT = "import" # RSS-Import
RECONSTRUCTED = "reconstructed" # Automatisch rekonstruiert
class DigestStatusEnum(str, enum.Enum):
"""Status der Digest-Generierung."""
PENDING = "pending"
GENERATING = "generating"
SENT = "sent"
FAILED = "failed"
class UserRoleEnum(str, enum.Enum):
"""Rolle des Nutzers für Template-Empfehlungen."""
LEHRKRAFT = "lehrkraft"
SCHULLEITUNG = "schulleitung"
IT_BEAUFTRAGTE = "it_beauftragte"
# Re-export dual-mode models
from .models_dual_mode import (
AlertTemplateDB,
AlertSourceDB,
UserAlertSubscriptionDB,
AlertDigestDB,
)
class AlertTopicDB(Base):
"""
Alert Topic / Feed-Quelle.
Repräsentiert eine Google Alert-Konfiguration oder einen RSS-Feed.
Repraesentiert eine Google Alert-Konfiguration oder einen RSS-Feed.
"""
__tablename__ = 'alert_topics'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), nullable=True, index=True) # Optional: Multi-User
# Topic-Details
user_id = Column(String(36), nullable=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, default="")
# Feed-Konfiguration
feed_url = Column(String(2000), nullable=True)
feed_type = Column(
SQLEnum(FeedTypeEnum),
default=FeedTypeEnum.RSS,
nullable=False
)
# Scheduling
feed_type = Column(SQLEnum(FeedTypeEnum), default=FeedTypeEnum.RSS, nullable=False)
is_active = Column(Boolean, default=True, index=True)
fetch_interval_minutes = Column(Integer, default=60)
last_fetched_at = Column(DateTime, nullable=True)
last_fetch_error = Column(Text, nullable=True)
# Statistiken
total_items_fetched = Column(Integer, default=0)
items_kept = Column(Integer, default=0)
items_dropped = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
alerts = relationship("AlertItemDB", back_populates="topic", cascade="all, delete-orphan")
rules = relationship("AlertRuleDB", back_populates="topic", cascade="all, delete-orphan")
@@ -146,84 +74,47 @@ class AlertTopicDB(Base):
class AlertItemDB(Base):
"""
Einzelner Alert-Eintrag.
Entspricht einem Artikel/Link aus Google Alerts oder RSS.
"""
__tablename__ = 'alert_items'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=False, index=True)
# Content
title = Column(Text, nullable=False)
url = Column(String(2000), nullable=False)
snippet = Column(Text, default="")
article_text = Column(Text, nullable=True) # Volltext (optional)
# Metadaten
article_text = Column(Text, nullable=True)
lang = Column(String(10), default="de")
published_at = Column(DateTime, nullable=True, index=True)
fetched_at = Column(DateTime, default=datetime.utcnow, index=True)
processed_at = Column(DateTime, nullable=True)
# Source
source = Column(
SQLEnum(AlertSourceEnum),
default=AlertSourceEnum.GOOGLE_ALERTS_RSS,
nullable=False
)
# Deduplication
source = Column(SQLEnum(AlertSourceEnum), default=AlertSourceEnum.GOOGLE_ALERTS_RSS, nullable=False)
url_hash = Column(String(64), unique=True, nullable=False, index=True)
content_hash = Column(String(64), nullable=True) # SimHash für Fuzzy-Matching
content_hash = Column(String(64), nullable=True)
canonical_url = Column(String(2000), nullable=True)
# Status
status = Column(
SQLEnum(AlertStatusEnum),
default=AlertStatusEnum.NEW,
nullable=False,
index=True
)
cluster_id = Column(String(36), nullable=True) # Gruppierung ähnlicher Alerts
# Relevanz-Scoring
status = Column(SQLEnum(AlertStatusEnum), default=AlertStatusEnum.NEW, nullable=False, index=True)
cluster_id = Column(String(36), nullable=True)
relevance_score = Column(Float, nullable=True)
relevance_decision = Column(
SQLEnum(RelevanceDecisionEnum),
nullable=True,
index=True
)
relevance_reasons = Column(JSON, default=list) # ["matches_priority", ...]
relevance_decision = Column(SQLEnum(RelevanceDecisionEnum), nullable=True, index=True)
relevance_reasons = Column(JSON, default=list)
relevance_summary = Column(Text, nullable=True)
scored_by_model = Column(String(100), nullable=True) # "llama3.1:8b"
scored_by_model = Column(String(100), nullable=True)
scored_at = Column(DateTime, nullable=True)
# User Actions
user_marked_relevant = Column(Boolean, nullable=True) # Explizites Feedback
user_tags = Column(JSON, default=list) # ["wichtig", "später lesen"]
user_marked_relevant = Column(Boolean, nullable=True)
user_tags = Column(JSON, default=list)
user_notes = Column(Text, nullable=True)
# Guided Mode Fields (NEU)
importance_level = Column(
SQLEnum(ImportanceLevelEnum),
nullable=True,
index=True
)
why_relevant = Column(Text, nullable=True) # "Warum relevant?" Erklärung
next_steps = Column(JSON, default=list) # ["Schulleitung informieren", "Frist beachten"]
action_deadline = Column(DateTime, nullable=True) # Falls es eine Frist gibt
source_name = Column(String(255), nullable=True) # "Kultusministerium NRW"
source_credibility = Column(String(50), default="official") # official, news, blog
# Timestamps
# Guided Mode Fields
importance_level = Column(SQLEnum(ImportanceLevelEnum), nullable=True, index=True)
why_relevant = Column(Text, nullable=True)
next_steps = Column(JSON, default=list)
action_deadline = Column(DateTime, nullable=True)
source_name = Column(String(255), nullable=True)
source_credibility = Column(String(50), default="official")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
topic = relationship("AlertTopicDB", back_populates="alerts")
# Composite Index für häufige Queries
__table_args__ = (
Index('ix_alert_items_topic_status', 'topic_id', 'status'),
Index('ix_alert_items_topic_decision', 'topic_id', 'relevance_decision'),
@@ -234,46 +125,24 @@ class AlertItemDB(Base):
class AlertRuleDB(Base):
"""
Filterregel für Alerts.
Definiert Bedingungen und Aktionen für automatische Verarbeitung.
"""
"""Filterregel fuer Alerts."""
__tablename__ = 'alert_rules'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=True, index=True)
user_id = Column(String(36), nullable=True, index=True)
# Rule-Details
name = Column(String(255), nullable=False)
description = Column(Text, default="")
# Bedingungen (als JSON)
# Format: [{"field": "title", "op": "contains", "value": "..."}]
conditions = Column(JSON, nullable=False, default=list)
# Aktion
action_type = Column(
SQLEnum(RuleActionEnum),
default=RuleActionEnum.KEEP,
nullable=False
)
action_config = Column(JSON, default=dict) # {"email": "x@y.z", "tags": [...]}
# Priorisierung (höher = wird zuerst ausgeführt)
action_type = Column(SQLEnum(RuleActionEnum), default=RuleActionEnum.KEEP, nullable=False)
action_config = Column(JSON, default=dict)
priority = Column(Integer, default=0, index=True)
is_active = Column(Boolean, default=True, index=True)
# Statistiken
match_count = Column(Integer, default=0)
last_matched_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
topic = relationship("AlertTopicDB", back_populates="rules")
def __repr__(self):
@@ -281,42 +150,21 @@ class AlertRuleDB(Base):
class AlertProfileDB(Base):
"""
Nutzer-Profil für Relevanz-Scoring.
Speichert Prioritäten, Ausschlüsse und Lern-Beispiele.
"""
"""Nutzer-Profil fuer Relevanz-Scoring."""
__tablename__ = 'alert_profiles'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), unique=True, nullable=True, index=True)
# Name für Anzeige (falls mehrere Profile pro User)
name = Column(String(255), default="Default")
# Relevanz-Kriterien
# Format: [{"label": "Inklusion", "weight": 0.9, "keywords": [...], "description": "..."}]
priorities = Column(JSON, default=list)
# Ausschluss-Keywords
exclusions = Column(JSON, default=list) # ["Stellenanzeige", "Werbung"]
# Few-Shot Beispiele für LLM
# Format: [{"title": "...", "url": "...", "reason": "...", "added_at": "..."}]
exclusions = Column(JSON, default=list)
positive_examples = Column(JSON, default=list)
negative_examples = Column(JSON, default=list)
# Policies
# Format: {"prefer_german_sources": true, "max_age_days": 30}
policies = Column(JSON, default=dict)
# Statistiken
total_scored = Column(Integer, default=0)
total_kept = Column(Integer, default=0)
total_dropped = Column(Integer, default=0)
accuracy_estimate = Column(Float, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -324,16 +172,11 @@ class AlertProfileDB(Base):
return f"<AlertProfile {self.name} (user={self.user_id})>"
def get_prompt_context(self) -> str:
"""
Generiere Kontext für LLM-Prompt.
Dieser Text wird in den System-Prompt des Relevanz-Scorers eingefügt.
"""
"""Generiere Kontext fuer LLM-Prompt."""
lines = ["## Relevanzprofil des Nutzers\n"]
# Prioritäten
if self.priorities:
lines.append("### Prioritäten (Themen von Interesse):")
lines.append("### Prioritaeten (Themen von Interesse):")
for p in self.priorities:
weight = p.get("weight", 0.5)
weight_label = "Sehr wichtig" if weight > 0.7 else "Wichtig" if weight > 0.4 else "Interessant"
@@ -344,33 +187,29 @@ class AlertProfileDB(Base):
lines.append(f" Keywords: {', '.join(p['keywords'])}")
lines.append("")
# Ausschlüsse
if self.exclusions:
lines.append("### Ausschlüsse (ignorieren):")
lines.append("### Ausschluesse (ignorieren):")
lines.append(f"Themen mit diesen Keywords: {', '.join(self.exclusions)}")
lines.append("")
# Positive Beispiele (letzte 5)
if self.positive_examples:
lines.append("### Beispiele für relevante Alerts:")
lines.append("### Beispiele fuer relevante Alerts:")
for ex in self.positive_examples[-5:]:
lines.append(f"- \"{ex.get('title', '')}\"")
if ex.get("reason"):
lines.append(f" Grund: {ex['reason']}")
lines.append("")
# Negative Beispiele (letzte 5)
if self.negative_examples:
lines.append("### Beispiele für irrelevante Alerts:")
lines.append("### Beispiele fuer irrelevante Alerts:")
for ex in self.negative_examples[-5:]:
lines.append(f"- \"{ex.get('title', '')}\"")
if ex.get("reason"):
lines.append(f" Grund: {ex['reason']}")
lines.append("")
# Policies
if self.policies:
lines.append("### Zusätzliche Regeln:")
lines.append("### Zusaetzliche Regeln:")
for key, value in self.policies.items():
lines.append(f"- {key}: {value}")
@@ -378,33 +217,27 @@ class AlertProfileDB(Base):
@classmethod
def create_default_education_profile(cls) -> "AlertProfileDB":
"""
Erstelle ein Standard-Profil für Bildungsthemen.
"""
"""Erstelle ein Standard-Profil fuer Bildungsthemen."""
return cls(
name="Bildung Default",
priorities=[
{
"label": "Inklusion",
"weight": 0.9,
"keywords": ["inklusiv", "Förderbedarf", "Behinderung", "Barrierefreiheit"],
"description": "Inklusive Bildung, Förderschulen, Nachteilsausgleich"
"label": "Inklusion", "weight": 0.9,
"keywords": ["inklusiv", "Foerderbedarf", "Behinderung", "Barrierefreiheit"],
"description": "Inklusive Bildung, Foerderschulen, Nachteilsausgleich"
},
{
"label": "Datenschutz Schule",
"weight": 0.85,
"keywords": ["DSGVO", "Schülerfotos", "Einwilligung", "personenbezogene Daten"],
"label": "Datenschutz Schule", "weight": 0.85,
"keywords": ["DSGVO", "Schuelerfotos", "Einwilligung", "personenbezogene Daten"],
"description": "DSGVO in Schulen, Datenschutz bei Klassenfotos"
},
{
"label": "Schulrecht Bayern",
"weight": 0.8,
"label": "Schulrecht Bayern", "weight": 0.8,
"keywords": ["BayEUG", "Schulordnung", "Kultusministerium", "Bayern"],
"description": "Bayerisches Schulrecht, Verordnungen"
},
{
"label": "Digitalisierung Schule",
"weight": 0.7,
"label": "Digitalisierung Schule", "weight": 0.7,
"keywords": ["DigitalPakt", "Tablet-Klasse", "Lernplattform"],
"description": "Digitale Medien im Unterricht"
},
@@ -416,221 +249,3 @@ class AlertProfileDB(Base):
"min_content_length": 100,
}
)
# ============================================================================
# DUAL-MODE SYSTEM: Templates, Subscriptions, Sources, Digests
# ============================================================================
class AlertTemplateDB(Base):
"""
Vorkonfigurierte Alert-Templates (Playbooks).
Für Guided Mode: Lehrer wählen 1-3 Templates statt RSS-Feeds zu konfigurieren.
"""
__tablename__ = 'alert_templates'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Template-Identität
slug = Column(String(100), unique=True, nullable=False) # "foerderprogramme", "abitur-updates"
name = Column(String(255), nullable=False) # "Förderprogramme & Fristen"
description = Column(Text, default="") # B1/B2 Deutsch, 1-2 Sätze
icon = Column(String(50), default="") # Emoji: "💰", "📝", "⚖️"
category = Column(String(100), default="") # "administration", "teaching", "it"
# Zielgruppen (welche Rollen profitieren)
target_roles = Column(JSON, default=list) # ["schulleitung", "lehrkraft"]
# Template-Konfiguration
topics_config = Column(JSON, default=list) # Vorkonfigurierte RSS-Feeds
rules_config = Column(JSON, default=list) # Vorkonfigurierte Regeln
profile_config = Column(JSON, default=dict) # Prioritäten/Ausschlüsse
# Importance-Mapping (Score → 5 Stufen)
importance_config = Column(JSON, default=dict) # {"critical": 0.90, "urgent": 0.75, ...}
# Ausgabe-Einstellungen
max_cards_per_day = Column(Integer, default=10)
digest_enabled = Column(Boolean, default=True)
digest_day = Column(String(20), default="monday") # Tag für wöchentlichen Digest
# Lokalisierung
language = Column(String(10), default="de")
# Metadata
is_active = Column(Boolean, default=True)
is_premium = Column(Boolean, default=False) # Für kostenpflichtige Templates
sort_order = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
subscriptions = relationship("UserAlertSubscriptionDB", back_populates="template")
def __repr__(self):
return f"<AlertTemplate {self.slug}: {self.name}>"
class AlertSourceDB(Base):
"""
Alert-Quelle für Migration bestehender Alerts.
Unterstützt: E-Mail-Weiterleitung, RSS-Import, Rekonstruktion.
"""
__tablename__ = 'alert_sources'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id = Column(String(36), nullable=True, index=True) # Für Multi-Tenant
user_id = Column(String(36), nullable=True, index=True)
# Quellen-Typ
source_type = Column(
SQLEnum(FeedTypeEnum),
default=FeedTypeEnum.RSS,
nullable=False
)
# Original-Bezeichnung (vom Kunden)
original_label = Column(String(255), nullable=True) # "EU IT Ausschreibungen"
# E-Mail-Weiterleitung
inbound_address = Column(String(255), nullable=True, unique=True) # alerts+tenant123@breakpilot.app
# RSS-Import
rss_url = Column(String(2000), nullable=True)
# Migration-Modus
migration_mode = Column(
SQLEnum(MigrationModeEnum),
default=MigrationModeEnum.IMPORT,
nullable=False
)
# Verknüpfung zu erstelltem Topic
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='SET NULL'), nullable=True)
# Status
is_active = Column(Boolean, default=True)
items_received = Column(Integer, default=0)
last_item_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<AlertSource {self.source_type.value}: {self.original_label}>"
class UserAlertSubscriptionDB(Base):
"""
User-Subscription für Alert-Templates oder Expert-Profile.
Speichert Modus-Wahl, Template-Verknüpfung und Wizard-Zustand.
"""
__tablename__ = 'user_alert_subscriptions'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), nullable=False, index=True)
school_id = Column(String(36), nullable=True, index=True) # Optional: Schulkontext
# Modus-Auswahl
mode = Column(
SQLEnum(AlertModeEnum),
default=AlertModeEnum.GUIDED,
nullable=False
)
# Nutzer-Rolle (für Guided Mode)
user_role = Column(
SQLEnum(UserRoleEnum),
nullable=True
)
# Template-Verknüpfung (Guided Mode) - kann mehrere sein
template_id = Column(String(36), ForeignKey('alert_templates.id', ondelete='SET NULL'), nullable=True)
selected_template_ids = Column(JSON, default=list) # Bis zu 3 Templates
# Profil-Verknüpfung (Expert Mode)
profile_id = Column(String(36), ForeignKey('alert_profiles.id', ondelete='SET NULL'), nullable=True)
# Subscription-Einstellungen
is_active = Column(Boolean, default=True)
notification_email = Column(String(255), nullable=True)
# Digest-Präferenzen
digest_enabled = Column(Boolean, default=True)
digest_frequency = Column(String(20), default="weekly") # weekly, daily
digest_day = Column(String(20), default="monday")
last_digest_sent_at = Column(DateTime, nullable=True)
# Wizard-Zustand (für unvollständige Setups)
wizard_step = Column(Integer, default=0)
wizard_completed = Column(Boolean, default=False)
wizard_state = Column(JSON, default=dict) # Zwischenspeicher für Wizard-Daten
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
template = relationship("AlertTemplateDB", back_populates="subscriptions")
profile = relationship("AlertProfileDB")
digests = relationship("AlertDigestDB", back_populates="subscription", cascade="all, delete-orphan")
def __repr__(self):
return f"<UserAlertSubscription {self.user_id} ({self.mode.value})>"
class AlertDigestDB(Base):
"""
Wöchentliche Digest-Zusammenfassung.
Enthält gerenderte Zusammenfassung + Statistiken.
"""
__tablename__ = 'alert_digests'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
subscription_id = Column(String(36), ForeignKey('user_alert_subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(String(36), nullable=False, index=True)
# Zeitraum
period_start = Column(DateTime, nullable=False)
period_end = Column(DateTime, nullable=False)
# Content
title = Column(String(255), default="") # "KW 3/2026 - Ihre Bildungs-Alerts"
summary_html = Column(Text, default="") # Gerenderte HTML-Zusammenfassung
summary_pdf_url = Column(String(500), nullable=True) # Link zum PDF-Export
# Statistiken
total_alerts = Column(Integer, default=0)
kritisch_count = Column(Integer, default=0)
dringend_count = Column(Integer, default=0)
wichtig_count = Column(Integer, default=0)
pruefen_count = Column(Integer, default=0)
info_count = Column(Integer, default=0)
# Enthaltene Alert-IDs
alert_ids = Column(JSON, default=list)
# Status
status = Column(
SQLEnum(DigestStatusEnum),
default=DigestStatusEnum.PENDING,
nullable=False
)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
subscription = relationship("UserAlertSubscriptionDB", back_populates="digests")
def __repr__(self):
return f"<AlertDigest {self.title} ({self.status.value})>"

View File

@@ -0,0 +1,149 @@
"""
Dual-Mode System Models: Templates, Subscriptions, Sources, Digests.
These are additional ORM models for the Guided/Expert dual-mode alert system.
"""
from datetime import datetime
from sqlalchemy import (
Column, String, Integer, DateTime, JSON,
Boolean, Text, Enum as SQLEnum, ForeignKey,
)
from sqlalchemy.orm import relationship
import uuid
from classroom_engine.database import Base
from .enums import (
FeedTypeEnum,
MigrationModeEnum,
AlertModeEnum,
UserRoleEnum,
DigestStatusEnum,
)
class AlertTemplateDB(Base):
"""
Vorkonfigurierte Alert-Templates (Playbooks).
Fuer Guided Mode: Lehrer waehlen 1-3 Templates statt RSS-Feeds zu konfigurieren.
"""
__tablename__ = 'alert_templates'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
slug = Column(String(100), unique=True, nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, default="")
icon = Column(String(50), default="")
category = Column(String(100), default="")
target_roles = Column(JSON, default=list)
topics_config = Column(JSON, default=list)
rules_config = Column(JSON, default=list)
profile_config = Column(JSON, default=dict)
importance_config = Column(JSON, default=dict)
max_cards_per_day = Column(Integer, default=10)
digest_enabled = Column(Boolean, default=True)
digest_day = Column(String(20), default="monday")
language = Column(String(10), default="de")
is_active = Column(Boolean, default=True)
is_premium = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
subscriptions = relationship("UserAlertSubscriptionDB", back_populates="template")
def __repr__(self):
return f"<AlertTemplate {self.slug}: {self.name}>"
class AlertSourceDB(Base):
"""
Alert-Quelle fuer Migration bestehender Alerts.
Unterstuetzt: E-Mail-Weiterleitung, RSS-Import, Rekonstruktion.
"""
__tablename__ = 'alert_sources'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id = Column(String(36), nullable=True, index=True)
user_id = Column(String(36), nullable=True, index=True)
source_type = Column(SQLEnum(FeedTypeEnum), default=FeedTypeEnum.RSS, nullable=False)
original_label = Column(String(255), nullable=True)
inbound_address = Column(String(255), nullable=True, unique=True)
rss_url = Column(String(2000), nullable=True)
migration_mode = Column(SQLEnum(MigrationModeEnum), default=MigrationModeEnum.IMPORT, nullable=False)
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='SET NULL'), nullable=True)
is_active = Column(Boolean, default=True)
items_received = Column(Integer, default=0)
last_item_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<AlertSource {self.source_type.value}: {self.original_label}>"
class UserAlertSubscriptionDB(Base):
"""
User-Subscription fuer Alert-Templates oder Expert-Profile.
Speichert Modus-Wahl, Template-Verknuepfung und Wizard-Zustand.
"""
__tablename__ = 'user_alert_subscriptions'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), nullable=False, index=True)
school_id = Column(String(36), nullable=True, index=True)
mode = Column(SQLEnum(AlertModeEnum), default=AlertModeEnum.GUIDED, nullable=False)
user_role = Column(SQLEnum(UserRoleEnum), nullable=True)
template_id = Column(String(36), ForeignKey('alert_templates.id', ondelete='SET NULL'), nullable=True)
selected_template_ids = Column(JSON, default=list)
profile_id = Column(String(36), ForeignKey('alert_profiles.id', ondelete='SET NULL'), nullable=True)
is_active = Column(Boolean, default=True)
notification_email = Column(String(255), nullable=True)
digest_enabled = Column(Boolean, default=True)
digest_frequency = Column(String(20), default="weekly")
digest_day = Column(String(20), default="monday")
last_digest_sent_at = Column(DateTime, nullable=True)
wizard_step = Column(Integer, default=0)
wizard_completed = Column(Boolean, default=False)
wizard_state = Column(JSON, default=dict)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
template = relationship("AlertTemplateDB", back_populates="subscriptions")
profile = relationship("AlertProfileDB")
digests = relationship("AlertDigestDB", back_populates="subscription", cascade="all, delete-orphan")
def __repr__(self):
return f"<UserAlertSubscription {self.user_id} ({self.mode.value})>"
class AlertDigestDB(Base):
"""
Woechentliche Digest-Zusammenfassung.
Enthaelt gerenderte Zusammenfassung + Statistiken.
"""
__tablename__ = 'alert_digests'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
subscription_id = Column(String(36), ForeignKey('user_alert_subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(String(36), nullable=False, index=True)
period_start = Column(DateTime, nullable=False)
period_end = Column(DateTime, nullable=False)
title = Column(String(255), default="")
summary_html = Column(Text, default="")
summary_pdf_url = Column(String(500), nullable=True)
total_alerts = Column(Integer, default=0)
kritisch_count = Column(Integer, default=0)
dringend_count = Column(Integer, default=0)
wichtig_count = Column(Integer, default=0)
pruefen_count = Column(Integer, default=0)
info_count = Column(Integer, default=0)
alert_ids = Column(JSON, default=list)
status = Column(SQLEnum(DigestStatusEnum), default=DigestStatusEnum.PENDING, nullable=False)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
subscription = relationship("UserAlertSubscriptionDB", back_populates="digests")
def __repr__(self):
return f"<AlertDigest {self.title} ({self.status.value})>"

View File

@@ -1,25 +1,17 @@
"""
Certificates API - Zeugnisverwaltung für BreakPilot.
Certificates API - Zeugnisverwaltung fuer BreakPilot.
Bietet Endpoints für:
- Erstellen und Verwalten von Zeugnissen
- PDF-Export von Zeugnissen
- Notenübersicht und Statistiken
- Archivierung in DSMS
Arbeitet zusammen mit:
- services/pdf_service.py für PDF-Generierung
- Gradebook für Notenverwaltung
Split into:
- certificates_models.py: Enums, Pydantic models, helper functions
- certificates_api.py (this file): API endpoints and in-memory store
"""
import logging
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from typing import Optional, Dict, List, Any
from fastapi import APIRouter, HTTPException, Response, Query
from pydantic import BaseModel, Field
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
@@ -30,157 +22,26 @@ except (ImportError, OSError):
SchoolInfo = None # type: ignore
_pdf_available = False
from certificates_models import (
CertificateType,
CertificateStatus,
BehaviorGrade,
CertificateCreateRequest,
CertificateUpdateRequest,
CertificateResponse,
CertificateListResponse,
GradeStatistics,
get_type_label as _get_type_label,
calculate_average as _calculate_average,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/certificates", tags=["certificates"])
# =============================================================================
# Enums
# =============================================================================
class CertificateType(str, Enum):
"""Typen von Zeugnissen."""
HALBJAHR = "halbjahr" # Halbjahreszeugnis
JAHRES = "jahres" # Jahreszeugnis
ABSCHLUSS = "abschluss" # Abschlusszeugnis
ABGANG = "abgang" # Abgangszeugnis
UEBERGANG = "uebergang" # Übergangszeugnis
class CertificateStatus(str, Enum):
"""Status eines Zeugnisses."""
DRAFT = "draft" # Entwurf - noch in Bearbeitung
REVIEW = "review" # Zur Prüfung
APPROVED = "approved" # Genehmigt
ISSUED = "issued" # Ausgestellt
ARCHIVED = "archived" # Archiviert
class GradeType(str, Enum):
"""Notentyp."""
NUMERIC = "numeric" # 1-6
POINTS = "points" # 0-15 (Oberstufe)
TEXT = "text" # Verbal (Grundschule)
class BehaviorGrade(str, Enum):
"""Verhaltens-/Arbeitsnoten."""
A = "A" # Sehr gut
B = "B" # Gut
C = "C" # Befriedigend
D = "D" # Verbesserungswürdig
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen für Zeugnis."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class SubjectGrade(BaseModel):
"""Note für ein Fach."""
name: str = Field(..., description="Fachname")
grade: str = Field(..., description="Note (1-6 oder A-D)")
points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)")
note: Optional[str] = Field(None, description="Bemerkung zum Fach")
class AttendanceInfo(BaseModel):
"""Anwesenheitsinformationen."""
days_absent: int = Field(0, description="Fehlende Tage gesamt")
days_excused: int = Field(0, description="Entschuldigte Tage")
days_unexcused: int = Field(0, description="Unentschuldigte Tage")
hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt")
class CertificateCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Zeugnisses."""
student_id: str = Field(..., description="ID des Schülers")
student_name: str = Field(..., description="Name des Schülers")
student_birthdate: str = Field(..., description="Geburtsdatum")
student_class: str = Field(..., description="Klasse")
school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')")
certificate_type: CertificateType = Field(..., description="Art des Zeugnisses")
subjects: List[SubjectGrade] = Field(..., description="Fachnoten")
attendance: AttendanceInfo = Field(default_factory=AttendanceInfo, description="Anwesenheit")
remarks: Optional[str] = Field(None, description="Bemerkungen")
class_teacher: str = Field(..., description="Klassenlehrer/in")
principal: str = Field(..., description="Schulleiter/in")
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen")
issue_date: Optional[str] = Field(None, description="Ausstellungsdatum")
social_behavior: Optional[BehaviorGrade] = Field(None, description="Sozialverhalten")
work_behavior: Optional[BehaviorGrade] = Field(None, description="Arbeitsverhalten")
class CertificateUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Zeugnisses."""
subjects: Optional[List[SubjectGrade]] = None
attendance: Optional[AttendanceInfo] = None
remarks: Optional[str] = None
class_teacher: Optional[str] = None
principal: Optional[str] = None
social_behavior: Optional[BehaviorGrade] = None
work_behavior: Optional[BehaviorGrade] = None
status: Optional[CertificateStatus] = None
class CertificateResponse(BaseModel):
"""Response mit Zeugnisdaten."""
id: str
student_id: str
student_name: str
student_birthdate: str
student_class: str
school_year: str
certificate_type: CertificateType
subjects: List[SubjectGrade]
attendance: AttendanceInfo
remarks: Optional[str]
class_teacher: str
principal: str
school_info: Optional[SchoolInfoModel]
issue_date: Optional[str]
social_behavior: Optional[BehaviorGrade]
work_behavior: Optional[BehaviorGrade]
status: CertificateStatus
average_grade: Optional[float]
pdf_path: Optional[str]
dsms_cid: Optional[str]
created_at: datetime
updated_at: datetime
class CertificateListResponse(BaseModel):
"""Response mit Liste von Zeugnissen."""
certificates: List[CertificateResponse]
total: int
page: int
page_size: int
class GradeStatistics(BaseModel):
"""Notenstatistiken für eine Klasse."""
class_name: str
school_year: str
certificate_type: CertificateType
student_count: int
average_grade: float
grade_distribution: Dict[str, int]
subject_averages: Dict[str, float]
# =============================================================================
# In-Memory Storage (Prototyp - später durch DB ersetzen)
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
# =============================================================================
_certificates_store: Dict[str, Dict[str, Any]] = {}
@@ -194,7 +55,7 @@ def _get_certificate(cert_id: str) -> Dict[str, Any]:
def _save_certificate(cert_data: Dict[str, Any]) -> str:
"""Speichert Zeugnis und gibt ID zurück."""
"""Speichert Zeugnis und gibt ID zurueck."""
cert_id = cert_data.get("id") or str(uuid.uuid4())
cert_data["id"] = cert_id
cert_data["updated_at"] = datetime.now()
@@ -204,35 +65,13 @@ def _save_certificate(cert_data: Dict[str, Any]) -> str:
return cert_id
def _calculate_average(subjects: List[Dict[str, Any]]) -> Optional[float]:
"""Berechnet Notendurchschnitt."""
numeric_grades = []
for subject in subjects:
grade = subject.get("grade", "")
try:
numeric = float(grade)
if 1 <= numeric <= 6:
numeric_grades.append(numeric)
except (ValueError, TypeError):
pass
if numeric_grades:
return round(sum(numeric_grades) / len(numeric_grades), 2)
return None
# =============================================================================
# API Endpoints
# =============================================================================
@router.post("/", response_model=CertificateResponse)
async def create_certificate(request: CertificateCreateRequest):
"""
Erstellt ein neues Zeugnis.
Das Zeugnis wird als Entwurf gespeichert und kann später
bearbeitet, genehmigt und als PDF exportiert werden.
"""
"""Erstellt ein neues Zeugnis."""
logger.info(f"Creating new certificate for student: {request.student_name}")
subjects_list = [s.model_dump() for s in request.subjects]
@@ -261,74 +100,48 @@ async def create_certificate(request: CertificateCreateRequest):
cert_id = _save_certificate(cert_data)
cert_data["id"] = cert_id
logger.info(f"Certificate created with ID: {cert_id}")
return CertificateResponse(**cert_data)
# IMPORTANT: Static routes must be defined BEFORE dynamic /{cert_id} route
# to prevent "types" or "behavior-grades" being matched as cert_id
@router.get("/types")
async def get_certificate_types():
"""
Gibt alle verfügbaren Zeugnistypen zurück.
"""
return {
"types": [
{"value": t.value, "label": _get_type_label(t)}
for t in CertificateType
]
}
"""Gibt alle verfuegbaren Zeugnistypen zurueck."""
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in CertificateType]}
@router.get("/behavior-grades")
async def get_behavior_grades():
"""
Gibt alle verfügbaren Verhaltensnoten zurück.
"""
"""Gibt alle verfuegbaren Verhaltensnoten zurueck."""
labels = {
BehaviorGrade.A: "A - Sehr gut",
BehaviorGrade.B: "B - Gut",
BehaviorGrade.C: "C - Befriedigend",
BehaviorGrade.D: "D - Verbesserungswürdig"
}
return {
"grades": [
{"value": g.value, "label": labels[g]}
for g in BehaviorGrade
]
BehaviorGrade.A: "A - Sehr gut", BehaviorGrade.B: "B - Gut",
BehaviorGrade.C: "C - Befriedigend", BehaviorGrade.D: "D - Verbesserungswuerdig"
}
return {"grades": [{"value": g.value, "label": labels[g]} for g in BehaviorGrade]}
@router.get("/{cert_id}", response_model=CertificateResponse)
async def get_certificate(cert_id: str):
"""
Lädt ein gespeichertes Zeugnis.
"""
"""Laedt ein gespeichertes Zeugnis."""
logger.info(f"Getting certificate: {cert_id}")
cert_data = _get_certificate(cert_id)
return CertificateResponse(**cert_data)
return CertificateResponse(**_get_certificate(cert_id))
@router.get("/", response_model=CertificateListResponse)
async def list_certificates(
student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"),
class_name: Optional[str] = Query(None, description="Filter nach Klasse"),
school_year: Optional[str] = Query(None, description="Filter nach Schuljahr"),
certificate_type: Optional[CertificateType] = Query(None, description="Filter nach Zeugnistyp"),
status: Optional[CertificateStatus] = Query(None, description="Filter nach Status"),
page: int = Query(1, ge=1, description="Seitennummer"),
page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite")
student_id: Optional[str] = Query(None),
class_name: Optional[str] = Query(None),
school_year: Optional[str] = Query(None),
certificate_type: Optional[CertificateType] = Query(None),
status: Optional[CertificateStatus] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""
Listet alle gespeicherten Zeugnisse mit optionalen Filtern.
"""
"""Listet alle gespeicherten Zeugnisse mit optionalen Filtern."""
logger.info("Listing certificates with filters")
# Filter anwenden
filtered_certs = list(_certificates_store.values())
if student_id:
filtered_certs = [c for c in filtered_certs if c.get("student_id") == student_id]
if class_name:
@@ -340,39 +153,26 @@ async def list_certificates(
if status:
filtered_certs = [c for c in filtered_certs if c.get("status") == status]
# Sortieren nach Erstelldatum (neueste zuerst)
filtered_certs.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
# Paginierung
total = len(filtered_certs)
start = (page - 1) * page_size
end = start + page_size
paginated_certs = filtered_certs[start:end]
paginated_certs = filtered_certs[start:start + page_size]
return CertificateListResponse(
certificates=[CertificateResponse(**c) for c in paginated_certs],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
@router.put("/{cert_id}", response_model=CertificateResponse)
async def update_certificate(cert_id: str, request: CertificateUpdateRequest):
"""
Aktualisiert ein bestehendes Zeugnis.
"""
"""Aktualisiert ein bestehendes Zeugnis."""
logger.info(f"Updating certificate: {cert_id}")
cert_data = _get_certificate(cert_id)
# Prüfen ob Zeugnis noch bearbeitbar ist
if cert_data.get("status") in [CertificateStatus.ISSUED, CertificateStatus.ARCHIVED]:
raise HTTPException(
status_code=400,
detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden"
)
raise HTTPException(status_code=400, detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden")
# Nur übergebene Felder aktualisieren
update_data = request.model_dump(exclude_unset=True)
for key, value in update_data.items():
if value is not None:
@@ -385,61 +185,43 @@ async def update_certificate(cert_id: str, request: CertificateUpdateRequest):
cert_data[key] = value
_save_certificate(cert_data)
return CertificateResponse(**cert_data)
@router.delete("/{cert_id}")
async def delete_certificate(cert_id: str):
"""
Löscht ein Zeugnis.
Nur Entwürfe können gelöscht werden.
"""
"""Loescht ein Zeugnis. Nur Entwuerfe koennen geloescht werden."""
logger.info(f"Deleting certificate: {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.DRAFT:
raise HTTPException(
status_code=400,
detail="Nur Zeugnis-Entwürfe können gelöscht werden"
)
raise HTTPException(status_code=400, detail="Nur Zeugnis-Entwuerfe koennen geloescht werden")
del _certificates_store[cert_id]
return {"message": f"Zeugnis {cert_id} wurde gelöscht"}
return {"message": f"Zeugnis {cert_id} wurde geloescht"}
@router.post("/{cert_id}/export-pdf")
async def export_certificate_pdf(cert_id: str):
"""
Exportiert ein Zeugnis als PDF.
"""
"""Exportiert ein Zeugnis als PDF."""
logger.info(f"Exporting certificate {cert_id} as PDF")
cert_data = _get_certificate(cert_id)
# PDF generieren
try:
pdf_bytes = generate_certificate_pdf(cert_data)
except Exception as e:
logger.error(f"Error generating PDF: {e}")
raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}")
# Dateiname erstellen (ASCII-safe für HTTP Header)
student_name = cert_data.get("student_name", "Zeugnis").replace(" ", "_")
school_year = cert_data.get("school_year", "").replace("/", "-")
cert_type = cert_data.get("certificate_type", "zeugnis")
filename = f"Zeugnis_{student_name}_{cert_type}_{school_year}.pdf"
# Für HTTP Header: ASCII-Fallback und UTF-8 encoded filename (RFC 5987)
from urllib.parse import quote
filename_ascii = filename.encode('ascii', 'replace').decode('ascii')
filename_encoded = quote(filename, safe='')
# PDF als Download zurückgeben
return Response(
content=pdf_bytes,
media_type="application/pdf",
content=pdf_bytes, media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
"Content-Length": str(len(pdf_bytes))
@@ -449,106 +231,57 @@ async def export_certificate_pdf(cert_id: str):
@router.post("/{cert_id}/submit-review")
async def submit_for_review(cert_id: str):
"""
Reicht Zeugnis zur Prüfung ein.
"""
"""Reicht Zeugnis zur Pruefung ein."""
logger.info(f"Submitting certificate {cert_id} for review")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.DRAFT:
raise HTTPException(
status_code=400,
detail="Nur Entwürfe können zur Prüfung eingereicht werden"
)
# Prüfen ob alle Pflichtfelder ausgefüllt sind
raise HTTPException(status_code=400, detail="Nur Entwuerfe koennen zur Pruefung eingereicht werden")
if not cert_data.get("subjects"):
raise HTTPException(status_code=400, detail="Keine Fachnoten eingetragen")
cert_data["status"] = CertificateStatus.REVIEW
_save_certificate(cert_data)
return {"message": "Zeugnis wurde zur Prüfung eingereicht", "status": CertificateStatus.REVIEW}
return {"message": "Zeugnis wurde zur Pruefung eingereicht", "status": CertificateStatus.REVIEW}
@router.post("/{cert_id}/approve")
async def approve_certificate(cert_id: str):
"""
Genehmigt ein Zeugnis.
Erfordert Schulleiter-Rechte (in Produktion).
"""
"""Genehmigt ein Zeugnis."""
logger.info(f"Approving certificate {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.REVIEW:
raise HTTPException(
status_code=400,
detail="Nur Zeugnisse in Prüfung können genehmigt werden"
)
raise HTTPException(status_code=400, detail="Nur Zeugnisse in Pruefung koennen genehmigt werden")
cert_data["status"] = CertificateStatus.APPROVED
_save_certificate(cert_data)
return {"message": "Zeugnis wurde genehmigt", "status": CertificateStatus.APPROVED}
@router.post("/{cert_id}/issue")
async def issue_certificate(cert_id: str):
"""
Stellt ein Zeugnis offiziell aus.
Nach Ausstellung kann das Zeugnis nicht mehr bearbeitet werden.
"""
"""Stellt ein Zeugnis offiziell aus."""
logger.info(f"Issuing certificate {cert_id}")
cert_data = _get_certificate(cert_id)
if cert_data.get("status") != CertificateStatus.APPROVED:
raise HTTPException(
status_code=400,
detail="Nur genehmigte Zeugnisse können ausgestellt werden"
)
raise HTTPException(status_code=400, detail="Nur genehmigte Zeugnisse koennen ausgestellt werden")
cert_data["status"] = CertificateStatus.ISSUED
cert_data["issue_date"] = datetime.now().strftime("%d.%m.%Y")
_save_certificate(cert_data)
return {
"message": "Zeugnis wurde ausgestellt",
"status": CertificateStatus.ISSUED,
"issue_date": cert_data["issue_date"]
}
return {"message": "Zeugnis wurde ausgestellt", "status": CertificateStatus.ISSUED, "issue_date": cert_data["issue_date"]}
@router.get("/student/{student_id}", response_model=CertificateListResponse)
async def get_certificates_for_student(
student_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)
):
"""
Lädt alle Zeugnisse für einen bestimmten Schüler.
"""
"""Laedt alle Zeugnisse fuer einen bestimmten Schueler."""
logger.info(f"Getting certificates for student: {student_id}")
filtered_certs = [
c for c in _certificates_store.values()
if c.get("student_id") == student_id
]
# Sortieren nach Schuljahr und Typ
filtered_certs = [c for c in _certificates_store.values() if c.get("student_id") == student_id]
filtered_certs.sort(key=lambda x: (x.get("school_year", ""), x.get("certificate_type", "")), reverse=True)
total = len(filtered_certs)
start = (page - 1) * page_size
end = start + page_size
paginated_certs = filtered_certs[start:end]
paginated_certs = filtered_certs[start:start + page_size]
return CertificateListResponse(
certificates=[CertificateResponse(**c) for c in paginated_certs],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
@@ -556,14 +289,11 @@ async def get_certificates_for_student(
async def get_class_statistics(
class_name: str,
school_year: str = Query(..., description="Schuljahr"),
certificate_type: CertificateType = Query(CertificateType.HALBJAHR, description="Zeugnistyp")
certificate_type: CertificateType = Query(CertificateType.HALBJAHR)
):
"""
Berechnet Notenstatistiken für eine Klasse.
"""
"""Berechnet Notenstatistiken fuer eine Klasse."""
logger.info(f"Calculating statistics for class {class_name}")
# Filter Zeugnisse
class_certs = [
c for c in _certificates_store.values()
if c.get("student_class") == class_name
@@ -572,13 +302,9 @@ async def get_class_statistics(
]
if not class_certs:
raise HTTPException(
status_code=404,
detail=f"Keine Zeugnisse für Klasse {class_name} im Schuljahr {school_year} gefunden"
)
raise HTTPException(status_code=404, detail=f"Keine Zeugnisse fuer Klasse {class_name} im Schuljahr {school_year} gefunden")
# Statistiken berechnen
all_grades = []
all_grades: List[float] = []
subject_grades: Dict[str, List[float]] = {}
grade_counts = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}
@@ -586,7 +312,6 @@ async def get_class_statistics(
avg = cert.get("average_grade")
if avg:
all_grades.append(avg)
# Runde für Verteilung
rounded = str(round(avg))
if rounded in grade_counts:
grade_counts[rounded] += 1
@@ -602,35 +327,14 @@ async def get_class_statistics(
except (ValueError, TypeError):
pass
# Fachdurchschnitte berechnen
subject_averages = {
name: round(sum(grades) / len(grades), 2)
for name, grades in subject_grades.items()
if grades
for name, grades in subject_grades.items() if grades
}
return GradeStatistics(
class_name=class_name,
school_year=school_year,
certificate_type=certificate_type,
student_count=len(class_certs),
class_name=class_name, school_year=school_year,
certificate_type=certificate_type, student_count=len(class_certs),
average_grade=round(sum(all_grades) / len(all_grades), 2) if all_grades else 0.0,
grade_distribution=grade_counts,
subject_averages=subject_averages
grade_distribution=grade_counts, subject_averages=subject_averages
)
# =============================================================================
# Helper Functions
# =============================================================================
def _get_type_label(cert_type: CertificateType) -> str:
"""Gibt menschenlesbare Labels für Zeugnistypen zurück."""
labels = {
CertificateType.HALBJAHR: "Halbjahreszeugnis",
CertificateType.JAHRES: "Jahreszeugnis",
CertificateType.ABSCHLUSS: "Abschlusszeugnis",
CertificateType.ABGANG: "Abgangszeugnis",
CertificateType.UEBERGANG: "Übergangszeugnis",
}
return labels.get(cert_type, cert_type.value)

View File

@@ -0,0 +1,184 @@
"""
Certificates Models - Pydantic models and enums for Zeugnisverwaltung.
"""
from datetime import datetime
from typing import Optional, List, Dict
from enum import Enum
from pydantic import BaseModel, Field
# =============================================================================
# Enums
# =============================================================================
class CertificateType(str, Enum):
"""Typen von Zeugnissen."""
HALBJAHR = "halbjahr"
JAHRES = "jahres"
ABSCHLUSS = "abschluss"
ABGANG = "abgang"
UEBERGANG = "uebergang"
class CertificateStatus(str, Enum):
"""Status eines Zeugnisses."""
DRAFT = "draft"
REVIEW = "review"
APPROVED = "approved"
ISSUED = "issued"
ARCHIVED = "archived"
class GradeType(str, Enum):
"""Notentyp."""
NUMERIC = "numeric"
POINTS = "points"
TEXT = "text"
class BehaviorGrade(str, Enum):
"""Verhaltens-/Arbeitsnoten."""
A = "A"
B = "B"
C = "C"
D = "D"
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen fuer Zeugnis."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class SubjectGrade(BaseModel):
"""Note fuer ein Fach."""
name: str = Field(..., description="Fachname")
grade: str = Field(..., description="Note (1-6 oder A-D)")
points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)")
note: Optional[str] = Field(None, description="Bemerkung zum Fach")
class AttendanceInfo(BaseModel):
"""Anwesenheitsinformationen."""
days_absent: int = Field(0, description="Fehlende Tage gesamt")
days_excused: int = Field(0, description="Entschuldigte Tage")
days_unexcused: int = Field(0, description="Unentschuldigte Tage")
hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt")
class CertificateCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Zeugnisses."""
student_id: str = Field(..., description="ID des Schuelers")
student_name: str = Field(..., description="Name des Schuelers")
student_birthdate: str = Field(..., description="Geburtsdatum")
student_class: str = Field(..., description="Klasse")
school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')")
certificate_type: CertificateType = Field(..., description="Art des Zeugnisses")
subjects: List[SubjectGrade] = Field(..., description="Fachnoten")
attendance: AttendanceInfo = Field(default_factory=AttendanceInfo)
remarks: Optional[str] = Field(None, description="Bemerkungen")
class_teacher: str = Field(..., description="Klassenlehrer/in")
principal: str = Field(..., description="Schulleiter/in")
school_info: Optional[SchoolInfoModel] = Field(None)
issue_date: Optional[str] = Field(None, description="Ausstellungsdatum")
social_behavior: Optional[BehaviorGrade] = Field(None)
work_behavior: Optional[BehaviorGrade] = Field(None)
class CertificateUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Zeugnisses."""
subjects: Optional[List[SubjectGrade]] = None
attendance: Optional[AttendanceInfo] = None
remarks: Optional[str] = None
class_teacher: Optional[str] = None
principal: Optional[str] = None
social_behavior: Optional[BehaviorGrade] = None
work_behavior: Optional[BehaviorGrade] = None
status: Optional[CertificateStatus] = None
class CertificateResponse(BaseModel):
"""Response mit Zeugnisdaten."""
id: str
student_id: str
student_name: str
student_birthdate: str
student_class: str
school_year: str
certificate_type: CertificateType
subjects: List[SubjectGrade]
attendance: AttendanceInfo
remarks: Optional[str]
class_teacher: str
principal: str
school_info: Optional[SchoolInfoModel]
issue_date: Optional[str]
social_behavior: Optional[BehaviorGrade]
work_behavior: Optional[BehaviorGrade]
status: CertificateStatus
average_grade: Optional[float]
pdf_path: Optional[str]
dsms_cid: Optional[str]
created_at: datetime
updated_at: datetime
class CertificateListResponse(BaseModel):
"""Response mit Liste von Zeugnissen."""
certificates: List[CertificateResponse]
total: int
page: int
page_size: int
class GradeStatistics(BaseModel):
"""Notenstatistiken fuer eine Klasse."""
class_name: str
school_year: str
certificate_type: CertificateType
student_count: int
average_grade: float
grade_distribution: Dict[str, int]
subject_averages: Dict[str, float]
# =============================================================================
# Helper Functions
# =============================================================================
def get_type_label(cert_type: CertificateType) -> str:
"""Gibt menschenlesbare Labels fuer Zeugnistypen zurueck."""
labels = {
CertificateType.HALBJAHR: "Halbjahreszeugnis",
CertificateType.JAHRES: "Jahreszeugnis",
CertificateType.ABSCHLUSS: "Abschlusszeugnis",
CertificateType.ABGANG: "Abgangszeugnis",
CertificateType.UEBERGANG: "Uebergangszeugnis",
}
return labels.get(cert_type, cert_type.value)
def calculate_average(subjects: List[Dict]) -> Optional[float]:
"""Berechnet Notendurchschnitt."""
numeric_grades = []
for subject in subjects:
grade = subject.get("grade", "")
try:
numeric = float(grade)
if 1 <= numeric <= 6:
numeric_grades.append(numeric)
except (ValueError, TypeError):
pass
if numeric_grades:
return round(sum(numeric_grades) / len(numeric_grades), 2)
return None

View File

@@ -1,29 +1,25 @@
"""
Letters API - Elternbrief-Verwaltung für BreakPilot.
Letters API - Elternbrief-Verwaltung fuer BreakPilot.
Bietet Endpoints für:
Bietet Endpoints fuer:
- Speichern und Laden von Elternbriefen
- PDF-Export von Briefen
- Versenden per Email
- GFK-Integration für Textverbesserung
- GFK-Integration fuer Textverbesserung
Arbeitet zusammen mit:
- services/pdf_service.py für PDF-Generierung
- llm_gateway/services/communication_service.py für GFK-Verbesserungen
Split into:
- letters_models.py: Enums, Pydantic models, helper functions
- letters_api.py (this file): API endpoints and in-memory store
"""
import logging
import os
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Response, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
import httpx
import io
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
@@ -34,171 +30,30 @@ except (ImportError, OSError):
SchoolInfo = None # type: ignore
_pdf_available = False
from letters_models import (
LetterType,
LetterTone,
LetterStatus,
LetterCreateRequest,
LetterUpdateRequest,
LetterResponse,
LetterListResponse,
ExportPDFRequest,
ImproveRequest,
ImproveResponse,
SendEmailRequest,
SendEmailResponse,
get_type_label as _get_type_label,
get_tone_label as _get_tone_label,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/letters", tags=["letters"])
# =============================================================================
# Enums
# =============================================================================
class LetterType(str, Enum):
"""Typen von Elternbriefen."""
GENERAL = "general" # Allgemeine Information
HALBJAHR = "halbjahr" # Halbjahresinformation
FEHLZEITEN = "fehlzeiten" # Fehlzeiten-Mitteilung
ELTERNABEND = "elternabend" # Einladung Elternabend
LOB = "lob" # Positives Feedback
CUSTOM = "custom" # Benutzerdefiniert
class LetterTone(str, Enum):
"""Tonalität der Briefe."""
FORMAL = "formal"
PROFESSIONAL = "professional"
WARM = "warm"
CONCERNED = "concerned"
APPRECIATIVE = "appreciative"
class LetterStatus(str, Enum):
"""Status eines Briefes."""
DRAFT = "draft"
SENT = "sent"
ARCHIVED = "archived"
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen für Briefkopf."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class LegalReferenceModel(BaseModel):
"""Rechtliche Referenz."""
law: str
paragraph: str
title: str
summary: Optional[str] = None
relevance: Optional[str] = None
class LetterCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Briefes."""
recipient_name: str = Field(..., description="Name des Empfängers (z.B. 'Familie Müller')")
recipient_address: str = Field(..., description="Adresse des Empfängers")
student_name: str = Field(..., description="Name des Schülers")
student_class: str = Field(..., description="Klasse des Schülers")
subject: str = Field(..., description="Betreff des Briefes")
content: str = Field(..., description="Inhalt des Briefes")
letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes")
tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalität des Briefes")
teacher_name: str = Field(..., description="Name des Lehrers")
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers (z.B. 'Klassenlehrerin')")
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen für Briefkopf")
legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen")
gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien")
class LetterUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Briefes."""
recipient_name: Optional[str] = None
recipient_address: Optional[str] = None
student_name: Optional[str] = None
student_class: Optional[str] = None
subject: Optional[str] = None
content: Optional[str] = None
letter_type: Optional[LetterType] = None
tone: Optional[LetterTone] = None
teacher_name: Optional[str] = None
teacher_title: Optional[str] = None
school_info: Optional[SchoolInfoModel] = None
legal_references: Optional[List[LegalReferenceModel]] = None
gfk_principles_applied: Optional[List[str]] = None
status: Optional[LetterStatus] = None
class LetterResponse(BaseModel):
"""Response mit Briefdaten."""
id: str
recipient_name: str
recipient_address: str
student_name: str
student_class: str
subject: str
content: str
letter_type: LetterType
tone: LetterTone
teacher_name: str
teacher_title: Optional[str]
school_info: Optional[SchoolInfoModel]
legal_references: Optional[List[LegalReferenceModel]]
gfk_principles_applied: Optional[List[str]]
gfk_score: Optional[float]
status: LetterStatus
pdf_path: Optional[str]
dsms_cid: Optional[str]
sent_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class LetterListResponse(BaseModel):
"""Response mit Liste von Briefen."""
letters: List[LetterResponse]
total: int
page: int
page_size: int
class ExportPDFRequest(BaseModel):
"""Request zum PDF-Export."""
letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes")
letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten")
class ImproveRequest(BaseModel):
"""Request zur GFK-Verbesserung."""
content: str = Field(..., description="Text zur Verbesserung")
communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation")
tone: Optional[str] = Field("professional", description="Gewünschte Tonalität")
class ImproveResponse(BaseModel):
"""Response mit verbessertem Text."""
improved_content: str
changes: List[str]
gfk_score: float
gfk_principles_applied: List[str]
class SendEmailRequest(BaseModel):
"""Request zum Email-Versand."""
letter_id: str
recipient_email: str
cc_emails: Optional[List[str]] = None
include_pdf: bool = True
class SendEmailResponse(BaseModel):
"""Response nach Email-Versand."""
success: bool
message: str
sent_at: Optional[datetime]
# =============================================================================
# In-Memory Storage (Prototyp - später durch DB ersetzen)
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
# =============================================================================
_letters_store: Dict[str, Dict[str, Any]] = {}
@@ -212,7 +67,7 @@ def _get_letter(letter_id: str) -> Dict[str, Any]:
def _save_letter(letter_data: Dict[str, Any]) -> str:
"""Speichert Brief und gibt ID zurück."""
"""Speichert Brief und gibt ID zurueck."""
letter_id = letter_data.get("id") or str(uuid.uuid4())
letter_data["id"] = letter_id
letter_data["updated_at"] = datetime.now()
@@ -228,12 +83,7 @@ def _save_letter(letter_data: Dict[str, Any]) -> str:
@router.post("/", response_model=LetterResponse)
async def create_letter(request: LetterCreateRequest):
"""
Erstellt einen neuen Elternbrief.
Der Brief wird als Entwurf gespeichert und kann später bearbeitet,
als PDF exportiert oder per Email versendet werden.
"""
"""Erstellt einen neuen Elternbrief."""
logger.info(f"Creating new letter for student: {request.student_name}")
letter_data = {
@@ -259,7 +109,6 @@ async def create_letter(request: LetterCreateRequest):
letter_id = _save_letter(letter_data)
letter_data["id"] = letter_id
logger.info(f"Letter created with ID: {letter_id}")
return LetterResponse(**letter_data)
@@ -267,35 +116,19 @@ async def create_letter(request: LetterCreateRequest):
# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id}
@router.get("/types")
async def get_letter_types():
"""
Gibt alle verfügbaren Brieftypen zurück.
"""
return {
"types": [
{"value": t.value, "label": _get_type_label(t)}
for t in LetterType
]
}
"""Gibt alle verfuegbaren Brieftypen zurueck."""
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]}
@router.get("/tones")
async def get_letter_tones():
"""
Gibt alle verfügbaren Tonalitäten zurück.
"""
return {
"tones": [
{"value": t.value, "label": _get_tone_label(t)}
for t in LetterTone
]
}
"""Gibt alle verfuegbaren Tonalitaeten zurueck."""
return {"tones": [{"value": t.value, "label": _get_tone_label(t)} for t in LetterTone]}
@router.get("/{letter_id}", response_model=LetterResponse)
async def get_letter(letter_id: str):
"""
Lädt einen gespeicherten Brief.
"""
"""Laedt einen gespeicherten Brief."""
logger.info(f"Getting letter: {letter_id}")
letter_data = _get_letter(letter_id)
return LetterResponse(**letter_data)
@@ -303,21 +136,17 @@ async def get_letter(letter_id: str):
@router.get("/", response_model=LetterListResponse)
async def list_letters(
student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"),
class_name: Optional[str] = Query(None, description="Filter nach Klasse"),
letter_type: Optional[LetterType] = Query(None, description="Filter nach Brief-Typ"),
status: Optional[LetterStatus] = Query(None, description="Filter nach Status"),
page: int = Query(1, ge=1, description="Seitennummer"),
page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite")
student_id: Optional[str] = Query(None),
class_name: Optional[str] = Query(None),
letter_type: Optional[LetterType] = Query(None),
status: Optional[LetterStatus] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""
Listet alle gespeicherten Briefe mit optionalen Filtern.
"""
"""Listet alle gespeicherten Briefe mit optionalen Filtern."""
logger.info("Listing letters with filters")
# Filter anwenden
filtered_letters = list(_letters_store.values())
if class_name:
filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name]
if letter_type:
@@ -325,32 +154,23 @@ async def list_letters(
if status:
filtered_letters = [l for l in filtered_letters if l.get("status") == status]
# Sortieren nach Erstelldatum (neueste zuerst)
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
# Paginierung
total = len(filtered_letters)
start = (page - 1) * page_size
end = start + page_size
paginated_letters = filtered_letters[start:end]
paginated_letters = filtered_letters[start:start + page_size]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
@router.put("/{letter_id}", response_model=LetterResponse)
async def update_letter(letter_id: str, request: LetterUpdateRequest):
"""
Aktualisiert einen bestehenden Brief.
"""
"""Aktualisiert einen bestehenden Brief."""
logger.info(f"Updating letter: {letter_id}")
letter_data = _get_letter(letter_id)
# Nur übergebene Felder aktualisieren
update_data = request.model_dump(exclude_unset=True)
for key, value in update_data.items():
if value is not None:
@@ -362,118 +182,80 @@ async def update_letter(letter_id: str, request: LetterUpdateRequest):
letter_data[key] = value
_save_letter(letter_data)
return LetterResponse(**letter_data)
@router.delete("/{letter_id}")
async def delete_letter(letter_id: str):
"""
Löscht einen Brief.
"""
"""Loescht einen Brief."""
logger.info(f"Deleting letter: {letter_id}")
if letter_id not in _letters_store:
raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden")
del _letters_store[letter_id]
return {"message": f"Brief {letter_id} wurde gelöscht"}
return {"message": f"Brief {letter_id} wurde geloescht"}
@router.post("/export-pdf")
async def export_letter_pdf(request: ExportPDFRequest):
"""
Exportiert einen Brief als PDF.
Kann entweder einen gespeicherten Brief (per letter_id) oder
direkte Briefdaten (per letter_data) als PDF exportieren.
Gibt das PDF als Download zurück.
"""
"""Exportiert einen Brief als PDF."""
logger.info("Exporting letter as PDF")
# Briefdaten ermitteln
if request.letter_id:
letter_data = _get_letter(request.letter_id)
elif request.letter_data:
letter_data = request.letter_data.model_dump()
else:
raise HTTPException(
status_code=400,
detail="Entweder letter_id oder letter_data muss angegeben werden"
)
raise HTTPException(status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden")
# Datum hinzufügen falls nicht vorhanden
if "date" not in letter_data:
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
# PDF generieren
try:
pdf_bytes = generate_letter_pdf(letter_data)
except Exception as e:
logger.error(f"Error generating PDF: {e}")
raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}")
# Dateiname erstellen
student_name = letter_data.get("student_name", "Brief").replace(" ", "_")
date_str = datetime.now().strftime("%Y%m%d")
filename = f"Elternbrief_{student_name}_{date_str}.pdf"
# PDF als Download zurückgeben
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes))
}
content=pdf_bytes, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes))}
)
@router.post("/{letter_id}/export-pdf")
async def export_saved_letter_pdf(letter_id: str):
"""
Exportiert einen gespeicherten Brief als PDF (Kurzform).
"""
"""Exportiert einen gespeicherten Brief als PDF (Kurzform)."""
return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id))
@router.post("/improve", response_model=ImproveResponse)
async def improve_letter_content(request: ImproveRequest):
"""
Verbessert den Briefinhalt nach GFK-Prinzipien.
Nutzt die Communication Service API für KI-gestützte Verbesserungen.
"""
"""Verbessert den Briefinhalt nach GFK-Prinzipien."""
logger.info("Improving letter content with GFK principles")
# Communication Service URL (läuft im gleichen Backend)
comm_service_url = os.getenv(
"COMMUNICATION_SERVICE_URL",
"http://localhost:8000/v1/communication"
)
comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication")
try:
async with httpx.AsyncClient() as client:
# Validierung des aktuellen Textes
validate_response = await client.post(
f"{comm_service_url}/validate",
json={"text": request.content},
timeout=30.0
json={"text": request.content}, timeout=30.0
)
if validate_response.status_code != 200:
logger.warning(f"Validation service returned {validate_response.status_code}")
# Fallback: Original-Text zurückgeben
return ImproveResponse(
improved_content=request.content,
changes=["Verbesserungsservice nicht verfügbar"],
gfk_score=0.5,
gfk_principles_applied=[]
changes=["Verbesserungsservice nicht verfuegbar"],
gfk_score=0.5, gfk_principles_applied=[]
)
validation_data = validate_response.json()
# Falls Text schon gut ist, keine Änderungen
if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8:
return ImproveResponse(
improved_content=request.content,
@@ -482,84 +264,48 @@ async def improve_letter_content(request: ImproveRequest):
gfk_principles_applied=validation_data.get("positive_elements", [])
)
# Verbesserungsvorschläge als Änderungen
changes = validation_data.get("suggestions", [])
gfk_score = validation_data.get("gfk_score", 0.5)
gfk_principles = validation_data.get("positive_elements", [])
# TODO: Hier könnte ein LLM den Text basierend auf den Vorschlägen verbessern
# Für jetzt geben wir den Original-Text mit den Verbesserungsvorschlägen zurück
return ImproveResponse(
improved_content=request.content,
changes=changes,
gfk_score=gfk_score,
gfk_principles_applied=gfk_principles
changes=validation_data.get("suggestions", []),
gfk_score=validation_data.get("gfk_score", 0.5),
gfk_principles_applied=validation_data.get("positive_elements", [])
)
except httpx.TimeoutException:
logger.error("Timeout while calling communication service")
return ImproveResponse(
improved_content=request.content,
changes=["Zeitüberschreitung beim Verbesserungsservice"],
gfk_score=0.5,
gfk_principles_applied=[]
changes=["Zeitueberschreitung beim Verbesserungsservice"],
gfk_score=0.5, gfk_principles_applied=[]
)
except Exception as e:
logger.error(f"Error improving content: {e}")
return ImproveResponse(
improved_content=request.content,
changes=[f"Fehler: {str(e)}"],
gfk_score=0.5,
gfk_principles_applied=[]
gfk_score=0.5, gfk_principles_applied=[]
)
@router.post("/{letter_id}/send", response_model=SendEmailResponse)
async def send_letter_email(letter_id: str, request: SendEmailRequest):
"""
Versendet einen Brief per Email.
Der Brief wird als PDF angehängt (wenn include_pdf=True)
und der Status wird auf 'sent' gesetzt.
"""
"""Versendet einen Brief per Email."""
logger.info(f"Sending letter {letter_id} to {request.recipient_email}")
# Brief laden
letter_data = _get_letter(letter_id)
# Email-Service URL (Mailpit oder SMTP)
email_service_url = os.getenv(
"EMAIL_SERVICE_URL",
"http://localhost:8025/api/v1/send" # Mailpit default
)
try:
# PDF generieren falls gewünscht
pdf_attachment = None
if request.include_pdf:
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
pdf_bytes = generate_letter_pdf(letter_data)
pdf_attachment = {
"filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf",
"content": pdf_bytes.hex(), # Hex-encoded für JSON
"content": pdf_bytes.hex(),
"content_type": "application/pdf"
}
# Email senden (vereinfachte Implementierung)
# In der Praxis würde hier ein richtiger Email-Service aufgerufen
async with httpx.AsyncClient() as client:
email_data = {
"to": request.recipient_email,
"cc": request.cc_emails or [],
"subject": letter_data.get("subject", "Elternbrief"),
"body": letter_data.get("content", ""),
"attachments": [pdf_attachment] if pdf_attachment else []
}
# Für Prototyp: Nur loggen, nicht wirklich senden
logger.info(f"Would send email: {email_data['subject']} to {email_data['to']}")
# Status aktualisieren
logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}")
letter_data["status"] = LetterStatus.SENT
letter_data["sent_at"] = datetime.now()
_save_letter(letter_data)
@@ -572,11 +318,7 @@ async def send_letter_email(letter_id: str, request: SendEmailRequest):
except Exception as e:
logger.error(f"Error sending email: {e}")
return SendEmailResponse(
success=False,
message=f"Fehler beim Versenden: {str(e)}",
sent_at=None
)
return SendEmailResponse(success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None)
@router.get("/student/{student_id}", response_model=LetterListResponse)
@@ -585,57 +327,20 @@ async def get_letters_for_student(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
"""
Lädt alle Briefe für einen bestimmten Schüler.
"""
"""Laedt alle Briefe fuer einen bestimmten Schueler."""
logger.info(f"Getting letters for student: {student_id}")
# In einem echten System würde hier nach student_id gefiltert
# Für Prototyp filtern wir nach student_name
filtered_letters = [
l for l in _letters_store.values()
if student_id.lower() in l.get("student_name", "").lower()
]
# Sortieren und Paginierung
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
total = len(filtered_letters)
start = (page - 1) * page_size
end = start + page_size
paginated_letters = filtered_letters[start:end]
paginated_letters = filtered_letters[start:start + page_size]
return LetterListResponse(
letters=[LetterResponse(**l) for l in paginated_letters],
total=total,
page=page,
page_size=page_size
total=total, page=page, page_size=page_size
)
# =============================================================================
# Helper Functions
# =============================================================================
def _get_type_label(letter_type: LetterType) -> str:
"""Gibt menschenlesbare Labels für Brieftypen zurück."""
labels = {
LetterType.GENERAL: "Allgemeine Information",
LetterType.HALBJAHR: "Halbjahresinformation",
LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung",
LetterType.ELTERNABEND: "Einladung Elternabend",
LetterType.LOB: "Positives Feedback",
LetterType.CUSTOM: "Benutzerdefiniert",
}
return labels.get(letter_type, letter_type.value)
def _get_tone_label(tone: LetterTone) -> str:
"""Gibt menschenlesbare Labels für Tonalitäten zurück."""
labels = {
LetterTone.FORMAL: "Sehr förmlich",
LetterTone.PROFESSIONAL: "Professionell-freundlich",
LetterTone.WARM: "Warmherzig",
LetterTone.CONCERNED: "Besorgt",
LetterTone.APPRECIATIVE: "Wertschätzend",
}
return labels.get(tone, tone.value)

View File

@@ -0,0 +1,195 @@
"""
Letters Models - Pydantic models and enums for Elternbrief-Verwaltung.
"""
from datetime import datetime
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field
# =============================================================================
# Enums
# =============================================================================
class LetterType(str, Enum):
"""Typen von Elternbriefen."""
GENERAL = "general"
HALBJAHR = "halbjahr"
FEHLZEITEN = "fehlzeiten"
ELTERNABEND = "elternabend"
LOB = "lob"
CUSTOM = "custom"
class LetterTone(str, Enum):
"""Tonalitaet der Briefe."""
FORMAL = "formal"
PROFESSIONAL = "professional"
WARM = "warm"
CONCERNED = "concerned"
APPRECIATIVE = "appreciative"
class LetterStatus(str, Enum):
"""Status eines Briefes."""
DRAFT = "draft"
SENT = "sent"
ARCHIVED = "archived"
# =============================================================================
# Pydantic Models
# =============================================================================
class SchoolInfoModel(BaseModel):
"""Schulinformationen fuer Briefkopf."""
name: str
address: str
phone: str
email: str
website: Optional[str] = None
principal: Optional[str] = None
logo_path: Optional[str] = None
class LegalReferenceModel(BaseModel):
"""Rechtliche Referenz."""
law: str
paragraph: str
title: str
summary: Optional[str] = None
relevance: Optional[str] = None
class LetterCreateRequest(BaseModel):
"""Request zum Erstellen eines neuen Briefes."""
recipient_name: str = Field(..., description="Name des Empfaengers")
recipient_address: str = Field(..., description="Adresse des Empfaengers")
student_name: str = Field(..., description="Name des Schuelers")
student_class: str = Field(..., description="Klasse des Schuelers")
subject: str = Field(..., description="Betreff des Briefes")
content: str = Field(..., description="Inhalt des Briefes")
letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes")
tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalitaet des Briefes")
teacher_name: str = Field(..., description="Name des Lehrers")
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers")
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen")
legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen")
gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien")
class LetterUpdateRequest(BaseModel):
"""Request zum Aktualisieren eines Briefes."""
recipient_name: Optional[str] = None
recipient_address: Optional[str] = None
student_name: Optional[str] = None
student_class: Optional[str] = None
subject: Optional[str] = None
content: Optional[str] = None
letter_type: Optional[LetterType] = None
tone: Optional[LetterTone] = None
teacher_name: Optional[str] = None
teacher_title: Optional[str] = None
school_info: Optional[SchoolInfoModel] = None
legal_references: Optional[List[LegalReferenceModel]] = None
gfk_principles_applied: Optional[List[str]] = None
status: Optional[LetterStatus] = None
class LetterResponse(BaseModel):
"""Response mit Briefdaten."""
id: str
recipient_name: str
recipient_address: str
student_name: str
student_class: str
subject: str
content: str
letter_type: LetterType
tone: LetterTone
teacher_name: str
teacher_title: Optional[str]
school_info: Optional[SchoolInfoModel]
legal_references: Optional[List[LegalReferenceModel]]
gfk_principles_applied: Optional[List[str]]
gfk_score: Optional[float]
status: LetterStatus
pdf_path: Optional[str]
dsms_cid: Optional[str]
sent_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class LetterListResponse(BaseModel):
"""Response mit Liste von Briefen."""
letters: List[LetterResponse]
total: int
page: int
page_size: int
class ExportPDFRequest(BaseModel):
"""Request zum PDF-Export."""
letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes")
letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten")
class ImproveRequest(BaseModel):
"""Request zur GFK-Verbesserung."""
content: str = Field(..., description="Text zur Verbesserung")
communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation")
tone: Optional[str] = Field("professional", description="Gewuenschte Tonalitaet")
class ImproveResponse(BaseModel):
"""Response mit verbessertem Text."""
improved_content: str
changes: List[str]
gfk_score: float
gfk_principles_applied: List[str]
class SendEmailRequest(BaseModel):
"""Request zum Email-Versand."""
letter_id: str
recipient_email: str
cc_emails: Optional[List[str]] = None
include_pdf: bool = True
class SendEmailResponse(BaseModel):
"""Response nach Email-Versand."""
success: bool
message: str
sent_at: Optional[datetime]
# =============================================================================
# Helper Functions
# =============================================================================
def get_type_label(letter_type: LetterType) -> str:
"""Gibt menschenlesbare Labels fuer Brieftypen zurueck."""
labels = {
LetterType.GENERAL: "Allgemeine Information",
LetterType.HALBJAHR: "Halbjahresinformation",
LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung",
LetterType.ELTERNABEND: "Einladung Elternabend",
LetterType.LOB: "Positives Feedback",
LetterType.CUSTOM: "Benutzerdefiniert",
}
return labels.get(letter_type, letter_type.value)
def get_tone_label(tone: LetterTone) -> str:
"""Gibt menschenlesbare Labels fuer Tonalitaeten zurueck."""
labels = {
LetterTone.FORMAL: "Sehr foermlich",
LetterTone.PROFESSIONAL: "Professionell-freundlich",
LetterTone.WARM: "Warmherzig",
LetterTone.CONCERNED: "Besorgt",
LetterTone.APPRECIATIVE: "Wertschaetzend",
}
return labels.get(tone, tone.value)

View File

@@ -6,6 +6,8 @@ from .inference import InferenceService, get_inference_service
from .playbook_service import PlaybookService
from .pii_detector import PIIDetector, get_pii_detector, PIIType, RedactionResult
from .tool_gateway import ToolGateway, get_tool_gateway, SearchDepth
from .communication_service import CommunicationService, get_communication_service
from .communication_types import CommunicationType, CommunicationTone, LegalReference, GFKPrinciple
__all__ = [
"InferenceService",
@@ -18,4 +20,10 @@ __all__ = [
"ToolGateway",
"get_tool_gateway",
"SearchDepth",
"CommunicationService",
"get_communication_service",
"CommunicationType",
"CommunicationTone",
"LegalReference",
"GFKPrinciple",
]

View File

@@ -1,371 +1,95 @@
"""
Communication Service - KI-gestützte Lehrer-Eltern-Kommunikation.
Communication Service - KI-gestuetzte Lehrer-Eltern-Kommunikation.
Unterstützt Lehrkräfte bei der Erstellung professioneller, rechtlich fundierter
Kommunikation mit Eltern. Basiert auf den Prinzipien der gewaltfreien Kommunikation
(GFK nach Marshall Rosenberg) und deutschen Schulgesetzen.
Split into:
- communication_types.py: Enums, data classes, templates, legal references
- communication_service.py (this file): CommunicationService class
Die rechtlichen Referenzen werden dynamisch aus der Datenbank geladen
(edu_search_documents Tabelle), nicht mehr hardcoded.
All symbols are re-exported here for backward compatibility.
"""
import logging
import os
from typing import Optional, List, Dict, Any
from enum import Enum, auto
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
# Legal Crawler API URL (für dynamische Rechtsinhalte)
LEGAL_CRAWLER_API_URL = os.getenv(
"LEGAL_CRAWLER_API_URL",
"http://localhost:8000/v1/legal-crawler"
from .communication_types import (
CommunicationType,
CommunicationTone,
LegalReference,
GFKPrinciple,
FALLBACK_LEGAL_REFERENCES,
GFK_PRINCIPLES,
COMMUNICATION_TEMPLATES,
fetch_legal_references_from_db,
parse_db_references_to_legal_refs,
)
class CommunicationType(str, Enum):
"""Arten von Eltern-Kommunikation."""
GENERAL_INFO = "general_info" # Allgemeine Information
BEHAVIOR = "behavior" # Verhalten/Disziplin
ACADEMIC = "academic" # Schulleistungen
ATTENDANCE = "attendance" # Anwesenheit/Fehlzeiten
MEETING_INVITE = "meeting_invite" # Einladung zum Gespräch
POSITIVE_FEEDBACK = "positive_feedback" # Positives Feedback
CONCERN = "concern" # Bedenken äußern
CONFLICT = "conflict" # Konfliktlösung
SPECIAL_NEEDS = "special_needs" # Förderbedarf
class CommunicationTone(str, Enum):
"""Tonalität der Kommunikation."""
FORMAL = "formal" # Sehr förmlich
PROFESSIONAL = "professional" # Professionell-freundlich
WARM = "warm" # Warmherzig
CONCERNED = "concerned" # Besorgt
APPRECIATIVE = "appreciative" # Wertschätzend
@dataclass
class LegalReference:
"""Rechtliche Referenz für Kommunikation."""
law: str # z.B. "SchulG NRW"
paragraph: str # z.B. "§ 42"
title: str # z.B. "Pflichten der Eltern"
summary: str # Kurzzusammenfassung
relevance: str # Warum relevant für diesen Fall
@dataclass
class GFKPrinciple:
"""Prinzip der Gewaltfreien Kommunikation."""
principle: str # z.B. "Beobachtung"
description: str # Erklärung
example: str # Beispiel im Kontext
# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer)
# Die primäre Quelle sind gecrawlte Dokumente in der edu_search_documents Tabelle
FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = {
"DEFAULT": {
"elternpflichten": LegalReference(
law="Landesschulgesetz",
paragraph="(je nach Bundesland)",
title="Pflichten der Eltern",
summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstützen.",
relevance="Grundlage für Kooperationsaufforderungen"
),
"schulpflicht": LegalReference(
law="Landesschulgesetz",
paragraph="(je nach Bundesland)",
title="Schulpflicht",
summary="Kinder sind schulpflichtig. Eltern sind verantwortlich für regelmäßigen Schulbesuch.",
relevance="Bei Fehlzeiten und Anwesenheitsproblemen"
),
}
}
async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]:
"""
Lädt rechtliche Referenzen aus der Datenbank (via Legal Crawler API).
Args:
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
Returns:
Liste von Rechtsdokumenten mit Paragraphen
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{LEGAL_CRAWLER_API_URL}/references/{state}"
)
if response.status_code == 200:
data = response.json()
return data.get("documents", [])
else:
logger.warning(f"Legal API returned {response.status_code} for state {state}")
return []
except Exception as e:
logger.error(f"Fehler beim Laden rechtlicher Referenzen für {state}: {e}")
return []
def parse_db_references_to_legal_refs(
db_docs: List[Dict[str, Any]],
topic: str
) -> List[LegalReference]:
"""
Konvertiert DB-Dokumente in LegalReference-Objekte.
Filtert nach relevanten Paragraphen basierend auf dem Topic.
"""
references = []
# Topic zu relevanten Paragraph-Nummern mapping
topic_keywords = {
"elternpflichten": ["42", "76", "85", "eltern", "pflicht"],
"schulpflicht": ["41", "35", "schulpflicht", "pflicht"],
"ordnungsmassnahmen": ["53", "ordnung", "erzieh", "maßnahm"],
"datenschutz": ["120", "daten", "schutz"],
"foerderung": ["2", "förder", "bildung", "auftrag"],
}
keywords = topic_keywords.get(topic, ["eltern"])
for doc in db_docs:
law_name = doc.get("law_name", doc.get("title", "Schulgesetz"))
paragraphs = doc.get("paragraphs", [])
if not paragraphs:
# Wenn keine Paragraphen extrahiert, allgemeine Referenz erstellen
references.append(LegalReference(
law=law_name,
paragraph="(siehe Gesetzestext)",
title=doc.get("title", "Schulgesetz"),
summary=f"Rechtliche Grundlage aus {law_name}",
relevance=f"Relevant für {topic}"
))
continue
# Relevante Paragraphen finden
for para in paragraphs[:10]: # Max 10 Paragraphen prüfen
para_nr = para.get("nr", "")
para_title = para.get("title", "")
# Prüfen ob Paragraph relevant ist
is_relevant = False
for keyword in keywords:
if keyword.lower() in para_nr.lower() or keyword.lower() in para_title.lower():
is_relevant = True
break
if is_relevant:
references.append(LegalReference(
law=law_name,
paragraph=para_nr,
title=para_title[:100],
summary=f"{para_title[:150]}",
relevance=f"Relevant für {topic}"
))
return references
# GFK-Prinzipien
GFK_PRINCIPLES = [
GFKPrinciple(
principle="Beobachtung",
description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation",
example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'"
),
GFKPrinciple(
principle="Gefühle",
description="Eigene Gefühle ausdrücken (Ich-Botschaften)",
example="'Ich mache mir Sorgen...' statt 'Sie müssen endlich...'"
),
GFKPrinciple(
principle="Bedürfnisse",
description="Dahinterliegende Bedürfnisse benennen",
example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie müssen mehr kontrollieren.'"
),
GFKPrinciple(
principle="Bitten",
description="Konkrete, erfüllbare Bitten formulieren",
example="'Wären Sie bereit, täglich die Hausaufgaben zu prüfen?' statt 'Tun Sie endlich etwas!'"
),
# Re-export for backward compatibility
__all__ = [
"CommunicationType",
"CommunicationTone",
"LegalReference",
"GFKPrinciple",
"CommunicationService",
"get_communication_service",
"fetch_legal_references_from_db",
"parse_db_references_to_legal_refs",
]
# Kommunikationsvorlagen
COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = {
CommunicationType.GENERAL_INFO: {
"subject": "Information: {topic}",
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über folgendes informieren:",
"closing": "Bei Fragen stehe ich Ihnen gerne zur Verfügung.\n\nMit freundlichen Grüßen",
},
CommunicationType.BEHAVIOR: {
"subject": "Gesprächswunsch: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.",
"closing": "Ich bin überzeugt, dass wir gemeinsam eine gute Lösung finden können. Ich würde mich über ein Gespräch freuen.\n\nMit freundlichen Grüßen",
},
CommunicationType.ACADEMIC: {
"subject": "Schulische Entwicklung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über die schulische Entwicklung von {student_name} informieren.",
"closing": "Ich würde mich freuen, wenn wir gemeinsam überlegen könnten, wie wir {student_name} optimal unterstützen können.\n\nMit freundlichen Grüßen",
},
CommunicationType.ATTENDANCE: {
"subject": "Fehlzeiten: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezüglich der Anwesenheit von {student_name}.",
"closing": "Gemäß {legal_reference} sind regelmäßige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Lösung finden.\n\nMit freundlichen Grüßen",
},
CommunicationType.MEETING_INVITE: {
"subject": "Einladung zum Elterngespräch",
"opening": "Sehr geehrte/r {parent_name},\n\nich würde mich freuen, Sie zu einem persönlichen Gespräch einzuladen.",
"closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine für Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Grüßen",
},
CommunicationType.POSITIVE_FEEDBACK: {
"subject": "Positive Rückmeldung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu können.",
"closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu dürfen.\n\nMit herzlichen Grüßen",
},
CommunicationType.CONCERN: {
"subject": "Gemeinsame Sorge: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen würde.",
"closing": "Ich bin überzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Lösung finden werden.\n\nMit freundlichen Grüßen",
},
CommunicationType.CONFLICT: {
"subject": "Bitte um ein klärendes Gespräch",
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte das Gespräch mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.",
"closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin überzeugt, dass wir im Dialog eine für alle Seiten gute Lösung finden können.\n\nMit freundlichen Grüßen",
},
CommunicationType.SPECIAL_NEEDS: {
"subject": "Förderung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte mit Ihnen über die individuelle Förderung von {student_name} sprechen.",
"closing": "Gemäß dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu fördern. Lassen Sie uns gemeinsam überlegen, wie wir {student_name} bestmöglich unterstützen können.\n\nMit freundlichen Grüßen",
},
}
logger = logging.getLogger(__name__)
class CommunicationService:
"""
Service zur Unterstützung von Lehrer-Eltern-Kommunikation.
Service zur Unterstuetzung von Lehrer-Eltern-Kommunikation.
Generiert professionelle, rechtlich fundierte und empathische Nachrichten
basierend auf den Prinzipien der gewaltfreien Kommunikation.
Rechtliche Referenzen werden dynamisch aus der DB geladen (via Legal Crawler API).
"""
def __init__(self):
self.fallback_references = FALLBACK_LEGAL_REFERENCES
self.gfk_principles = GFK_PRINCIPLES
self.templates = COMMUNICATION_TEMPLATES
# Cache für DB-Referenzen (um wiederholte API-Calls zu vermeiden)
self._cached_references: Dict[str, List[LegalReference]] = {}
async def get_legal_references_async(
self,
state: str,
topic: str
self, state: str, topic: str
) -> List[LegalReference]:
"""
Gibt relevante rechtliche Referenzen für ein Bundesland und Thema zurück.
Lädt aus DB via Legal Crawler API.
Args:
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
topic: Themenbereich (z.B. "elternpflichten", "schulpflicht")
Returns:
Liste relevanter LegalReference-Objekte
"""
"""Gibt relevante rechtliche Referenzen fuer ein Bundesland und Thema zurueck."""
cache_key = f"{state}:{topic}"
# Cache prüfen
if cache_key in self._cached_references:
return self._cached_references[cache_key]
# Aus DB laden
db_docs = await fetch_legal_references_from_db(state)
if db_docs:
# DB-Dokumente in LegalReference konvertieren
references = parse_db_references_to_legal_refs(db_docs, topic)
if references:
self._cached_references[cache_key] = references
return references
# Fallback wenn DB leer
logger.info(f"Keine DB-Referenzen für {state}/{topic}, nutze Fallback")
logger.info(f"Keine DB-Referenzen fuer {state}/{topic}, nutze Fallback")
return self._get_fallback_references(state, topic)
def get_legal_references(
self,
state: str,
topic: str
) -> List[LegalReference]:
"""
Synchrone Methode für Rückwärtskompatibilität.
Nutzt nur Fallback-Referenzen (für non-async Kontexte).
Für dynamische DB-Referenzen bitte get_legal_references_async() verwenden.
"""
def get_legal_references(self, state: str, topic: str) -> List[LegalReference]:
"""Synchrone Methode fuer Rueckwaertskompatibilitaet."""
return self._get_fallback_references(state, topic)
def _get_fallback_references(
self,
state: str,
topic: str
) -> List[LegalReference]:
"""Gibt Fallback-Referenzen zurück."""
def _get_fallback_references(self, state: str, topic: str) -> List[LegalReference]:
"""Gibt Fallback-Referenzen zurueck."""
state_refs = self.fallback_references.get("DEFAULT", {})
if topic in state_refs:
return [state_refs[topic]]
return list(state_refs.values())
def get_gfk_guidance(
self,
comm_type: CommunicationType
) -> List[GFKPrinciple]:
"""
Gibt GFK-Leitlinien für einen Kommunikationstyp zurück.
"""
def get_gfk_guidance(self, comm_type: CommunicationType) -> List[GFKPrinciple]:
return self.gfk_principles
def get_template(
self,
comm_type: CommunicationType
) -> Dict[str, str]:
"""
Gibt die Vorlage für einen Kommunikationstyp zurück.
"""
def get_template(self, comm_type: CommunicationType) -> Dict[str, str]:
return self.templates.get(comm_type, self.templates[CommunicationType.GENERAL_INFO])
def build_system_prompt(
self,
comm_type: CommunicationType,
state: str,
tone: CommunicationTone
self, comm_type: CommunicationType, state: str, tone: CommunicationTone
) -> str:
"""
Erstellt den System-Prompt für die KI-gestützte Nachrichtengenerierung.
Args:
comm_type: Art der Kommunikation
state: Bundesland für rechtliche Referenzen
tone: Gewünschte Tonalität
Returns:
System-Prompt für LLM
"""
# Rechtliche Referenzen sammeln
"""Erstellt den System-Prompt fuer die KI-gestuetzte Nachrichtengenerierung."""
topic_map = {
CommunicationType.ATTENDANCE: "schulpflicht",
CommunicationType.BEHAVIOR: "ordnungsmassnahmen",
@@ -383,17 +107,16 @@ class CommunicationService:
for ref in legal_refs:
legal_context += f"- {ref.law} {ref.paragraph} ({ref.title}): {ref.summary}\n"
# Tonalität beschreiben
tone_descriptions = {
CommunicationTone.FORMAL: "Verwende eine sehr formelle, sachliche Sprache.",
CommunicationTone.PROFESSIONAL: "Verwende eine professionelle, aber freundliche Sprache.",
CommunicationTone.WARM: "Verwende eine warmherzige, einladende Sprache.",
CommunicationTone.CONCERNED: "Drücke aufrichtige Sorge und Empathie aus.",
CommunicationTone.APPRECIATIVE: "Betone Wertschätzung und positives Feedback.",
CommunicationTone.CONCERNED: "Druecke aufrichtige Sorge und Empathie aus.",
CommunicationTone.APPRECIATIVE: "Betone Wertschaetzung und positives Feedback.",
}
tone_desc = tone_descriptions.get(tone, tone_descriptions[CommunicationTone.PROFESSIONAL])
system_prompt = f"""Du bist ein erfahrener Kommunikationsberater für Lehrkräfte im deutschen Schulsystem.
return f"""Du bist ein erfahrener Kommunikationsberater fuer Lehrkraefte im deutschen Schulsystem.
Deine Aufgabe ist es, professionelle, empathische und rechtlich fundierte Elternbriefe zu verfassen.
GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg):
@@ -401,55 +124,42 @@ GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg):
1. BEOBACHTUNG: Beschreibe konkrete Handlungen ohne Bewertung
Beispiel: "Ich habe bemerkt, dass..." statt "Das Kind ist..."
2. GEFÜHLE: Drücke Gefühle als Ich-Botschaften aus
Beispiel: "Ich mache mir Sorgen..." statt "Sie müssen..."
2. GEFUEHLE: Druecke Gefuehle als Ich-Botschaften aus
Beispiel: "Ich mache mir Sorgen..." statt "Sie muessen..."
3. BEDÜRFNISSE: Benenne dahinterliegende Bedürfnisse
3. BEDUERFNISSE: Benenne dahinterliegende Beduerfnisse
Beispiel: "Mir ist wichtig, dass..." statt "Sie sollten..."
4. BITTEN: Formuliere konkrete, erfüllbare Bitten
Beispiel: "Wären Sie bereit, ...?" statt "Tun Sie endlich...!"
4. BITTEN: Formuliere konkrete, erfuellbare Bitten
Beispiel: "Waeren Sie bereit, ...?" statt "Tun Sie endlich...!"
WICHTIGE REGELN:
- Immer die Würde aller Beteiligten wahren
- Keine Schuldzuweisungen oder Vorwürfe
- Lösungsorientiert statt problemfokussiert
- Auf Augenhöhe kommunizieren
- Immer die Wuerde aller Beteiligten wahren
- Keine Schuldzuweisungen oder Vorwuerfe
- Loesungsorientiert statt problemfokussiert
- Auf Augenhoehe kommunizieren
- Kooperation statt Konfrontation
- Deutsche Sprache, förmliche Anrede (Sie)
- Deutsche Sprache, foermliche Anrede (Sie)
- Sachlich, aber empathisch
{legal_context}
TONALITÄT:
TONALITAET:
{tone_desc}
FORMAT:
- Verfasse den Brief als vollständigen, versandfertigen Text
- Verfasse den Brief als vollstaendigen, versandfertigen Text
- Beginne mit der Anrede
- Strukturiere den Inhalt klar und verständlich
- Schließe mit einer freundlichen Grußformel
- Die Signatur (Name der Lehrkraft) wird später hinzugefügt
- Strukturiere den Inhalt klar und verstaendlich
- Schliesse mit einer freundlichen Grussformel
- Die Signatur (Name der Lehrkraft) wird spaeter hinzugefuegt
WICHTIG: Der Brief soll professionell und rechtlich einwandfrei sein, aber gleichzeitig
menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit."""
return system_prompt
def build_user_prompt(
self,
comm_type: CommunicationType,
context: Dict[str, Any]
self, comm_type: CommunicationType, context: Dict[str, Any]
) -> str:
"""
Erstellt den User-Prompt aus dem Kontext.
Args:
comm_type: Art der Kommunikation
context: Kontextinformationen (student_name, parent_name, situation, etc.)
Returns:
User-Prompt für LLM
"""
"""Erstellt den User-Prompt aus dem Kontext."""
student_name = context.get("student_name", "das Kind")
parent_name = context.get("parent_name", "Frau/Herr")
situation = context.get("situation", "")
@@ -460,59 +170,48 @@ menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit
CommunicationType.BEHAVIOR: "ein Verhalten, das besprochen werden sollte",
CommunicationType.ACADEMIC: "die schulische Entwicklung",
CommunicationType.ATTENDANCE: "Fehlzeiten oder Anwesenheitsprobleme",
CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespräch",
CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespraech",
CommunicationType.POSITIVE_FEEDBACK: "positives Feedback",
CommunicationType.CONCERN: "eine Sorge oder ein Anliegen",
CommunicationType.CONFLICT: "eine konflikthafte Situation",
CommunicationType.SPECIAL_NEEDS: "Förderbedarf oder besondere Unterstützung",
CommunicationType.SPECIAL_NEEDS: "Foerderbedarf oder besondere Unterstuetzung",
}
type_desc = type_descriptions.get(comm_type, "ein Anliegen")
user_prompt = f"""Schreibe einen Elternbrief zu folgendem Anlass: {type_desc}
Schülername: {student_name}
Schuelername: {student_name}
Elternname: {parent_name}
Situation:
{situation}
"""
if additional_info:
user_prompt += f"\nZusätzliche Informationen:\n{additional_info}\n"
user_prompt += f"\nZusaetzliche Informationen:\n{additional_info}\n"
user_prompt += """
Bitte verfasse einen professionellen, empathischen Brief nach den GFK-Prinzipien.
Der Brief sollte:
- Die Situation sachlich beschreiben (Beobachtung)
- Verständnis und Sorge ausdrücken (Gefühle)
- Das gemeinsame Ziel betonen (Bedürfnisse)
- Verstaendnis und Sorge ausdruecken (Gefuehle)
- Das gemeinsame Ziel betonen (Beduerfnisse)
- Einen konstruktiven Vorschlag machen (Bitte)
"""
return user_prompt
def validate_communication(self, text: str) -> Dict[str, Any]:
"""
Validiert eine generierte Kommunikation auf GFK-Konformität.
Args:
text: Der zu prüfende Text
Returns:
Validierungsergebnis mit Verbesserungsvorschlägen
"""
"""Validiert eine generierte Kommunikation auf GFK-Konformitaet."""
issues = []
suggestions = []
# Prüfe auf problematische Formulierungen
problematic_patterns = [
("Sie müssen", "Vorschlag: 'Wären Sie bereit, ...' oder 'Ich bitte Sie, ...'"),
("Sie sollten", "Vorschlag: 'Ich würde mir wünschen, ...'"),
("Sie muessen", "Vorschlag: 'Waeren Sie bereit, ...' oder 'Ich bitte Sie, ...'"),
("Sie sollten", "Vorschlag: 'Ich wuerde mir wuenschen, ...'"),
("Das Kind ist", "Vorschlag: 'Ich habe beobachtet, dass ...'"),
("immer", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
("nie", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
("faul", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
("unverschämt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
("unverschaemt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
("respektlos", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
]
@@ -521,15 +220,14 @@ Der Brief sollte:
issues.append(f"Problematische Formulierung gefunden: '{pattern}'")
suggestions.append(suggestion)
# Prüfe auf positive Elemente
positive_elements = []
positive_patterns = [
("Ich habe bemerkt", "Gute Beobachtung"),
("Ich möchte", "Gute Ich-Botschaft"),
("Ich moechte", "Gute Ich-Botschaft"),
("gemeinsam", "Gute Kooperationsorientierung"),
("wichtig", "Gutes Bedürfnis-Statement"),
("freuen", "Positive Tonalität"),
("Wären Sie bereit", "Gute Bitte-Formulierung"),
("wichtig", "Gutes Beduerfnis-Statement"),
("freuen", "Positive Tonalitaet"),
("Waeren Sie bereit", "Gute Bitte-Formulierung"),
]
for pattern, feedback in positive_patterns:
@@ -545,47 +243,37 @@ Der Brief sollte:
}
def get_all_communication_types(self) -> List[Dict[str, str]]:
"""Gibt alle verfügbaren Kommunikationstypen zurück."""
return [
{"value": ct.value, "label": self._get_type_label(ct)}
for ct in CommunicationType
]
return [{"value": ct.value, "label": self._get_type_label(ct)} for ct in CommunicationType]
def _get_type_label(self, ct: CommunicationType) -> str:
"""Gibt das deutsche Label für einen Kommunikationstyp zurück."""
labels = {
CommunicationType.GENERAL_INFO: "Allgemeine Information",
CommunicationType.BEHAVIOR: "Verhalten/Disziplin",
CommunicationType.ACADEMIC: "Schulleistungen",
CommunicationType.ATTENDANCE: "Fehlzeiten",
CommunicationType.MEETING_INVITE: "Einladung zum Gespräch",
CommunicationType.MEETING_INVITE: "Einladung zum Gespraech",
CommunicationType.POSITIVE_FEEDBACK: "Positives Feedback",
CommunicationType.CONCERN: "Bedenken äußern",
CommunicationType.CONFLICT: "Konfliktlösung",
CommunicationType.SPECIAL_NEEDS: "Förderbedarf",
CommunicationType.CONCERN: "Bedenken aeussern",
CommunicationType.CONFLICT: "Konfliktloesung",
CommunicationType.SPECIAL_NEEDS: "Foerderbedarf",
}
return labels.get(ct, ct.value)
def get_all_tones(self) -> List[Dict[str, str]]:
"""Gibt alle verfügbaren Tonalitäten zurück."""
labels = {
CommunicationTone.FORMAL: "Sehr förmlich",
CommunicationTone.FORMAL: "Sehr foermlich",
CommunicationTone.PROFESSIONAL: "Professionell-freundlich",
CommunicationTone.WARM: "Warmherzig",
CommunicationTone.CONCERNED: "Besorgt",
CommunicationTone.APPRECIATIVE: "Wertschätzend",
CommunicationTone.APPRECIATIVE: "Wertschaetzend",
}
return [
{"value": t.value, "label": labels.get(t, t.value)}
for t in CommunicationTone
]
return [{"value": t.value, "label": labels.get(t, t.value)} for t in CommunicationTone]
def get_states(self) -> List[Dict[str, str]]:
"""Gibt alle verfügbaren Bundesländer zurück."""
return [
{"value": "NRW", "label": "Nordrhein-Westfalen"},
{"value": "BY", "label": "Bayern"},
{"value": "BW", "label": "Baden-Württemberg"},
{"value": "BW", "label": "Baden-Wuerttemberg"},
{"value": "NI", "label": "Niedersachsen"},
{"value": "HE", "label": "Hessen"},
{"value": "SN", "label": "Sachsen"},
@@ -595,19 +283,18 @@ Der Brief sollte:
{"value": "BB", "label": "Brandenburg"},
{"value": "MV", "label": "Mecklenburg-Vorpommern"},
{"value": "ST", "label": "Sachsen-Anhalt"},
{"value": "TH", "label": "Thüringen"},
{"value": "TH", "label": "Thueringen"},
{"value": "HH", "label": "Hamburg"},
{"value": "HB", "label": "Bremen"},
{"value": "SL", "label": "Saarland"},
]
# Singleton-Instanz
_communication_service: Optional[CommunicationService] = None
def get_communication_service() -> CommunicationService:
"""Gibt die Singleton-Instanz des CommunicationService zurück."""
"""Gibt die Singleton-Instanz des CommunicationService zurueck."""
global _communication_service
if _communication_service is None:
_communication_service = CommunicationService()

View File

@@ -0,0 +1,209 @@
"""
Communication Types - Enums, data classes, templates, and legal references.
"""
import logging
import os
from typing import Optional, List, Dict, Any
from enum import Enum
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
LEGAL_CRAWLER_API_URL = os.getenv(
"LEGAL_CRAWLER_API_URL",
"http://localhost:8000/v1/legal-crawler"
)
class CommunicationType(str, Enum):
"""Arten von Eltern-Kommunikation."""
GENERAL_INFO = "general_info"
BEHAVIOR = "behavior"
ACADEMIC = "academic"
ATTENDANCE = "attendance"
MEETING_INVITE = "meeting_invite"
POSITIVE_FEEDBACK = "positive_feedback"
CONCERN = "concern"
CONFLICT = "conflict"
SPECIAL_NEEDS = "special_needs"
class CommunicationTone(str, Enum):
"""Tonalitaet der Kommunikation."""
FORMAL = "formal"
PROFESSIONAL = "professional"
WARM = "warm"
CONCERNED = "concerned"
APPRECIATIVE = "appreciative"
@dataclass
class LegalReference:
"""Rechtliche Referenz fuer Kommunikation."""
law: str
paragraph: str
title: str
summary: str
relevance: str
@dataclass
class GFKPrinciple:
"""Prinzip der Gewaltfreien Kommunikation."""
principle: str
description: str
example: str
# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer)
FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = {
"DEFAULT": {
"elternpflichten": LegalReference(
law="Landesschulgesetz",
paragraph="(je nach Bundesland)",
title="Pflichten der Eltern",
summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstuetzen.",
relevance="Grundlage fuer Kooperationsaufforderungen"
),
"schulpflicht": LegalReference(
law="Landesschulgesetz",
paragraph="(je nach Bundesland)",
title="Schulpflicht",
summary="Kinder sind schulpflichtig. Eltern sind verantwortlich fuer regelmaessigen Schulbesuch.",
relevance="Bei Fehlzeiten und Anwesenheitsproblemen"
),
}
}
GFK_PRINCIPLES = [
GFKPrinciple(
principle="Beobachtung",
description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation",
example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'"
),
GFKPrinciple(
principle="Gefuehle",
description="Eigene Gefuehle ausdruecken (Ich-Botschaften)",
example="'Ich mache mir Sorgen...' statt 'Sie muessen endlich...'"
),
GFKPrinciple(
principle="Beduerfnisse",
description="Dahinterliegende Beduerfnisse benennen",
example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie muessen mehr kontrollieren.'"
),
GFKPrinciple(
principle="Bitten",
description="Konkrete, erfuellbare Bitten formulieren",
example="'Waeren Sie bereit, taeglich die Hausaufgaben zu pruefen?' statt 'Tun Sie endlich etwas!'"
),
]
COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = {
CommunicationType.GENERAL_INFO: {
"subject": "Information: {topic}",
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte Sie ueber folgendes informieren:",
"closing": "Bei Fragen stehe ich Ihnen gerne zur Verfuegung.\n\nMit freundlichen Gruessen",
},
CommunicationType.BEHAVIOR: {
"subject": "Gespraechswunsch: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.",
"closing": "Ich bin ueberzeugt, dass wir gemeinsam eine gute Loesung finden koennen. Ich wuerde mich ueber ein Gespraech freuen.\n\nMit freundlichen Gruessen",
},
CommunicationType.ACADEMIC: {
"subject": "Schulische Entwicklung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte Sie ueber die schulische Entwicklung von {student_name} informieren.",
"closing": "Ich wuerde mich freuen, wenn wir gemeinsam ueberlegen koennten, wie wir {student_name} optimal unterstuetzen koennen.\n\nMit freundlichen Gruessen",
},
CommunicationType.ATTENDANCE: {
"subject": "Fehlzeiten: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezueglich der Anwesenheit von {student_name}.",
"closing": "Gemaess {legal_reference} sind regelmaessige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Loesung finden.\n\nMit freundlichen Gruessen",
},
CommunicationType.MEETING_INVITE: {
"subject": "Einladung zum Elterngespraech",
"opening": "Sehr geehrte/r {parent_name},\n\nich wuerde mich freuen, Sie zu einem persoenlichen Gespraech einzuladen.",
"closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine fuer Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Gruessen",
},
CommunicationType.POSITIVE_FEEDBACK: {
"subject": "Positive Rueckmeldung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu koennen.",
"closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu duerfen.\n\nMit herzlichen Gruessen",
},
CommunicationType.CONCERN: {
"subject": "Gemeinsame Sorge: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen wuerde.",
"closing": "Ich bin ueberzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Loesung finden werden.\n\nMit freundlichen Gruessen",
},
CommunicationType.CONFLICT: {
"subject": "Bitte um ein klaerendes Gespraech",
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte das Gespraech mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.",
"closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin ueberzeugt, dass wir im Dialog eine fuer alle Seiten gute Loesung finden koennen.\n\nMit freundlichen Gruessen",
},
CommunicationType.SPECIAL_NEEDS: {
"subject": "Foerderung: {student_name}",
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte mit Ihnen ueber die individuelle Foerderung von {student_name} sprechen.",
"closing": "Gemaess dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu foerdern. Lassen Sie uns gemeinsam ueberlegen, wie wir {student_name} bestmoeglich unterstuetzen koennen.\n\nMit freundlichen Gruessen",
},
}
async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]:
"""Laedt rechtliche Referenzen aus der Datenbank (via Legal Crawler API)."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{LEGAL_CRAWLER_API_URL}/references/{state}")
if response.status_code == 200:
return response.json().get("documents", [])
else:
logger.warning(f"Legal API returned {response.status_code} for state {state}")
return []
except Exception as e:
logger.error(f"Fehler beim Laden rechtlicher Referenzen fuer {state}: {e}")
return []
def parse_db_references_to_legal_refs(
db_docs: List[Dict[str, Any]], topic: str
) -> List[LegalReference]:
"""Konvertiert DB-Dokumente in LegalReference-Objekte."""
references = []
topic_keywords = {
"elternpflichten": ["42", "76", "85", "eltern", "pflicht"],
"schulpflicht": ["41", "35", "schulpflicht", "pflicht"],
"ordnungsmassnahmen": ["53", "ordnung", "erzieh", "massnahm"],
"datenschutz": ["120", "daten", "schutz"],
"foerderung": ["2", "foerder", "bildung", "auftrag"],
}
keywords = topic_keywords.get(topic, ["eltern"])
for doc in db_docs:
law_name = doc.get("law_name", doc.get("title", "Schulgesetz"))
paragraphs = doc.get("paragraphs", [])
if not paragraphs:
references.append(LegalReference(
law=law_name, paragraph="(siehe Gesetzestext)",
title=doc.get("title", "Schulgesetz"),
summary=f"Rechtliche Grundlage aus {law_name}",
relevance=f"Relevant fuer {topic}"
))
continue
for para in paragraphs[:10]:
para_nr = para.get("nr", "")
para_title = para.get("title", "")
is_relevant = any(
kw.lower() in para_nr.lower() or kw.lower() in para_title.lower()
for kw in keywords
)
if is_relevant:
references.append(LegalReference(
law=law_name, paragraph=para_nr,
title=para_title[:100],
summary=f"{para_title[:150]}",
relevance=f"Relevant fuer {topic}"
))
return references