[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:
@@ -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",
|
||||
]
|
||||
|
||||
84
backend-lehrer/alerts_agent/db/enums.py
Normal file
84
backend-lehrer/alerts_agent/db/enums.py
Normal 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"
|
||||
@@ -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})>"
|
||||
|
||||
149
backend-lehrer/alerts_agent/db/models_dual_mode.py
Normal file
149
backend-lehrer/alerts_agent/db/models_dual_mode.py
Normal 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})>"
|
||||
@@ -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)
|
||||
|
||||
184
backend-lehrer/certificates_models.py
Normal file
184
backend-lehrer/certificates_models.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
195
backend-lehrer/letters_models.py
Normal file
195
backend-lehrer/letters_models.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
209
backend-lehrer/llm_gateway/services/communication_types.py
Normal file
209
backend-lehrer/llm_gateway/services/communication_types.py
Normal 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
|
||||
Reference in New Issue
Block a user