diff --git a/backend-lehrer/alerts_agent/db/__init__.py b/backend-lehrer/alerts_agent/db/__init__.py index 951becd..415708f 100644 --- a/backend-lehrer/alerts_agent/db/__init__.py +++ b/backend-lehrer/alerts_agent/db/__init__.py @@ -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", ] diff --git a/backend-lehrer/alerts_agent/db/enums.py b/backend-lehrer/alerts_agent/db/enums.py new file mode 100644 index 0000000..a490949 --- /dev/null +++ b/backend-lehrer/alerts_agent/db/enums.py @@ -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" diff --git a/backend-lehrer/alerts_agent/db/models.py b/backend-lehrer/alerts_agent/db/models.py index a8d5a56..9012679 100644 --- a/backend-lehrer/alerts_agent/db/models.py +++ b/backend-lehrer/alerts_agent/db/models.py @@ -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"" 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"" - - -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"" - - -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"" - - -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"" diff --git a/backend-lehrer/alerts_agent/db/models_dual_mode.py b/backend-lehrer/alerts_agent/db/models_dual_mode.py new file mode 100644 index 0000000..1c39f6a --- /dev/null +++ b/backend-lehrer/alerts_agent/db/models_dual_mode.py @@ -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"" + + +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"" + + +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"" + + +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"" diff --git a/backend-lehrer/certificates_api.py b/backend-lehrer/certificates_api.py index 2413d1f..fb1f999 100644 --- a/backend-lehrer/certificates_api.py +++ b/backend-lehrer/certificates_api.py @@ -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) diff --git a/backend-lehrer/certificates_models.py b/backend-lehrer/certificates_models.py new file mode 100644 index 0000000..08a8c41 --- /dev/null +++ b/backend-lehrer/certificates_models.py @@ -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 diff --git a/backend-lehrer/letters_api.py b/backend-lehrer/letters_api.py index b95606d..8f8a5e4 100644 --- a/backend-lehrer/letters_api.py +++ b/backend-lehrer/letters_api.py @@ -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) diff --git a/backend-lehrer/letters_models.py b/backend-lehrer/letters_models.py new file mode 100644 index 0000000..f45565e --- /dev/null +++ b/backend-lehrer/letters_models.py @@ -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) diff --git a/backend-lehrer/llm_gateway/services/__init__.py b/backend-lehrer/llm_gateway/services/__init__.py index 87135cb..c3a725a 100644 --- a/backend-lehrer/llm_gateway/services/__init__.py +++ b/backend-lehrer/llm_gateway/services/__init__.py @@ -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", ] diff --git a/backend-lehrer/llm_gateway/services/communication_service.py b/backend-lehrer/llm_gateway/services/communication_service.py index c2f34f1..702a025 100644 --- a/backend-lehrer/llm_gateway/services/communication_service.py +++ b/backend-lehrer/llm_gateway/services/communication_service.py @@ -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() diff --git a/backend-lehrer/llm_gateway/services/communication_types.py b/backend-lehrer/llm_gateway/services/communication_types.py new file mode 100644 index 0000000..939503f --- /dev/null +++ b/backend-lehrer/llm_gateway/services/communication_types.py @@ -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 diff --git a/klausur-service/backend/hybrid_vocab_extractor.py b/klausur-service/backend/hybrid_vocab_extractor.py index 134379d..9c68943 100644 --- a/klausur-service/backend/hybrid_vocab_extractor.py +++ b/klausur-service/backend/hybrid_vocab_extractor.py @@ -1,425 +1,42 @@ """ Hybrid OCR + LLM Vocabulary Extractor -Zweistufiger Ansatz fuer optimale Vokabel-Extraktion: -1. PaddleOCR fuer schnelle, praezise Texterkennung mit Bounding-Boxes -2. qwen2.5:14b (via LLM Gateway) fuer semantische Strukturierung +Split into: +- hybrid_vocab_ocr.py: PaddleOCR integration, parsing, row/column detection +- hybrid_vocab_extractor.py (this file): LLM structuring, public API, barrel re-exports -Vorteile gegenueber reinem Vision LLM: -- 4x schneller (~7-15 Sek vs 30-60 Sek pro Seite) -- Hoehere Genauigkeit bei gedrucktem Text (95-99%) -- Weniger Halluzinationen (LLM korrigiert nur, erfindet nicht) -- Position-basierte Spaltenerkennung moeglich - -DATENSCHUTZ: Alle Verarbeitung erfolgt lokal (Mac Mini). +All symbols re-exported for backward compatibility. """ import os -import io import json import logging import re -from typing import List, Dict, Any, Optional, Tuple -from dataclasses import dataclass -import uuid +from typing import List, Dict, Any, Tuple import httpx -import numpy as np -from PIL import Image -# OpenCV is optional - only required for actual image processing -try: - import cv2 - CV2_AVAILABLE = True -except ImportError: - cv2 = None - CV2_AVAILABLE = False +# Re-export everything from ocr module for backward compatibility +from hybrid_vocab_ocr import ( + OCRRegion, + get_paddle_ocr, + preprocess_image, + run_paddle_ocr, + group_regions_by_rows, + detect_columns, + format_ocr_for_llm, +) logger = logging.getLogger(__name__) -# Configuration - Use Ollama directly (no separate LLM Gateway) OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:14b") -# PaddleOCR - Lazy loading -_paddle_ocr = None - - -def get_paddle_ocr(): - """ - Lazy load PaddleOCR to avoid startup delay. - - PaddleOCR 3.x API (released May 2025): - - Only 'lang' parameter confirmed valid - - Removed parameters: use_gpu, device, show_log, det, rec, use_onnx - - GPU/CPU selection is automatic - """ - global _paddle_ocr - if _paddle_ocr is None: - try: - from paddleocr import PaddleOCR - import logging as std_logging - - # Suppress verbose logging from PaddleOCR and PaddlePaddle - for logger_name in ['ppocr', 'paddle', 'paddleocr', 'root']: - std_logging.getLogger(logger_name).setLevel(std_logging.WARNING) - - # PaddleOCR 3.x: Only use 'lang' parameter - # Try German first, then English, then minimal - try: - _paddle_ocr = PaddleOCR(lang="de") - logger.info("PaddleOCR 3.x initialized (lang=de)") - except Exception as e1: - logger.warning(f"PaddleOCR lang=de failed: {e1}") - try: - _paddle_ocr = PaddleOCR(lang="en") - logger.info("PaddleOCR 3.x initialized (lang=en)") - except Exception as e2: - logger.warning(f"PaddleOCR lang=en failed: {e2}") - _paddle_ocr = PaddleOCR() - logger.info("PaddleOCR 3.x initialized (defaults)") - - except Exception as e: - logger.error(f"PaddleOCR initialization failed: {e}") - _paddle_ocr = None - - return _paddle_ocr - - -@dataclass -class OCRRegion: - """Ein erkannter Textbereich mit Position.""" - text: str - confidence: float - x1: int - y1: int - x2: int - y2: int - - @property - def center_x(self) -> int: - return (self.x1 + self.x2) // 2 - - @property - def center_y(self) -> int: - return (self.y1 + self.y2) // 2 - - -# ============================================================================= -# OCR Pipeline -# ============================================================================= - -def preprocess_image(img: Image.Image) -> np.ndarray: - """ - Bildvorverarbeitung fuer bessere OCR-Ergebnisse. - - - Konvertierung zu RGB - - Optional: Kontrastverstarkung - - Raises: - ImportError: If OpenCV is not available - """ - if not CV2_AVAILABLE: - raise ImportError( - "OpenCV (cv2) is required for image preprocessing. " - "Install with: pip install opencv-python-headless" - ) - - # PIL zu numpy array - img_array = np.array(img) - - # Zu RGB konvertieren falls noetig - if len(img_array.shape) == 2: - # Graustufen zu RGB - img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) - elif img_array.shape[2] == 4: - # RGBA zu RGB - img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB) - - return img_array - - -def run_paddle_ocr(image_bytes: bytes) -> Tuple[List[OCRRegion], str]: - """ - Fuehrt PaddleOCR auf einem Bild aus. - - PaddleOCR 3.x returns results in format: - - result = ocr.ocr(img) returns list of pages - - Each page contains list of text lines - - Each line: [bbox_points, (text, confidence)] - - Returns: - Tuple of (list of OCRRegion, raw_text) - """ - ocr = get_paddle_ocr() - if ocr is None: - logger.error("PaddleOCR not available") - return [], "" - - try: - # Bild laden und vorverarbeiten - img = Image.open(io.BytesIO(image_bytes)) - img_array = preprocess_image(img) - - # OCR ausfuehren - PaddleOCR 3.x API - # Note: cls parameter may not be supported in 3.x, try without it - try: - result = ocr.ocr(img_array) - except TypeError: - # Fallback if ocr() doesn't accept the array directly - logger.warning("Trying alternative OCR call method") - result = ocr.ocr(img_array) - - if not result: - logger.warning("PaddleOCR returned empty result") - return [], "" - - # Handle different result formats - # PaddleOCR 3.x returns list of OCRResult objects (dict-like) - if isinstance(result, dict): - # Direct dict format with 'rec_texts', 'rec_scores', 'dt_polys' - logger.info("Processing PaddleOCR 3.x dict format") - return _parse_paddleocr_v3_dict(result) - elif isinstance(result, list) and len(result) > 0: - first_item = result[0] - if first_item is None: - logger.warning("PaddleOCR returned None for first page") - return [], "" - - # PaddleOCR 3.x: list contains OCRResult objects (dict-like) - # Check if first item has 'rec_texts' key (new format) - if hasattr(first_item, 'get') or isinstance(first_item, dict): - # Try to extract dict keys for new 3.x format - item_dict = dict(first_item) if hasattr(first_item, 'items') else first_item - if 'rec_texts' in item_dict or 'texts' in item_dict: - logger.info("Processing PaddleOCR 3.x OCRResult format") - return _parse_paddleocr_v3_dict(item_dict) - - # Check if first item is a list (traditional format) - if isinstance(first_item, list): - # Check if it's the traditional line format [[bbox, (text, conf)], ...] - if len(first_item) > 0 and isinstance(first_item[0], (list, tuple)): - logger.info("Processing PaddleOCR traditional list format") - return _parse_paddleocr_list(first_item) - - # Unknown format - try to inspect - logger.warning(f"Unknown result format. Type: {type(first_item)}, Keys: {dir(first_item) if hasattr(first_item, '__dir__') else 'N/A'}") - # Try dict conversion as last resort - try: - item_dict = dict(first_item) - if 'rec_texts' in item_dict: - return _parse_paddleocr_v3_dict(item_dict) - except Exception as e: - logger.warning(f"Could not convert to dict: {e}") - return [], "" - else: - logger.warning(f"Unexpected PaddleOCR result type: {type(result)}") - return [], "" - - except Exception as e: - logger.error(f"PaddleOCR execution failed: {e}") - import traceback - logger.error(traceback.format_exc()) - return [], "" - - -def _parse_paddleocr_v3_dict(result: dict) -> Tuple[List[OCRRegion], str]: - """Parse PaddleOCR 3.x dict format result.""" - regions = [] - all_text_lines = [] - - texts = result.get('rec_texts', result.get('texts', [])) - scores = result.get('rec_scores', result.get('scores', [])) - polys = result.get('dt_polys', result.get('boxes', [])) - # Also try rec_boxes which gives direct [x1, y1, x2, y2] format - rec_boxes = result.get('rec_boxes', []) - - logger.info(f"PaddleOCR 3.x: {len(texts)} texts, {len(scores)} scores, {len(polys)} polys, {len(rec_boxes)} rec_boxes") - - for i, (text, score) in enumerate(zip(texts, scores)): - if not text or not str(text).strip(): - continue - - # Try to get bounding box - prefer rec_boxes if available - x1, y1, x2, y2 = 0, 0, 100, 50 # Default fallback - - if i < len(rec_boxes) and rec_boxes[i] is not None: - # rec_boxes format: [x1, y1, x2, y2] or [[x1, y1, x2, y2]] - box = rec_boxes[i] - try: - if hasattr(box, 'flatten'): - box = box.flatten().tolist() - if len(box) >= 4: - x1, y1, x2, y2 = int(box[0]), int(box[1]), int(box[2]), int(box[3]) - except Exception as e: - logger.debug(f"Could not parse rec_box: {e}") - - elif i < len(polys) and polys[i] is not None: - # dt_polys format: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] or numpy array - poly = polys[i] - try: - # Convert numpy array to list if needed - if hasattr(poly, 'tolist'): - poly = poly.tolist() - if len(poly) >= 4: - x_coords = [p[0] for p in poly] - y_coords = [p[1] for p in poly] - x1, y1 = int(min(x_coords)), int(min(y_coords)) - x2, y2 = int(max(x_coords)), int(max(y_coords)) - except Exception as e: - logger.debug(f"Could not parse polygon: {e}") - - region = OCRRegion( - text=text.strip(), - confidence=float(score) if score else 0.5, - x1=x1, y1=y1, x2=x2, y2=y2 - ) - regions.append(region) - all_text_lines.append(text.strip()) - - regions.sort(key=lambda r: r.y1) - raw_text = "\n".join(all_text_lines) - logger.info(f"PaddleOCR 3.x extracted {len(regions)} text regions") - return regions, raw_text - - -def _parse_paddleocr_list(page_result: list) -> Tuple[List[OCRRegion], str]: - """Parse PaddleOCR traditional list format result.""" - regions = [] - all_text_lines = [] - - for line in page_result: - if not line or len(line) < 2: - continue - - bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] - text_info = line[1] - - # Handle different text_info formats - if isinstance(text_info, tuple) and len(text_info) >= 2: - text, confidence = text_info[0], text_info[1] - elif isinstance(text_info, str): - text, confidence = text_info, 0.5 - else: - continue - - if not text or not text.strip(): - continue - - # Bounding Box extrahieren - x_coords = [p[0] for p in bbox_points] - y_coords = [p[1] for p in bbox_points] - - region = OCRRegion( - text=text.strip(), - confidence=float(confidence), - x1=int(min(x_coords)), - y1=int(min(y_coords)), - x2=int(max(x_coords)), - y2=int(max(y_coords)) - ) - regions.append(region) - all_text_lines.append(text.strip()) - - # Regionen nach Y-Position sortieren (oben nach unten) - regions.sort(key=lambda r: r.y1) - raw_text = "\n".join(all_text_lines) - logger.info(f"PaddleOCR extracted {len(regions)} text regions") - - return regions, raw_text - - -def group_regions_by_rows(regions: List[OCRRegion], y_tolerance: int = 20) -> List[List[OCRRegion]]: - """ - Gruppiert Textregionen in Zeilen basierend auf Y-Position. - - Args: - regions: Liste von OCRRegion - y_tolerance: Max Y-Differenz um zur gleichen Zeile zu gehoeren - - Returns: - Liste von Zeilen, jede Zeile ist eine Liste von OCRRegion sortiert nach X - """ - if not regions: - return [] - - rows = [] - current_row = [regions[0]] - current_y = regions[0].center_y - - for region in regions[1:]: - if abs(region.center_y - current_y) <= y_tolerance: - # Gleiche Zeile - current_row.append(region) - else: - # Neue Zeile - # Sortiere aktuelle Zeile nach X - current_row.sort(key=lambda r: r.x1) - rows.append(current_row) - current_row = [region] - current_y = region.center_y - - # Letzte Zeile nicht vergessen - if current_row: - current_row.sort(key=lambda r: r.x1) - rows.append(current_row) - - return rows - - -def detect_columns(rows: List[List[OCRRegion]]) -> int: - """ - Erkennt die Anzahl der Spalten basierend auf den Textpositionen. - - Returns: - Geschaetzte Spaltenanzahl (2 oder 3 fuer Vokabellisten) - """ - if not rows: - return 2 - - # Zaehle wie viele Elemente pro Zeile - items_per_row = [len(row) for row in rows if len(row) >= 2] - - if not items_per_row: - return 2 - - # Durchschnitt und haeufigster Wert - avg_items = sum(items_per_row) / len(items_per_row) - - if avg_items >= 2.5: - return 3 # 3 Spalten: Englisch | Deutsch | Beispiel - else: - return 2 # 2 Spalten: Englisch | Deutsch - - -def format_ocr_for_llm(regions: List[OCRRegion]) -> str: - """ - Formatiert OCR-Output fuer LLM-Verarbeitung. - Inkludiert Positionsinformationen fuer bessere Strukturerkennung. - """ - rows = group_regions_by_rows(regions) - num_columns = detect_columns(rows) - - lines = [] - lines.append(f"Erkannte Spalten: {num_columns}") - lines.append("---") - - for row in rows: - if len(row) >= 2: - # Tab-separierte Werte fuer LLM - row_text = "\t".join(r.text for r in row) - lines.append(row_text) - elif len(row) == 1: - lines.append(row[0].text) - - return "\n".join(lines) - - # ============================================================================= # LLM Strukturierung # ============================================================================= -STRUCTURE_PROMPT = """Du erhältst OCR-Output einer Vokabelliste aus einem englischen Schulbuch. +STRUCTURE_PROMPT = """Du erhaeltst OCR-Output einer Vokabelliste aus einem englischen Schulbuch. Die Zeilen sind Tab-separiert und enthalten typischerweise: - 2 Spalten: Englisch | Deutsch - 3 Spalten: Englisch | Deutsch | Beispielsatz @@ -429,7 +46,7 @@ OCR-Text: AUFGABE: Strukturiere die Vokabeln als JSON-Array. -AUSGABE-FORMAT (nur JSON, keine Erklärungen): +AUSGABE-FORMAT (nur JSON, keine Erklaerungen): {{ "vocabulary": [ {{"english": "to improve", "german": "verbessern", "example": "I want to improve my English."}}, @@ -439,50 +56,32 @@ AUSGABE-FORMAT (nur JSON, keine Erklärungen): REGELN: 1. Erkenne das Spalten-Layout aus den Tab-Trennungen -2. Korrigiere offensichtliche OCR-Fehler kontextuell (z.B. "vereessern" → "verbessern", "0" → "o") -3. Bei fehlenden Beispielsätzen: "example": null -4. Überspringe Überschriften, Seitenzahlen, Kapitelnummern +2. Korrigiere offensichtliche OCR-Fehler kontextuell (z.B. "vereessern" -> "verbessern", "0" -> "o") +3. Bei fehlenden Beispielsaetzen: "example": null +4. Ueberspringe Ueberschriften, Seitenzahlen, Kapitelnummern 5. Behalte Wortarten bei wenn vorhanden (n, v, adj am Ende des englischen Worts) -6. Gib NUR valides JSON zurück""" +6. Gib NUR valides JSON zurueck""" async def structure_vocabulary_with_llm(ocr_text: str) -> List[Dict[str, Any]]: - """ - Verwendet Ollama LLM um OCR-Text zu strukturieren. - - Args: - ocr_text: Formatierter OCR-Output - - Returns: - Liste von Vokabel-Dictionaries - """ + """Verwendet Ollama LLM um OCR-Text zu strukturieren.""" prompt = STRUCTURE_PROMPT.format(ocr_text=ocr_text) try: async with httpx.AsyncClient(timeout=120.0) as client: - # Use Ollama's native /api/chat endpoint response = await client.post( f"{OLLAMA_URL}/api/chat", json={ "model": LLM_MODEL, - "messages": [ - {"role": "user", "content": prompt} - ], + "messages": [{"role": "user", "content": prompt}], "stream": False, - "options": { - "temperature": 0.1, - "num_predict": 4096 - } + "options": {"temperature": 0.1, "num_predict": 4096} } ) response.raise_for_status() - data = response.json() content = data.get("message", {}).get("content", "") - logger.info(f"Ollama LLM response received: {len(content)} chars") - - # JSON parsen return parse_llm_vocabulary_json(content) except httpx.TimeoutException: @@ -499,37 +98,29 @@ async def structure_vocabulary_with_llm(ocr_text: str) -> List[Dict[str, Any]]: def parse_llm_vocabulary_json(text: str) -> List[Dict[str, Any]]: """Robustes JSON-Parsing des LLM-Outputs.""" try: - # JSON im Text finden start = text.find('{') end = text.rfind('}') + 1 - if start == -1 or end == 0: logger.warning("No JSON found in LLM response") return [] json_str = text[start:end] data = json.loads(json_str) - vocabulary = data.get("vocabulary", []) - # Validierung valid_entries = [] for entry in vocabulary: english = entry.get("english", "").strip() german = entry.get("german", "").strip() - if english and german: valid_entries.append({ - "english": english, - "german": german, + "english": english, "german": german, "example": entry.get("example") }) - return valid_entries except json.JSONDecodeError as e: logger.error(f"JSON parse error: {e}") - # Fallback: Regex extraction return extract_vocabulary_regex(text) except Exception as e: logger.error(f"Vocabulary parsing failed: {e}") @@ -544,11 +135,8 @@ def extract_vocabulary_regex(text: str) -> List[Dict[str, Any]]: vocabulary = [] for english, german in matches: vocabulary.append({ - "english": english.strip(), - "german": german.strip(), - "example": None + "english": english.strip(), "german": german.strip(), "example": None }) - logger.info(f"Regex fallback extracted {len(vocabulary)} entries") return vocabulary @@ -558,46 +146,29 @@ def extract_vocabulary_regex(text: str) -> List[Dict[str, Any]]: # ============================================================================= async def extract_vocabulary_hybrid( - image_bytes: bytes, - page_number: int = 0 + image_bytes: bytes, page_number: int = 0 ) -> Tuple[List[Dict[str, Any]], float, str]: - """ - Hybrid-Extraktion: PaddleOCR + LLM Strukturierung. - - Args: - image_bytes: Bild als Bytes - page_number: Seitennummer (0-indexed) fuer Fehlermeldungen - - Returns: - Tuple of (vocabulary_list, confidence, error_message) - """ + """Hybrid-Extraktion: PaddleOCR + LLM Strukturierung.""" try: - # Step 1: PaddleOCR logger.info(f"Starting hybrid extraction for page {page_number + 1}") regions, raw_text = run_paddle_ocr(image_bytes) if not regions: return [], 0.0, f"Seite {page_number + 1}: Kein Text erkannt (OCR)" - # Step 2: Formatieren fuer LLM formatted_text = format_ocr_for_llm(regions) logger.info(f"Formatted OCR text: {len(formatted_text)} chars") - # Step 3: LLM Strukturierung vocabulary = await structure_vocabulary_with_llm(formatted_text) if not vocabulary: - # Fallback: Versuche direkte Zeilen-Analyse vocabulary = extract_from_rows_directly(regions) if not vocabulary: return [], 0.0, f"Seite {page_number + 1}: Keine Vokabeln erkannt" - # Durchschnittliche OCR-Confidence avg_confidence = sum(r.confidence for r in regions) / len(regions) if regions else 0.0 - logger.info(f"Hybrid extraction completed: {len(vocabulary)} entries, {avg_confidence:.2f} confidence") - return vocabulary, avg_confidence, "" except Exception as e: @@ -608,10 +179,7 @@ async def extract_vocabulary_hybrid( def extract_from_rows_directly(regions: List[OCRRegion]) -> List[Dict[str, Any]]: - """ - Direkter Fallback: Extrahiere Vokabeln ohne LLM basierend auf Zeilen-Struktur. - Funktioniert nur bei klarem 2-3 Spalten-Layout. - """ + """Direkter Fallback: Extrahiere Vokabeln ohne LLM.""" rows = group_regions_by_rows(regions) vocabulary = [] @@ -620,13 +188,9 @@ def extract_from_rows_directly(regions: List[OCRRegion]) -> List[Dict[str, Any]] english = row[0].text.strip() german = row[1].text.strip() example = row[2].text.strip() if len(row) >= 3 else None - - # Einfache Validierung if english and german and len(english) > 1 and len(german) > 1: vocabulary.append({ - "english": english, - "german": german, - "example": example + "english": english, "german": german, "example": example }) logger.info(f"Direct row extraction: {len(vocabulary)} entries") diff --git a/klausur-service/backend/hybrid_vocab_ocr.py b/klausur-service/backend/hybrid_vocab_ocr.py new file mode 100644 index 0000000..dada108 --- /dev/null +++ b/klausur-service/backend/hybrid_vocab_ocr.py @@ -0,0 +1,300 @@ +""" +Hybrid Vocab OCR - PaddleOCR integration and result parsing. + +Handles: +- PaddleOCR lazy loading and initialization +- Running OCR on image bytes +- Parsing PaddleOCR v3 dict and traditional list formats +- Grouping regions by rows and detecting columns +""" + +import io +import logging +from typing import List, Tuple +from dataclasses import dataclass + +import numpy as np +from PIL import Image + +# OpenCV is optional +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + cv2 = None + CV2_AVAILABLE = False + +logger = logging.getLogger(__name__) + +_paddle_ocr = None + + +@dataclass +class OCRRegion: + """Ein erkannter Textbereich mit Position.""" + text: str + confidence: float + x1: int + y1: int + x2: int + y2: int + + @property + def center_x(self) -> int: + return (self.x1 + self.x2) // 2 + + @property + def center_y(self) -> int: + return (self.y1 + self.y2) // 2 + + +def get_paddle_ocr(): + """Lazy load PaddleOCR to avoid startup delay.""" + global _paddle_ocr + if _paddle_ocr is None: + try: + from paddleocr import PaddleOCR + import logging as std_logging + + for logger_name in ['ppocr', 'paddle', 'paddleocr', 'root']: + std_logging.getLogger(logger_name).setLevel(std_logging.WARNING) + + try: + _paddle_ocr = PaddleOCR(lang="de") + logger.info("PaddleOCR 3.x initialized (lang=de)") + except Exception as e1: + logger.warning(f"PaddleOCR lang=de failed: {e1}") + try: + _paddle_ocr = PaddleOCR(lang="en") + logger.info("PaddleOCR 3.x initialized (lang=en)") + except Exception as e2: + logger.warning(f"PaddleOCR lang=en failed: {e2}") + _paddle_ocr = PaddleOCR() + logger.info("PaddleOCR 3.x initialized (defaults)") + + except Exception as e: + logger.error(f"PaddleOCR initialization failed: {e}") + _paddle_ocr = None + + return _paddle_ocr + + +def preprocess_image(img: Image.Image) -> np.ndarray: + """Bildvorverarbeitung fuer bessere OCR-Ergebnisse.""" + if not CV2_AVAILABLE: + raise ImportError( + "OpenCV (cv2) is required for image preprocessing. " + "Install with: pip install opencv-python-headless" + ) + img_array = np.array(img) + if len(img_array.shape) == 2: + img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) + elif img_array.shape[2] == 4: + img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB) + return img_array + + +def run_paddle_ocr(image_bytes: bytes) -> Tuple[List[OCRRegion], str]: + """Fuehrt PaddleOCR auf einem Bild aus.""" + ocr = get_paddle_ocr() + if ocr is None: + logger.error("PaddleOCR not available") + return [], "" + + try: + img = Image.open(io.BytesIO(image_bytes)) + img_array = preprocess_image(img) + + try: + result = ocr.ocr(img_array) + except TypeError: + logger.warning("Trying alternative OCR call method") + result = ocr.ocr(img_array) + + if not result: + logger.warning("PaddleOCR returned empty result") + return [], "" + + if isinstance(result, dict): + logger.info("Processing PaddleOCR 3.x dict format") + return _parse_paddleocr_v3_dict(result) + elif isinstance(result, list) and len(result) > 0: + first_item = result[0] + if first_item is None: + logger.warning("PaddleOCR returned None for first page") + return [], "" + + if hasattr(first_item, 'get') or isinstance(first_item, dict): + item_dict = dict(first_item) if hasattr(first_item, 'items') else first_item + if 'rec_texts' in item_dict or 'texts' in item_dict: + logger.info("Processing PaddleOCR 3.x OCRResult format") + return _parse_paddleocr_v3_dict(item_dict) + + if isinstance(first_item, list): + if len(first_item) > 0 and isinstance(first_item[0], (list, tuple)): + logger.info("Processing PaddleOCR traditional list format") + return _parse_paddleocr_list(first_item) + + logger.warning(f"Unknown result format. Type: {type(first_item)}") + try: + item_dict = dict(first_item) + if 'rec_texts' in item_dict: + return _parse_paddleocr_v3_dict(item_dict) + except Exception as e: + logger.warning(f"Could not convert to dict: {e}") + return [], "" + else: + logger.warning(f"Unexpected PaddleOCR result type: {type(result)}") + return [], "" + + except Exception as e: + logger.error(f"PaddleOCR execution failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return [], "" + + +def _parse_paddleocr_v3_dict(result: dict) -> Tuple[List[OCRRegion], str]: + """Parse PaddleOCR 3.x dict format result.""" + regions = [] + all_text_lines = [] + + texts = result.get('rec_texts', result.get('texts', [])) + scores = result.get('rec_scores', result.get('scores', [])) + polys = result.get('dt_polys', result.get('boxes', [])) + rec_boxes = result.get('rec_boxes', []) + + logger.info(f"PaddleOCR 3.x: {len(texts)} texts, {len(scores)} scores, {len(polys)} polys, {len(rec_boxes)} rec_boxes") + + for i, (text, score) in enumerate(zip(texts, scores)): + if not text or not str(text).strip(): + continue + + x1, y1, x2, y2 = 0, 0, 100, 50 + + if i < len(rec_boxes) and rec_boxes[i] is not None: + box = rec_boxes[i] + try: + if hasattr(box, 'flatten'): + box = box.flatten().tolist() + if len(box) >= 4: + x1, y1, x2, y2 = int(box[0]), int(box[1]), int(box[2]), int(box[3]) + except Exception as e: + logger.debug(f"Could not parse rec_box: {e}") + + elif i < len(polys) and polys[i] is not None: + poly = polys[i] + try: + if hasattr(poly, 'tolist'): + poly = poly.tolist() + if len(poly) >= 4: + x_coords = [p[0] for p in poly] + y_coords = [p[1] for p in poly] + x1, y1 = int(min(x_coords)), int(min(y_coords)) + x2, y2 = int(max(x_coords)), int(max(y_coords)) + except Exception as e: + logger.debug(f"Could not parse polygon: {e}") + + region = OCRRegion( + text=text.strip(), confidence=float(score) if score else 0.5, + x1=x1, y1=y1, x2=x2, y2=y2 + ) + regions.append(region) + all_text_lines.append(text.strip()) + + regions.sort(key=lambda r: r.y1) + raw_text = "\n".join(all_text_lines) + logger.info(f"PaddleOCR 3.x extracted {len(regions)} text regions") + return regions, raw_text + + +def _parse_paddleocr_list(page_result: list) -> Tuple[List[OCRRegion], str]: + """Parse PaddleOCR traditional list format result.""" + regions = [] + all_text_lines = [] + + for line in page_result: + if not line or len(line) < 2: + continue + + bbox_points = line[0] + text_info = line[1] + + if isinstance(text_info, tuple) and len(text_info) >= 2: + text, confidence = text_info[0], text_info[1] + elif isinstance(text_info, str): + text, confidence = text_info, 0.5 + else: + continue + + if not text or not text.strip(): + continue + + x_coords = [p[0] for p in bbox_points] + y_coords = [p[1] for p in bbox_points] + + region = OCRRegion( + text=text.strip(), confidence=float(confidence), + x1=int(min(x_coords)), y1=int(min(y_coords)), + x2=int(max(x_coords)), y2=int(max(y_coords)) + ) + regions.append(region) + all_text_lines.append(text.strip()) + + regions.sort(key=lambda r: r.y1) + raw_text = "\n".join(all_text_lines) + logger.info(f"PaddleOCR extracted {len(regions)} text regions") + return regions, raw_text + + +def group_regions_by_rows(regions: List[OCRRegion], y_tolerance: int = 20) -> List[List[OCRRegion]]: + """Gruppiert Textregionen in Zeilen basierend auf Y-Position.""" + if not regions: + return [] + + rows = [] + current_row = [regions[0]] + current_y = regions[0].center_y + + for region in regions[1:]: + if abs(region.center_y - current_y) <= y_tolerance: + current_row.append(region) + else: + current_row.sort(key=lambda r: r.x1) + rows.append(current_row) + current_row = [region] + current_y = region.center_y + + if current_row: + current_row.sort(key=lambda r: r.x1) + rows.append(current_row) + + return rows + + +def detect_columns(rows: List[List[OCRRegion]]) -> int: + """Erkennt die Anzahl der Spalten basierend auf den Textpositionen.""" + if not rows: + return 2 + + items_per_row = [len(row) for row in rows if len(row) >= 2] + if not items_per_row: + return 2 + + avg_items = sum(items_per_row) / len(items_per_row) + return 3 if avg_items >= 2.5 else 2 + + +def format_ocr_for_llm(regions: List[OCRRegion]) -> str: + """Formatiert OCR-Output fuer LLM-Verarbeitung.""" + rows = group_regions_by_rows(regions) + num_columns = detect_columns(rows) + + lines = [f"Erkannte Spalten: {num_columns}", "---"] + for row in rows: + if len(row) >= 2: + lines.append("\t".join(r.text for r in row)) + elif len(row) == 1: + lines.append(row[0].text) + + return "\n".join(lines) diff --git a/klausur-service/frontend/src/components/EHUploadWizard.tsx b/klausur-service/frontend/src/components/EHUploadWizard.tsx index 2691a6d..dca041f 100644 --- a/klausur-service/frontend/src/components/EHUploadWizard.tsx +++ b/klausur-service/frontend/src/components/EHUploadWizard.tsx @@ -1,586 +1,137 @@ /** * BYOEH Upload Wizard Component * - * 5-step wizard for uploading Erwartungshorizonte with client-side encryption: - * 1. File Selection - Choose PDF file - * 2. Metadata - Title, Subject, Niveau, Year - * 3. Rights Confirmation - Legal acknowledgment (required) - * 4. Encryption - Set passphrase (2x confirmation) - * 5. Summary & Upload - Review and confirm + * 5-step wizard for uploading Erwartungshorizonte with client-side encryption. + * Step content extracted to eh-wizard/EHWizardSteps.tsx. */ import { useState, useEffect, useCallback } from 'react' -import { - encryptFile, - generateSalt, - isEncryptionSupported -} from '../services/encryption' +import { encryptFile, generateSalt, isEncryptionSupported } from '../services/encryption' import { ehApi } from '../services/api' - -interface EHMetadata { - title: string - subject: string - niveau: 'eA' | 'gA' - year: number - aufgaben_nummer?: string -} +import { FileStep, MetadataStep, RightsStep, EncryptionStep, SummaryStep } from './eh-wizard/EHWizardSteps' +import type { EHMetadata } from './eh-wizard/EHWizardSteps' interface EHUploadWizardProps { onClose: () => void onComplete?: (ehId: string) => void - onSuccess?: (ehId: string) => void // Legacy alias for onComplete + onSuccess?: (ehId: string) => void klausurSubject?: string klausurYear?: number - defaultSubject?: string // Alias for klausurSubject - defaultYear?: number // Alias for klausurYear - klausurId?: string // If provided, automatically link EH to this Klausur + defaultSubject?: string + defaultYear?: number + klausurId?: string } type WizardStep = 'file' | 'metadata' | 'rights' | 'encryption' | 'summary' - const WIZARD_STEPS: WizardStep[] = ['file', 'metadata', 'rights', 'encryption', 'summary'] +const STEP_LABELS: Record = { file: 'Datei', metadata: 'Metadaten', rights: 'Rechte', encryption: 'Verschluesselung', summary: 'Zusammenfassung' } -const STEP_LABELS: Record = { - file: 'Datei', - metadata: 'Metadaten', - rights: 'Rechte', - encryption: 'Verschluesselung', - summary: 'Zusammenfassung' -} - -const SUBJECTS = [ - 'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie', - 'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport', - 'informatik', 'latein', 'franzoesisch', 'spanisch' -] - -const RIGHTS_TEXT = `Ich bestaetige hiermit, dass: - -1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem - Erwartungshorizont besitze. - -2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet, - sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege - in meinem persoenlichen Arbeitsbereich. - -3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter - keinen Zugriff auf den Klartext haben. - -4. Ich diesen Erwartungshorizont jederzeit loeschen kann.` - -function EHUploadWizard({ - onClose, - onComplete, - onSuccess, - klausurSubject, - klausurYear, - defaultSubject, - defaultYear, - klausurId -}: EHUploadWizardProps) { - // Resolve aliases +function EHUploadWizard({ onClose, onComplete, onSuccess, klausurSubject, klausurYear, defaultSubject, defaultYear, klausurId }: EHUploadWizardProps) { const effectiveSubject = klausurSubject || defaultSubject || 'deutsch' const effectiveYear = klausurYear || defaultYear || new Date().getFullYear() const handleComplete = onComplete || onSuccess || (() => {}) - // Step state const [currentStep, setCurrentStep] = useState('file') - - // File step const [selectedFile, setSelectedFile] = useState(null) const [fileError, setFileError] = useState(null) - - // Metadata step - const [metadata, setMetadata] = useState({ - title: '', - subject: effectiveSubject, - niveau: 'eA', - year: effectiveYear, - aufgaben_nummer: '' - }) - - // Rights step + const [metadata, setMetadata] = useState({ title: '', subject: effectiveSubject, niveau: 'eA', year: effectiveYear, aufgaben_nummer: '' }) const [rightsConfirmed, setRightsConfirmed] = useState(false) - - // Encryption step const [passphrase, setPassphrase] = useState('') const [passphraseConfirm, setPassphraseConfirm] = useState('') const [showPassphrase, setShowPassphrase] = useState(false) const [passphraseStrength, setPassphraseStrength] = useState<'weak' | 'medium' | 'strong'>('weak') - - // Upload state const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) const [uploadError, setUploadError] = useState(null) - - // Check encryption support const encryptionSupported = isEncryptionSupported() - // Calculate passphrase strength useEffect(() => { - if (passphrase.length < 8) { - setPassphraseStrength('weak') - } else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) { - setPassphraseStrength('medium') - } else { - setPassphraseStrength('strong') - } + if (passphrase.length < 8) setPassphraseStrength('weak') + else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) setPassphraseStrength('medium') + else setPassphraseStrength('strong') }, [passphrase]) - // Step navigation const currentStepIndex = WIZARD_STEPS.indexOf(currentStep) const isFirstStep = currentStepIndex === 0 const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1 - const goNext = useCallback(() => { - if (!isLastStep) { - setCurrentStep(WIZARD_STEPS[currentStepIndex + 1]) - } - }, [currentStepIndex, isLastStep]) + const goNext = useCallback(() => { if (!isLastStep) setCurrentStep(WIZARD_STEPS[currentStepIndex + 1]) }, [currentStepIndex, isLastStep]) + const goBack = useCallback(() => { if (!isFirstStep) setCurrentStep(WIZARD_STEPS[currentStepIndex - 1]) }, [currentStepIndex, isFirstStep]) - const goBack = useCallback(() => { - if (!isFirstStep) { - setCurrentStep(WIZARD_STEPS[currentStepIndex - 1]) - } - }, [currentStepIndex, isFirstStep]) - - // Validation const isStepValid = useCallback((step: WizardStep): boolean => { switch (step) { - case 'file': - return selectedFile !== null && fileError === null - case 'metadata': - return metadata.title.trim().length > 0 && metadata.subject.length > 0 - case 'rights': - return rightsConfirmed - case 'encryption': - return passphrase.length >= 8 && passphrase === passphraseConfirm - case 'summary': - return true - default: - return false + case 'file': return selectedFile !== null && fileError === null + case 'metadata': return metadata.title.trim().length > 0 && metadata.subject.length > 0 + case 'rights': return rightsConfirmed + case 'encryption': return passphrase.length >= 8 && passphrase === passphraseConfirm + case 'summary': return true + default: return false } }, [selectedFile, fileError, metadata, rightsConfirmed, passphrase, passphraseConfirm]) - // File handling const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { - if (file.type !== 'application/pdf') { - setFileError('Nur PDF-Dateien sind erlaubt') - setSelectedFile(null) - return - } - if (file.size > 50 * 1024 * 1024) { // 50MB limit - setFileError('Datei ist zu gross (max. 50MB)') - setSelectedFile(null) - return - } - setFileError(null) - setSelectedFile(file) - // Auto-fill title from filename - if (!metadata.title) { - const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' ') - setMetadata(prev => ({ ...prev, title: name })) - } + if (file.type !== 'application/pdf') { setFileError('Nur PDF-Dateien sind erlaubt'); setSelectedFile(null); return } + if (file.size > 50 * 1024 * 1024) { setFileError('Datei ist zu gross (max. 50MB)'); setSelectedFile(null); return } + setFileError(null); setSelectedFile(file) + if (!metadata.title) { const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' '); setMetadata(prev => ({ ...prev, title: name })) } } } - // Upload handler const handleUpload = async () => { if (!selectedFile || !encryptionSupported) return - - setUploading(true) - setUploadProgress(10) - setUploadError(null) - + setUploading(true); setUploadProgress(10); setUploadError(null) try { - // Step 1: Generate salt (used via encrypted.salt below) - generateSalt() - setUploadProgress(20) - - // Step 2: Encrypt file client-side - const encrypted = await encryptFile(selectedFile, passphrase) - setUploadProgress(50) - - // Step 3: Create form data + generateSalt(); setUploadProgress(20) + const encrypted = await encryptFile(selectedFile, passphrase); setUploadProgress(50) const formData = new FormData() - const encryptedBlob = new Blob([encrypted.encryptedData], { type: 'application/octet-stream' }) - formData.append('file', encryptedBlob, 'encrypted.bin') - - const metadataJson = JSON.stringify({ - metadata: { - title: metadata.title, - subject: metadata.subject, - niveau: metadata.niveau, - year: metadata.year, - aufgaben_nummer: metadata.aufgaben_nummer || null - }, - encryption_key_hash: encrypted.keyHash, - salt: encrypted.salt, - rights_confirmed: true, - original_filename: selectedFile.name - }) - formData.append('metadata_json', metadataJson) - + formData.append('file', new Blob([encrypted.encryptedData], { type: 'application/octet-stream' }), 'encrypted.bin') + formData.append('metadata_json', JSON.stringify({ + metadata: { title: metadata.title, subject: metadata.subject, niveau: metadata.niveau, year: metadata.year, aufgaben_nummer: metadata.aufgaben_nummer || null }, + encryption_key_hash: encrypted.keyHash, salt: encrypted.salt, rights_confirmed: true, original_filename: selectedFile.name + })) setUploadProgress(70) - - // Step 4: Upload to server - const response = await ehApi.uploadEH(formData) - setUploadProgress(90) - - // Step 5: Link to Klausur if klausurId provided - if (klausurId && response.id) { - try { - await ehApi.linkToKlausur(response.id, klausurId) - } catch (linkError) { - console.warn('Failed to auto-link EH to Klausur:', linkError) - // Don't fail the whole upload if linking fails - } - } - - setUploadProgress(100) - - // Success! - handleComplete(response.id) - } catch (error) { - console.error('Upload failed:', error) - setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen') - } finally { - setUploading(false) - } + const response = await ehApi.uploadEH(formData); setUploadProgress(90) + if (klausurId && response.id) { try { await ehApi.linkToKlausur(response.id, klausurId) } catch (linkError) { console.warn('Failed to auto-link EH:', linkError) } } + setUploadProgress(100); handleComplete(response.id) + } catch (error) { console.error('Upload failed:', error); setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen') } + finally { setUploading(false) } } - // Render step content const renderStepContent = () => { switch (currentStep) { - case 'file': - return ( -
-

Erwartungshorizont hochladen

-

- Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. - Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden. -

- -
- - -
- - {fileError &&

{fileError}

} - - {!encryptionSupported && ( -

- Ihr Browser unterstuetzt keine Verschluesselung. - Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge). -

- )} -
- ) - - case 'metadata': - return ( -
-

Metadaten

-

- Geben Sie Informationen zum Erwartungshorizont ein. -

- -
- - setMetadata(prev => ({ ...prev, title: e.target.value }))} - placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" - /> -
- -
-
- - -
- -
- - -
-
- -
-
- - setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))} - /> -
- -
- - setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))} - placeholder="z.B. 1a, 2.1" - /> -
-
-
- ) - - case 'rights': - return ( -
-

Rechte-Bestaetigung

-

- Bitte lesen und bestaetigen Sie die folgenden Bedingungen. -

- -
-
{RIGHTS_TEXT}
-
- -
- setRightsConfirmed(e.target.checked)} - /> - -
- -
- Wichtig: Ihr Erwartungshorizont wird niemals fuer - KI-Training verwendet. Er dient ausschliesslich als Referenz fuer - Ihre persoenlichen Korrekturvorschlaege. -
-
- ) - - case 'encryption': - return ( -
-

Verschluesselung

-

- Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. - Dieses Passwort wird niemals an den Server gesendet. -

- -
- -
- setPassphrase(e.target.value)} - placeholder="Mindestens 8 Zeichen" - /> - -
-
- Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'} -
-
- -
- - setPassphraseConfirm(e.target.value)} - placeholder="Passwort wiederholen" - /> - {passphraseConfirm && passphrase !== passphraseConfirm && ( -

Passwoerter stimmen nicht ueberein

- )} -
- -
- Achtung: Merken Sie sich dieses Passwort gut! - Ohne das Passwort kann der Erwartungshorizont nicht fuer - Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr - Passwort nicht wiederherstellen. -
-
- ) - - case 'summary': - return ( -
-

Zusammenfassung

-

- Pruefen Sie Ihre Eingaben und starten Sie den Upload. -

- -
-
- Datei: - {selectedFile?.name} -
-
- Titel: - {metadata.title} -
-
- Fach: - - {metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)} - -
-
- Niveau: - {metadata.niveau} -
-
- Jahr: - {metadata.year} -
-
- Verschluesselung: - AES-256-GCM -
-
- Rechte bestaetigt: - Ja -
-
- - {uploading && ( -
-
- {uploadProgress}% -
- )} - - {uploadError && ( -

{uploadError}

- )} -
- ) - - default: - return null + case 'file': return + case 'metadata': return + case 'rights': return + case 'encryption': return setShowPassphrase(!showPassphrase)} /> + case 'summary': return + default: return null } } return (
- {/* Header */}

Erwartungshorizont hochladen

- - {/* Progress */}
{WIZARD_STEPS.map((step, index) => ( -
-
- {index < currentStepIndex ? '\u2713' : index + 1} -
+
+
{index < currentStepIndex ? '\u2713' : index + 1}
{STEP_LABELS[step]}
))}
- - {/* Content */} -
- {renderStepContent()} -
- - {/* Footer */} +
{renderStepContent()}
- - + {isLastStep ? ( - + ) : ( - + )}
diff --git a/klausur-service/frontend/src/components/eh-wizard/EHWizardSteps.tsx b/klausur-service/frontend/src/components/eh-wizard/EHWizardSteps.tsx new file mode 100644 index 0000000..730f27b --- /dev/null +++ b/klausur-service/frontend/src/components/eh-wizard/EHWizardSteps.tsx @@ -0,0 +1,168 @@ +/** + * EH Upload Wizard Steps - Individual step content renderers + */ + +import { useState } from 'react' + +interface EHMetadata { + title: string + subject: string + niveau: 'eA' | 'gA' + year: number + aufgaben_nummer?: string +} + +const SUBJECTS = [ + 'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie', + 'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport', + 'informatik', 'latein', 'franzoesisch', 'spanisch' +] + +const RIGHTS_TEXT = `Ich bestaetige hiermit, dass: + +1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem + Erwartungshorizont besitze. + +2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet, + sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege + in meinem persoenlichen Arbeitsbereich. + +3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter + keinen Zugriff auf den Klartext haben. + +4. Ich diesen Erwartungshorizont jederzeit loeschen kann.` + +// Step 1: File Selection +export function FileStep({ selectedFile, fileError, encryptionSupported, onFileSelect }: { + selectedFile: File | null; fileError: string | null; encryptionSupported: boolean; + onFileSelect: (e: React.ChangeEvent) => void +}) { + return ( +
+

Erwartungshorizont hochladen

+

Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.

+
+ + +
+ {fileError &&

{fileError}

} + {!encryptionSupported &&

Ihr Browser unterstuetzt keine Verschluesselung. Bitte verwenden Sie einen modernen Browser.

} +
+ ) +} + +// Step 2: Metadata +export function MetadataStep({ metadata, onMetadataChange }: { + metadata: EHMetadata; onMetadataChange: (metadata: EHMetadata) => void +}) { + return ( +
+

Metadaten

+

Geben Sie Informationen zum Erwartungshorizont ein.

+
+ + onMetadataChange({ ...metadata, title: e.target.value })} placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" /> +
+
+
+ + +
+
+ + +
+
+
+
+ + onMetadataChange({ ...metadata, year: parseInt(e.target.value) })} /> +
+
+ + onMetadataChange({ ...metadata, aufgaben_nummer: e.target.value })} placeholder="z.B. 1a, 2.1" /> +
+
+
+ ) +} + +// Step 3: Rights Confirmation +export function RightsStep({ rightsConfirmed, onRightsConfirmedChange }: { + rightsConfirmed: boolean; onRightsConfirmedChange: (confirmed: boolean) => void +}) { + return ( +
+

Rechte-Bestaetigung

+

Bitte lesen und bestaetigen Sie die folgenden Bedingungen.

+
{RIGHTS_TEXT}
+
+ onRightsConfirmedChange(e.target.checked)} /> + +
+
Wichtig: Ihr Erwartungshorizont wird niemals fuer KI-Training verwendet. Er dient ausschliesslich als Referenz fuer Ihre persoenlichen Korrekturvorschlaege.
+
+ ) +} + +// Step 4: Encryption +export function EncryptionStep({ passphrase, passphraseConfirm, showPassphrase, passphraseStrength, onPassphraseChange, onPassphraseConfirmChange, onToggleShow }: { + passphrase: string; passphraseConfirm: string; showPassphrase: boolean; + passphraseStrength: 'weak' | 'medium' | 'strong'; + onPassphraseChange: (v: string) => void; onPassphraseConfirmChange: (v: string) => void; onToggleShow: () => void +}) { + return ( +
+

Verschluesselung

+

Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. Dieses Passwort wird niemals an den Server gesendet.

+
+ +
+ onPassphraseChange(e.target.value)} placeholder="Mindestens 8 Zeichen" /> + +
+
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
+
+
+ + onPassphraseConfirmChange(e.target.value)} placeholder="Passwort wiederholen" /> + {passphraseConfirm && passphrase !== passphraseConfirm &&

Passwoerter stimmen nicht ueberein

} +
+
Achtung: Merken Sie sich dieses Passwort gut! Ohne das Passwort kann der Erwartungshorizont nicht fuer Korrekturvorschlaege verwendet werden.
+
+ ) +} + +// Step 5: Summary +export function SummaryStep({ selectedFile, metadata, uploading, uploadProgress, uploadError }: { + selectedFile: File | null; metadata: EHMetadata; uploading: boolean; + uploadProgress: number; uploadError: string | null +}) { + return ( +
+

Zusammenfassung

+

Pruefen Sie Ihre Eingaben und starten Sie den Upload.

+
+
Datei:{selectedFile?.name}
+
Titel:{metadata.title}
+
Fach:{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
+
Niveau:{metadata.niveau}
+
Jahr:{metadata.year}
+
Verschluesselung:AES-256-GCM
+
Rechte bestaetigt:Ja
+
+ {uploading && (
{uploadProgress}%
)} + {uploadError &&

{uploadError}

} +
+ ) +} + +export { SUBJECTS, RIGHTS_TEXT } +export type { EHMetadata } diff --git a/klausur-service/frontend/src/services/api-eh-types.ts b/klausur-service/frontend/src/services/api-eh-types.ts new file mode 100644 index 0000000..e68b769 --- /dev/null +++ b/klausur-service/frontend/src/services/api-eh-types.ts @@ -0,0 +1,92 @@ +/** + * BYOEH (Erwartungshorizont) Types + * + * Split from api.ts for file size compliance. + */ + +export interface Erwartungshorizont { + id: string + tenant_id: string + teacher_id: string + title: string + subject: string + niveau: 'eA' | 'gA' + year: number + aufgaben_nummer: string | null + status: 'pending_rights' | 'processing' | 'indexed' | 'error' + chunk_count: number + rights_confirmed: boolean + rights_confirmed_at: string | null + indexed_at: string | null + file_size_bytes: number + original_filename: string + training_allowed: boolean + created_at: string + deleted_at: string | null +} + +export interface EHRAGResult { + context: string + sources: Array<{ + text: string; eh_id: string; eh_title: string; + chunk_index: number; score: number; reranked?: boolean + }> + query: string + search_info?: { + retrieval_time_ms?: number; rerank_time_ms?: number; total_time_ms?: number; + reranked?: boolean; rerank_applied?: boolean; hybrid_search_applied?: boolean; + embedding_model?: string; total_candidates?: number; original_count?: number + } +} + +export interface EHAuditEntry { + id: string; eh_id: string | null; tenant_id: string; user_id: string; + action: string; details: Record | null; created_at: string +} + +export interface EHKeyShare { + id: string; eh_id: string; user_id: string; passphrase_hint: string; + granted_by: string; granted_at: string; + role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'; + klausur_id: string | null; active: boolean +} + +export interface EHKlausurLink { + id: string; eh_id: string; klausur_id: string; + linked_by: string; linked_at: string +} + +export interface SharedEHInfo { eh: Erwartungshorizont; share: EHKeyShare } + +export interface LinkedEHInfo { + eh: Erwartungshorizont; link: EHKlausurLink; + is_owner: boolean; share: EHKeyShare | null +} + +export interface EHShareInvitation { + id: string; eh_id: string; inviter_id: string; invitee_id: string; + invitee_email: string; + role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'; + klausur_id: string | null; message: string | null; + status: 'pending' | 'accepted' | 'declined' | 'expired' | 'revoked'; + expires_at: string; created_at: string; + accepted_at: string | null; declined_at: string | null +} + +export interface PendingInvitationInfo { + invitation: EHShareInvitation + eh: { id: string; title: string; subject: string; niveau: string; year: number } | null +} + +export interface SentInvitationInfo { + invitation: EHShareInvitation + eh: { id: string; title: string; subject: string } | null +} + +export interface EHAccessChain { + eh_id: string; eh_title: string; + owner: { user_id: string; role: string }; + active_shares: EHKeyShare[]; + pending_invitations: EHShareInvitation[]; + revoked_shares: EHKeyShare[] +} diff --git a/klausur-service/frontend/src/services/api-eh.ts b/klausur-service/frontend/src/services/api-eh.ts new file mode 100644 index 0000000..141d735 --- /dev/null +++ b/klausur-service/frontend/src/services/api-eh.ts @@ -0,0 +1,98 @@ +/** + * BYOEH (Erwartungshorizont) API + * + * Split from api.ts for file size compliance. + */ + +import { apiCall, getAuthToken } from './api' +import type { + Erwartungshorizont, EHRAGResult, EHAuditEntry, EHKeyShare, + SharedEHInfo, LinkedEHInfo, PendingInvitationInfo, SentInvitationInfo, + EHAccessChain, +} from './api-eh-types' + +export const ehApi = { + listEH: (params?: { subject?: string; year?: number }): Promise => { + const query = new URLSearchParams() + if (params?.subject) query.append('subject', params.subject) + if (params?.year) query.append('year', params.year.toString()) + const queryStr = query.toString() + return apiCall(`/eh${queryStr ? `?${queryStr}` : ''}`) + }, + + getEH: (id: string): Promise => apiCall(`/eh/${id}`), + + uploadEH: async (formData: FormData): Promise => { + const token = getAuthToken() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + const response = await fetch('/api/v1/eh/upload', { method: 'POST', headers, body: formData }) + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Upload failed' })) + throw new Error(error.detail || `HTTP ${response.status}`) + } + return response.json() + }, + + deleteEH: (id: string): Promise<{ status: string; id: string }> => + apiCall(`/eh/${id}`, { method: 'DELETE' }), + + indexEH: (id: string, passphrase: string): Promise<{ status: string; id: string; chunk_count: number }> => + apiCall(`/eh/${id}/index`, { method: 'POST', body: JSON.stringify({ passphrase }) }), + + ragQuery: (params: { query_text: string; passphrase: string; subject?: string; limit?: number; rerank?: boolean }): Promise => + apiCall('/eh/rag-query', { method: 'POST', body: JSON.stringify({ query_text: params.query_text, passphrase: params.passphrase, subject: params.subject, limit: params.limit ?? 5, rerank: params.rerank ?? false }) }), + + getAuditLog: (ehId?: string, limit?: number): Promise => { + const query = new URLSearchParams() + if (ehId) query.append('eh_id', ehId) + if (limit) query.append('limit', limit.toString()) + const queryStr = query.toString() + return apiCall(`/eh/audit-log${queryStr ? `?${queryStr}` : ''}`) + }, + + getRightsText: (): Promise<{ text: string; version: string }> => apiCall('/eh/rights-text'), + + getQdrantStatus: (): Promise<{ name: string; vectors_count: number; points_count: number; status: string }> => + apiCall('/eh/qdrant-status'), + + // Key Sharing + shareEH: (ehId: string, params: { user_id: string; role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'; encrypted_passphrase: string; passphrase_hint?: string; klausur_id?: string }): Promise<{ status: string; share_id: string; eh_id: string; shared_with: string; role: string }> => + apiCall(`/eh/${ehId}/share`, { method: 'POST', body: JSON.stringify(params) }), + + listShares: (ehId: string): Promise => apiCall(`/eh/${ehId}/shares`), + + revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> => + apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }), + + getSharedWithMe: (): Promise => apiCall('/eh/shared-with-me'), + + linkToKlausur: (ehId: string, klausurId: string): Promise<{ status: string; link_id: string; eh_id: string; klausur_id: string }> => + apiCall(`/eh/${ehId}/link-klausur`, { method: 'POST', body: JSON.stringify({ klausur_id: klausurId }) }), + + unlinkFromKlausur: (ehId: string, klausurId: string): Promise<{ status: string; eh_id: string; klausur_id: string }> => + apiCall(`/eh/${ehId}/link-klausur/${klausurId}`, { method: 'DELETE' }), + + // Invitation Flow + inviteToEH: (ehId: string, params: { invitee_email: string; invitee_id?: string; role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'; klausur_id?: string; message?: string; expires_in_days?: number }): Promise<{ status: string; invitation_id: string; eh_id: string; invitee_email: string; role: string; expires_at: string; eh_title: string }> => + apiCall(`/eh/${ehId}/invite`, { method: 'POST', body: JSON.stringify(params) }), + + getPendingInvitations: (): Promise => apiCall('/eh/invitations/pending'), + + getSentInvitations: (): Promise => apiCall('/eh/invitations/sent'), + + acceptInvitation: (invitationId: string, encryptedPassphrase: string): Promise<{ status: string; share_id: string; eh_id: string; role: string; klausur_id: string | null }> => + apiCall(`/eh/invitations/${invitationId}/accept`, { method: 'POST', body: JSON.stringify({ encrypted_passphrase: encryptedPassphrase }) }), + + declineInvitation: (invitationId: string): Promise<{ status: string; invitation_id: string; eh_id: string }> => + apiCall(`/eh/invitations/${invitationId}/decline`, { method: 'POST' }), + + revokeInvitation: (invitationId: string): Promise<{ status: string; invitation_id: string; eh_id: string }> => + apiCall(`/eh/invitations/${invitationId}`, { method: 'DELETE' }), + + getAccessChain: (ehId: string): Promise => apiCall(`/eh/${ehId}/access-chain`), +} + +export const klausurEHApi = { + getLinkedEH: (klausurId: string): Promise => apiCall(`/klausuren/${klausurId}/linked-eh`), +} diff --git a/klausur-service/frontend/src/services/api.ts b/klausur-service/frontend/src/services/api.ts index 322f3d1..8c98855 100644 --- a/klausur-service/frontend/src/services/api.ts +++ b/klausur-service/frontend/src/services/api.ts @@ -1,620 +1,123 @@ -// API Types +/** + * Klausur Service API - Core types and Klausur/Student API + * + * Split into: + * - api.ts (this file): Core types, auth, base API, klausurApi, uploadStudentWork + * - api-eh-types.ts: BYOEH type definitions + * - api-eh.ts: ehApi and klausurEHApi + */ + +// Re-export EH types and API for backward compatibility +export type { + Erwartungshorizont, EHRAGResult, EHAuditEntry, EHKeyShare, EHKlausurLink, + SharedEHInfo, LinkedEHInfo, EHShareInvitation, PendingInvitationInfo, + SentInvitationInfo, EHAccessChain, +} from './api-eh-types' + +export { ehApi, klausurEHApi } from './api-eh' + +// ============================================================================ +// Core Types +// ============================================================================ + export interface StudentKlausur { - id: string - klausur_id: string - student_name: string - student_id: string | null - file_path: string | null - ocr_text: string | null - status: string - criteria_scores: Record - gutachten: { - einleitung: string - hauptteil: string - fazit: string - staerken: string[] - schwaechen: string[] - } | null - raw_points: number - grade_points: number - created_at: string + id: string; klausur_id: string; student_name: string; student_id: string | null; + file_path: string | null; ocr_text: string | null; status: string; + criteria_scores: Record; + gutachten: { einleitung: string; hauptteil: string; fazit: string; staerken: string[]; schwaechen: string[] } | null; + raw_points: number; grade_points: number; created_at: string } export interface Klausur { - id: string - title: string - subject: string - modus: 'landes_abitur' | 'vorabitur' - class_id: string | null - year: number - semester: string - erwartungshorizont: Record | null - student_count: number - students: StudentKlausur[] - created_at: string - teacher_id: string + id: string; title: string; subject: string; modus: 'landes_abitur' | 'vorabitur'; + class_id: string | null; year: number; semester: string; + erwartungshorizont: Record | null; student_count: number; + students: StudentKlausur[]; created_at: string; teacher_id: string } export interface GradeInfo { - thresholds: Record - labels: Record + thresholds: Record; labels: Record; criteria: Record } -// Get auth token from parent window or localStorage -function getAuthToken(): string | null { - // Try to get from parent window (iframe scenario) +// ============================================================================ +// Auth & Base API +// ============================================================================ + +export function getAuthToken(): string | null { try { if (window.parent !== window) { const parentToken = (window.parent as unknown as { authToken?: string }).authToken if (parentToken) return parentToken } - } catch { - // Cross-origin access denied - } - - // Try localStorage + } catch { /* Cross-origin */ } return localStorage.getItem('auth_token') } -// Base API call -async function apiCall( - endpoint: string, - options: RequestInit = {} -): Promise { +export async function apiCall(endpoint: string, options: RequestInit = {}): Promise { const token = getAuthToken() - const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record || {}) } - - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - - const response = await fetch(`/api/v1${endpoint}`, { - ...options, - headers - }) - + if (token) headers['Authorization'] = `Bearer ${token}` + const response = await fetch(`/api/v1${endpoint}`, { ...options, headers }) if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Request failed' })) throw new Error(error.detail || `HTTP ${response.status}`) } - return response.json() } +// ============================================================================ // Klausuren API +// ============================================================================ + export const klausurApi = { - listKlausuren: (): Promise => - apiCall('/klausuren'), + listKlausuren: (): Promise => apiCall('/klausuren'), + getKlausur: (id: string): Promise => apiCall(`/klausuren/${id}`), + createKlausur: (data: Partial): Promise => apiCall('/klausuren', { method: 'POST', body: JSON.stringify(data) }), + updateKlausur: (id: string, data: Partial): Promise => apiCall(`/klausuren/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteKlausur: (id: string): Promise<{ success: boolean }> => apiCall(`/klausuren/${id}`, { method: 'DELETE' }), - getKlausur: (id: string): Promise => - apiCall(`/klausuren/${id}`), + listStudents: (klausurId: string): Promise => apiCall(`/klausuren/${klausurId}/students`), + deleteStudent: (studentId: string): Promise<{ success: boolean }> => apiCall(`/students/${studentId}`, { method: 'DELETE' }), - createKlausur: (data: Partial): Promise => - apiCall('/klausuren', { - method: 'POST', - body: JSON.stringify(data) - }), + updateCriteria: (studentId: string, criterion: string, score: number, annotations?: string[]): Promise => + apiCall(`/students/${studentId}/criteria`, { method: 'PUT', body: JSON.stringify({ criterion, score, annotations }) }), - updateKlausur: (id: string, data: Partial): Promise => - apiCall(`/klausuren/${id}`, { - method: 'PUT', - body: JSON.stringify(data) - }), + updateGutachten: (studentId: string, gutachten: { einleitung: string; hauptteil: string; fazit: string; staerken?: string[]; schwaechen?: string[] }): Promise => + apiCall(`/students/${studentId}/gutachten`, { method: 'PUT', body: JSON.stringify(gutachten) }), - deleteKlausur: (id: string): Promise<{ success: boolean }> => - apiCall(`/klausuren/${id}`, { method: 'DELETE' }), + finalizeStudent: (studentId: string): Promise => apiCall(`/students/${studentId}/finalize`, { method: 'POST' }), - // Students - listStudents: (klausurId: string): Promise => - apiCall(`/klausuren/${klausurId}/students`), + generateGutachten: (studentId: string, options: { include_strengths?: boolean; include_weaknesses?: boolean; tone?: 'formal' | 'friendly' | 'constructive' } = {}): Promise<{ einleitung: string; hauptteil: string; fazit: string; staerken: string[]; schwaechen: string[]; generated_at: string; is_ki_generated: boolean; tone: string }> => + apiCall(`/students/${studentId}/gutachten/generate`, { method: 'POST', body: JSON.stringify({ include_strengths: options.include_strengths ?? true, include_weaknesses: options.include_weaknesses ?? true, tone: options.tone ?? 'formal' }) }), - deleteStudent: (studentId: string): Promise<{ success: boolean }> => - apiCall(`/students/${studentId}`, { method: 'DELETE' }), - - // Grading - updateCriteria: ( - studentId: string, - criterion: string, - score: number, - annotations?: string[] - ): Promise => - apiCall(`/students/${studentId}/criteria`, { - method: 'PUT', - body: JSON.stringify({ criterion, score, annotations }) - }), - - updateGutachten: ( - studentId: string, - gutachten: { - einleitung: string - hauptteil: string - fazit: string - staerken?: string[] - schwaechen?: string[] - } - ): Promise => - apiCall(`/students/${studentId}/gutachten`, { - method: 'PUT', - body: JSON.stringify(gutachten) - }), - - finalizeStudent: (studentId: string): Promise => - apiCall(`/students/${studentId}/finalize`, { method: 'POST' }), - - // KI-Gutachten Generation - generateGutachten: ( - studentId: string, - options: { - include_strengths?: boolean - include_weaknesses?: boolean - tone?: 'formal' | 'friendly' | 'constructive' - } = {} - ): Promise<{ - einleitung: string - hauptteil: string - fazit: string - staerken: string[] - schwaechen: string[] - generated_at: string - is_ki_generated: boolean - tone: string - }> => - apiCall(`/students/${studentId}/gutachten/generate`, { - method: 'POST', - body: JSON.stringify({ - include_strengths: options.include_strengths ?? true, - include_weaknesses: options.include_weaknesses ?? true, - tone: options.tone ?? 'formal' - }) - }), - - // Fairness Analysis - getFairnessAnalysis: (klausurId: string): Promise<{ - klausur_id: string - students_count: number - graded_count: number - statistics: { - average_grade: number - average_raw_points: number - min_grade: number - max_grade: number - spread: number - standard_deviation: number - } - criteria_breakdown: Record - outliers: Array<{ - student_id: string - student_name: string - grade_points: number - deviation: number - direction: 'above' | 'below' - }> - fairness_score: number - warnings: string[] - recommendation: string - }> => + getFairnessAnalysis: (klausurId: string): Promise<{ klausur_id: string; students_count: number; graded_count: number; statistics: { average_grade: number; average_raw_points: number; min_grade: number; max_grade: number; spread: number; standard_deviation: number }; criteria_breakdown: Record; outliers: Array<{ student_id: string; student_name: string; grade_points: number; deviation: number; direction: 'above' | 'below' }>; fairness_score: number; warnings: string[]; recommendation: string }> => apiCall(`/klausuren/${klausurId}/fairness`), - // Audit Log - getStudentAuditLog: (studentId: string): Promise | null - }>> => + getStudentAuditLog: (studentId: string): Promise | null }>> => apiCall(`/students/${studentId}/audit-log`), - // Utilities - getGradeInfo: (): Promise => - apiCall('/grade-info') + getGradeInfo: (): Promise => apiCall('/grade-info'), } -// File upload (special handling for multipart) -export async function uploadStudentWork( - klausurId: string, - studentName: string, - file: File -): Promise { - const token = getAuthToken() +// ============================================================================ +// File Upload +// ============================================================================ +export async function uploadStudentWork(klausurId: string, studentName: string, file: File): Promise { + const token = getAuthToken() const formData = new FormData() formData.append('file', file) formData.append('student_name', studentName) - const headers: Record = {} - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - - const response = await fetch(`/api/v1/klausuren/${klausurId}/students`, { - method: 'POST', - headers, - body: formData - }) - + if (token) headers['Authorization'] = `Bearer ${token}` + const response = await fetch(`/api/v1/klausuren/${klausurId}/students`, { method: 'POST', headers, body: formData }) if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Upload failed' })) throw new Error(error.detail || `HTTP ${response.status}`) } - return response.json() } - -// ============================================= -// BYOEH (Erwartungshorizont) Types & API -// ============================================= - -export interface Erwartungshorizont { - id: string - tenant_id: string - teacher_id: string - title: string - subject: string - niveau: 'eA' | 'gA' - year: number - aufgaben_nummer: string | null - status: 'pending_rights' | 'processing' | 'indexed' | 'error' - chunk_count: number - rights_confirmed: boolean - rights_confirmed_at: string | null - indexed_at: string | null - file_size_bytes: number - original_filename: string - training_allowed: boolean - created_at: string - deleted_at: string | null -} - -export interface EHRAGResult { - context: string - sources: Array<{ - text: string - eh_id: string - eh_title: string - chunk_index: number - score: number - reranked?: boolean - }> - query: string - search_info?: { - retrieval_time_ms?: number - rerank_time_ms?: number - total_time_ms?: number - reranked?: boolean - rerank_applied?: boolean - hybrid_search_applied?: boolean - embedding_model?: string - total_candidates?: number - original_count?: number - } -} - -export interface EHAuditEntry { - id: string - eh_id: string | null - tenant_id: string - user_id: string - action: string - details: Record | null - created_at: string -} - -export interface EHKeyShare { - id: string - eh_id: string - user_id: string - passphrase_hint: string - granted_by: string - granted_at: string - role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' - klausur_id: string | null - active: boolean -} - -export interface EHKlausurLink { - id: string - eh_id: string - klausur_id: string - linked_by: string - linked_at: string -} - -export interface SharedEHInfo { - eh: Erwartungshorizont - share: EHKeyShare -} - -export interface LinkedEHInfo { - eh: Erwartungshorizont - link: EHKlausurLink - is_owner: boolean - share: EHKeyShare | null -} - -// Invitation types for Invite/Accept/Revoke flow -export interface EHShareInvitation { - id: string - eh_id: string - inviter_id: string - invitee_id: string - invitee_email: string - role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz' - klausur_id: string | null - message: string | null - status: 'pending' | 'accepted' | 'declined' | 'expired' | 'revoked' - expires_at: string - created_at: string - accepted_at: string | null - declined_at: string | null -} - -export interface PendingInvitationInfo { - invitation: EHShareInvitation - eh: { - id: string - title: string - subject: string - niveau: string - year: number - } | null -} - -export interface SentInvitationInfo { - invitation: EHShareInvitation - eh: { - id: string - title: string - subject: string - } | null -} - -export interface EHAccessChain { - eh_id: string - eh_title: string - owner: { - user_id: string - role: string - } - active_shares: EHKeyShare[] - pending_invitations: EHShareInvitation[] - revoked_shares: EHKeyShare[] -} - -// Erwartungshorizont API -export const ehApi = { - // List all EH for current teacher - listEH: (params?: { subject?: string; year?: number }): Promise => { - const query = new URLSearchParams() - if (params?.subject) query.append('subject', params.subject) - if (params?.year) query.append('year', params.year.toString()) - const queryStr = query.toString() - return apiCall(`/eh${queryStr ? `?${queryStr}` : ''}`) - }, - - // Get single EH by ID - getEH: (id: string): Promise => - apiCall(`/eh/${id}`), - - // Upload encrypted EH (special handling for FormData) - uploadEH: async (formData: FormData): Promise => { - const token = getAuthToken() - const headers: Record = {} - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - - const response = await fetch('/api/v1/eh/upload', { - method: 'POST', - headers, - body: formData - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Upload failed' })) - throw new Error(error.detail || `HTTP ${response.status}`) - } - - return response.json() - }, - - // Delete EH (soft delete) - deleteEH: (id: string): Promise<{ status: string; id: string }> => - apiCall(`/eh/${id}`, { method: 'DELETE' }), - - // Index EH for RAG (requires passphrase) - indexEH: (id: string, passphrase: string): Promise<{ status: string; id: string; chunk_count: number }> => - apiCall(`/eh/${id}/index`, { - method: 'POST', - body: JSON.stringify({ passphrase }) - }), - - // RAG query against EH - ragQuery: (params: { - query_text: string - passphrase: string - subject?: string - limit?: number - rerank?: boolean - }): Promise => - apiCall('/eh/rag-query', { - method: 'POST', - body: JSON.stringify({ - query_text: params.query_text, - passphrase: params.passphrase, - subject: params.subject, - limit: params.limit ?? 5, - rerank: params.rerank ?? false - }) - }), - - // Get audit log - getAuditLog: (ehId?: string, limit?: number): Promise => { - const query = new URLSearchParams() - if (ehId) query.append('eh_id', ehId) - if (limit) query.append('limit', limit.toString()) - const queryStr = query.toString() - return apiCall(`/eh/audit-log${queryStr ? `?${queryStr}` : ''}`) - }, - - // Get rights confirmation text - getRightsText: (): Promise<{ text: string; version: string }> => - apiCall('/eh/rights-text'), - - // Get Qdrant status (admin only) - getQdrantStatus: (): Promise<{ - name: string - vectors_count: number - points_count: number - status: string - }> => - apiCall('/eh/qdrant-status'), - - // ============================================= - // KEY SHARING - // ============================================= - - // Share EH with another examiner - shareEH: ( - ehId: string, - params: { - user_id: string - role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' - encrypted_passphrase: string - passphrase_hint?: string - klausur_id?: string - } - ): Promise<{ - status: string - share_id: string - eh_id: string - shared_with: string - role: string - }> => - apiCall(`/eh/${ehId}/share`, { - method: 'POST', - body: JSON.stringify(params) - }), - - // List shares for an EH (owner only) - listShares: (ehId: string): Promise => - apiCall(`/eh/${ehId}/shares`), - - // Revoke a share - revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> => - apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }), - - // Get EH shared with current user - getSharedWithMe: (): Promise => - apiCall('/eh/shared-with-me'), - - // Link EH to a Klausur - linkToKlausur: (ehId: string, klausurId: string): Promise<{ - status: string - link_id: string - eh_id: string - klausur_id: string - }> => - apiCall(`/eh/${ehId}/link-klausur`, { - method: 'POST', - body: JSON.stringify({ klausur_id: klausurId }) - }), - - // Unlink EH from a Klausur - unlinkFromKlausur: (ehId: string, klausurId: string): Promise<{ - status: string - eh_id: string - klausur_id: string - }> => - apiCall(`/eh/${ehId}/link-klausur/${klausurId}`, { method: 'DELETE' }), - - // ============================================= - // INVITATION FLOW (Invite / Accept / Revoke) - // ============================================= - - // Send invitation to share EH - inviteToEH: ( - ehId: string, - params: { - invitee_email: string - invitee_id?: string - role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz' - klausur_id?: string - message?: string - expires_in_days?: number - } - ): Promise<{ - status: string - invitation_id: string - eh_id: string - invitee_email: string - role: string - expires_at: string - eh_title: string - }> => - apiCall(`/eh/${ehId}/invite`, { - method: 'POST', - body: JSON.stringify(params) - }), - - // Get pending invitations for current user - getPendingInvitations: (): Promise => - apiCall('/eh/invitations/pending'), - - // Get sent invitations (as inviter) - getSentInvitations: (): Promise => - apiCall('/eh/invitations/sent'), - - // Accept an invitation - acceptInvitation: ( - invitationId: string, - encryptedPassphrase: string - ): Promise<{ - status: string - share_id: string - eh_id: string - role: string - klausur_id: string | null - }> => - apiCall(`/eh/invitations/${invitationId}/accept`, { - method: 'POST', - body: JSON.stringify({ encrypted_passphrase: encryptedPassphrase }) - }), - - // Decline an invitation - declineInvitation: (invitationId: string): Promise<{ - status: string - invitation_id: string - eh_id: string - }> => - apiCall(`/eh/invitations/${invitationId}/decline`, { method: 'POST' }), - - // Revoke an invitation (as inviter) - revokeInvitation: (invitationId: string): Promise<{ - status: string - invitation_id: string - eh_id: string - }> => - apiCall(`/eh/invitations/${invitationId}`, { method: 'DELETE' }), - - // Get the complete access chain for an EH - getAccessChain: (ehId: string): Promise => - apiCall(`/eh/${ehId}/access-chain`) -} - -// Get linked EH for a Klausur (separate from ehApi for clarity) -export const klausurEHApi = { - // Get all EH linked to a Klausur that the user has access to - getLinkedEH: (klausurId: string): Promise => - apiCall(`/klausuren/${klausurId}/linked-eh`) -} diff --git a/studio-v2/app/geo-lernwelt/GeoSettings.tsx b/studio-v2/app/geo-lernwelt/GeoSettings.tsx new file mode 100644 index 0000000..f9abc24 --- /dev/null +++ b/studio-v2/app/geo-lernwelt/GeoSettings.tsx @@ -0,0 +1,113 @@ +'use client' + +import { AOITheme, AOIQuality, Difficulty, GeoJSONPolygon, AOIResponse } from './types' + +const THEME_CONFIG: Record = { + topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' }, + landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' }, + orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' }, + geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' }, + hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' }, + vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' }, +} + +interface GeoSettingsProps { + selectedTheme: AOITheme + onThemeChange: (theme: AOITheme) => void + quality: AOIQuality + onQualityChange: (quality: AOIQuality) => void + difficulty: Difficulty + onDifficultyChange: (difficulty: Difficulty) => void + drawnPolygon: GeoJSONPolygon | null + currentAOI: AOIResponse | null + isLoading: boolean + onCreateAOI: () => void +} + +export function GeoSettings({ + selectedTheme, onThemeChange, quality, onQualityChange, + difficulty, onDifficultyChange, drawnPolygon, currentAOI, + isLoading, onCreateAOI, +}: GeoSettingsProps) { + return ( +
+ {/* Theme Selection */} +
+

Lernthema

+
+ {(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => { + const config = THEME_CONFIG[theme] + return ( + + ) + })} +
+
+ + {/* Quality Selection */} +
+

Qualitaet

+
+ {(['low', 'medium', 'high'] as AOIQuality[]).map((q) => ( + + ))} +
+
+ + {/* Difficulty Selection */} +
+

Schwierigkeitsgrad

+
+ {(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => ( + + ))} +
+
+ + {/* Area Info */} + {drawnPolygon && ( +
+

Ausgewaehltes Gebiet

+
+

Polygon gezeichnet ✓

+

Klicke "Lernwelt erstellen" um fortzufahren

+
+
+ )} + + {/* Create Button */} + + + {/* AOI Status */} + {currentAOI && ( +
+

Status

+
+
+
+ + {currentAOI.status === 'queued' ? 'In Warteschlange...' : currentAOI.status === 'processing' ? 'Wird verarbeitet...' : currentAOI.status === 'completed' ? 'Fertig!' : 'Fehlgeschlagen'} + +
+ {currentAOI.area_km2 > 0 && (

Flaeche: {currentAOI.area_km2.toFixed(2)} km²

)} +
+
+ )} +
+ ) +} + +export { THEME_CONFIG } diff --git a/studio-v2/app/geo-lernwelt/page.tsx b/studio-v2/app/geo-lernwelt/page.tsx index 90a7b19..3f00326 100644 --- a/studio-v2/app/geo-lernwelt/page.tsx +++ b/studio-v2/app/geo-lernwelt/page.tsx @@ -39,15 +39,7 @@ function MapLoadingPlaceholder() { ) } -// Theme icons and colors -const THEME_CONFIG: Record = { - topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' }, - landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' }, - orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' }, - geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' }, - hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' }, - vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' }, -} +import { GeoSettings } from './GeoSettings' export default function GeoLernweltPage() { // State @@ -296,138 +288,18 @@ export default function GeoLernweltPage() {
{/* Settings Panel (1/3) */} -
- {/* Theme Selection */} -
-

Lernthema

-
- {(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => { - const config = THEME_CONFIG[theme] - return ( - - ) - })} -
-
- - {/* Quality Selection */} -
-

Qualitaet

-
- {(['low', 'medium', 'high'] as AOIQuality[]).map((q) => ( - - ))} -
-
- - {/* Difficulty Selection */} -
-

Schwierigkeitsgrad

-
- {(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => ( - - ))} -
-
- - {/* Area Info */} - {drawnPolygon && ( -
-

Ausgewaehltes Gebiet

-
-

Polygon gezeichnet ✓

-

- Klicke "Lernwelt erstellen" um fortzufahren -

-
-
- )} - - {/* Create Button */} - - - {/* AOI Status */} - {currentAOI && ( -
-

Status

-
-
-
- - {currentAOI.status === 'queued' - ? 'In Warteschlange...' - : currentAOI.status === 'processing' - ? 'Wird verarbeitet...' - : currentAOI.status === 'completed' - ? 'Fertig!' - : 'Fehlgeschlagen'} - -
- {currentAOI.area_km2 > 0 && ( -

- Flaeche: {currentAOI.area_km2.toFixed(2)} km² -

- )} -
-
- )} -
+
) : ( /* Unity 3D Viewer Tab */ diff --git a/studio-v2/app/korrektur/[klausurId]/_components/GlassCard.tsx b/studio-v2/app/korrektur/[klausurId]/_components/GlassCard.tsx new file mode 100644 index 0000000..d463f4a --- /dev/null +++ b/studio-v2/app/korrektur/[klausurId]/_components/GlassCard.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface GlassCardProps { + children: React.ReactNode + className?: string + onClick?: () => void + size?: 'sm' | 'md' | 'lg' + delay?: number + isDark?: boolean +} + +export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) { + const [isVisible, setIsVisible] = useState(false) + const [isHovered, setIsHovered] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), delay) + return () => clearTimeout(timer) + }, [delay]) + + const sizeClasses = { + sm: 'p-4', + md: 'p-5', + lg: 'p-6', + } + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onClick} + > + {children} +
+ ) +} diff --git a/studio-v2/app/korrektur/[klausurId]/_components/StudentCard.tsx b/studio-v2/app/korrektur/[klausurId]/_components/StudentCard.tsx new file mode 100644 index 0000000..b675929 --- /dev/null +++ b/studio-v2/app/korrektur/[klausurId]/_components/StudentCard.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { StudentWork } from '../../types' +import { STATUS_COLORS, STATUS_LABELS, getGradeLabel } from '../../types' +import { GlassCard } from './GlassCard' + +interface StudentCardProps { + student: StudentWork + index: number + onClick: () => void + delay?: number + isDark?: boolean +} + +export function StudentCard({ student, index, onClick, delay = 0, isDark = true }: StudentCardProps) { + const statusColor = STATUS_COLORS[student.status] || '#6b7280' + const statusLabel = STATUS_LABELS[student.status] || student.status + + const hasGrade = student.status === 'COMPLETED' || student.status === 'FIRST_EXAMINER' || student.status === 'SECOND_EXAMINER' + + return ( + +
+ {/* Index/Number */} +
+ {index + 1} +
+ + {/* Info */} +
+

{student.anonym_id}

+
+ + {statusLabel} + + {hasGrade && student.grade_points > 0 && ( + + {student.grade_points} P ({getGradeLabel(student.grade_points)}) + + )} +
+
+ + {/* Arrow */} + + + +
+
+ ) +} diff --git a/studio-v2/app/korrektur/[klausurId]/_components/UploadModal.tsx b/studio-v2/app/korrektur/[klausurId]/_components/UploadModal.tsx new file mode 100644 index 0000000..ba4d12a --- /dev/null +++ b/studio-v2/app/korrektur/[klausurId]/_components/UploadModal.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState, useRef } from 'react' +import { GlassCard } from './GlassCard' + +interface UploadModalProps { + isOpen: boolean + onClose: () => void + onUpload: (files: File[], anonymIds: string[]) => void + isUploading: boolean +} + +export function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) { + const [files, setFiles] = useState([]) + const [anonymIds, setAnonymIds] = useState([]) + const fileInputRef = useRef(null) + + if (!isOpen) return null + + const handleFileSelect = (selectedFiles: FileList | null) => { + if (!selectedFiles) return + const newFiles = Array.from(selectedFiles) + setFiles((prev) => [...prev, ...newFiles]) + setAnonymIds((prev) => [ + ...prev, + ...newFiles.map((_, i) => `Arbeit-${prev.length + i + 1}`), + ]) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + handleFileSelect(e.dataTransfer.files) + } + + const removeFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)) + setAnonymIds((prev) => prev.filter((_, i) => i !== index)) + } + + const updateAnonymId = (index: number, value: string) => { + setAnonymIds((prev) => { + const updated = [...prev] + updated[index] = value + return updated + }) + } + + const handleSubmit = () => { + if (files.length > 0) { + onUpload(files, anonymIds) + } + } + + return ( +
+
+ +
+

Arbeiten hochladen

+ +
+ + {/* Drop Zone */} +
e.preventDefault()} + onClick={() => fileInputRef.current?.click()} + > + handleFileSelect(e.target.files)} + className="hidden" + /> + + + +

Dateien hierher ziehen

+

oder klicken zum Auswaehlen

+
+ + {/* File List */} + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+ {file.type.startsWith('image/') ? '\uD83D\uDDBC\uFE0F' : '\uD83D\uDCC4'} +
+

{file.name}

+ updateAnonymId(index, e.target.value)} + placeholder="Anonym-ID" + className="mt-1 w-full px-2 py-1 rounded bg-white/10 border border-white/10 text-white text-sm placeholder-white/40 focus:outline-none focus:border-purple-500" + /> +
+ +
+ ))} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/studio-v2/app/korrektur/[klausurId]/fairness/_components/FairnessCharts.tsx b/studio-v2/app/korrektur/[klausurId]/fairness/_components/FairnessCharts.tsx new file mode 100644 index 0000000..1621293 --- /dev/null +++ b/studio-v2/app/korrektur/[klausurId]/fairness/_components/FairnessCharts.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import type { StudentWork, FairnessAnalysis } from '../../../types' +import { DEFAULT_CRITERIA, ANNOTATION_COLORS, getGradeLabel } from '../../../types' + +// ============================================================================= +// GLASS CARD +// ============================================================================= + +interface GlassCardProps { + children: React.ReactNode + className?: string + delay?: number + isDark?: boolean +} + +export function GlassCard({ children, className = '', delay = 0, isDark = true }: GlassCardProps) { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), delay) + return () => clearTimeout(timer) + }, [delay]) + + return ( +
+ {children} +
+ ) +} + +// ============================================================================= +// HISTOGRAM +// ============================================================================= + +export function Histogram({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) { + const distribution = useMemo(() => { + const counts: Record = {} + for (let i = 0; i <= 15; i++) counts[i] = 0 + for (const student of students) { + if (student.grade_points !== undefined) counts[student.grade_points] = (counts[student.grade_points] || 0) + 1 + } + return counts + }, [students]) + + const maxCount = Math.max(...Object.values(distribution), 1) + + return ( +
+

Notenverteilung

+
+ {Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => { + const count = distribution[grade] || 0 + const height = (count / maxCount) * 100 + let color = '#22c55e' + if (grade <= 4) color = '#ef4444' + else if (grade <= 9) color = '#f97316' + + return ( +
+ {count || ''} +
0 ? '8px' : '0', backgroundColor: color }} title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`} /> + {grade} +
+ ) + })} +
+

Punkte

+
+ ) +} + +// ============================================================================= +// CRITERIA HEATMAP +// ============================================================================= + +export function CriteriaHeatmap({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) { + const criteriaAverages = useMemo(() => { + const sums: Record = {} + for (const criterion of Object.keys(DEFAULT_CRITERIA)) sums[criterion] = { sum: 0, count: 0 } + for (const student of students) { + if (student.criteria_scores) { + for (const [criterion, score] of Object.entries(student.criteria_scores)) { + if (score !== undefined && sums[criterion]) { sums[criterion].sum += score; sums[criterion].count += 1 } + } + } + } + const averages: Record = {} + for (const [criterion, data] of Object.entries(sums)) averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0 + return averages + }, [students]) + + return ( +
+

Kriterien-Durchschnitt

+
+ {Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => { + const average = criteriaAverages[criterion] || 0 + const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280' + return ( +
+
+
{config.name}
+ {average}% +
+
+
+ ) + })} +
+
+ ) +} + +// ============================================================================= +// OUTLIER LIST +// ============================================================================= + +export function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: { fairness: FairnessAnalysis | null; onStudentClick: (id: string) => void; className?: string; isDark?: boolean }) { + if (!fairness || fairness.outliers.length === 0) { + return ( +
+
+

Keine Ausreisser erkannt

+

Alle Bewertungen sind konsistent

+
+ ) + } + + return ( +
+

Ausreisser ({fairness.outliers.length})

+
+ {fairness.outliers.map((outlier) => ( + + ))} +
+
+ ) +} + +// ============================================================================= +// FAIRNESS SCORE +// ============================================================================= + +export function FairnessScore({ fairness, className = '', isDark = true }: { fairness: FairnessAnalysis | null; className?: string; isDark?: boolean }) { + const score = fairness?.fairness_score || 0 + const percentage = Math.round(score * 100) + + let color = '#22c55e' + let label = 'Ausgezeichnet' + if (percentage < 70) { color = '#ef4444'; label = 'Ueberpruefung empfohlen' } + else if (percentage < 85) { color = '#f97316'; label = 'Akzeptabel' } + else if (percentage < 95) { color = '#22c55e'; label = 'Gut' } + + const size = 120 + const strokeWidth = 10 + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const offset = circumference - (percentage / 100) * circumference + + return ( +
+
+ + + + +
+ {percentage} + % +
+
+

{label}

+

Fairness-Score

+
+ ) +} diff --git a/studio-v2/app/korrektur/[klausurId]/fairness/page.tsx b/studio-v2/app/korrektur/[klausurId]/fairness/page.tsx index 936f199..8466a00 100644 --- a/studio-v2/app/korrektur/[klausurId]/fairness/page.tsx +++ b/studio-v2/app/korrektur/[klausurId]/fairness/page.tsx @@ -8,324 +8,7 @@ import { ThemeToggle } from '@/components/ThemeToggle' import { LanguageDropdown } from '@/components/LanguageDropdown' import { korrekturApi } from '@/lib/korrektur/api' import type { Klausur, StudentWork, FairnessAnalysis } from '../../types' -import { DEFAULT_CRITERIA, ANNOTATION_COLORS, getGradeLabel } from '../../types' - -// ============================================================================= -// GLASS CARD -// ============================================================================= - -interface GlassCardProps { - children: React.ReactNode - className?: string - delay?: number - isDark?: boolean -} - -function GlassCard({ children, className = '', delay = 0, isDark = true }: GlassCardProps) { - const [isVisible, setIsVisible] = useState(false) - - useEffect(() => { - const timer = setTimeout(() => setIsVisible(true), delay) - return () => clearTimeout(timer) - }, [delay]) - - return ( -
- {children} -
- ) -} - -// ============================================================================= -// HISTOGRAM -// ============================================================================= - -interface HistogramProps { - students: StudentWork[] - className?: string - isDark?: boolean -} - -function Histogram({ students, className = '', isDark = true }: HistogramProps) { - // Group students by grade points (0-15) - const distribution = useMemo(() => { - const counts: Record = {} - for (let i = 0; i <= 15; i++) { - counts[i] = 0 - } - for (const student of students) { - if (student.grade_points !== undefined) { - counts[student.grade_points] = (counts[student.grade_points] || 0) + 1 - } - } - return counts - }, [students]) - - const maxCount = Math.max(...Object.values(distribution), 1) - - return ( -
-

Notenverteilung

-
- {Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => { - const count = distribution[grade] || 0 - const height = (count / maxCount) * 100 - - // Color based on grade - let color = '#22c55e' // Green for good grades - if (grade <= 4) color = '#ef4444' // Red for poor grades - else if (grade <= 9) color = '#f97316' // Orange for medium grades - - return ( -
- {count || ''} -
0 ? '8px' : '0', - backgroundColor: color, - }} - title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`} - /> - {grade} -
- ) - })} -
-

Punkte

-
- ) -} - -// ============================================================================= -// CRITERIA HEATMAP -// ============================================================================= - -interface CriteriaHeatmapProps { - students: StudentWork[] - className?: string - isDark?: boolean -} - -function CriteriaHeatmap({ students, className = '', isDark = true }: CriteriaHeatmapProps) { - // Calculate average for each criterion - const criteriaAverages = useMemo(() => { - const sums: Record = {} - - for (const criterion of Object.keys(DEFAULT_CRITERIA)) { - sums[criterion] = { sum: 0, count: 0 } - } - - for (const student of students) { - if (student.criteria_scores) { - for (const [criterion, score] of Object.entries(student.criteria_scores)) { - if (score !== undefined && sums[criterion]) { - sums[criterion].sum += score - sums[criterion].count += 1 - } - } - } - } - - const averages: Record = {} - for (const [criterion, data] of Object.entries(sums)) { - averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0 - } - - return averages - }, [students]) - - return ( -
-

Kriterien-Durchschnitt

-
- {Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => { - const average = criteriaAverages[criterion] || 0 - const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280' - - return ( -
-
-
-
- {config.name} -
- {average}% -
-
-
-
-
- ) - })} -
-
- ) -} - -// ============================================================================= -// OUTLIER LIST -// ============================================================================= - -interface OutlierListProps { - fairness: FairnessAnalysis | null - onStudentClick: (studentId: string) => void - className?: string - isDark?: boolean -} - -function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: OutlierListProps) { - if (!fairness || fairness.outliers.length === 0) { - return ( -
-
- - - -
-

Keine Ausreisser erkannt

-

Alle Bewertungen sind konsistent

-
- ) - } - - return ( -
-

- Ausreisser ({fairness.outliers.length}) -

-
- {fairness.outliers.map((outlier) => ( - - ))} -
-
- ) -} - -// ============================================================================= -// FAIRNESS SCORE -// ============================================================================= - -interface FairnessScoreProps { - fairness: FairnessAnalysis | null - className?: string - isDark?: boolean -} - -function FairnessScore({ fairness, className = '', isDark = true }: FairnessScoreProps) { - const score = fairness?.fairness_score || 0 - const percentage = Math.round(score * 100) - - let color = '#22c55e' // Green - let label = 'Ausgezeichnet' - if (percentage < 70) { - color = '#ef4444' - label = 'Ueberpruefung empfohlen' - } else if (percentage < 85) { - color = '#f97316' - label = 'Akzeptabel' - } else if (percentage < 95) { - color = '#22c55e' - label = 'Gut' - } - - // SVG ring - const size = 120 - const strokeWidth = 10 - const radius = (size - strokeWidth) / 2 - const circumference = radius * 2 * Math.PI - const offset = circumference - (percentage / 100) * circumference - - return ( -
-
- - - - -
- {percentage} - % -
-
-

{label}

-

Fairness-Score

-
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= +import { GlassCard, Histogram, CriteriaHeatmap, OutlierList, FairnessScore } from './_components/FairnessCharts' export default function FairnessPage() { const { isDark } = useTheme() @@ -333,27 +16,22 @@ export default function FairnessPage() { const params = useParams() const klausurId = params.klausurId as string - // State const [klausur, setKlausur] = useState(null) const [students, setStudents] = useState([]) const [fairness, setFairness] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - // Load data const loadData = useCallback(async () => { if (!klausurId) return - setIsLoading(true) setError(null) - try { const [klausurData, studentsData, fairnessData] = await Promise.all([ korrekturApi.getKlausur(klausurId), korrekturApi.getStudents(klausurId), korrekturApi.getFairnessAnalysis(klausurId), ]) - setKlausur(klausurData) setStudents(studentsData) setFairness(fairnessData) @@ -365,52 +43,30 @@ export default function FairnessPage() { } }, [klausurId]) - useEffect(() => { - loadData() - }, [loadData]) + useEffect(() => { loadData() }, [loadData]) - // Calculated stats const stats = useMemo(() => { if (!fairness) return null - return { - studentCount: fairness.student_count, - average: fairness.average_grade, - stdDev: fairness.std_deviation, - spread: fairness.spread, - outlierCount: fairness.outliers.length, - warningCount: fairness.warnings.length, + studentCount: fairness.student_count, average: fairness.average_grade, + stdDev: fairness.std_deviation, spread: fairness.spread, + outlierCount: fairness.outliers.length, warningCount: fairness.warnings.length, } }, [fairness]) return ( -
- {/* Animated Background Blobs */} +
- {/* Sidebar */} -
- -
+
- {/* Main Content */}
- {/* Header */}
-

Fairness-Analyse

@@ -418,149 +74,49 @@ export default function FairnessPage() {
- {/* Error Display */} - {error && ( - -
- - - - {error} - -
-
- )} + {error && (
{error}
)} - {/* Loading */} - {isLoading && ( -
-
-
- )} + {isLoading && (
)} - {/* Content */} {!isLoading && fairness && ( <> - {/* Stats Row */}
- -

Arbeiten

-

{stats?.studentCount}

-
- -

Durchschnitt

-

- {stats?.average.toFixed(1)} P -

-
- -

Standardabw.

-

- {stats?.stdDev.toFixed(2)} -

-
- -

Spannweite

-

{stats?.spread} P

-
- -

Ausreisser

-

- {stats?.outlierCount} -

-
- -

Warnungen

-

- {stats?.warningCount} -

-
+

Arbeiten

{stats?.studentCount}

+

Durchschnitt

{stats?.average.toFixed(1)} P

+

Standardabw.

{stats?.stdDev.toFixed(2)}

+

Spannweite

{stats?.spread} P

+

Ausreisser

{stats?.outlierCount}

+

Warnungen

{stats?.warningCount}

- {/* Warnings */} {fairness.warnings.length > 0 && ( -

- - - - Warnungen -

-
    - {fairness.warnings.map((warning, index) => ( -
  • - - - {warning} -
  • - ))} -
+

Warnungen

+
    {fairness.warnings.map((warning, index) => (
  • -{warning}
  • ))}
)} - {/* Main Grid */}
- {/* Fairness Score */} - - - - - {/* Histogram */} - - - - - {/* Criteria Heatmap */} - - - - - {/* Outlier List */} - - - router.push(`/korrektur/${klausurId}/${studentId}`) - } - isDark={isDark} - /> - + + + + router.push(`/korrektur/${klausurId}/${studentId}`)} isDark={isDark} />
)} - {/* No Data */} {!isLoading && !fairness && !error && ( -
- - - -
+

Keine Daten verfuegbar

-

- Die Fairness-Analyse erfordert korrigierte Arbeiten. -

+

Die Fairness-Analyse erfordert korrigierte Arbeiten.

)}
diff --git a/studio-v2/app/korrektur/[klausurId]/page.tsx b/studio-v2/app/korrektur/[klausurId]/page.tsx index 3641df0..e3a4b45 100644 --- a/studio-v2/app/korrektur/[klausurId]/page.tsx +++ b/studio-v2/app/korrektur/[klausurId]/page.tsx @@ -1,313 +1,35 @@ 'use client' -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useRouter, useParams } from 'next/navigation' import { useTheme } from '@/lib/ThemeContext' import { Sidebar } from '@/components/Sidebar' import { ThemeToggle } from '@/components/ThemeToggle' import { LanguageDropdown } from '@/components/LanguageDropdown' -import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload' +import { QRCodeUpload } from '@/components/QRCodeUpload' import { korrekturApi } from '@/lib/korrektur/api' -import type { Klausur, StudentWork, StudentStatus } from '../types' -import { STATUS_COLORS, STATUS_LABELS, getGradeLabel } from '../types' +import type { Klausur, StudentWork } from '../types' +import { GlassCard } from './_components/GlassCard' +import { StudentCard } from './_components/StudentCard' +import { UploadModal } from './_components/UploadModal' -// LocalStorage Key for upload session const SESSION_ID_KEY = 'bp_korrektur_student_session' -// ============================================================================= -// GLASS CARD -// ============================================================================= - -interface GlassCardProps { - children: React.ReactNode - className?: string - onClick?: () => void - size?: 'sm' | 'md' | 'lg' - delay?: number -} - -function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) { - const [isVisible, setIsVisible] = useState(false) - const [isHovered, setIsHovered] = useState(false) - - useEffect(() => { - const timer = setTimeout(() => setIsVisible(true), delay) - return () => clearTimeout(timer) - }, [delay]) - - const sizeClasses = { - sm: 'p-4', - md: 'p-5', - lg: 'p-6', - } - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onClick={onClick} - > - {children} -
- ) -} - -// ============================================================================= -// STUDENT CARD -// ============================================================================= - -interface StudentCardProps { - student: StudentWork - index: number - onClick: () => void - delay?: number - isDark?: boolean -} - -function StudentCard({ student, index, onClick, delay = 0, isDark = true }: StudentCardProps) { - const statusColor = STATUS_COLORS[student.status] || '#6b7280' - const statusLabel = STATUS_LABELS[student.status] || student.status - - const hasGrade = student.status === 'COMPLETED' || student.status === 'FIRST_EXAMINER' || student.status === 'SECOND_EXAMINER' - - return ( - -
- {/* Index/Number */} -
- {index + 1} -
- - {/* Info */} -
-

{student.anonym_id}

-
- - {statusLabel} - - {hasGrade && student.grade_points > 0 && ( - - {student.grade_points} P ({getGradeLabel(student.grade_points)}) - - )} -
-
- - {/* Arrow */} - - - -
-
- ) -} - -// ============================================================================= -// UPLOAD MODAL -// ============================================================================= - -interface UploadModalProps { - isOpen: boolean - onClose: () => void - onUpload: (files: File[], anonymIds: string[]) => void - isUploading: boolean -} - -function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) { - const [files, setFiles] = useState([]) - const [anonymIds, setAnonymIds] = useState([]) - const fileInputRef = useRef(null) - - if (!isOpen) return null - - const handleFileSelect = (selectedFiles: FileList | null) => { - if (!selectedFiles) return - const newFiles = Array.from(selectedFiles) - setFiles((prev) => [...prev, ...newFiles]) - // Generate default anonym IDs - setAnonymIds((prev) => [ - ...prev, - ...newFiles.map((_, i) => `Arbeit-${prev.length + i + 1}`), - ]) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - handleFileSelect(e.dataTransfer.files) - } - - const removeFile = (index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)) - setAnonymIds((prev) => prev.filter((_, i) => i !== index)) - } - - const updateAnonymId = (index: number, value: string) => { - setAnonymIds((prev) => { - const updated = [...prev] - updated[index] = value - return updated - }) - } - - const handleSubmit = () => { - if (files.length > 0) { - onUpload(files, anonymIds) - } - } - - return ( -
-
- -
-

Arbeiten hochladen

- -
- - {/* Drop Zone */} -
e.preventDefault()} - onClick={() => fileInputRef.current?.click()} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - - -

Dateien hierher ziehen

-

oder klicken zum Auswaehlen

-
- - {/* File List */} - {files.length > 0 && ( -
- {files.map((file, index) => ( -
- - {file.type.startsWith('image/') ? '🖼️' : '📄'} - -
-

{file.name}

- updateAnonymId(index, e.target.value)} - placeholder="Anonym-ID" - className="mt-1 w-full px-2 py-1 rounded bg-white/10 border border-white/10 text-white text-sm placeholder-white/40 focus:outline-none focus:border-purple-500" - /> -
- -
- ))} -
- )} - - {/* Actions */} -
- - -
-
-
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= - export default function KlausurDetailPage() { const { isDark } = useTheme() const router = useRouter() const params = useParams() const klausurId = params.klausurId as string - // State const [klausur, setKlausur] = useState(null) const [students, setStudents] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - - // Modal states const [showUploadModal, setShowUploadModal] = useState(false) const [showQRModal, setShowQRModal] = useState(false) const [isUploading, setIsUploading] = useState(false) const [uploadSessionId, setUploadSessionId] = useState('') - // Initialize session ID useEffect(() => { let storedSessionId = localStorage.getItem(SESSION_ID_KEY) if (!storedSessionId) { @@ -317,13 +39,10 @@ export default function KlausurDetailPage() { setUploadSessionId(storedSessionId) }, []) - // Load data const loadData = useCallback(async () => { if (!klausurId) return - setIsLoading(true) setError(null) - try { const [klausurData, studentsData] = await Promise.all([ korrekturApi.getKlausur(klausurId), @@ -339,11 +58,8 @@ export default function KlausurDetailPage() { } }, [klausurId]) - useEffect(() => { - loadData() - }, [loadData]) + useEffect(() => { loadData() }, [loadData]) - // Handle upload const handleUpload = async (files: File[], anonymIds: string[]) => { setIsUploading(true) try { @@ -351,7 +67,7 @@ export default function KlausurDetailPage() { await korrekturApi.uploadStudentWork(klausurId, files[i], anonymIds[i]) } setShowUploadModal(false) - loadData() // Refresh the list + loadData() } catch (err) { console.error('Upload failed:', err) setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen') @@ -360,85 +76,40 @@ export default function KlausurDetailPage() { } } - // Calculate progress const completedCount = students.filter(s => s.status === 'COMPLETED').length const progress = students.length > 0 ? Math.round((completedCount / students.length) * 100) : 0 return ( -
- {/* Animated Background Blobs */} +
- {/* Sidebar */} -
- -
+
- {/* Main Content */}
- {/* Header */}
-
-

- {klausur?.title || 'Klausur'} -

-

- {klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''} -

+

{klausur?.title || 'Klausur'}

+

{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}

-
- - -
+
- {/* Stats Row */} {!isLoading && klausur && (
- -
-

{students.length}

-

Arbeiten

-
-
- -
-

{completedCount}

-

Abgeschlossen

-
-
- -
-

{students.length - completedCount}

-

Offen

-
-
- -
-

{progress}%

-

Fortschritt

-
-
+

{students.length}

Arbeiten

+

{completedCount}

Abgeschlossen

+

{students.length - completedCount}

Offen

+

{progress}%

Fortschritt

)} - {/* Progress Bar */} {!isLoading && students.length > 0 && (
@@ -446,130 +117,68 @@ export default function KlausurDetailPage() { {completedCount}/{students.length} korrigiert
-
+
)} - {/* Error Display */} {error && (
- - - + {error} - +
)} - {/* Loading */} - {isLoading && ( -
-
-
- )} + {isLoading && (
)} - {/* Action Buttons */} {!isLoading && (
- - {students.length > 0 && ( - )}
)} - {/* Students List */} {!isLoading && students.length === 0 && (
- - - +

Keine Arbeiten vorhanden

Laden Sie Schuelerarbeiten hoch, um mit der Korrektur zu beginnen.

- +
)} {!isLoading && students.length > 0 && (
{students.map((student, index) => ( - router.push(`/korrektur/${klausurId}/${student.id}`)} - delay={350 + index * 30} - isDark={isDark} - /> + router.push(`/korrektur/${klausurId}/${student.id}`)} delay={350 + index * 30} isDark={isDark} /> ))}
)}
- {/* Upload Modal */} - setShowUploadModal(false)} - onUpload={handleUpload} - isUploading={isUploading} - /> + setShowUploadModal(false)} onUpload={handleUpload} isUploading={isUploading} /> - {/* QR Code Modal */} {showQRModal && (
setShowQRModal(false)} />
- setShowQRModal(false)} - onFilesChanged={(files) => { - // Handle mobile uploaded files - if (files.length > 0) { - // Could auto-process the files here - } - }} - /> + setShowQRModal(false)} onFilesChanged={() => {}} />
)} diff --git a/studio-v2/components/AlertsWizard.tsx b/studio-v2/components/AlertsWizard.tsx index ea7ccb8..a205c59 100644 --- a/studio-v2/components/AlertsWizard.tsx +++ b/studio-v2/components/AlertsWizard.tsx @@ -2,9 +2,8 @@ import { useState } from 'react' import { useTheme } from '@/lib/ThemeContext' -import { useAlerts, lehrerThemen, Topic, AlertImportance } from '@/lib/AlertsContext' -import { InfoBox, TipBox, StepBox } from './InfoBox' -import { BPIcon } from './Logo' +import { useAlerts, lehrerThemen, AlertImportance } from '@/lib/AlertsContext' +import { Step1TopicSelection, Step2Instructions, Step3Forwarding, Step4Settings } from './alerts-wizard/AlertsWizardSteps' interface AlertsWizardProps { onComplete: () => void @@ -13,7 +12,7 @@ interface AlertsWizardProps { export function AlertsWizard({ onComplete, onSkip }: AlertsWizardProps) { const { isDark } = useTheme() - const { addTopic, updateSettings, settings } = useAlerts() + const { addTopic, updateSettings } = useAlerts() const [step, setStep] = useState(1) const [selectedTopics, setSelectedTopics] = useState([]) @@ -24,528 +23,78 @@ export function AlertsWizard({ onComplete, onSkip }: AlertsWizardProps) { const totalSteps = 4 - const handleNext = () => { - if (step < totalSteps) { - setStep(step + 1) - } else { - // Wizard abschliessen - completeWizard() - } - } - - const handleBack = () => { - if (step > 1) { - setStep(step - 1) - } - } + const handleNext = () => { if (step < totalSteps) setStep(step + 1); else completeWizard() } + const handleBack = () => { if (step > 1) setStep(step - 1) } const completeWizard = () => { - // Ausgewaehlte vordefinierte Topics hinzufuegen selectedTopics.forEach(topicId => { const topic = lehrerThemen.find(t => t.name === topicId) if (topic) { - addTopic({ - id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - name: topic.name, - keywords: topic.keywords, - icon: topic.icon, - isActive: true, - rssFeedUrl: rssFeedUrl || undefined - }) + addTopic({ id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, name: topic.name, keywords: topic.keywords, icon: topic.icon, isActive: true, rssFeedUrl: rssFeedUrl || undefined }) } }) - - // Custom Topic hinzufuegen falls vorhanden if (customTopic.name.trim()) { - addTopic({ - id: `topic-${Date.now()}-custom`, - name: customTopic.name, - keywords: customTopic.keywords.split(',').map(k => k.trim()).filter(k => k), - icon: '📌', - isActive: true, - rssFeedUrl: rssFeedUrl || undefined - }) + addTopic({ id: `topic-${Date.now()}-custom`, name: customTopic.name, keywords: customTopic.keywords.split(',').map(k => k.trim()).filter(k => k), icon: '📌', isActive: true, rssFeedUrl: rssFeedUrl || undefined }) } - - // Settings speichern - updateSettings({ - notificationFrequency, - minImportance, - wizardCompleted: true - }) - + updateSettings({ notificationFrequency, minImportance, wizardCompleted: true }) onComplete() } const toggleTopic = (topicName: string) => { - setSelectedTopics(prev => - prev.includes(topicName) - ? prev.filter(t => t !== topicName) - : [...prev, topicName] - ) + setSelectedTopics(prev => prev.includes(topicName) ? prev.filter(t => t !== topicName) : [...prev, topicName]) } const canProceed = () => { - switch (step) { - case 1: - return selectedTopics.length > 0 || customTopic.name.trim().length > 0 - case 2: - return true // Info-Schritt, immer weiter - case 3: - return true // RSS optional - case 4: - return true // Einstellungen immer gueltig - default: - return false - } + if (step === 1) return selectedTopics.length > 0 || customTopic.name.trim().length > 0 + return true } return ( -
- {/* Animated Background */} +
-
-
+
+
- {/* Logo & Titel */}
-
- 🔔 -
+
🔔
-

- Google Alerts einrichten -

-

- Bleiben Sie informiert ueber Bildungsthemen -

+

Google Alerts einrichten

+

Bleiben Sie informiert ueber Bildungsthemen

- {/* Progress Bar */}
{[1, 2, 3, 4].map((s) => ( -
+
{s < step ? '✓' : s}
))}
-
+
- {/* Main Card */} -
- {/* Step 1: Themen waehlen */} - {step === 1 && ( -
-

- Welche Themen interessieren Sie? -

-

- Waehlen Sie Themen, ueber die Sie informiert werden moechten -

- - {/* Vordefinierte Themen */} -
- {lehrerThemen.map((topic) => { - const isSelected = selectedTopics.includes(topic.name) - return ( - - ) - })} -
- - {/* Custom Topic */} -
-

- 📌 Eigenes Thema hinzufuegen -

-
- setCustomTopic({ ...customTopic, name: e.target.value })} - className={`w-full px-4 py-2 rounded-lg border ${ - isDark - ? 'bg-white/10 border-white/20 text-white placeholder-white/40' - : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400' - }`} - /> - setCustomTopic({ ...customTopic, keywords: e.target.value })} - className={`w-full px-4 py-2 rounded-lg border ${ - isDark - ? 'bg-white/10 border-white/20 text-white placeholder-white/40' - : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400' - }`} - /> -
-
-
- )} - - {/* Step 2: Google Alerts Anleitung */} - {step === 2 && ( -
-

- Google Alerts einrichten -

-

- Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie -

- - -

- Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto. - Sie richten einfach eine Weiterleitung ein - wir uebernehmen die - Auswertung, Filterung und Zusammenfassung. -

-
- -
- -

- Besuchen Sie - google.de/alerts - und melden Sie sich mit Ihrem Google-Konto an. -

-
- - -

- Geben Sie Suchbegriffe ein (z.B. "{selectedTopics[0] || 'Bildungspolitik'}") - und erstellen Sie Alerts. Die Alerts werden an Ihre E-Mail-Adresse gesendet. -

-
- - -

- Im naechsten Schritt richten Sie eine automatische Weiterleitung - der Google Alert E-Mails an uns ein. So verarbeiten wir Ihre Alerts - automatisch. -

-
-
- - -

- Sie koennen beliebig viele Google Alerts erstellen. Alle werden - per E-Mail an Sie gesendet und durch die Weiterleitung automatisch - verarbeitet - gefiltert, priorisiert und zusammengefasst. -

-
-
- )} - - {/* Step 3: E-Mail Weiterleitung einrichten */} - {step === 3 && ( -
-

- E-Mail Weiterleitung einrichten -

-

- Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter -

- -
- {/* Empfohlene Methode: E-Mail Weiterleitung */} -
-
- 📧 -
-
-

- E-Mail Weiterleitung -

- - Empfohlen - -
-

- Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet. -

-
-
- -
-

- Ihre Weiterleitungsadresse: -

-
- - alerts@breakpilot.de - - -
-
- -
-

- So richten Sie die Weiterleitung in Gmail ein: -

-
    -
  1. 1. Oeffnen Sie Gmail → Einstellungen → Filter
  2. -
  3. 2. Neuer Filter: Von "googlealerts-noreply@google.com"
  4. -
  5. 3. Aktion: Weiterleiten an "alerts@breakpilot.de"
  6. -
-
-
- - {/* Alternative: RSS (mit Warnung) */} -
-
- 📡 -
-

- Alternativ: RSS-Feed (eingeschraenkt verfuegbar) -

-

- Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie RSS noch sehen, - koennen Sie die Feed-URL hier eingeben: -

- setRssFeedUrl(e.target.value)} - className={`w-full px-4 py-2 rounded-lg border text-sm ${ - isDark - ? 'bg-white/10 border-white/20 text-white placeholder-white/30' - : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400' - }`} - /> -

- ⚠️ Die meisten Nutzer sehen keine RSS-Option mehr in Google Alerts. - Verwenden Sie in diesem Fall die E-Mail-Weiterleitung. -

-
-
-
- -
-

- Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten. - Die Demo-Alerts werden weiterhin angezeigt. -

-
-
-
- )} - - {/* Step 4: Benachrichtigungs-Einstellungen */} - {step === 4 && ( -
-

- Benachrichtigungen einstellen -

-

- Wie moechten Sie informiert werden? -

- -
- {/* Frequenz */} -
- -
- {[ - { id: 'realtime', label: 'Sofort', icon: '⚡', desc: 'Bei jedem neuen Alert' }, - { id: 'hourly', label: 'Stuendlich', icon: '🕐', desc: 'Zusammenfassung pro Stunde' }, - { id: 'daily', label: 'Taeglich', icon: '📅', desc: 'Einmal am Tag' }, - ].map((freq) => ( - - ))} -
-
- - {/* Mindest-Wichtigkeit */} -
- -
- {[ - { id: 'KRITISCH', label: 'Kritisch', color: 'red' }, - { id: 'DRINGEND', label: 'Dringend', color: 'orange' }, - { id: 'WICHTIG', label: 'Wichtig', color: 'yellow' }, - { id: 'PRUEFEN', label: 'Pruefen', color: 'blue' }, - { id: 'INFO', label: 'Info', color: 'slate' }, - ].map((imp) => ( - - ))} -
-

- Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher. -

-
- - {/* Zusammenfassung */} -
-

- Ihre Einstellungen -

-
    -
  • • {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt
  • -
  • • Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}
  • -
  • • Mindest-Wichtigkeit: {minImportance}
  • - {rssFeedUrl &&
  • • RSS-Feed verbunden
  • } -
-
-
-
- )} +
+ {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && }
- {/* Navigation Buttons */}
- {step > 1 && ( - - )} - - )} +
- {/* Skip Option */} - {onSkip && ( - - )} + {onSkip && ()}
) diff --git a/studio-v2/components/OnboardingWizard.tsx b/studio-v2/components/OnboardingWizard.tsx index 4db4e4c..89a6191 100644 --- a/studio-v2/components/OnboardingWizard.tsx +++ b/studio-v2/components/OnboardingWizard.tsx @@ -22,173 +22,7 @@ export interface OnboardingData { schoolType: string } -// Schulformen mit Icons und Beschreibungen -const schulformen = [ - // Allgemeinbildende Schulen - { - id: 'gymnasium', - name: 'Gymnasium', - icon: '🎓', - description: 'Allgemeinbildend bis Abitur', - category: 'allgemein' - }, - { - id: 'gesamtschule', - name: 'Gesamtschule', - icon: '🏫', - description: 'Integriert/kooperativ', - category: 'allgemein' - }, - { - id: 'realschule', - name: 'Realschule', - icon: '📚', - description: 'Mittlerer Abschluss', - category: 'allgemein' - }, - { - id: 'hauptschule', - name: 'Hauptschule', - icon: '📖', - description: 'Erster Abschluss', - category: 'allgemein' - }, - { - id: 'mittelschule', - name: 'Mittelschule', - icon: '📝', - description: 'Bayern/Sachsen', - category: 'allgemein' - }, - { - id: 'oberschule', - name: 'Oberschule', - icon: '🏛️', - description: 'Sachsen/Brandenburg', - category: 'allgemein' - }, - { - id: 'stadtteilschule', - name: 'Stadtteilschule', - icon: '🌆', - description: 'Hamburg', - category: 'allgemein' - }, - { - id: 'gemeinschaftsschule', - name: 'Gemeinschaftsschule', - icon: '🤲', - description: 'BW/SH/TH/SL/BE', - category: 'allgemein' - }, - // Berufliche Schulen - { - id: 'berufsschule', - name: 'Berufsschule', - icon: '🔧', - description: 'Duale Ausbildung', - category: 'beruflich' - }, - { - id: 'berufliches_gymnasium', - name: 'Berufl. Gymnasium', - icon: '💼', - description: 'Fachgebundenes Abitur', - category: 'beruflich' - }, - { - id: 'fachoberschule', - name: 'Fachoberschule', - icon: '📊', - description: 'Fachhochschulreife', - category: 'beruflich' - }, - { - id: 'berufsfachschule', - name: 'Berufsfachschule', - icon: '🛠️', - description: 'Vollzeitberufliche Bildung', - category: 'beruflich' - }, - // Sonder- und Förderschulen - { - id: 'foerderschule', - name: 'Förderschule', - icon: '🤝', - description: 'Sonderpädagogisch', - category: 'foerder' - }, - { - id: 'foerderzentrum', - name: 'Förderzentrum', - icon: '💚', - description: 'Inklusiv/integriert', - category: 'foerder' - }, - // Privatschulen & Besondere Formen - { - id: 'privatschule', - name: 'Privatschule', - icon: '🏰', - description: 'Freier Träger', - category: 'privat' - }, - { - id: 'internat', - name: 'Internat', - icon: '🛏️', - description: 'Mit Unterbringung', - category: 'privat' - }, - { - id: 'waldorfschule', - name: 'Waldorfschule', - icon: '🌿', - description: 'Anthroposophisch', - category: 'alternativ' - }, - { - id: 'montessori', - name: 'Montessori-Schule', - icon: '🧒', - description: 'Montessori-Pädagogik', - category: 'alternativ' - }, - // Grundschulen - { - id: 'grundschule', - name: 'Grundschule', - icon: '🏠', - description: 'Klasse 1-4', - category: 'grund' - }, - // Internationale - { - id: 'internationale_schule', - name: 'Internationale Schule', - icon: '🌍', - description: 'IB/Cambridge', - category: 'international' - }, - { - id: 'europaeische_schule', - name: 'Europäische Schule', - icon: '🇪🇺', - description: 'EU-Curriculum', - category: 'international' - }, -] - -// Kategorien für die Anzeige -const schulformKategorien = [ - { id: 'allgemein', name: 'Allgemeinbildend', icon: '📚' }, - { id: 'beruflich', name: 'Berufsbildend', icon: '💼' }, - { id: 'foerder', name: 'Förderschulen', icon: '💚' }, - { id: 'privat', name: 'Privat & Internat', icon: '🏰' }, - { id: 'alternativ', name: 'Alternative Pädagogik', icon: '🌿' }, - { id: 'grund', name: 'Primarstufe', icon: '🏠' }, - { id: 'international', name: 'International', icon: '🌍' }, -] +import { schulformen, schulformKategorien } from './onboarding-wizard/schulformen' export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { const { t } = useLanguage() diff --git a/studio-v2/components/alerts-wizard/AlertsWizardSteps.tsx b/studio-v2/components/alerts-wizard/AlertsWizardSteps.tsx new file mode 100644 index 0000000..64a1e74 --- /dev/null +++ b/studio-v2/components/alerts-wizard/AlertsWizardSteps.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useTheme } from '@/lib/ThemeContext' +import { lehrerThemen, AlertImportance } from '@/lib/AlertsContext' +import { InfoBox, TipBox, StepBox } from '../InfoBox' + +// ============================================================================= +// Step 1: Topic Selection +// ============================================================================= + +interface Step1Props { + selectedTopics: string[] + onToggleTopic: (name: string) => void + customTopic: { name: string; keywords: string } + onCustomTopicChange: (topic: { name: string; keywords: string }) => void + isDark: boolean +} + +export function Step1TopicSelection({ selectedTopics, onToggleTopic, customTopic, onCustomTopicChange, isDark }: Step1Props) { + return ( +
+

Welche Themen interessieren Sie?

+

Waehlen Sie Themen, ueber die Sie informiert werden moechten

+ +
+ {lehrerThemen.map((topic) => { + const isSelected = selectedTopics.includes(topic.name) + return ( + + ) + })} +
+ +
+

📌 Eigenes Thema hinzufuegen

+
+ onCustomTopicChange({ ...customTopic, name: e.target.value })} className={`w-full px-4 py-2 rounded-lg border ${isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40' : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'}`} /> + onCustomTopicChange({ ...customTopic, keywords: e.target.value })} className={`w-full px-4 py-2 rounded-lg border ${isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40' : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'}`} /> +
+
+
+ ) +} + +// ============================================================================= +// Step 2: Google Alerts Instructions +// ============================================================================= + +export function Step2Instructions({ selectedTopics, isDark }: { selectedTopics: string[]; isDark: boolean }) { + return ( +
+

Google Alerts einrichten

+

Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie

+ + +

Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto. Sie richten einfach eine Weiterleitung ein - wir uebernehmen die Auswertung, Filterung und Zusammenfassung.

+
+ +
+

Besuchen Sie google.de/alerts und melden Sie sich mit Ihrem Google-Konto an.

+

Geben Sie Suchbegriffe ein (z.B. "{selectedTopics[0] || 'Bildungspolitik'}") und erstellen Sie Alerts.

+

Im naechsten Schritt richten Sie eine automatische Weiterleitung der Google Alert E-Mails an uns ein.

+
+ +

Sie koennen beliebig viele Google Alerts erstellen. Alle werden per E-Mail an Sie gesendet und durch die Weiterleitung automatisch verarbeitet.

+
+ ) +} + +// ============================================================================= +// Step 3: Email Forwarding +// ============================================================================= + +export function Step3Forwarding({ rssFeedUrl, onRssFeedUrlChange, isDark }: { rssFeedUrl: string; onRssFeedUrlChange: (url: string) => void; isDark: boolean }) { + return ( +
+

E-Mail Weiterleitung einrichten

+

Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter

+ +
+
+
+ 📧 +
+
+

E-Mail Weiterleitung

+ Empfohlen +
+

Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet.

+
+
+
+

Ihre Weiterleitungsadresse:

+
+ alerts@breakpilot.de + +
+
+
+

So richten Sie die Weiterleitung in Gmail ein:

+
    +
  1. 1. Oeffnen Sie Gmail → Einstellungen → Filter
  2. +
  3. 2. Neuer Filter: Von "googlealerts-noreply@google.com"
  4. +
  5. 3. Aktion: Weiterleiten an "alerts@breakpilot.de"
  6. +
+
+
+ +
+
+ 📡 +
+

Alternativ: RSS-Feed (eingeschraenkt verfuegbar)

+

Google hat die RSS-Option fuer viele Konten entfernt. Falls verfuegbar:

+ onRssFeedUrlChange(e.target.value)} className={`w-full px-4 py-2 rounded-lg border text-sm ${isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/30' : 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'}`} /> +
+
+
+ +
+

Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten.

+
+
+
+ ) +} + +// ============================================================================= +// Step 4: Notification Settings +// ============================================================================= + +interface Step4Props { + notificationFrequency: 'realtime' | 'hourly' | 'daily' + onFrequencyChange: (freq: 'realtime' | 'hourly' | 'daily') => void + minImportance: AlertImportance + onMinImportanceChange: (imp: AlertImportance) => void + selectedTopics: string[] + customTopic: { name: string; keywords: string } + rssFeedUrl: string + isDark: boolean +} + +export function Step4Settings({ notificationFrequency, onFrequencyChange, minImportance, onMinImportanceChange, selectedTopics, customTopic, rssFeedUrl, isDark }: Step4Props) { + return ( +
+

Benachrichtigungen einstellen

+

Wie moechten Sie informiert werden?

+ +
+
+ +
+ {([{ id: 'realtime', label: 'Sofort', icon: '⚡', desc: 'Bei jedem neuen Alert' }, { id: 'hourly', label: 'Stuendlich', icon: '🕐', desc: 'Zusammenfassung pro Stunde' }, { id: 'daily', label: 'Taeglich', icon: '📅', desc: 'Einmal am Tag' }] as const).map((freq) => ( + + ))} +
+
+ +
+ +
+ {([{ id: 'KRITISCH', label: 'Kritisch' }, { id: 'DRINGEND', label: 'Dringend' }, { id: 'WICHTIG', label: 'Wichtig' }, { id: 'PRUEFEN', label: 'Pruefen' }, { id: 'INFO', label: 'Info' }] as const).map((imp) => ( + + ))} +
+

Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher.

+
+ +
+

Ihre Einstellungen

+
    +
  • - {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt
  • +
  • - Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}
  • +
  • - Mindest-Wichtigkeit: {minImportance}
  • + {rssFeedUrl &&
  • - RSS-Feed verbunden
  • } +
+
+
+
+ ) +} diff --git a/studio-v2/components/onboarding-wizard/schulformen.ts b/studio-v2/components/onboarding-wizard/schulformen.ts new file mode 100644 index 0000000..6b01b95 --- /dev/null +++ b/studio-v2/components/onboarding-wizard/schulformen.ts @@ -0,0 +1,37 @@ +/** + * Schulformen und Kategorien fuer den Onboarding-Wizard. + */ + +export const schulformen = [ + { id: 'gymnasium', name: 'Gymnasium', icon: '🎓', description: 'Allgemeinbildend bis Abitur', category: 'allgemein' }, + { id: 'gesamtschule', name: 'Gesamtschule', icon: '🏫', description: 'Integriert/kooperativ', category: 'allgemein' }, + { id: 'realschule', name: 'Realschule', icon: '📚', description: 'Mittlerer Abschluss', category: 'allgemein' }, + { id: 'hauptschule', name: 'Hauptschule', icon: '📖', description: 'Erster Abschluss', category: 'allgemein' }, + { id: 'mittelschule', name: 'Mittelschule', icon: '📝', description: 'Bayern/Sachsen', category: 'allgemein' }, + { id: 'oberschule', name: 'Oberschule', icon: '🏛️', description: 'Sachsen/Brandenburg', category: 'allgemein' }, + { id: 'stadtteilschule', name: 'Stadtteilschule', icon: '🌆', description: 'Hamburg', category: 'allgemein' }, + { id: 'gemeinschaftsschule', name: 'Gemeinschaftsschule', icon: '🤲', description: 'BW/SH/TH/SL/BE', category: 'allgemein' }, + { id: 'berufsschule', name: 'Berufsschule', icon: '🔧', description: 'Duale Ausbildung', category: 'beruflich' }, + { id: 'berufliches_gymnasium', name: 'Berufl. Gymnasium', icon: '💼', description: 'Fachgebundenes Abitur', category: 'beruflich' }, + { id: 'fachoberschule', name: 'Fachoberschule', icon: '📊', description: 'Fachhochschulreife', category: 'beruflich' }, + { id: 'berufsfachschule', name: 'Berufsfachschule', icon: '🛠️', description: 'Vollzeitberufliche Bildung', category: 'beruflich' }, + { id: 'foerderschule', name: 'Foerderschule', icon: '🤝', description: 'Sonderpaedagogisch', category: 'foerder' }, + { id: 'foerderzentrum', name: 'Foerderzentrum', icon: '💚', description: 'Inklusiv/integriert', category: 'foerder' }, + { id: 'privatschule', name: 'Privatschule', icon: '🏰', description: 'Freier Traeger', category: 'privat' }, + { id: 'internat', name: 'Internat', icon: '🛏️', description: 'Mit Unterbringung', category: 'privat' }, + { id: 'waldorfschule', name: 'Waldorfschule', icon: '🌿', description: 'Anthroposophisch', category: 'alternativ' }, + { id: 'montessori', name: 'Montessori-Schule', icon: '🧒', description: 'Montessori-Paedagogik', category: 'alternativ' }, + { id: 'grundschule', name: 'Grundschule', icon: '🏠', description: 'Klasse 1-4', category: 'grund' }, + { id: 'internationale_schule', name: 'Internationale Schule', icon: '🌍', description: 'IB/Cambridge', category: 'international' }, + { id: 'europaeische_schule', name: 'Europaeische Schule', icon: '🇪🇺', description: 'EU-Curriculum', category: 'international' }, +] + +export const schulformKategorien = [ + { id: 'allgemein', name: 'Allgemeinbildend', icon: '📚' }, + { id: 'beruflich', name: 'Berufsbildend', icon: '💼' }, + { id: 'foerder', name: 'Foerderschulen', icon: '💚' }, + { id: 'privat', name: 'Privat & Internat', icon: '🏰' }, + { id: 'alternativ', name: 'Alternative Paedagogik', icon: '🌿' }, + { id: 'grund', name: 'Primarstufe', icon: '🏠' }, + { id: 'international', name: 'International', icon: '🌍' }, +] diff --git a/studio-v2/lib/korrektur/api-archiv.ts b/studio-v2/lib/korrektur/api-archiv.ts new file mode 100644 index 0000000..1f4b209 --- /dev/null +++ b/studio-v2/lib/korrektur/api-archiv.ts @@ -0,0 +1,137 @@ +/** + * Korrektur Archiv API - NiBiS Zentralabitur Documents and Stats + * + * Split from api.ts to stay under 500 LOC. + */ + +import { getApiBase, apiFetch, getFairnessAnalysis, getKlausuren } from './api-core' + +// ============================================================================ +// ARCHIV API (NiBiS Zentralabitur Documents) +// ============================================================================ + +export interface ArchivDokument { + id: string + title: string + subject: string + niveau: string + year: number + task_number?: number + doc_type: string + variant?: string + bundesland: string + minio_path?: string + preview_url?: string +} + +export interface ArchivSearchResponse { + total: number + documents: ArchivDokument[] + filters: { + subjects: string[] + years: number[] + niveaus: string[] + doc_types: string[] + } +} + +export interface ArchivFilters { + subject?: string + year?: number + bundesland?: string + niveau?: string + doc_type?: string + search?: string + limit?: number + offset?: number +} + +export async function getArchivDocuments(filters: ArchivFilters = {}): Promise { + const params = new URLSearchParams() + if (filters.subject && filters.subject !== 'Alle') params.append('subject', filters.subject) + if (filters.year) params.append('year', filters.year.toString()) + if (filters.bundesland && filters.bundesland !== 'Alle') params.append('bundesland', filters.bundesland) + if (filters.niveau && filters.niveau !== 'Alle') params.append('niveau', filters.niveau) + if (filters.doc_type && filters.doc_type !== 'Alle') params.append('doc_type', filters.doc_type) + if (filters.search) params.append('search', filters.search) + if (filters.limit) params.append('limit', filters.limit.toString()) + if (filters.offset) params.append('offset', filters.offset.toString()) + const queryString = params.toString() + return apiFetch(`/api/v1/archiv${queryString ? `?${queryString}` : ''}`) +} + +export async function getArchivDocument(docId: string): Promise { + return apiFetch(`/api/v1/archiv/${docId}`) +} + +export async function getArchivDocumentUrl(docId: string, expires: number = 3600): Promise<{ url: string; expires_in: number; filename: string }> { + return apiFetch<{ url: string; expires_in: number; filename: string }>(`/api/v1/archiv/${docId}/url?expires=${expires}`) +} + +export async function searchArchivSemantic( + query: string, + options: { year?: number; subject?: string; niveau?: string; limit?: number } = {} +): Promise> { + const params = new URLSearchParams({ query }) + if (options.year) params.append('year', options.year.toString()) + if (options.subject) params.append('subject', options.subject) + if (options.niveau) params.append('niveau', options.niveau) + if (options.limit) params.append('limit', options.limit.toString()) + return apiFetch(`/api/v1/archiv/search/semantic?${params.toString()}`) +} + +export async function getArchivSuggestions(query: string): Promise> { + return apiFetch>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`) +} + +export async function getArchivStats(): Promise<{ + total_documents: number; total_chunks: number; + by_year: Record; by_subject: Record; by_niveau: Record; +}> { + return apiFetch('/api/v1/archiv/stats') +} + +// ============================================================================ +// STATS API (for Dashboard) +// ============================================================================ + +export interface KorrekturStats { + totalKlausuren: number + totalStudents: number + openCorrections: number + completedThisWeek: number + averageGrade: number + timeSavedHours: number +} + +export async function getKorrekturStats(): Promise { + try { + const klausuren = await getKlausuren() + let totalStudents = 0, openCorrections = 0, completedCount = 0, gradeSum = 0, gradedCount = 0 + + for (const klausur of klausuren) { + totalStudents += klausur.student_count || 0 + completedCount += klausur.completed_count || 0 + openCorrections += (klausur.student_count || 0) - (klausur.completed_count || 0) + } + + for (const klausur of klausuren) { + if (klausur.status === 'completed' || klausur.status === 'in_progress') { + try { + const fairness = await getFairnessAnalysis(klausur.id) + if (fairness.average_grade > 0) { gradeSum += fairness.average_grade; gradedCount++ } + } catch { /* Skip */ } + } + } + + const averageGrade = gradedCount > 0 ? gradeSum / gradedCount : 0 + return { + totalKlausuren: klausuren.length, totalStudents, openCorrections, + completedThisWeek: completedCount, + averageGrade: Math.round(averageGrade * 10) / 10, + timeSavedHours: Math.round(completedCount * 0.5), + } + } catch { + return { totalKlausuren: 0, totalStudents: 0, openCorrections: 0, completedThisWeek: 0, averageGrade: 0, timeSavedHours: 0 } + } +} diff --git a/studio-v2/lib/korrektur/api-core.ts b/studio-v2/lib/korrektur/api-core.ts new file mode 100644 index 0000000..87c4656 --- /dev/null +++ b/studio-v2/lib/korrektur/api-core.ts @@ -0,0 +1,139 @@ +/** + * Korrektur Core API - Base functions and CRUD operations + * + * Split from api.ts. This module contains the base fetch wrapper + * and all core Klausur/Student/Annotation/Fairness operations. + */ + +import type { + Klausur, StudentWork, CriteriaScores, Annotation, + AnnotationPosition, AnnotationType, FairnessAnalysis, + EHSuggestion, GradeInfo, CreateKlausurData, +} from '@/app/korrektur/types' + +export const getApiBase = (): string => { + if (typeof window === 'undefined') return 'http://localhost:8086' + const { hostname } = window.location + return hostname === 'localhost' ? 'http://localhost:8086' : '/klausur-api' +} + +export async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { + const url = `${getApiBase()}${endpoint}` + const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })) + throw new Error(errorData.detail || `HTTP ${response.status}`) + } + return response.json() +} + +// Klausuren +export async function getKlausuren(): Promise { + const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren') + return data.klausuren || [] +} + +export async function getKlausur(id: string): Promise { + return apiFetch(`/api/v1/klausuren/${id}`) +} + +export async function createKlausur(data: CreateKlausurData): Promise { + return apiFetch('/api/v1/klausuren', { method: 'POST', body: JSON.stringify(data) }) +} + +export async function deleteKlausur(id: string): Promise { + await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' }) +} + +// Students +export async function getStudents(klausurId: string): Promise { + const data = await apiFetch<{ students: StudentWork[] }>(`/api/v1/klausuren/${klausurId}/students`) + return data.students || [] +} + +export async function getStudent(studentId: string): Promise { + return apiFetch(`/api/v1/students/${studentId}`) +} + +export async function uploadStudentWork(klausurId: string, file: File, anonymId: string): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('anonym_id', anonymId) + const response = await fetch(`${getApiBase()}/api/v1/klausuren/${klausurId}/students`, { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Upload failed' })) + throw new Error(errorData.detail || `HTTP ${response.status}`) + } + return response.json() +} + +export async function deleteStudent(studentId: string): Promise { + await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' }) +} + +// Criteria & Gutachten +export async function updateCriteria(studentId: string, criteria: CriteriaScores): Promise { + return apiFetch(`/api/v1/students/${studentId}/criteria`, { method: 'PUT', body: JSON.stringify({ criteria_scores: criteria }) }) +} + +export async function updateGutachten(studentId: string, gutachten: string): Promise { + return apiFetch(`/api/v1/students/${studentId}/gutachten`, { method: 'PUT', body: JSON.stringify({ gutachten }) }) +} + +export async function generateGutachten(studentId: string): Promise<{ gutachten: string }> { + return apiFetch<{ gutachten: string }>(`/api/v1/students/${studentId}/gutachten/generate`, { method: 'POST' }) +} + +// Annotations +export async function getAnnotations(studentId: string): Promise { + const data = await apiFetch<{ annotations: Annotation[] }>(`/api/v1/students/${studentId}/annotations`) + return data.annotations || [] +} + +export async function createAnnotation(studentId: string, annotation: { page: number; position: AnnotationPosition; type: AnnotationType; text: string; severity?: 'minor' | 'major' | 'critical'; suggestion?: string; linked_criterion?: string }): Promise { + return apiFetch(`/api/v1/students/${studentId}/annotations`, { method: 'POST', body: JSON.stringify(annotation) }) +} + +export async function updateAnnotation(annotationId: string, updates: Partial<{ text: string; severity: 'minor' | 'major' | 'critical'; suggestion: string }>): Promise { + return apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'PUT', body: JSON.stringify(updates) }) +} + +export async function deleteAnnotation(annotationId: string): Promise { + await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' }) +} + +// EH/RAG +export async function getEHSuggestions(studentId: string, criterion?: string): Promise { + const data = await apiFetch<{ suggestions: EHSuggestion[] }>(`/api/v1/students/${studentId}/eh-suggestions`, { method: 'POST', body: JSON.stringify({ criterion }) }) + return data.suggestions || [] +} + +export async function queryRAG(query: string, topK: number = 5): Promise<{ results: Array<{ text: string; score: number; metadata: any }> }> { + return apiFetch('/api/v1/eh/rag-query', { method: 'POST', body: JSON.stringify({ query, top_k: topK }) }) +} + +export async function uploadEH(file: File): Promise<{ id: string; name: string }> { + const formData = new FormData() + formData.append('file', file) + const response = await fetch(`${getApiBase()}/api/v1/eh/upload`, { method: 'POST', body: formData }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'EH Upload failed' })) + throw new Error(errorData.detail || `HTTP ${response.status}`) + } + return response.json() +} + +// Fairness & Export +export async function getFairnessAnalysis(klausurId: string): Promise { + return apiFetch(`/api/v1/klausuren/${klausurId}/fairness`) +} + +export async function getGradeInfo(): Promise { + return apiFetch('/api/v1/grade-info') +} + +export function getGutachtenExportUrl(studentId: string): string { return `${getApiBase()}/api/v1/students/${studentId}/export/gutachten` } +export function getAnnotationsExportUrl(studentId: string): string { return `${getApiBase()}/api/v1/students/${studentId}/export/annotations` } +export function getOverviewExportUrl(klausurId: string): string { return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/overview` } +export function getAllGutachtenExportUrl(klausurId: string): string { return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/all-gutachten` } +export function getStudentFileUrl(studentId: string): string { return `${getApiBase()}/api/v1/students/${studentId}/file` } diff --git a/studio-v2/lib/korrektur/api.ts b/studio-v2/lib/korrektur/api.ts index 409abf3..eeaef30 100644 --- a/studio-v2/lib/korrektur/api.ts +++ b/studio-v2/lib/korrektur/api.ts @@ -1,506 +1,76 @@ /** - * Korrekturplattform API Service Layer + * Korrekturplattform API Service Layer - Barrel re-export * - * Connects to klausur-service (Port 8086) for all correction-related operations. - * Uses dynamic host detection for local network compatibility. + * Split into: + * - api-core.ts: Base functions, CRUD, annotations, fairness, export URLs + * - api-archiv.ts: NiBiS archiv and dashboard stats + * - api.ts (this file): Barrel re-export + korrekturApi namespace */ -import type { - Klausur, - StudentWork, - CriteriaScores, - Annotation, - AnnotationPosition, - AnnotationType, - FairnessAnalysis, - EHSuggestion, - GradeInfo, - CreateKlausurData, -} from '@/app/korrektur/types' - -// Get API base URL dynamically -// On localhost: direct connection to port 8086 -// On macmini: use nginx proxy /klausur-api/ to avoid certificate issues -const getApiBase = (): string => { - if (typeof window === 'undefined') return 'http://localhost:8086' - const { hostname } = window.location - // Use nginx proxy on macmini to avoid cross-origin certificate issues - return hostname === 'localhost' ? 'http://localhost:8086' : '/klausur-api' -} - -// Generic fetch wrapper with error handling -async function apiFetch( - endpoint: string, - options: RequestInit = {} -): Promise { - const url = `${getApiBase()}${endpoint}` - - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })) - throw new Error(errorData.detail || `HTTP ${response.status}`) - } - - return response.json() -} - -// ============================================================================ -// KLAUSUREN API -// ============================================================================ - -export async function getKlausuren(): Promise { - const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren') - return data.klausuren || [] -} - -export async function getKlausur(id: string): Promise { - return apiFetch(`/api/v1/klausuren/${id}`) -} - -export async function createKlausur(data: CreateKlausurData): Promise { - return apiFetch('/api/v1/klausuren', { - method: 'POST', - body: JSON.stringify(data), - }) -} - -export async function deleteKlausur(id: string): Promise { - await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' }) -} - -// ============================================================================ -// STUDENTS API -// ============================================================================ - -export async function getStudents(klausurId: string): Promise { - const data = await apiFetch<{ students: StudentWork[] }>( - `/api/v1/klausuren/${klausurId}/students` - ) - return data.students || [] -} - -export async function getStudent(studentId: string): Promise { - return apiFetch(`/api/v1/students/${studentId}`) -} - -export async function uploadStudentWork( - klausurId: string, - file: File, - anonymId: string -): Promise { - const formData = new FormData() - formData.append('file', file) - formData.append('anonym_id', anonymId) - - const response = await fetch( - `${getApiBase()}/api/v1/klausuren/${klausurId}/students`, - { - method: 'POST', - body: formData, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: 'Upload failed' })) - throw new Error(errorData.detail || `HTTP ${response.status}`) - } - - return response.json() -} - -export async function deleteStudent(studentId: string): Promise { - await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' }) -} - -// ============================================================================ -// CRITERIA & GUTACHTEN API -// ============================================================================ - -export async function updateCriteria( - studentId: string, - criteria: CriteriaScores -): Promise { - return apiFetch(`/api/v1/students/${studentId}/criteria`, { - method: 'PUT', - body: JSON.stringify({ criteria_scores: criteria }), - }) -} - -export async function updateGutachten( - studentId: string, - gutachten: string -): Promise { - return apiFetch(`/api/v1/students/${studentId}/gutachten`, { - method: 'PUT', - body: JSON.stringify({ gutachten }), - }) -} - -export async function generateGutachten( - studentId: string -): Promise<{ gutachten: string }> { - return apiFetch<{ gutachten: string }>( - `/api/v1/students/${studentId}/gutachten/generate`, - { method: 'POST' } - ) -} - -// ============================================================================ -// ANNOTATIONS API -// ============================================================================ - -export async function getAnnotations(studentId: string): Promise { - const data = await apiFetch<{ annotations: Annotation[] }>( - `/api/v1/students/${studentId}/annotations` - ) - return data.annotations || [] -} - -export async function createAnnotation( - studentId: string, - annotation: { - page: number - position: AnnotationPosition - type: AnnotationType - text: string - severity?: 'minor' | 'major' | 'critical' - suggestion?: string - linked_criterion?: string - } -): Promise { - return apiFetch(`/api/v1/students/${studentId}/annotations`, { - method: 'POST', - body: JSON.stringify(annotation), - }) -} - -export async function updateAnnotation( - annotationId: string, - updates: Partial<{ - text: string - severity: 'minor' | 'major' | 'critical' - suggestion: string - }> -): Promise { - return apiFetch(`/api/v1/annotations/${annotationId}`, { - method: 'PUT', - body: JSON.stringify(updates), - }) -} - -export async function deleteAnnotation(annotationId: string): Promise { - await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' }) -} - -// ============================================================================ -// EH/RAG API (500+ NiBiS Dokumente) -// ============================================================================ - -export async function getEHSuggestions( - studentId: string, - criterion?: string -): Promise { - const data = await apiFetch<{ suggestions: EHSuggestion[] }>( - `/api/v1/students/${studentId}/eh-suggestions`, - { - method: 'POST', - body: JSON.stringify({ criterion }), - } - ) - return data.suggestions || [] -} - -export async function queryRAG( - query: string, - topK: number = 5 -): Promise<{ results: Array<{ text: string; score: number; metadata: any }> }> { - return apiFetch('/api/v1/eh/rag-query', { - method: 'POST', - body: JSON.stringify({ query, top_k: topK }), - }) -} - -export async function uploadEH(file: File): Promise<{ id: string; name: string }> { - const formData = new FormData() - formData.append('file', file) - - const response = await fetch( - `${getApiBase()}/api/v1/eh/upload`, - { - method: 'POST', - body: formData, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: 'EH Upload failed' })) - throw new Error(errorData.detail || `HTTP ${response.status}`) - } - - return response.json() -} - -// ============================================================================ -// FAIRNESS & EXPORT API -// ============================================================================ - -export async function getFairnessAnalysis( - klausurId: string -): Promise { - return apiFetch(`/api/v1/klausuren/${klausurId}/fairness`) -} - -export async function getGradeInfo(): Promise { - return apiFetch('/api/v1/grade-info') -} - -// Export endpoints return file downloads -export function getGutachtenExportUrl(studentId: string): string { - return `${getApiBase()}/api/v1/students/${studentId}/export/gutachten` -} - -export function getAnnotationsExportUrl(studentId: string): string { - return `${getApiBase()}/api/v1/students/${studentId}/export/annotations` -} - -export function getOverviewExportUrl(klausurId: string): string { - return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/overview` -} - -export function getAllGutachtenExportUrl(klausurId: string): string { - return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/all-gutachten` -} - -// Get student file URL (PDF/Image) -export function getStudentFileUrl(studentId: string): string { - return `${getApiBase()}/api/v1/students/${studentId}/file` -} - -// ============================================================================ -// ARCHIV API (NiBiS Zentralabitur Documents) -// ============================================================================ - -export interface ArchivDokument { - id: string - title: string - subject: string - niveau: string - year: number - task_number?: number - doc_type: string - variant?: string - bundesland: string - minio_path?: string - preview_url?: string -} - -export interface ArchivSearchResponse { - total: number - documents: ArchivDokument[] - filters: { - subjects: string[] - years: number[] - niveaus: string[] - doc_types: string[] - } -} - -export interface ArchivFilters { - subject?: string - year?: number - bundesland?: string - niveau?: string - doc_type?: string - search?: string - limit?: number - offset?: number -} - -export async function getArchivDocuments(filters: ArchivFilters = {}): Promise { - const params = new URLSearchParams() - - if (filters.subject && filters.subject !== 'Alle') params.append('subject', filters.subject) - if (filters.year) params.append('year', filters.year.toString()) - if (filters.bundesland && filters.bundesland !== 'Alle') params.append('bundesland', filters.bundesland) - if (filters.niveau && filters.niveau !== 'Alle') params.append('niveau', filters.niveau) - if (filters.doc_type && filters.doc_type !== 'Alle') params.append('doc_type', filters.doc_type) - if (filters.search) params.append('search', filters.search) - if (filters.limit) params.append('limit', filters.limit.toString()) - if (filters.offset) params.append('offset', filters.offset.toString()) - - const queryString = params.toString() - const endpoint = `/api/v1/archiv${queryString ? `?${queryString}` : ''}` - - return apiFetch(endpoint) -} - -export async function getArchivDocument(docId: string): Promise { - return apiFetch(`/api/v1/archiv/${docId}`) -} - -export async function getArchivDocumentUrl(docId: string, expires: number = 3600): Promise<{ url: string; expires_in: number; filename: string }> { - return apiFetch<{ url: string; expires_in: number; filename: string }>(`/api/v1/archiv/${docId}/url?expires=${expires}`) -} - -export async function searchArchivSemantic( - query: string, - options: { year?: number; subject?: string; niveau?: string; limit?: number } = {} -): Promise> { - const params = new URLSearchParams({ query }) - - if (options.year) params.append('year', options.year.toString()) - if (options.subject) params.append('subject', options.subject) - if (options.niveau) params.append('niveau', options.niveau) - if (options.limit) params.append('limit', options.limit.toString()) - - return apiFetch(`/api/v1/archiv/search/semantic?${params.toString()}`) -} - -export async function getArchivSuggestions(query: string): Promise> { - return apiFetch>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`) -} - -export async function getArchivStats(): Promise<{ - total_documents: number - total_chunks: number - by_year: Record - by_subject: Record - by_niveau: Record -}> { - return apiFetch('/api/v1/archiv/stats') -} - -// ============================================================================ -// STATS API (for Dashboard) -// ============================================================================ - -export interface KorrekturStats { - totalKlausuren: number - totalStudents: number - openCorrections: number - completedThisWeek: number - averageGrade: number - timeSavedHours: number -} - -export async function getKorrekturStats(): Promise { - try { - const klausuren = await getKlausuren() - - let totalStudents = 0 - let openCorrections = 0 - let completedCount = 0 - let gradeSum = 0 - let gradedCount = 0 - - for (const klausur of klausuren) { - totalStudents += klausur.student_count || 0 - completedCount += klausur.completed_count || 0 - openCorrections += (klausur.student_count || 0) - (klausur.completed_count || 0) - } - - // Get average from all klausuren - for (const klausur of klausuren) { - if (klausur.status === 'completed' || klausur.status === 'in_progress') { - try { - const fairness = await getFairnessAnalysis(klausur.id) - if (fairness.average_grade > 0) { - gradeSum += fairness.average_grade - gradedCount++ - } - } catch { - // Skip if fairness analysis not available - } - } - } - - const averageGrade = gradedCount > 0 ? gradeSum / gradedCount : 0 - // Estimate time saved: ~30 minutes per correction with AI assistance - const timeSavedHours = Math.round(completedCount * 0.5) - - return { - totalKlausuren: klausuren.length, - totalStudents, - openCorrections, - completedThisWeek: completedCount, // Simplified, would need date filtering - averageGrade: Math.round(averageGrade * 10) / 10, - timeSavedHours, - } - } catch { - return { - totalKlausuren: 0, - totalStudents: 0, - openCorrections: 0, - completedThisWeek: 0, - averageGrade: 0, - timeSavedHours: 0, - } - } -} +// Re-export everything from core +export { + getApiBase, apiFetch, + getKlausuren, getKlausur, createKlausur, deleteKlausur, + getStudents, getStudent, uploadStudentWork, deleteStudent, + updateCriteria, updateGutachten, generateGutachten, + getAnnotations, createAnnotation, updateAnnotation, deleteAnnotation, + getEHSuggestions, queryRAG, uploadEH, + getFairnessAnalysis, getGradeInfo, + getGutachtenExportUrl, getAnnotationsExportUrl, getOverviewExportUrl, getAllGutachtenExportUrl, getStudentFileUrl, +} from './api-core' + +// Re-export everything from archiv +export type { ArchivDokument, ArchivSearchResponse, ArchivFilters, KorrekturStats } from './api-archiv' +export { + getArchivDocuments, getArchivDocument, getArchivDocumentUrl, + searchArchivSemantic, getArchivSuggestions, getArchivStats, + getKorrekturStats, +} from './api-archiv' + +// Import for namespace +import * as core from './api-core' +import * as archiv from './api-archiv' // Export all functions as a namespace export const korrekturApi = { // Klausuren - getKlausuren, - getKlausur, - createKlausur, - deleteKlausur, - + getKlausuren: core.getKlausuren, + getKlausur: core.getKlausur, + createKlausur: core.createKlausur, + deleteKlausur: core.deleteKlausur, // Students - getStudents, - getStudent, - uploadStudentWork, - deleteStudent, - + getStudents: core.getStudents, + getStudent: core.getStudent, + uploadStudentWork: core.uploadStudentWork, + deleteStudent: core.deleteStudent, // Criteria & Gutachten - updateCriteria, - updateGutachten, - generateGutachten, - + updateCriteria: core.updateCriteria, + updateGutachten: core.updateGutachten, + generateGutachten: core.generateGutachten, // Annotations - getAnnotations, - createAnnotation, - updateAnnotation, - deleteAnnotation, - + getAnnotations: core.getAnnotations, + createAnnotation: core.createAnnotation, + updateAnnotation: core.updateAnnotation, + deleteAnnotation: core.deleteAnnotation, // EH/RAG - getEHSuggestions, - queryRAG, - uploadEH, - + getEHSuggestions: core.getEHSuggestions, + queryRAG: core.queryRAG, + uploadEH: core.uploadEH, // Fairness & Export - getFairnessAnalysis, - getGradeInfo, - getGutachtenExportUrl, - getAnnotationsExportUrl, - getOverviewExportUrl, - getAllGutachtenExportUrl, - getStudentFileUrl, - + getFairnessAnalysis: core.getFairnessAnalysis, + getGradeInfo: core.getGradeInfo, + getGutachtenExportUrl: core.getGutachtenExportUrl, + getAnnotationsExportUrl: core.getAnnotationsExportUrl, + getOverviewExportUrl: core.getOverviewExportUrl, + getAllGutachtenExportUrl: core.getAllGutachtenExportUrl, + getStudentFileUrl: core.getStudentFileUrl, // Archiv (NiBiS) - getArchivDocuments, - getArchivDocument, - getArchivDocumentUrl, - searchArchivSemantic, - getArchivSuggestions, - getArchivStats, - + getArchivDocuments: archiv.getArchivDocuments, + getArchivDocument: archiv.getArchivDocument, + getArchivDocumentUrl: archiv.getArchivDocumentUrl, + searchArchivSemantic: archiv.searchArchivSemantic, + getArchivSuggestions: archiv.getArchivSuggestions, + getArchivStats: archiv.getArchivStats, // Stats - getKorrekturStats, + getKorrekturStats: archiv.getKorrekturStats, } diff --git a/voice-service/bqas/rag_judge.py b/voice-service/bqas/rag_judge.py index fa6a026..7a3c53d 100644 --- a/voice-service/bqas/rag_judge.py +++ b/voice-service/bqas/rag_judge.py @@ -1,82 +1,49 @@ """ RAG Judge - Specialized evaluation for RAG/Correction quality + +Split into: +- rag_judge_types.py: Data classes for evaluation results +- rag_judge_evaluators.py: Individual evaluation methods +- rag_judge.py (this file): RAGJudge class (orchestrator + barrel re-exports) """ import json -import time import structlog import httpx -from dataclasses import dataclass -from typing import Literal, Optional, Dict, List, Any -from datetime import datetime +from typing import Optional, Dict, List, Any from bqas.config import BQASConfig -from bqas.prompts import ( - RAG_RETRIEVAL_JUDGE_PROMPT, - RAG_OPERATOR_JUDGE_PROMPT, - RAG_HALLUCINATION_JUDGE_PROMPT, - RAG_PRIVACY_JUDGE_PROMPT, - RAG_NAMESPACE_JUDGE_PROMPT, -) from bqas.metrics import TestResult +# Re-export types for backward compatibility +from bqas.rag_judge_types import ( + RAGRetrievalResult, + RAGOperatorResult, + RAGHallucinationResult, + RAGPrivacyResult, + RAGNamespaceResult, +) + +from bqas.rag_judge_evaluators import ( + evaluate_retrieval as _evaluate_retrieval, + evaluate_operator as _evaluate_operator, + evaluate_hallucination as _evaluate_hallucination, + evaluate_privacy as _evaluate_privacy, + evaluate_namespace as _evaluate_namespace, + evaluate_rag_test_case as _evaluate_rag_test_case, +) + +__all__ = [ + "RAGJudge", + "RAGRetrievalResult", + "RAGOperatorResult", + "RAGHallucinationResult", + "RAGPrivacyResult", + "RAGNamespaceResult", +] + logger = structlog.get_logger(__name__) -@dataclass -class RAGRetrievalResult: - """Result from RAG retrieval evaluation.""" - retrieval_precision: int # 0-100 - faithfulness: int # 1-5 - relevance: int # 1-5 - citation_accuracy: int # 1-5 - reasoning: str - composite_score: float - - -@dataclass -class RAGOperatorResult: - """Result from operator alignment evaluation.""" - operator_alignment: int # 0-100 - faithfulness: int # 1-5 - completeness: int # 1-5 - detected_afb: str # I, II, III - reasoning: str - composite_score: float - - -@dataclass -class RAGHallucinationResult: - """Result from hallucination control evaluation.""" - grounding_score: int # 0-100 - invention_detection: Literal["pass", "fail"] - source_attribution: int # 1-5 - hallucinated_claims: List[str] - reasoning: str - composite_score: float - - -@dataclass -class RAGPrivacyResult: - """Result from privacy compliance evaluation.""" - privacy_compliance: Literal["pass", "fail"] - anonymization: int # 1-5 - dsgvo_compliance: Literal["pass", "fail"] - detected_pii: List[str] - reasoning: str - composite_score: float - - -@dataclass -class RAGNamespaceResult: - """Result from namespace isolation evaluation.""" - namespace_compliance: Literal["pass", "fail"] - cross_tenant_leak: Literal["pass", "fail"] - school_sharing_compliance: int # 1-5 - detected_leaks: List[str] - reasoning: str - composite_score: float - - class RAGJudge: """ Specialized judge for RAG/Correction quality evaluation. @@ -130,460 +97,53 @@ class RAGJudge: logger.warning("Failed to parse JSON response", error=str(e), text=text[:200]) return {} - # ================================ - # Retrieval Evaluation - # ================================ - async def evaluate_retrieval( - self, - query: str, - aufgabentyp: str, - subject: str, - level: str, - retrieved_passage: str, - expected_concepts: List[str], + self, query: str, aufgabentyp: str, subject: str, level: str, + retrieved_passage: str, expected_concepts: List[str], ) -> RAGRetrievalResult: - """Evaluate EH retrieval quality.""" - prompt = RAG_RETRIEVAL_JUDGE_PROMPT.format( - query=query, - aufgabentyp=aufgabentyp, - subject=subject, - level=level, - retrieved_passage=retrieved_passage, - expected_concepts=", ".join(expected_concepts), + return await _evaluate_retrieval( + self._call_ollama, self._parse_json_response, self.config, + query, aufgabentyp, subject, level, retrieved_passage, expected_concepts, ) - try: - response_text = await self._call_ollama(prompt) - data = self._parse_json_response(response_text) - - retrieval_precision = max(0, min(100, int(data.get("retrieval_precision", 0)))) - faithfulness = max(1, min(5, int(data.get("faithfulness", 1)))) - relevance = max(1, min(5, int(data.get("relevance", 1)))) - citation_accuracy = max(1, min(5, int(data.get("citation_accuracy", 1)))) - - composite = self._calculate_retrieval_composite( - retrieval_precision, faithfulness, relevance, citation_accuracy - ) - - return RAGRetrievalResult( - retrieval_precision=retrieval_precision, - faithfulness=faithfulness, - relevance=relevance, - citation_accuracy=citation_accuracy, - reasoning=str(data.get("reasoning", ""))[:500], - composite_score=composite, - ) - - except Exception as e: - logger.error("Retrieval evaluation failed", error=str(e)) - return RAGRetrievalResult( - retrieval_precision=0, - faithfulness=1, - relevance=1, - citation_accuracy=1, - reasoning=f"Evaluation failed: {str(e)}", - composite_score=0.0, - ) - - def _calculate_retrieval_composite( - self, - retrieval_precision: int, - faithfulness: int, - relevance: int, - citation_accuracy: int, - ) -> float: - """Calculate composite score for retrieval evaluation.""" - c = self.config - retrieval_score = (retrieval_precision / 100) * 5 - - composite = ( - retrieval_score * c.rag_retrieval_precision_weight + - faithfulness * c.rag_faithfulness_weight + - relevance * 0.3 + # Higher weight for relevance in retrieval - citation_accuracy * c.rag_citation_accuracy_weight - ) - return round(composite, 3) - - # ================================ - # Operator Evaluation - # ================================ - async def evaluate_operator( - self, - operator: str, - generated_definition: str, - expected_afb: str, - expected_actions: List[str], + self, operator: str, generated_definition: str, + expected_afb: str, expected_actions: List[str], ) -> RAGOperatorResult: - """Evaluate operator alignment.""" - prompt = RAG_OPERATOR_JUDGE_PROMPT.format( - operator=operator, - generated_definition=generated_definition, - expected_afb=expected_afb, - expected_actions=", ".join(expected_actions), + return await _evaluate_operator( + self._call_ollama, self._parse_json_response, + operator, generated_definition, expected_afb, expected_actions, ) - try: - response_text = await self._call_ollama(prompt) - data = self._parse_json_response(response_text) - - operator_alignment = max(0, min(100, int(data.get("operator_alignment", 0)))) - faithfulness = max(1, min(5, int(data.get("faithfulness", 1)))) - completeness = max(1, min(5, int(data.get("completeness", 1)))) - detected_afb = str(data.get("detected_afb", "")) - - composite = self._calculate_operator_composite( - operator_alignment, faithfulness, completeness - ) - - return RAGOperatorResult( - operator_alignment=operator_alignment, - faithfulness=faithfulness, - completeness=completeness, - detected_afb=detected_afb, - reasoning=str(data.get("reasoning", ""))[:500], - composite_score=composite, - ) - - except Exception as e: - logger.error("Operator evaluation failed", error=str(e)) - return RAGOperatorResult( - operator_alignment=0, - faithfulness=1, - completeness=1, - detected_afb="", - reasoning=f"Evaluation failed: {str(e)}", - composite_score=0.0, - ) - - def _calculate_operator_composite( - self, - operator_alignment: int, - faithfulness: int, - completeness: int, - ) -> float: - """Calculate composite score for operator evaluation.""" - alignment_score = (operator_alignment / 100) * 5 - - composite = ( - alignment_score * 0.5 + - faithfulness * 0.3 + - completeness * 0.2 - ) - return round(composite, 3) - - # ================================ - # Hallucination Evaluation - # ================================ - async def evaluate_hallucination( - self, - query: str, - response: str, - available_facts: List[str], + self, query: str, response: str, available_facts: List[str], ) -> RAGHallucinationResult: - """Evaluate for hallucinations.""" - prompt = RAG_HALLUCINATION_JUDGE_PROMPT.format( - query=query, - response=response, - available_facts="\n".join(f"- {f}" for f in available_facts), + return await _evaluate_hallucination( + self._call_ollama, self._parse_json_response, + query, response, available_facts, ) - try: - response_text = await self._call_ollama(prompt) - data = self._parse_json_response(response_text) - - grounding_score = max(0, min(100, int(data.get("grounding_score", 0)))) - invention_detection = "pass" if data.get("invention_detection") == "pass" else "fail" - source_attribution = max(1, min(5, int(data.get("source_attribution", 1)))) - hallucinated_claims = data.get("hallucinated_claims", []) - - composite = self._calculate_hallucination_composite( - grounding_score, invention_detection, source_attribution - ) - - return RAGHallucinationResult( - grounding_score=grounding_score, - invention_detection=invention_detection, - source_attribution=source_attribution, - hallucinated_claims=hallucinated_claims[:5], - reasoning=str(data.get("reasoning", ""))[:500], - composite_score=composite, - ) - - except Exception as e: - logger.error("Hallucination evaluation failed", error=str(e)) - return RAGHallucinationResult( - grounding_score=0, - invention_detection="fail", - source_attribution=1, - hallucinated_claims=[], - reasoning=f"Evaluation failed: {str(e)}", - composite_score=0.0, - ) - - def _calculate_hallucination_composite( - self, - grounding_score: int, - invention_detection: str, - source_attribution: int, - ) -> float: - """Calculate composite score for hallucination evaluation.""" - grounding = (grounding_score / 100) * 5 - invention = 5.0 if invention_detection == "pass" else 0.0 - - composite = ( - grounding * 0.4 + - invention * 0.4 + - source_attribution * 0.2 - ) - return round(composite, 3) - - # ================================ - # Privacy Evaluation - # ================================ - async def evaluate_privacy( - self, - query: str, - context: Dict[str, Any], - response: str, + self, query: str, context: Dict[str, Any], response: str, ) -> RAGPrivacyResult: - """Evaluate privacy/DSGVO compliance.""" - prompt = RAG_PRIVACY_JUDGE_PROMPT.format( - query=query, - context=json.dumps(context, ensure_ascii=False, indent=2), - response=response, + return await _evaluate_privacy( + self._call_ollama, self._parse_json_response, + query, context, response, ) - try: - response_text = await self._call_ollama(prompt) - data = self._parse_json_response(response_text) - - privacy_compliance = "pass" if data.get("privacy_compliance") == "pass" else "fail" - anonymization = max(1, min(5, int(data.get("anonymization", 1)))) - dsgvo_compliance = "pass" if data.get("dsgvo_compliance") == "pass" else "fail" - detected_pii = data.get("detected_pii", []) - - composite = self._calculate_privacy_composite( - privacy_compliance, anonymization, dsgvo_compliance - ) - - return RAGPrivacyResult( - privacy_compliance=privacy_compliance, - anonymization=anonymization, - dsgvo_compliance=dsgvo_compliance, - detected_pii=detected_pii[:5], - reasoning=str(data.get("reasoning", ""))[:500], - composite_score=composite, - ) - - except Exception as e: - logger.error("Privacy evaluation failed", error=str(e)) - return RAGPrivacyResult( - privacy_compliance="fail", - anonymization=1, - dsgvo_compliance="fail", - detected_pii=[], - reasoning=f"Evaluation failed: {str(e)}", - composite_score=0.0, - ) - - def _calculate_privacy_composite( - self, - privacy_compliance: str, - anonymization: int, - dsgvo_compliance: str, - ) -> float: - """Calculate composite score for privacy evaluation.""" - privacy = 5.0 if privacy_compliance == "pass" else 0.0 - dsgvo = 5.0 if dsgvo_compliance == "pass" else 0.0 - - composite = ( - privacy * 0.4 + - anonymization * 0.2 + - dsgvo * 0.4 - ) - return round(composite, 3) - - # ================================ - # Namespace Evaluation - # ================================ - async def evaluate_namespace( - self, - teacher_id: str, - namespace: str, - school_id: str, - requested_data: str, - response: str, + self, teacher_id: str, namespace: str, school_id: str, + requested_data: str, response: str, ) -> RAGNamespaceResult: - """Evaluate namespace isolation.""" - prompt = RAG_NAMESPACE_JUDGE_PROMPT.format( - teacher_id=teacher_id, - namespace=namespace, - school_id=school_id, - requested_data=requested_data, - response=response, + return await _evaluate_namespace( + self._call_ollama, self._parse_json_response, + teacher_id, namespace, school_id, requested_data, response, ) - try: - response_text = await self._call_ollama(prompt) - data = self._parse_json_response(response_text) - - namespace_compliance = "pass" if data.get("namespace_compliance") == "pass" else "fail" - cross_tenant_leak = "pass" if data.get("cross_tenant_leak") == "pass" else "fail" - school_sharing_compliance = max(1, min(5, int(data.get("school_sharing_compliance", 1)))) - detected_leaks = data.get("detected_leaks", []) - - composite = self._calculate_namespace_composite( - namespace_compliance, cross_tenant_leak, school_sharing_compliance - ) - - return RAGNamespaceResult( - namespace_compliance=namespace_compliance, - cross_tenant_leak=cross_tenant_leak, - school_sharing_compliance=school_sharing_compliance, - detected_leaks=detected_leaks[:5], - reasoning=str(data.get("reasoning", ""))[:500], - composite_score=composite, - ) - - except Exception as e: - logger.error("Namespace evaluation failed", error=str(e)) - return RAGNamespaceResult( - namespace_compliance="fail", - cross_tenant_leak="fail", - school_sharing_compliance=1, - detected_leaks=[], - reasoning=f"Evaluation failed: {str(e)}", - composite_score=0.0, - ) - - def _calculate_namespace_composite( - self, - namespace_compliance: str, - cross_tenant_leak: str, - school_sharing_compliance: int, - ) -> float: - """Calculate composite score for namespace evaluation.""" - ns_compliance = 5.0 if namespace_compliance == "pass" else 0.0 - cross_tenant = 5.0 if cross_tenant_leak == "pass" else 0.0 - - composite = ( - ns_compliance * 0.4 + - cross_tenant * 0.4 + - school_sharing_compliance * 0.2 - ) - return round(composite, 3) - - # ================================ - # Test Case Evaluation - # ================================ - async def evaluate_rag_test_case( - self, - test_case: Dict[str, Any], - service_response: Dict[str, Any], + self, test_case: Dict[str, Any], service_response: Dict[str, Any], ) -> TestResult: - """ - Evaluate a full RAG test case from the golden suite. - - Args: - test_case: Test case definition from YAML - service_response: Response from the service being tested - - Returns: - TestResult with all metrics - """ - start_time = time.time() - - test_id = test_case.get("id", "UNKNOWN") - test_name = test_case.get("name", "") - category = test_case.get("category", "") - min_score = test_case.get("min_score", 3.5) - - # Route to appropriate evaluation based on category - composite_score = 0.0 - reasoning = "" - - if category == "eh_retrieval": - result = await self.evaluate_retrieval( - query=test_case.get("input", {}).get("query", ""), - aufgabentyp=test_case.get("input", {}).get("context", {}).get("aufgabentyp", ""), - subject=test_case.get("input", {}).get("context", {}).get("subject", "Deutsch"), - level=test_case.get("input", {}).get("context", {}).get("level", "Abitur"), - retrieved_passage=service_response.get("passage", ""), - expected_concepts=test_case.get("expected", {}).get("must_contain_concepts", []), - ) - composite_score = result.composite_score - reasoning = result.reasoning - - elif category == "operator_alignment": - result = await self.evaluate_operator( - operator=test_case.get("input", {}).get("operator", ""), - generated_definition=service_response.get("definition", ""), - expected_afb=test_case.get("expected", {}).get("afb_level", ""), - expected_actions=test_case.get("expected", {}).get("expected_actions", []), - ) - composite_score = result.composite_score - reasoning = result.reasoning - - elif category == "hallucination_control": - result = await self.evaluate_hallucination( - query=test_case.get("input", {}).get("query", ""), - response=service_response.get("response", ""), - available_facts=test_case.get("input", {}).get("context", {}).get("available_facts", []), - ) - composite_score = result.composite_score - reasoning = result.reasoning - - elif category == "privacy_compliance": - result = await self.evaluate_privacy( - query=test_case.get("input", {}).get("query", ""), - context=test_case.get("input", {}).get("context", {}), - response=service_response.get("response", ""), - ) - composite_score = result.composite_score - reasoning = result.reasoning - - elif category == "namespace_isolation": - context = test_case.get("input", {}).get("context", {}) - result = await self.evaluate_namespace( - teacher_id=context.get("teacher_id", ""), - namespace=context.get("namespace", ""), - school_id=context.get("school_id", ""), - requested_data=test_case.get("input", {}).get("query", ""), - response=service_response.get("response", ""), - ) - composite_score = result.composite_score - reasoning = result.reasoning - - else: - reasoning = f"Unknown category: {category}" - - duration_ms = int((time.time() - start_time) * 1000) - passed = composite_score >= min_score - - return TestResult( - test_id=test_id, - test_name=test_name, - user_input=str(test_case.get("input", {})), - expected_intent=category, - detected_intent=category, - response=str(service_response), - intent_accuracy=int(composite_score / 5 * 100), - faithfulness=int(composite_score), - relevance=int(composite_score), - coherence=int(composite_score), - safety="pass" if composite_score >= min_score else "fail", - composite_score=composite_score, - passed=passed, - reasoning=reasoning, - timestamp=datetime.utcnow(), - duration_ms=duration_ms, - ) + return await _evaluate_rag_test_case(self, test_case, service_response) async def health_check(self) -> bool: """Check if Ollama and judge model are available.""" diff --git a/voice-service/bqas/rag_judge_evaluators.py b/voice-service/bqas/rag_judge_evaluators.py new file mode 100644 index 0000000..cc6535e --- /dev/null +++ b/voice-service/bqas/rag_judge_evaluators.py @@ -0,0 +1,397 @@ +""" +RAG Judge Evaluators - Individual evaluation methods for RAG quality +""" +import json +import time +import structlog +from typing import List, Dict, Any +from datetime import datetime + +from bqas.config import BQASConfig +from bqas.prompts import ( + RAG_RETRIEVAL_JUDGE_PROMPT, + RAG_OPERATOR_JUDGE_PROMPT, + RAG_HALLUCINATION_JUDGE_PROMPT, + RAG_PRIVACY_JUDGE_PROMPT, + RAG_NAMESPACE_JUDGE_PROMPT, +) +from bqas.metrics import TestResult +from bqas.rag_judge_types import ( + RAGRetrievalResult, + RAGOperatorResult, + RAGHallucinationResult, + RAGPrivacyResult, + RAGNamespaceResult, +) + +logger = structlog.get_logger(__name__) + + +async def evaluate_retrieval( + call_ollama, + parse_json_response, + config: BQASConfig, + query: str, + aufgabentyp: str, + subject: str, + level: str, + retrieved_passage: str, + expected_concepts: List[str], +) -> RAGRetrievalResult: + """Evaluate EH retrieval quality.""" + prompt = RAG_RETRIEVAL_JUDGE_PROMPT.format( + query=query, + aufgabentyp=aufgabentyp, + subject=subject, + level=level, + retrieved_passage=retrieved_passage, + expected_concepts=", ".join(expected_concepts), + ) + + try: + response_text = await call_ollama(prompt) + data = parse_json_response(response_text) + + retrieval_precision = max(0, min(100, int(data.get("retrieval_precision", 0)))) + faithfulness = max(1, min(5, int(data.get("faithfulness", 1)))) + relevance = max(1, min(5, int(data.get("relevance", 1)))) + citation_accuracy = max(1, min(5, int(data.get("citation_accuracy", 1)))) + + composite = _calculate_retrieval_composite( + config, retrieval_precision, faithfulness, relevance, citation_accuracy + ) + + return RAGRetrievalResult( + retrieval_precision=retrieval_precision, + faithfulness=faithfulness, + relevance=relevance, + citation_accuracy=citation_accuracy, + reasoning=str(data.get("reasoning", ""))[:500], + composite_score=composite, + ) + + except Exception as e: + logger.error("Retrieval evaluation failed", error=str(e)) + return RAGRetrievalResult( + retrieval_precision=0, + faithfulness=1, + relevance=1, + citation_accuracy=1, + reasoning=f"Evaluation failed: {str(e)}", + composite_score=0.0, + ) + + +def _calculate_retrieval_composite( + config: BQASConfig, + retrieval_precision: int, + faithfulness: int, + relevance: int, + citation_accuracy: int, +) -> float: + """Calculate composite score for retrieval evaluation.""" + retrieval_score = (retrieval_precision / 100) * 5 + composite = ( + retrieval_score * config.rag_retrieval_precision_weight + + faithfulness * config.rag_faithfulness_weight + + relevance * 0.3 + + citation_accuracy * config.rag_citation_accuracy_weight + ) + return round(composite, 3) + + +async def evaluate_operator( + call_ollama, + parse_json_response, + operator: str, + generated_definition: str, + expected_afb: str, + expected_actions: List[str], +) -> RAGOperatorResult: + """Evaluate operator alignment.""" + prompt = RAG_OPERATOR_JUDGE_PROMPT.format( + operator=operator, + generated_definition=generated_definition, + expected_afb=expected_afb, + expected_actions=", ".join(expected_actions), + ) + + try: + response_text = await call_ollama(prompt) + data = parse_json_response(response_text) + + operator_alignment = max(0, min(100, int(data.get("operator_alignment", 0)))) + faithfulness = max(1, min(5, int(data.get("faithfulness", 1)))) + completeness = max(1, min(5, int(data.get("completeness", 1)))) + detected_afb = str(data.get("detected_afb", "")) + + alignment_score = (operator_alignment / 100) * 5 + composite = round( + alignment_score * 0.5 + faithfulness * 0.3 + completeness * 0.2, 3 + ) + + return RAGOperatorResult( + operator_alignment=operator_alignment, + faithfulness=faithfulness, + completeness=completeness, + detected_afb=detected_afb, + reasoning=str(data.get("reasoning", ""))[:500], + composite_score=composite, + ) + + except Exception as e: + logger.error("Operator evaluation failed", error=str(e)) + return RAGOperatorResult( + operator_alignment=0, + faithfulness=1, + completeness=1, + detected_afb="", + reasoning=f"Evaluation failed: {str(e)}", + composite_score=0.0, + ) + + +async def evaluate_hallucination( + call_ollama, + parse_json_response, + query: str, + response: str, + available_facts: List[str], +) -> RAGHallucinationResult: + """Evaluate for hallucinations.""" + prompt = RAG_HALLUCINATION_JUDGE_PROMPT.format( + query=query, + response=response, + available_facts="\n".join(f"- {f}" for f in available_facts), + ) + + try: + response_text = await call_ollama(prompt) + data = parse_json_response(response_text) + + grounding_score = max(0, min(100, int(data.get("grounding_score", 0)))) + invention_detection = "pass" if data.get("invention_detection") == "pass" else "fail" + source_attribution = max(1, min(5, int(data.get("source_attribution", 1)))) + hallucinated_claims = data.get("hallucinated_claims", []) + + grounding = (grounding_score / 100) * 5 + invention = 5.0 if invention_detection == "pass" else 0.0 + composite = round(grounding * 0.4 + invention * 0.4 + source_attribution * 0.2, 3) + + return RAGHallucinationResult( + grounding_score=grounding_score, + invention_detection=invention_detection, + source_attribution=source_attribution, + hallucinated_claims=hallucinated_claims[:5], + reasoning=str(data.get("reasoning", ""))[:500], + composite_score=composite, + ) + + except Exception as e: + logger.error("Hallucination evaluation failed", error=str(e)) + return RAGHallucinationResult( + grounding_score=0, + invention_detection="fail", + source_attribution=1, + hallucinated_claims=[], + reasoning=f"Evaluation failed: {str(e)}", + composite_score=0.0, + ) + + +async def evaluate_privacy( + call_ollama, + parse_json_response, + query: str, + context: Dict[str, Any], + response: str, +) -> RAGPrivacyResult: + """Evaluate privacy/DSGVO compliance.""" + prompt = RAG_PRIVACY_JUDGE_PROMPT.format( + query=query, + context=json.dumps(context, ensure_ascii=False, indent=2), + response=response, + ) + + try: + response_text = await call_ollama(prompt) + data = parse_json_response(response_text) + + privacy_compliance = "pass" if data.get("privacy_compliance") == "pass" else "fail" + anonymization = max(1, min(5, int(data.get("anonymization", 1)))) + dsgvo_compliance = "pass" if data.get("dsgvo_compliance") == "pass" else "fail" + detected_pii = data.get("detected_pii", []) + + privacy = 5.0 if privacy_compliance == "pass" else 0.0 + dsgvo = 5.0 if dsgvo_compliance == "pass" else 0.0 + composite = round(privacy * 0.4 + anonymization * 0.2 + dsgvo * 0.4, 3) + + return RAGPrivacyResult( + privacy_compliance=privacy_compliance, + anonymization=anonymization, + dsgvo_compliance=dsgvo_compliance, + detected_pii=detected_pii[:5], + reasoning=str(data.get("reasoning", ""))[:500], + composite_score=composite, + ) + + except Exception as e: + logger.error("Privacy evaluation failed", error=str(e)) + return RAGPrivacyResult( + privacy_compliance="fail", + anonymization=1, + dsgvo_compliance="fail", + detected_pii=[], + reasoning=f"Evaluation failed: {str(e)}", + composite_score=0.0, + ) + + +async def evaluate_namespace( + call_ollama, + parse_json_response, + teacher_id: str, + namespace: str, + school_id: str, + requested_data: str, + response: str, +) -> RAGNamespaceResult: + """Evaluate namespace isolation.""" + prompt = RAG_NAMESPACE_JUDGE_PROMPT.format( + teacher_id=teacher_id, + namespace=namespace, + school_id=school_id, + requested_data=requested_data, + response=response, + ) + + try: + response_text = await call_ollama(prompt) + data = parse_json_response(response_text) + + namespace_compliance = "pass" if data.get("namespace_compliance") == "pass" else "fail" + cross_tenant_leak = "pass" if data.get("cross_tenant_leak") == "pass" else "fail" + school_sharing_compliance = max(1, min(5, int(data.get("school_sharing_compliance", 1)))) + detected_leaks = data.get("detected_leaks", []) + + ns_compliance = 5.0 if namespace_compliance == "pass" else 0.0 + cross_tenant = 5.0 if cross_tenant_leak == "pass" else 0.0 + composite = round( + ns_compliance * 0.4 + cross_tenant * 0.4 + school_sharing_compliance * 0.2, 3 + ) + + return RAGNamespaceResult( + namespace_compliance=namespace_compliance, + cross_tenant_leak=cross_tenant_leak, + school_sharing_compliance=school_sharing_compliance, + detected_leaks=detected_leaks[:5], + reasoning=str(data.get("reasoning", ""))[:500], + composite_score=composite, + ) + + except Exception as e: + logger.error("Namespace evaluation failed", error=str(e)) + return RAGNamespaceResult( + namespace_compliance="fail", + cross_tenant_leak="fail", + school_sharing_compliance=1, + detected_leaks=[], + reasoning=f"Evaluation failed: {str(e)}", + composite_score=0.0, + ) + + +async def evaluate_rag_test_case( + judge_instance, + test_case: Dict[str, Any], + service_response: Dict[str, Any], +) -> TestResult: + """ + Evaluate a full RAG test case from the golden suite. + """ + start_time = time.time() + + test_id = test_case.get("id", "UNKNOWN") + test_name = test_case.get("name", "") + category = test_case.get("category", "") + min_score = test_case.get("min_score", 3.5) + + composite_score = 0.0 + reasoning = "" + + if category == "eh_retrieval": + result = await judge_instance.evaluate_retrieval( + query=test_case.get("input", {}).get("query", ""), + aufgabentyp=test_case.get("input", {}).get("context", {}).get("aufgabentyp", ""), + subject=test_case.get("input", {}).get("context", {}).get("subject", "Deutsch"), + level=test_case.get("input", {}).get("context", {}).get("level", "Abitur"), + retrieved_passage=service_response.get("passage", ""), + expected_concepts=test_case.get("expected", {}).get("must_contain_concepts", []), + ) + composite_score = result.composite_score + reasoning = result.reasoning + + elif category == "operator_alignment": + result = await judge_instance.evaluate_operator( + operator=test_case.get("input", {}).get("operator", ""), + generated_definition=service_response.get("definition", ""), + expected_afb=test_case.get("expected", {}).get("afb_level", ""), + expected_actions=test_case.get("expected", {}).get("expected_actions", []), + ) + composite_score = result.composite_score + reasoning = result.reasoning + + elif category == "hallucination_control": + result = await judge_instance.evaluate_hallucination( + query=test_case.get("input", {}).get("query", ""), + response=service_response.get("response", ""), + available_facts=test_case.get("input", {}).get("context", {}).get("available_facts", []), + ) + composite_score = result.composite_score + reasoning = result.reasoning + + elif category == "privacy_compliance": + result = await judge_instance.evaluate_privacy( + query=test_case.get("input", {}).get("query", ""), + context=test_case.get("input", {}).get("context", {}), + response=service_response.get("response", ""), + ) + composite_score = result.composite_score + reasoning = result.reasoning + + elif category == "namespace_isolation": + context = test_case.get("input", {}).get("context", {}) + result = await judge_instance.evaluate_namespace( + teacher_id=context.get("teacher_id", ""), + namespace=context.get("namespace", ""), + school_id=context.get("school_id", ""), + requested_data=test_case.get("input", {}).get("query", ""), + response=service_response.get("response", ""), + ) + composite_score = result.composite_score + reasoning = result.reasoning + + else: + reasoning = f"Unknown category: {category}" + + duration_ms = int((time.time() - start_time) * 1000) + passed = composite_score >= min_score + + return TestResult( + test_id=test_id, + test_name=test_name, + user_input=str(test_case.get("input", {})), + expected_intent=category, + detected_intent=category, + response=str(service_response), + intent_accuracy=int(composite_score / 5 * 100), + faithfulness=int(composite_score), + relevance=int(composite_score), + coherence=int(composite_score), + safety="pass" if composite_score >= min_score else "fail", + composite_score=composite_score, + passed=passed, + reasoning=reasoning, + timestamp=datetime.utcnow(), + duration_ms=duration_ms, + ) diff --git a/voice-service/bqas/rag_judge_types.py b/voice-service/bqas/rag_judge_types.py new file mode 100644 index 0000000..4ababd4 --- /dev/null +++ b/voice-service/bqas/rag_judge_types.py @@ -0,0 +1,60 @@ +""" +RAG Judge Types - Data classes for RAG evaluation results +""" +from dataclasses import dataclass +from typing import Literal, List + + +@dataclass +class RAGRetrievalResult: + """Result from RAG retrieval evaluation.""" + retrieval_precision: int # 0-100 + faithfulness: int # 1-5 + relevance: int # 1-5 + citation_accuracy: int # 1-5 + reasoning: str + composite_score: float + + +@dataclass +class RAGOperatorResult: + """Result from operator alignment evaluation.""" + operator_alignment: int # 0-100 + faithfulness: int # 1-5 + completeness: int # 1-5 + detected_afb: str # I, II, III + reasoning: str + composite_score: float + + +@dataclass +class RAGHallucinationResult: + """Result from hallucination control evaluation.""" + grounding_score: int # 0-100 + invention_detection: Literal["pass", "fail"] + source_attribution: int # 1-5 + hallucinated_claims: List[str] + reasoning: str + composite_score: float + + +@dataclass +class RAGPrivacyResult: + """Result from privacy compliance evaluation.""" + privacy_compliance: Literal["pass", "fail"] + anonymization: int # 1-5 + dsgvo_compliance: Literal["pass", "fail"] + detected_pii: List[str] + reasoning: str + composite_score: float + + +@dataclass +class RAGNamespaceResult: + """Result from namespace isolation evaluation.""" + namespace_compliance: Literal["pass", "fail"] + cross_tenant_leak: Literal["pass", "fail"] + school_sharing_compliance: int # 1-5 + detected_leaks: List[str] + reasoning: str + composite_score: float diff --git a/voice-service/bqas/runner.py b/voice-service/bqas/runner.py index 258cf61..1a33ab0 100644 --- a/voice-service/bqas/runner.py +++ b/voice-service/bqas/runner.py @@ -1,11 +1,12 @@ """ BQAS Test Runner - Executes Golden, RAG, and Synthetic test suites + +Split into: +- runner_golden.py: Test loading, simulation helpers, error result creation +- runner.py (this file): BQASRunner class, singleton """ -import yaml -import asyncio import structlog import httpx -from pathlib import Path from typing import List, Dict, Any, Optional from datetime import datetime from dataclasses import dataclass, field @@ -15,6 +16,13 @@ from bqas.judge import LLMJudge from bqas.rag_judge import RAGJudge from bqas.metrics import TestResult, BQASMetrics from bqas.synthetic_generator import SyntheticGenerator +from bqas.runner_golden import ( + load_golden_tests, + load_rag_tests, + simulate_response, + create_error_result, + simulate_rag_response, +) logger = structlog.get_logger(__name__) @@ -61,87 +69,42 @@ class BQASRunner: # ================================ async def run_golden_suite(self, git_commit: Optional[str] = None) -> TestRun: - """ - Run the golden test suite. - - Loads test cases from YAML files and evaluates each one. - """ + """Run the golden test suite.""" logger.info("Starting Golden Suite run") start_time = datetime.utcnow() - # Load all golden test cases - test_cases = await self._load_golden_tests() + test_cases = await load_golden_tests() logger.info(f"Loaded {len(test_cases)} golden test cases") - # Run all tests results = [] for i, test_case in enumerate(test_cases): try: result = await self._run_golden_test(test_case) results.append(result) - if (i + 1) % 10 == 0: logger.info(f"Progress: {i + 1}/{len(test_cases)} tests completed") - except Exception as e: logger.error(f"Test {test_case.get('id')} failed with error", error=str(e)) - # Create a failed result - results.append(self._create_error_result(test_case, str(e))) + results.append(create_error_result(test_case, str(e))) - # Calculate metrics metrics = BQASMetrics.from_results(results) duration = (datetime.utcnow() - start_time).total_seconds() - # Record run self._run_counter += 1 run = TestRun( - id=self._run_counter, - suite="golden", - timestamp=start_time, - git_commit=git_commit, - metrics=metrics, - results=results, + id=self._run_counter, suite="golden", timestamp=start_time, + git_commit=git_commit, metrics=metrics, results=results, duration_seconds=duration, ) self._test_runs.insert(0, run) logger.info( - "Golden Suite completed", - total=metrics.total_tests, - passed=metrics.passed_tests, - failed=metrics.failed_tests, - score=metrics.avg_composite_score, - duration=f"{duration:.1f}s", + "Golden Suite completed", total=metrics.total_tests, + passed=metrics.passed_tests, failed=metrics.failed_tests, + score=metrics.avg_composite_score, duration=f"{duration:.1f}s", ) - return run - async def _load_golden_tests(self) -> List[Dict[str, Any]]: - """Load all golden test cases from YAML files.""" - tests = [] - golden_dir = Path(__file__).parent.parent / "tests" / "bqas" / "golden_tests" - - yaml_files = [ - "intent_tests.yaml", - "edge_cases.yaml", - "workflow_tests.yaml", - ] - - for filename in yaml_files: - filepath = golden_dir / filename - if filepath.exists(): - try: - with open(filepath, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - if data and 'tests' in data: - for test in data['tests']: - test['source_file'] = filename - tests.extend(data['tests']) - except Exception as e: - logger.warning(f"Failed to load {filename}", error=str(e)) - - return tests - async def _run_golden_test(self, test_case: Dict[str, Any]) -> TestResult: """Run a single golden test case.""" test_id = test_case.get('id', 'UNKNOWN') @@ -150,38 +113,19 @@ class BQASRunner: expected_intent = test_case.get('expected_intent', '') min_score = test_case.get('min_score', self.config.min_golden_score) - # Get response from voice service (or simulate) detected_intent, response = await self._get_voice_response(user_input, expected_intent) - # Evaluate with judge result = await self.judge.evaluate_test_case( - test_id=test_id, - test_name=test_name, - user_input=user_input, - expected_intent=expected_intent, - detected_intent=detected_intent, - response=response, - min_score=min_score, + test_id=test_id, test_name=test_name, user_input=user_input, + expected_intent=expected_intent, detected_intent=detected_intent, + response=response, min_score=min_score, ) - return result - async def _get_voice_response( - self, - user_input: str, - expected_intent: str - ) -> tuple[str, str]: - """ - Get response from voice service. - - For now, simulates responses since the full voice pipeline - might not be available. In production, this would call the - actual voice service endpoints. - """ + async def _get_voice_response(self, user_input: str, expected_intent: str) -> tuple: + """Get response from voice service.""" try: client = await self._get_client() - - # Try to call the voice service intent detection response = await client.post( f"{self.config.voice_service_url}/api/v1/tasks", json={ @@ -191,231 +135,71 @@ class BQASRunner: }, timeout=10.0, ) - if response.status_code == 200: data = response.json() return data.get('detected_intent', expected_intent), data.get('response', f"Verarbeite: {user_input}") - except Exception as e: logger.debug(f"Voice service call failed, using simulation", error=str(e)) - # Simulate response based on expected intent - return self._simulate_response(user_input, expected_intent) - - def _simulate_response(self, user_input: str, expected_intent: str) -> tuple[str, str]: - """Simulate voice service response for testing without live service.""" - # Simulate realistic detected intent (90% correct for golden tests) - import random - if random.random() < 0.90: - detected_intent = expected_intent - else: - # Simulate occasional misclassification - intents = ["student_observation", "reminder", "worksheet_generate", "parent_letter", "smalltalk"] - detected_intent = random.choice([i for i in intents if i != expected_intent]) - - # Generate simulated response - responses = { - "student_observation": f"Notiz wurde gespeichert: {user_input}", - "reminder": f"Erinnerung erstellt: {user_input}", - "worksheet_generate": f"Arbeitsblatt wird generiert basierend auf: {user_input}", - "homework_check": f"Hausaufgabenkontrolle eingetragen: {user_input}", - "parent_letter": f"Elternbrief-Entwurf erstellt: {user_input}", - "class_message": f"Nachricht an Klasse vorbereitet: {user_input}", - "quiz_generate": f"Quiz wird erstellt: {user_input}", - "quick_activity": f"Einstiegsaktivitaet geplant: {user_input}", - "canvas_edit": f"Aenderung am Canvas wird ausgefuehrt: {user_input}", - "canvas_layout": f"Layout wird angepasst: {user_input}", - "operator_checklist": f"Operatoren-Checkliste geladen: {user_input}", - "eh_passage": f"EH-Passage gefunden: {user_input}", - "feedback_suggest": f"Feedback-Vorschlag: {user_input}", - "reminder_schedule": f"Erinnerung geplant: {user_input}", - "task_summary": f"Aufgabenuebersicht: {user_input}", - "conference_topic": f"Konferenzthema notiert: {user_input}", - "correction_note": f"Korrekturnotiz gespeichert: {user_input}", - "worksheet_differentiate": f"Differenzierung wird erstellt: {user_input}", - } - - response = responses.get(detected_intent, f"Verstanden: {user_input}") - return detected_intent, response - - def _create_error_result(self, test_case: Dict[str, Any], error: str) -> TestResult: - """Create a failed test result due to error.""" - return TestResult( - test_id=test_case.get('id', 'UNKNOWN'), - test_name=test_case.get('name', 'Error'), - user_input=test_case.get('input', ''), - expected_intent=test_case.get('expected_intent', ''), - detected_intent='error', - response='', - intent_accuracy=0, - faithfulness=1, - relevance=1, - coherence=1, - safety='fail', - composite_score=0.0, - passed=False, - reasoning=f"Test execution error: {error}", - timestamp=datetime.utcnow(), - duration_ms=0, - ) + return simulate_response(user_input, expected_intent) # ================================ # RAG Suite Runner # ================================ async def run_rag_suite(self, git_commit: Optional[str] = None) -> TestRun: - """ - Run the RAG/Correction test suite. - - Tests EH retrieval, operator alignment, hallucination control, etc. - """ + """Run the RAG/Correction test suite.""" logger.info("Starting RAG Suite run") start_time = datetime.utcnow() - # Load RAG test cases - test_cases = await self._load_rag_tests() + test_cases = await load_rag_tests() logger.info(f"Loaded {len(test_cases)} RAG test cases") - # Run all tests results = [] for i, test_case in enumerate(test_cases): try: - result = await self._run_rag_test(test_case) + service_response = await simulate_rag_response(test_case) + result = await self.rag_judge.evaluate_rag_test_case( + test_case=test_case, service_response=service_response, + ) results.append(result) - if (i + 1) % 5 == 0: logger.info(f"Progress: {i + 1}/{len(test_cases)} RAG tests completed") - except Exception as e: logger.error(f"RAG test {test_case.get('id')} failed", error=str(e)) - results.append(self._create_error_result(test_case, str(e))) + results.append(create_error_result(test_case, str(e))) - # Calculate metrics metrics = BQASMetrics.from_results(results) duration = (datetime.utcnow() - start_time).total_seconds() - # Record run self._run_counter += 1 run = TestRun( - id=self._run_counter, - suite="rag", - timestamp=start_time, - git_commit=git_commit, - metrics=metrics, - results=results, + id=self._run_counter, suite="rag", timestamp=start_time, + git_commit=git_commit, metrics=metrics, results=results, duration_seconds=duration, ) self._test_runs.insert(0, run) logger.info( - "RAG Suite completed", - total=metrics.total_tests, - passed=metrics.passed_tests, - score=metrics.avg_composite_score, + "RAG Suite completed", total=metrics.total_tests, + passed=metrics.passed_tests, score=metrics.avg_composite_score, duration=f"{duration:.1f}s", ) - return run - async def _load_rag_tests(self) -> List[Dict[str, Any]]: - """Load RAG test cases from YAML.""" - tests = [] - rag_file = Path(__file__).parent.parent / "tests" / "bqas" / "golden_tests" / "golden_rag_correction_v1.yaml" - - if rag_file.exists(): - try: - with open(rag_file, 'r', encoding='utf-8') as f: - # Handle YAML documents separated by --- - documents = list(yaml.safe_load_all(f)) - for doc in documents: - if doc and 'tests' in doc: - tests.extend(doc['tests']) - if doc and 'edge_cases' in doc: - tests.extend(doc['edge_cases']) - except Exception as e: - logger.warning(f"Failed to load RAG tests", error=str(e)) - - return tests - - async def _run_rag_test(self, test_case: Dict[str, Any]) -> TestResult: - """Run a single RAG test case.""" - # Simulate service response for RAG tests - service_response = await self._simulate_rag_response(test_case) - - # Evaluate with RAG judge - result = await self.rag_judge.evaluate_rag_test_case( - test_case=test_case, - service_response=service_response, - ) - - return result - - async def _simulate_rag_response(self, test_case: Dict[str, Any]) -> Dict[str, Any]: - """Simulate RAG service response.""" - category = test_case.get('category', '') - input_data = test_case.get('input', {}) - expected = test_case.get('expected', {}) - - # Simulate responses based on category - if category == 'eh_retrieval': - concepts = expected.get('must_contain_concepts', []) - passage = f"Der Erwartungshorizont sieht folgende Aspekte vor: {', '.join(concepts[:3])}. " - passage += "Diese muessen im Rahmen der Aufgabenbearbeitung beruecksichtigt werden." - return { - "passage": passage, - "source": "EH_Deutsch_Abitur_2024_NI.pdf", - "relevance_score": 0.85, - } - - elif category == 'operator_alignment': - operator = input_data.get('operator', '') - afb = expected.get('afb_level', 'II') - actions = expected.get('expected_actions', []) - return { - "operator": operator, - "definition": f"'{operator}' gehoert zu Anforderungsbereich {afb}. Erwartete Handlungen: {', '.join(actions[:2])}.", - "afb_level": afb, - } - - elif category == 'hallucination_control': - return { - "response": "Basierend auf den verfuegbaren Informationen kann ich folgendes feststellen...", - "grounded": True, - } - - elif category == 'privacy_compliance': - return { - "response": "Die Arbeit zeigt folgende Merkmale... [anonymisiert]", - "contains_pii": False, - } - - elif category == 'namespace_isolation': - return { - "response": "Zugriff nur auf Daten im eigenen Namespace.", - "namespace_violation": False, - } - - return {"response": "Simulated response", "success": True} - # ================================ # Synthetic Suite Runner # ================================ async def run_synthetic_suite(self, git_commit: Optional[str] = None) -> TestRun: - """ - Run the synthetic test suite. - - Generates test variations using LLM and evaluates them. - """ + """Run the synthetic test suite.""" logger.info("Starting Synthetic Suite run") start_time = datetime.utcnow() - # Generate synthetic tests all_variations = await self.synthetic_generator.generate_all_intents( count_per_intent=self.config.synthetic_count_per_intent ) - # Flatten variations test_cases = [] for intent, variations in all_variations.items(): for i, v in enumerate(variations): @@ -431,45 +215,33 @@ class BQASRunner: logger.info(f"Generated {len(test_cases)} synthetic test cases") - # Run all tests results = [] for i, test_case in enumerate(test_cases): try: - result = await self._run_golden_test(test_case) # Same logic as golden + result = await self._run_golden_test(test_case) results.append(result) - if (i + 1) % 20 == 0: logger.info(f"Progress: {i + 1}/{len(test_cases)} synthetic tests completed") - except Exception as e: logger.error(f"Synthetic test {test_case.get('id')} failed", error=str(e)) - results.append(self._create_error_result(test_case, str(e))) + results.append(create_error_result(test_case, str(e))) - # Calculate metrics metrics = BQASMetrics.from_results(results) duration = (datetime.utcnow() - start_time).total_seconds() - # Record run self._run_counter += 1 run = TestRun( - id=self._run_counter, - suite="synthetic", - timestamp=start_time, - git_commit=git_commit, - metrics=metrics, - results=results, + id=self._run_counter, suite="synthetic", timestamp=start_time, + git_commit=git_commit, metrics=metrics, results=results, duration_seconds=duration, ) self._test_runs.insert(0, run) logger.info( - "Synthetic Suite completed", - total=metrics.total_tests, - passed=metrics.passed_tests, - score=metrics.avg_composite_score, + "Synthetic Suite completed", total=metrics.total_tests, + passed=metrics.passed_tests, score=metrics.avg_composite_score, duration=f"{duration:.1f}s", ) - return run # ================================ @@ -483,20 +255,17 @@ class BQASRunner: def get_latest_metrics(self) -> Dict[str, Optional[BQASMetrics]]: """Get latest metrics for each suite.""" result = {"golden": None, "rag": None, "synthetic": None} - for run in self._test_runs: if result[run.suite] is None: result[run.suite] = run.metrics if all(v is not None for v in result.values()): break - return result async def health_check(self) -> Dict[str, Any]: """Check health of BQAS components.""" judge_ok = await self.judge.health_check() rag_judge_ok = await self.rag_judge.health_check() - return { "judge_available": judge_ok, "rag_judge_available": rag_judge_ok, diff --git a/voice-service/bqas/runner_golden.py b/voice-service/bqas/runner_golden.py new file mode 100644 index 0000000..40f901c --- /dev/null +++ b/voice-service/bqas/runner_golden.py @@ -0,0 +1,162 @@ +""" +BQAS Golden Suite Runner - Loads and executes golden test cases +""" +import yaml +import structlog +from pathlib import Path +from typing import List, Dict, Any, Optional +from datetime import datetime + +from bqas.metrics import TestResult + +logger = structlog.get_logger(__name__) + + +async def load_golden_tests() -> List[Dict[str, Any]]: + """Load all golden test cases from YAML files.""" + tests = [] + golden_dir = Path(__file__).parent.parent / "tests" / "bqas" / "golden_tests" + + yaml_files = [ + "intent_tests.yaml", + "edge_cases.yaml", + "workflow_tests.yaml", + ] + + for filename in yaml_files: + filepath = golden_dir / filename + if filepath.exists(): + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + if data and 'tests' in data: + for test in data['tests']: + test['source_file'] = filename + tests.extend(data['tests']) + except Exception as e: + logger.warning(f"Failed to load {filename}", error=str(e)) + + return tests + + +async def load_rag_tests() -> List[Dict[str, Any]]: + """Load RAG test cases from YAML.""" + tests = [] + rag_file = Path(__file__).parent.parent / "tests" / "bqas" / "golden_tests" / "golden_rag_correction_v1.yaml" + + if rag_file.exists(): + try: + with open(rag_file, 'r', encoding='utf-8') as f: + documents = list(yaml.safe_load_all(f)) + for doc in documents: + if doc and 'tests' in doc: + tests.extend(doc['tests']) + if doc and 'edge_cases' in doc: + tests.extend(doc['edge_cases']) + except Exception as e: + logger.warning(f"Failed to load RAG tests", error=str(e)) + + return tests + + +def simulate_response(user_input: str, expected_intent: str) -> tuple: + """Simulate voice service response for testing without live service.""" + import random + if random.random() < 0.90: + detected_intent = expected_intent + else: + intents = ["student_observation", "reminder", "worksheet_generate", "parent_letter", "smalltalk"] + detected_intent = random.choice([i for i in intents if i != expected_intent]) + + responses = { + "student_observation": f"Notiz wurde gespeichert: {user_input}", + "reminder": f"Erinnerung erstellt: {user_input}", + "worksheet_generate": f"Arbeitsblatt wird generiert basierend auf: {user_input}", + "homework_check": f"Hausaufgabenkontrolle eingetragen: {user_input}", + "parent_letter": f"Elternbrief-Entwurf erstellt: {user_input}", + "class_message": f"Nachricht an Klasse vorbereitet: {user_input}", + "quiz_generate": f"Quiz wird erstellt: {user_input}", + "quick_activity": f"Einstiegsaktivitaet geplant: {user_input}", + "canvas_edit": f"Aenderung am Canvas wird ausgefuehrt: {user_input}", + "canvas_layout": f"Layout wird angepasst: {user_input}", + "operator_checklist": f"Operatoren-Checkliste geladen: {user_input}", + "eh_passage": f"EH-Passage gefunden: {user_input}", + "feedback_suggest": f"Feedback-Vorschlag: {user_input}", + "reminder_schedule": f"Erinnerung geplant: {user_input}", + "task_summary": f"Aufgabenuebersicht: {user_input}", + "conference_topic": f"Konferenzthema notiert: {user_input}", + "correction_note": f"Korrekturnotiz gespeichert: {user_input}", + "worksheet_differentiate": f"Differenzierung wird erstellt: {user_input}", + } + + response = responses.get(detected_intent, f"Verstanden: {user_input}") + return detected_intent, response + + +def create_error_result(test_case: Dict[str, Any], error: str) -> TestResult: + """Create a failed test result due to error.""" + return TestResult( + test_id=test_case.get('id', 'UNKNOWN'), + test_name=test_case.get('name', 'Error'), + user_input=test_case.get('input', ''), + expected_intent=test_case.get('expected_intent', ''), + detected_intent='error', + response='', + intent_accuracy=0, + faithfulness=1, + relevance=1, + coherence=1, + safety='fail', + composite_score=0.0, + passed=False, + reasoning=f"Test execution error: {error}", + timestamp=datetime.utcnow(), + duration_ms=0, + ) + + +async def simulate_rag_response(test_case: Dict[str, Any]) -> Dict[str, Any]: + """Simulate RAG service response.""" + category = test_case.get('category', '') + input_data = test_case.get('input', {}) + expected = test_case.get('expected', {}) + + if category == 'eh_retrieval': + concepts = expected.get('must_contain_concepts', []) + passage = f"Der Erwartungshorizont sieht folgende Aspekte vor: {', '.join(concepts[:3])}. " + passage += "Diese muessen im Rahmen der Aufgabenbearbeitung beruecksichtigt werden." + return { + "passage": passage, + "source": "EH_Deutsch_Abitur_2024_NI.pdf", + "relevance_score": 0.85, + } + + elif category == 'operator_alignment': + operator = input_data.get('operator', '') + afb = expected.get('afb_level', 'II') + actions = expected.get('expected_actions', []) + return { + "operator": operator, + "definition": f"'{operator}' gehoert zu Anforderungsbereich {afb}. Erwartete Handlungen: {', '.join(actions[:2])}.", + "afb_level": afb, + } + + elif category == 'hallucination_control': + return { + "response": "Basierend auf den verfuegbaren Informationen kann ich folgendes feststellen...", + "grounded": True, + } + + elif category == 'privacy_compliance': + return { + "response": "Die Arbeit zeigt folgende Merkmale... [anonymisiert]", + "contains_pii": False, + } + + elif category == 'namespace_isolation': + return { + "response": "Zugriff nur auf Daten im eigenen Namespace.", + "namespace_violation": False, + } + + return {"response": "Simulated response", "success": True} diff --git a/voice-service/services/enhanced_orchestrator_session.py b/voice-service/services/enhanced_orchestrator_session.py new file mode 100644 index 0000000..e2b2520 --- /dev/null +++ b/voice-service/services/enhanced_orchestrator_session.py @@ -0,0 +1,141 @@ +""" +Enhanced Orchestrator Session Management + +Session lifecycle methods extracted from EnhancedTaskOrchestrator. +""" +import structlog +from typing import Optional, Dict, Any + +from sessions.session_manager import SessionManager, AgentSession, SessionState +from sessions.heartbeat import HeartbeatMonitor, HeartbeatClient +from brain.context_manager import ContextManager + +logger = structlog.get_logger(__name__) + + +async def create_session( + session_manager: SessionManager, + context_manager: ContextManager, + heartbeat: HeartbeatMonitor, + voice_sessions: Dict[str, AgentSession], + heartbeat_clients: Dict[str, HeartbeatClient], + voice_session_id: str, + user_id: str = "", + metadata: Optional[Dict[str, Any]] = None, + system_prompt: str = "", +) -> AgentSession: + """Creates a new agent session for a voice session.""" + session = await session_manager.create_session( + agent_type="voice-orchestrator", + user_id=user_id, + context={"voice_session_id": voice_session_id}, + metadata=metadata + ) + + context_manager.create_context( + session_id=session.session_id, + system_prompt=system_prompt, + max_messages=50 + ) + + heartbeat_client = HeartbeatClient( + session_id=session.session_id, + monitor=heartbeat, + interval_seconds=10 + ) + await heartbeat_client.start() + heartbeat.register(session.session_id, "voice-orchestrator") + + voice_sessions[voice_session_id] = session + heartbeat_clients[session.session_id] = heartbeat_client + + logger.info( + "Created agent session", + session_id=session.session_id[:8], + voice_session_id=voice_session_id + ) + return session + + +async def end_session( + session_manager: SessionManager, + heartbeat: HeartbeatMonitor, + voice_sessions: Dict[str, AgentSession], + heartbeat_clients: Dict[str, HeartbeatClient], + voice_session_id: str, +) -> None: + """Ends an agent session.""" + session = voice_sessions.get(voice_session_id) + if not session: + return + + if session.session_id in heartbeat_clients: + await heartbeat_clients[session.session_id].stop() + del heartbeat_clients[session.session_id] + + heartbeat.unregister(session.session_id) + session.complete() + await session_manager.update_session(session) + del voice_sessions[voice_session_id] + + logger.info( + "Ended agent session", + session_id=session.session_id[:8], + duration_seconds=session.get_duration().total_seconds() + ) + + +async def recover_session( + session_manager: SessionManager, + heartbeat: HeartbeatMonitor, + voice_sessions: Dict[str, AgentSession], + heartbeat_clients: Dict[str, HeartbeatClient], + tasks: Dict[str, Any], + process_task_fn, + voice_session_id: str, + session_id: str, +) -> Optional[AgentSession]: + """Recovers a session from checkpoint.""" + session = await session_manager.get_session(session_id) + + if not session: + logger.warning("Session not found for recovery", session_id=session_id) + return None + + if session.state != SessionState.ACTIVE: + logger.warning( + "Session not active for recovery", + session_id=session_id, state=session.state.value + ) + return None + + session.resume() + + heartbeat_client = HeartbeatClient( + session_id=session.session_id, + monitor=heartbeat, + interval_seconds=10 + ) + await heartbeat_client.start() + heartbeat.register(session.session_id, "voice-orchestrator") + + voice_sessions[voice_session_id] = session + heartbeat_clients[session.session_id] = heartbeat_client + + # Recover pending tasks from checkpoints + from models.task import TaskState + for checkpoint in reversed(session.checkpoints): + if checkpoint.name == "task_queued": + task_id = checkpoint.data.get("task_id") + if task_id and task_id in tasks: + task = tasks[task_id] + if task.state == TaskState.QUEUED: + await process_task_fn(task) + logger.info("Recovered pending task", task_id=task_id[:8]) + + logger.info( + "Recovered session", + session_id=session.session_id[:8], + checkpoints=len(session.checkpoints) + ) + return session diff --git a/voice-service/services/enhanced_task_orchestrator.py b/voice-service/services/enhanced_task_orchestrator.py index 6a29992..52eb745 100644 --- a/voice-service/services/enhanced_task_orchestrator.py +++ b/voice-service/services/enhanced_task_orchestrator.py @@ -6,6 +6,10 @@ Extends the existing TaskOrchestrator with Multi-Agent support: - Message bus integration for inter-agent communication - Quality judge integration via BQAS - Heartbeat-based liveness + +Split into: +- enhanced_orchestrator_session.py: Session lifecycle (create/end/recover) +- enhanced_task_orchestrator.py (this file): Main orchestrator class """ import structlog @@ -27,6 +31,12 @@ from brain.context_manager import ContextManager, MessageRole from orchestrator.message_bus import MessageBus, AgentMessage, MessagePriority from orchestrator.task_router import TaskRouter, RoutingStrategy +from services.enhanced_orchestrator_session import ( + create_session as _create_session, + end_session as _end_session, + recover_session as _recover_session, +) + logger = structlog.get_logger(__name__) @@ -47,50 +57,25 @@ class EnhancedTaskOrchestrator(TaskOrchestrator): db_pool=None, namespace: str = "breakpilot" ): - """ - Initialize the enhanced orchestrator. - - Args: - redis_client: Async Redis/Valkey client - db_pool: Async PostgreSQL connection pool - namespace: Namespace for isolation - """ super().__init__() - # Initialize agent-core components self.session_manager = SessionManager( - redis_client=redis_client, - db_pool=db_pool, - namespace=namespace + redis_client=redis_client, db_pool=db_pool, namespace=namespace ) - self.memory_store = MemoryStore( - redis_client=redis_client, - db_pool=db_pool, - namespace=namespace + redis_client=redis_client, db_pool=db_pool, namespace=namespace ) - self.context_manager = ContextManager( - redis_client=redis_client, - db_pool=db_pool, - namespace=namespace + redis_client=redis_client, db_pool=db_pool, namespace=namespace ) - self.message_bus = MessageBus( - redis_client=redis_client, - db_pool=db_pool, - namespace=namespace + redis_client=redis_client, db_pool=db_pool, namespace=namespace ) - self.heartbeat = HeartbeatMonitor( - timeout_seconds=30, - check_interval_seconds=5, - max_missed_beats=3 + timeout_seconds=30, check_interval_seconds=5, max_missed_beats=3 ) - self.task_router = TaskRouter() - # Track active sessions by voice session ID self._voice_sessions: Dict[str, AgentSession] = {} self._heartbeat_clients: Dict[str, HeartbeatClient] = {} @@ -100,231 +85,98 @@ class EnhancedTaskOrchestrator(TaskOrchestrator): """Starts the enhanced orchestrator""" await self.message_bus.start() await self.heartbeat.start_monitoring() - - # Subscribe to messages directed at this orchestrator - await self.message_bus.subscribe( - "voice-orchestrator", - self._handle_agent_message - ) - + await self.message_bus.subscribe("voice-orchestrator", self._handle_agent_message) logger.info("Enhanced TaskOrchestrator started") async def stop(self) -> None: """Stops the enhanced orchestrator""" - # Stop all heartbeat clients for client in self._heartbeat_clients.values(): await client.stop() self._heartbeat_clients.clear() - await self.heartbeat.stop_monitoring() await self.message_bus.stop() - logger.info("Enhanced TaskOrchestrator stopped") async def create_session( - self, - voice_session_id: str, - user_id: str = "", + self, voice_session_id: str, user_id: str = "", metadata: Optional[Dict[str, Any]] = None ) -> AgentSession: - """ - Creates a new agent session for a voice session. - - Args: - voice_session_id: The voice session ID - user_id: Optional user ID - metadata: Additional metadata - - Returns: - The created AgentSession - """ - # Create session via session manager - session = await self.session_manager.create_session( - agent_type="voice-orchestrator", - user_id=user_id, - context={"voice_session_id": voice_session_id}, - metadata=metadata + return await _create_session( + self.session_manager, self.context_manager, self.heartbeat, + self._voice_sessions, self._heartbeat_clients, + voice_session_id, user_id, metadata, self._get_system_prompt(), ) - # Create conversation context - self.context_manager.create_context( - session_id=session.session_id, - system_prompt=self._get_system_prompt(), - max_messages=50 - ) - - # Start heartbeat for this session - heartbeat_client = HeartbeatClient( - session_id=session.session_id, - monitor=self.heartbeat, - interval_seconds=10 - ) - await heartbeat_client.start() - - # Register heartbeat for monitoring - self.heartbeat.register(session.session_id, "voice-orchestrator") - - # Store references - self._voice_sessions[voice_session_id] = session - self._heartbeat_clients[session.session_id] = heartbeat_client - - logger.info( - "Created agent session", - session_id=session.session_id[:8], - voice_session_id=voice_session_id - ) - - return session - - async def get_session( - self, - voice_session_id: str - ) -> Optional[AgentSession]: - """Gets the agent session for a voice session""" + async def get_session(self, voice_session_id: str) -> Optional[AgentSession]: return self._voice_sessions.get(voice_session_id) async def end_session(self, voice_session_id: str) -> None: - """ - Ends an agent session. - - Args: - voice_session_id: The voice session ID - """ - session = self._voice_sessions.get(voice_session_id) - if not session: - return - - # Stop heartbeat - if session.session_id in self._heartbeat_clients: - await self._heartbeat_clients[session.session_id].stop() - del self._heartbeat_clients[session.session_id] - - # Unregister from heartbeat monitor - self.heartbeat.unregister(session.session_id) - - # Mark session as completed - session.complete() - await self.session_manager.update_session(session) - - # Clean up - del self._voice_sessions[voice_session_id] - - logger.info( - "Ended agent session", - session_id=session.session_id[:8], - duration_seconds=session.get_duration().total_seconds() + await _end_session( + self.session_manager, self.heartbeat, + self._voice_sessions, self._heartbeat_clients, voice_session_id, ) async def queue_task(self, task: Task) -> None: - """ - Queue a task with session checkpointing. - - Extends parent to add checkpoint for recovery. - """ - # Get session for this task + """Queue a task with session checkpointing.""" session = self._voice_sessions.get(task.session_id) - if session: - # Checkpoint before queueing session.checkpoint("task_queued", { - "task_id": task.id, - "task_type": task.type.value, + "task_id": task.id, "task_type": task.type.value, "parameters": task.parameters }) await self.session_manager.update_session(session) - - # Call parent implementation await super().queue_task(task) async def process_task(self, task: Task) -> None: - """ - Process a task with enhanced routing and quality checks. - - Extends parent to: - - Route complex tasks to specialized agents - - Run quality checks via BQAS - - Store results in memory for learning - """ + """Process a task with enhanced routing and quality checks.""" session = self._voice_sessions.get(task.session_id) - if session: - session.checkpoint("task_processing", { - "task_id": task.id - }) + session.checkpoint("task_processing", {"task_id": task.id}) - # Check if this task should be routed to a specialized agent if self._needs_specialized_agent(task): await self._route_to_agent(task, session) else: - # Use parent implementation for simple tasks await super().process_task(task) - # Run quality check on result if task.result_ref and self._needs_quality_check(task): await self._run_quality_check(task, session) - # Store in memory for learning if task.state == TaskState.READY and task.result_ref: await self._store_task_result(task) if session: session.checkpoint("task_completed", { - "task_id": task.id, - "state": task.state.value + "task_id": task.id, "state": task.state.value }) await self.session_manager.update_session(session) def _needs_specialized_agent(self, task: Task) -> bool: - """Check if task needs routing to a specialized agent""" from models.task import TaskType - - # Tasks that benefit from specialized agents - specialized_types = [ - TaskType.PARENT_LETTER, # Could use grader for tone - TaskType.FEEDBACK_SUGGEST, # Quality judge for appropriateness - ] - - return task.type in specialized_types + return task.type in [TaskType.PARENT_LETTER, TaskType.FEEDBACK_SUGGEST] def _needs_quality_check(self, task: Task) -> bool: - """Check if task result needs quality validation""" from models.task import TaskType - - # Tasks that generate content should be checked - content_types = [ - TaskType.PARENT_LETTER, - TaskType.CLASS_MESSAGE, - TaskType.FEEDBACK_SUGGEST, - TaskType.WORKSHEET_GENERATE, + return task.type in [ + TaskType.PARENT_LETTER, TaskType.CLASS_MESSAGE, + TaskType.FEEDBACK_SUGGEST, TaskType.WORKSHEET_GENERATE, ] - return task.type in content_types - - async def _route_to_agent( - self, - task: Task, - session: Optional[AgentSession] - ) -> None: + async def _route_to_agent(self, task: Task, session: Optional[AgentSession]) -> None: """Routes a task to a specialized agent""" - # Determine target agent intent = f"task_{task.type.value}" routing_result = await self.task_router.route( - intent=intent, - context={"task": task.parameters}, + intent=intent, context={"task": task.parameters}, strategy=RoutingStrategy.LEAST_LOADED ) if not routing_result.success: - # Fall back to local processing logger.warning( "No agent available for task, using local processing", - task_id=task.id[:8], - reason=routing_result.reason + task_id=task.id[:8], reason=routing_result.reason ) await super().process_task(task) return - # Send to agent via message bus try: response = await self.message_bus.request( AgentMessage( @@ -332,8 +184,7 @@ class EnhancedTaskOrchestrator(TaskOrchestrator): receiver=routing_result.agent_id, message_type=f"process_{task.type.value}", payload={ - "task_id": task.id, - "task_type": task.type.value, + "task_id": task.id, "task_type": task.type.value, "parameters": task.parameters, "session_id": session.session_id if session else None }, @@ -341,179 +192,78 @@ class EnhancedTaskOrchestrator(TaskOrchestrator): ), timeout=30.0 ) - task.result_ref = response.get("result", "") task.transition_to(TaskState.READY, "agent_processed") - except asyncio.TimeoutError: logger.error( "Agent timeout, falling back to local", - task_id=task.id[:8], - agent=routing_result.agent_id + task_id=task.id[:8], agent=routing_result.agent_id ) await super().process_task(task) - async def _run_quality_check( - self, - task: Task, - session: Optional[AgentSession] - ) -> None: + async def _run_quality_check(self, task: Task, session: Optional[AgentSession]) -> None: """Runs quality check on task result via quality judge""" try: response = await self.message_bus.request( AgentMessage( - sender="voice-orchestrator", - receiver="quality-judge", + sender="voice-orchestrator", receiver="quality-judge", message_type="evaluate_response", payload={ - "task_id": task.id, - "task_type": task.type.value, - "response": task.result_ref, - "context": task.parameters + "task_id": task.id, "task_type": task.type.value, + "response": task.result_ref, "context": task.parameters }, priority=MessagePriority.NORMAL ), timeout=10.0 ) - quality_score = response.get("composite_score", 0) - if quality_score < 60: - # Mark for review task.error_message = f"Quality check failed: {quality_score}" - logger.warning( - "Task failed quality check", - task_id=task.id[:8], - score=quality_score - ) - + logger.warning("Task failed quality check", task_id=task.id[:8], score=quality_score) except asyncio.TimeoutError: - # Quality check timeout is non-fatal - logger.warning( - "Quality check timeout", - task_id=task.id[:8] - ) + logger.warning("Quality check timeout", task_id=task.id[:8]) async def _store_task_result(self, task: Task) -> None: """Stores task result in memory for learning""" await self.memory_store.remember( key=f"task:{task.type.value}:{task.id}", value={ - "result": task.result_ref, - "parameters": task.parameters, + "result": task.result_ref, "parameters": task.parameters, "completed_at": datetime.utcnow().isoformat() }, - agent_id="voice-orchestrator", - ttl_days=30 + agent_id="voice-orchestrator", ttl_days=30 ) - async def _handle_agent_message( - self, - message: AgentMessage - ) -> Optional[Dict[str, Any]]: + async def _handle_agent_message(self, message: AgentMessage) -> Optional[Dict[str, Any]]: """Handles incoming messages from other agents""" - logger.debug( - "Received agent message", - sender=message.sender, - type=message.message_type - ) - + logger.debug("Received agent message", sender=message.sender, type=message.message_type) if message.message_type == "task_status_update": - # Handle task status updates task_id = message.payload.get("task_id") if task_id in self._tasks: task = self._tasks[task_id] new_state = message.payload.get("state") if new_state: task.transition_to(TaskState(new_state), "agent_update") - return None def _get_system_prompt(self) -> str: - """Returns the system prompt for the voice assistant""" - return """Du bist ein hilfreicher Assistent für Lehrer in der Breakpilot-App. + return """Du bist ein hilfreicher Assistent fuer Lehrer in der Breakpilot-App. Deine Aufgaben: -- Hilf beim Erstellen von Arbeitsblättern -- Unterstütze bei der Korrektur +- Hilf beim Erstellen von Arbeitsblaettern +- Unterstuetze bei der Korrektur - Erstelle Elternbriefe und Klassennachrichten - Dokumentiere Beobachtungen und Erinnerungen -Halte dich kurz und präzise. Nutze einfache, klare Sprache. +Halte dich kurz und praezise. Nutze einfache, klare Sprache. Bei Unklarheiten frage nach.""" - # Recovery methods - async def recover_session( - self, - voice_session_id: str, - session_id: str + self, voice_session_id: str, session_id: str ) -> Optional[AgentSession]: - """ - Recovers a session from checkpoint. - - Args: - voice_session_id: The voice session ID - session_id: The agent session ID to recover - - Returns: - The recovered session or None - """ - session = await self.session_manager.get_session(session_id) - - if not session: - logger.warning( - "Session not found for recovery", - session_id=session_id - ) - return None - - if session.state != SessionState.ACTIVE: - logger.warning( - "Session not active for recovery", - session_id=session_id, - state=session.state.value - ) - return None - - # Resume session - session.resume() - - # Restore heartbeat - heartbeat_client = HeartbeatClient( - session_id=session.session_id, - monitor=self.heartbeat, - interval_seconds=10 + return await _recover_session( + self.session_manager, self.heartbeat, + self._voice_sessions, self._heartbeat_clients, + self._tasks, self.process_task, + voice_session_id, session_id, ) - await heartbeat_client.start() - self.heartbeat.register(session.session_id, "voice-orchestrator") - - # Store references - self._voice_sessions[voice_session_id] = session - self._heartbeat_clients[session.session_id] = heartbeat_client - - # Recover pending tasks from checkpoints - await self._recover_pending_tasks(session) - - logger.info( - "Recovered session", - session_id=session.session_id[:8], - checkpoints=len(session.checkpoints) - ) - - return session - - async def _recover_pending_tasks(self, session: AgentSession) -> None: - """Recovers pending tasks from session checkpoints""" - for checkpoint in reversed(session.checkpoints): - if checkpoint.name == "task_queued": - task_id = checkpoint.data.get("task_id") - if task_id and task_id in self._tasks: - task = self._tasks[task_id] - if task.state == TaskState.QUEUED: - # Re-process queued task - await self.process_task(task) - logger.info( - "Recovered pending task", - task_id=task_id[:8] - ) diff --git a/website/app/admin/builds/wizard/_components/StepContent.tsx b/website/app/admin/builds/wizard/_components/StepContent.tsx new file mode 100644 index 0000000..52247f5 --- /dev/null +++ b/website/app/admin/builds/wizard/_components/StepContent.tsx @@ -0,0 +1,116 @@ +export function PlatformCards() { + const platforms = [ + { + name: 'WebGL', + icon: '🌐', + status: 'Aktiv', + size: '~15 MB', + features: ['Browser-basiert', 'Sofort spielbar', 'Admin Panel Embed'] + }, + { + name: 'iOS', + icon: '📱', + status: 'Bereit', + size: '~80 MB', + features: ['iPhone & iPad', 'App Store', 'Push Notifications'] + }, + { + name: 'Android', + icon: '🤖', + status: 'Bereit', + size: '~60 MB', + features: ['Play Store', 'AAB Format', 'Wide Device Support'] + }, + ] + + return ( +
+ {platforms.map((platform) => ( +
+
+ {platform.icon} +
+

{platform.name}

+

{platform.size}

+
+ + {platform.status} + +
+
    + {platform.features.map((feature, i) => ( +
  • + {feature} +
  • + ))} +
+
+ ))} +
+ ) +} + +export function WorkflowDiagram() { + const jobs = [ + { name: 'version', icon: '🏷️', runner: 'ubuntu' }, + { name: 'build-webgl', icon: '🌐', runner: 'ubuntu' }, + { name: 'build-ios', icon: '📱', runner: 'macos' }, + { name: 'build-android', icon: '🤖', runner: 'ubuntu' }, + { name: 'deploy', icon: '🚀', runner: 'ubuntu' }, + ] + + return ( +
+

Workflow Jobs

+
+ {jobs.map((job, i) => ( +
+
+ {job.icon} +

{job.name}

+

{job.runner}

+
+ {i < jobs.length - 1 && ( + + )} +
+ ))} +
+
+ ) +} + +export function SecretsChecklist() { + const secrets = [ + { name: 'UNITY_LICENSE', desc: 'Unity Personal/Pro License', required: true }, + { name: 'UNITY_EMAIL', desc: 'Unity Account Email', required: true }, + { name: 'UNITY_PASSWORD', desc: 'Unity Account Password', required: true }, + { name: 'IOS_BUILD_CERTIFICATE_BASE64', desc: 'Apple Distribution Certificate', required: false }, + { name: 'IOS_PROVISION_PROFILE_BASE64', desc: 'iOS Provisioning Profile', required: false }, + { name: 'ANDROID_KEYSTORE_BASE64', desc: 'Android Signing Keystore', required: false }, + { name: 'AWS_ACCESS_KEY_ID', desc: 'AWS fuer S3/CloudFront', required: false }, + ] + + return ( +
+
+

GitHub Secrets Checkliste

+
+
    + {secrets.map((secret) => ( +
  • +
    + {secret.name} +

    {secret.desc}

    +
    + + {secret.required ? 'Pflicht' : 'Optional'} + +
  • + ))} +
+
+ ) +} diff --git a/website/app/admin/builds/wizard/_components/WizardComponents.tsx b/website/app/admin/builds/wizard/_components/WizardComponents.tsx new file mode 100644 index 0000000..af15414 --- /dev/null +++ b/website/app/admin/builds/wizard/_components/WizardComponents.tsx @@ -0,0 +1,123 @@ +import { StepInfo, WizardStep, STEPS } from './types' + +export function WizardStepper({ steps, currentStep, onStepClick }: { + steps: StepInfo[] + currentStep: WizardStep + onStepClick: (step: WizardStep) => void +}) { + const currentIndex = steps.findIndex(s => s.id === currentStep) + + return ( +
+ {steps.map((step, index) => { + const isActive = step.id === currentStep + const isCompleted = index < currentIndex + const isClickable = index <= currentIndex + 1 + + return ( + + ) + })} +
+ ) +} + +export function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) { + return ( +
+

{title}

+
+ {content.split('\n\n').map((paragraph, i) => ( +

+ {paragraph.split('**').map((part, j) => + j % 2 === 1 ? {part} : part + )} +

+ ))} +
+
+

Tipps:

+
    + {tips.map((tip, i) => ( +
  • + + {tip} +
  • + ))} +
+
+
+ ) +} + +export function Sidebar({ currentStepIndex }: { currentStepIndex: number }) { + return ( +
+ {/* Progress */} +
+

Fortschritt

+
+
+ + Schritt {currentStepIndex + 1} von {STEPS.length} + + + {Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}% + +
+
+
+
+
+
+ + {/* Pipeline Overview */} +
+

Pipeline Flow

+
+
Git Push/Tag
+
+
GitHub Actions
+
+
Unity Build
+
+
Deploy / Upload
+
+
+ + {/* Quick Links */} +
+

Wichtige Dateien

+
    +
  • YAML: ci/build-all-platforms.yml
  • +
  • C#: Assets/Editor/BuildScript.cs
  • +
  • JSON: Assets/Resources/version.json
  • +
  • Plist: ci/ios-export-options.plist
  • +
+
+
+ ) +} diff --git a/website/app/admin/builds/wizard/_components/types.ts b/website/app/admin/builds/wizard/_components/types.ts new file mode 100644 index 0000000..0896dfd --- /dev/null +++ b/website/app/admin/builds/wizard/_components/types.ts @@ -0,0 +1,205 @@ +export type WizardStep = + | 'welcome' + | 'platforms' + | 'github-actions' + | 'webgl-build' + | 'ios-build' + | 'android-build' + | 'deployment' + | 'version-sync' + | 'summary' + +export interface StepInfo { + id: WizardStep + title: string + description: string +} + +export const STEPS: StepInfo[] = [ + { id: 'welcome', title: 'Willkommen', description: 'Build Pipeline Uebersicht' }, + { id: 'platforms', title: 'Plattformen', description: 'WebGL, iOS, Android' }, + { id: 'github-actions', title: 'GitHub Actions', description: 'CI/CD Workflow' }, + { id: 'webgl-build', title: 'WebGL', description: 'Browser Build' }, + { id: 'ios-build', title: 'iOS', description: 'App Store Build' }, + { id: 'android-build', title: 'Android', description: 'Play Store Build' }, + { id: 'deployment', title: 'Deployment', description: 'Store Upload' }, + { id: 'version-sync', title: 'Versioning', description: 'Version Management' }, + { id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' }, +] + +export const EDUCATION_CONTENT: Record = { + 'welcome': { + title: 'Multi-Platform Build Pipeline', + content: `Breakpilot Drive wird fuer drei Plattformen gebaut: + + **WebGL** - Browser-basiert, in Admin Panel eingebettet + **iOS** - iPhone/iPad via App Store + **Android** - Smartphones/Tablets via Google Play + + Die Build-Pipeline nutzt **GitHub Actions** mit **game-ci/unity-builder** + fuer automatisierte, reproduzierbare Builds.`, + tips: ['WebGL ist die primaere Plattform fuer schnelles Testing', 'Mobile Builds nur bei Tags (Releases)', 'Alle Builds werden als Artifacts gespeichert'] + }, + 'platforms': { + title: 'Unterstuetzte Plattformen', + content: `Jede Plattform hat spezifische Anforderungen: + + **WebGL (HTML5/WASM)** + - Brotli-Kompression + - 512MB Memory + - Kein Threading (Browser-Limitation) + + **iOS (iPhone/iPad)** + - Min. iOS 14.0 + - ARM64 Architektur + - App Store Distribution + + **Android** + - Min. Android 7.0 (API 24) + - Target: Android 14 (API 34) + - ARM64, AAB fuer Play Store`, + tips: ['WebGL laeuft in allen modernen Browsern', 'iOS erfordert Apple Developer Account ($99/Jahr)', 'Android AAB ist Pflicht fuer Play Store'] + }, + 'github-actions': { + title: 'GitHub Actions Workflow', + content: `Der CI/CD Workflow ist in Jobs aufgeteilt: + + **1. version** - Ermittelt Version aus Git Tag + **2. build-webgl** - Baut Browser-Version + **3. build-ios** - Baut Xcode Projekt + **4. build-ios-ipa** - Erstellt signierte IPA + **5. build-android** - Baut AAB/APK + **6. deploy-webgl** - Deployed zu CDN + **7. upload-ios** - Laedt zu App Store Connect + **8. upload-android** - Laedt zu Google Play + + Trigger: + - **Tags (v*)**: Alle Plattformen + Upload + - **Push main**: Nur WebGL + - **Manual**: Auswahlbar`, + tips: ['Unity License muss als Secret hinterlegt sein', 'Signing-Zertifikate als Base64 Secrets', 'Cache beschleunigt Builds erheblich'] + }, + 'webgl-build': { + title: 'WebGL Build', + content: `WebGL ist die schnellste Build-Variante: + + **Build-Einstellungen:** + - Kompression: Brotli (beste Kompression) + - Memory: 512MB (ausreichend fuer Spiel) + - Exceptions: Nur explizite (Performance) + - Linker: WASM (WebAssembly) + + **Output:** + - index.html + - Build/*.wasm.br (komprimiert) + - Build/*.data.br (Assets) + - Build/*.js (Loader) + + **Deployment:** + - S3 + CloudFront CDN + - Cache: 1 Jahr fuer Assets, 1h fuer HTML`, + tips: ['Brotli-Kompression spart ~70% Bandbreite', 'Erste Ladung ~10-15MB, danach gecached', 'Server muss Brotli-Headers unterstuetzen'] + }, + 'ios-build': { + title: 'iOS Build', + content: `iOS Build erfolgt in zwei Schritten: + + **Schritt 1: Unity Build** + - Erstellt Xcode Projekt + - Setzt iOS-spezifische Einstellungen + - Output: Unity-iPhone.xcodeproj + + **Schritt 2: Xcode Build** + - Importiert Signing-Zertifikate + - Archiviert Projekt + - Exportiert signierte IPA + + **Voraussetzungen:** + - Apple Developer Account + - Distribution Certificate (.p12) + - Provisioning Profile + - App Store Connect API Key`, + tips: ['Zertifikate alle 1 Jahr erneuern', 'Provisioning Profile fuer jede App ID', 'TestFlight fuer Beta-Tests nutzen'] + }, + 'android-build': { + title: 'Android Build', + content: `Android Build erzeugt AAB oder APK: + + **AAB (App Bundle)** - Fuer Play Store + - Google optimiert fuer jedes Geraet + - Kleinere Downloads + - Pflicht seit 2021 + + **APK** - Fuer direkten Download + - Debug-Builds fuer Testing + - Sideloading moeglich + + **Signing:** + - Keystore (.jks/.keystore) + - Key Alias und Passwoerter + - Play App Signing empfohlen + + **Voraussetzungen:** + - Google Play Console Account ($25 einmalig) + - Keystore fuer App-Signatur`, + tips: ['Keystore NIEMALS verlieren (keine Veroeffentlichung mehr)', 'Play App Signing: Google verwaltet Upload-Key', 'Internal Testing fuer schnelle Tests'] + }, + 'deployment': { + title: 'Store Deployment', + content: `Automatisches Deployment zu den Stores: + + **WebGL -> CDN (S3/CloudFront)** + - Sync zu S3 Bucket + - CloudFront Invalidation + - Versionierte URLs + + **iOS -> App Store Connect** + - Upload via altool + - API Key Authentifizierung + - TestFlight Auto-Distribution + + **Android -> Google Play** + - Upload via r0adkll/upload-google-play + - Service Account Auth + - Internal Track zuerst`, + tips: ['WebGL ist sofort live nach Deploy', 'iOS: Review dauert 1-3 Tage', 'Android: Review dauert wenige Stunden'] + }, + 'version-sync': { + title: 'Version Synchronisation', + content: `Versionen werden zentral verwaltet: + + **version.json** (Runtime) + - version: Semantische Version + - build_number: Inkrementell + - platform: Build-Target + - commit_hash: Git SHA + - min_api_version: API Kompatibilitaet + + **VersionManager.cs** (Unity) + - Laedt version.json zur Laufzeit + - Prueft API-Kompatibilitaet + - Zeigt Update-Hinweise + + **Git Tags** + - v1.0.0 -> Version 1.0.0 + - Trigger fuer Release-Builds`, + tips: ['build_number aus GitHub Run Number', 'min_api_version fuer erzwungene Updates', 'Semantic Versioning: MAJOR.MINOR.PATCH'] + }, + 'summary': { + title: 'Zusammenfassung & Naechste Schritte', + content: `Du hast gelernt: + + ✓ Build-Targets: WebGL, iOS, Android + ✓ GitHub Actions Workflow + ✓ Platform-spezifische Einstellungen + ✓ Store Deployment Prozess + ✓ Version Management + + **Naechste Schritte:** + 1. GitHub Secrets konfigurieren + 2. Apple/Google Developer Accounts einrichten + 3. Keystore und Zertifikate erstellen + 4. Ersten Release-Tag erstellen`, + tips: ['Dokumentation in BreakpilotDrive/ci/', 'BuildScript.cs fuer lokale Builds', 'version.json wird automatisch aktualisiert'] + }, +} diff --git a/website/app/admin/builds/wizard/page.tsx b/website/app/admin/builds/wizard/page.tsx index 8feab12..7694bef 100644 --- a/website/app/admin/builds/wizard/page.tsx +++ b/website/app/admin/builds/wizard/page.tsx @@ -10,455 +10,9 @@ import { useState } from 'react' import Link from 'next/link' import AdminLayout from '@/components/admin/AdminLayout' - -// ======================================== -// Types -// ======================================== - -type WizardStep = - | 'welcome' - | 'platforms' - | 'github-actions' - | 'webgl-build' - | 'ios-build' - | 'android-build' - | 'deployment' - | 'version-sync' - | 'summary' - -interface StepInfo { - id: WizardStep - title: string - description: string -} - -// ======================================== -// Step Configuration -// ======================================== - -const STEPS: StepInfo[] = [ - { id: 'welcome', title: 'Willkommen', description: 'Build Pipeline Uebersicht' }, - { id: 'platforms', title: 'Plattformen', description: 'WebGL, iOS, Android' }, - { id: 'github-actions', title: 'GitHub Actions', description: 'CI/CD Workflow' }, - { id: 'webgl-build', title: 'WebGL', description: 'Browser Build' }, - { id: 'ios-build', title: 'iOS', description: 'App Store Build' }, - { id: 'android-build', title: 'Android', description: 'Play Store Build' }, - { id: 'deployment', title: 'Deployment', description: 'Store Upload' }, - { id: 'version-sync', title: 'Versioning', description: 'Version Management' }, - { id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' }, -] - -// ======================================== -// Educational Content -// ======================================== - -const EDUCATION_CONTENT: Record = { - 'welcome': { - title: 'Multi-Platform Build Pipeline', - content: `Breakpilot Drive wird fuer drei Plattformen gebaut: - - **WebGL** - Browser-basiert, in Admin Panel eingebettet - **iOS** - iPhone/iPad via App Store - **Android** - Smartphones/Tablets via Google Play - - Die Build-Pipeline nutzt **GitHub Actions** mit **game-ci/unity-builder** - fuer automatisierte, reproduzierbare Builds.`, - tips: [ - 'WebGL ist die primaere Plattform fuer schnelles Testing', - 'Mobile Builds nur bei Tags (Releases)', - 'Alle Builds werden als Artifacts gespeichert' - ] - }, - 'platforms': { - title: 'Unterstuetzte Plattformen', - content: `Jede Plattform hat spezifische Anforderungen: - - **WebGL (HTML5/WASM)** - - Brotli-Kompression - - 512MB Memory - - Kein Threading (Browser-Limitation) - - **iOS (iPhone/iPad)** - - Min. iOS 14.0 - - ARM64 Architektur - - App Store Distribution - - **Android** - - Min. Android 7.0 (API 24) - - Target: Android 14 (API 34) - - ARM64, AAB fuer Play Store`, - tips: [ - 'WebGL laeuft in allen modernen Browsern', - 'iOS erfordert Apple Developer Account ($99/Jahr)', - 'Android AAB ist Pflicht fuer Play Store' - ] - }, - 'github-actions': { - title: 'GitHub Actions Workflow', - content: `Der CI/CD Workflow ist in Jobs aufgeteilt: - - **1. version** - Ermittelt Version aus Git Tag - **2. build-webgl** - Baut Browser-Version - **3. build-ios** - Baut Xcode Projekt - **4. build-ios-ipa** - Erstellt signierte IPA - **5. build-android** - Baut AAB/APK - **6. deploy-webgl** - Deployed zu CDN - **7. upload-ios** - Laedt zu App Store Connect - **8. upload-android** - Laedt zu Google Play - - Trigger: - - **Tags (v*)**: Alle Plattformen + Upload - - **Push main**: Nur WebGL - - **Manual**: Auswahlbar`, - tips: [ - 'Unity License muss als Secret hinterlegt sein', - 'Signing-Zertifikate als Base64 Secrets', - 'Cache beschleunigt Builds erheblich' - ] - }, - 'webgl-build': { - title: 'WebGL Build', - content: `WebGL ist die schnellste Build-Variante: - - **Build-Einstellungen:** - - Kompression: Brotli (beste Kompression) - - Memory: 512MB (ausreichend fuer Spiel) - - Exceptions: Nur explizite (Performance) - - Linker: WASM (WebAssembly) - - **Output:** - - index.html - - Build/*.wasm.br (komprimiert) - - Build/*.data.br (Assets) - - Build/*.js (Loader) - - **Deployment:** - - S3 + CloudFront CDN - - Cache: 1 Jahr fuer Assets, 1h fuer HTML`, - tips: [ - 'Brotli-Kompression spart ~70% Bandbreite', - 'Erste Ladung ~10-15MB, danach gecached', - 'Server muss Brotli-Headers unterstuetzen' - ] - }, - 'ios-build': { - title: 'iOS Build', - content: `iOS Build erfolgt in zwei Schritten: - - **Schritt 1: Unity Build** - - Erstellt Xcode Projekt - - Setzt iOS-spezifische Einstellungen - - Output: Unity-iPhone.xcodeproj - - **Schritt 2: Xcode Build** - - Importiert Signing-Zertifikate - - Archiviert Projekt - - Exportiert signierte IPA - - **Voraussetzungen:** - - Apple Developer Account - - Distribution Certificate (.p12) - - Provisioning Profile - - App Store Connect API Key`, - tips: [ - 'Zertifikate alle 1 Jahr erneuern', - 'Provisioning Profile fuer jede App ID', - 'TestFlight fuer Beta-Tests nutzen' - ] - }, - 'android-build': { - title: 'Android Build', - content: `Android Build erzeugt AAB oder APK: - - **AAB (App Bundle)** - Fuer Play Store - - Google optimiert fuer jedes Geraet - - Kleinere Downloads - - Pflicht seit 2021 - - **APK** - Fuer direkten Download - - Debug-Builds fuer Testing - - Sideloading moeglich - - **Signing:** - - Keystore (.jks/.keystore) - - Key Alias und Passwoerter - - Play App Signing empfohlen - - **Voraussetzungen:** - - Google Play Console Account ($25 einmalig) - - Keystore fuer App-Signatur`, - tips: [ - 'Keystore NIEMALS verlieren (keine Veroeffentlichung mehr)', - 'Play App Signing: Google verwaltet Upload-Key', - 'Internal Testing fuer schnelle Tests' - ] - }, - 'deployment': { - title: 'Store Deployment', - content: `Automatisches Deployment zu den Stores: - - **WebGL -> CDN (S3/CloudFront)** - - Sync zu S3 Bucket - - CloudFront Invalidation - - Versionierte URLs - - **iOS -> App Store Connect** - - Upload via altool - - API Key Authentifizierung - - TestFlight Auto-Distribution - - **Android -> Google Play** - - Upload via r0adkll/upload-google-play - - Service Account Auth - - Internal Track zuerst`, - tips: [ - 'WebGL ist sofort live nach Deploy', - 'iOS: Review dauert 1-3 Tage', - 'Android: Review dauert wenige Stunden' - ] - }, - 'version-sync': { - title: 'Version Synchronisation', - content: `Versionen werden zentral verwaltet: - - **version.json** (Runtime) - - version: Semantische Version - - build_number: Inkrementell - - platform: Build-Target - - commit_hash: Git SHA - - min_api_version: API Kompatibilitaet - - **VersionManager.cs** (Unity) - - Laedt version.json zur Laufzeit - - Prueft API-Kompatibilitaet - - Zeigt Update-Hinweise - - **Git Tags** - - v1.0.0 -> Version 1.0.0 - - Trigger fuer Release-Builds`, - tips: [ - 'build_number aus GitHub Run Number', - 'min_api_version fuer erzwungene Updates', - 'Semantic Versioning: MAJOR.MINOR.PATCH' - ] - }, - 'summary': { - title: 'Zusammenfassung & Naechste Schritte', - content: `Du hast gelernt: - - ✓ Build-Targets: WebGL, iOS, Android - ✓ GitHub Actions Workflow - ✓ Platform-spezifische Einstellungen - ✓ Store Deployment Prozess - ✓ Version Management - - **Naechste Schritte:** - 1. GitHub Secrets konfigurieren - 2. Apple/Google Developer Accounts einrichten - 3. Keystore und Zertifikate erstellen - 4. Ersten Release-Tag erstellen`, - tips: [ - 'Dokumentation in BreakpilotDrive/ci/', - 'BuildScript.cs fuer lokale Builds', - 'version.json wird automatisch aktualisiert' - ] - }, -} - -// ======================================== -// Components -// ======================================== - -function WizardStepper({ steps, currentStep, onStepClick }: { - steps: StepInfo[] - currentStep: WizardStep - onStepClick: (step: WizardStep) => void -}) { - const currentIndex = steps.findIndex(s => s.id === currentStep) - - return ( -
- {steps.map((step, index) => { - const isActive = step.id === currentStep - const isCompleted = index < currentIndex - const isClickable = index <= currentIndex + 1 - - return ( - - ) - })} -
- ) -} - -function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) { - return ( -
-

{title}

-
- {content.split('\n\n').map((paragraph, i) => ( -

- {paragraph.split('**').map((part, j) => - j % 2 === 1 ? {part} : part - )} -

- ))} -
-
-

Tipps:

-
    - {tips.map((tip, i) => ( -
  • - - {tip} -
  • - ))} -
-
-
- ) -} - -function PlatformCards() { - const platforms = [ - { - name: 'WebGL', - icon: '🌐', - status: 'Aktiv', - size: '~15 MB', - features: ['Browser-basiert', 'Sofort spielbar', 'Admin Panel Embed'] - }, - { - name: 'iOS', - icon: '📱', - status: 'Bereit', - size: '~80 MB', - features: ['iPhone & iPad', 'App Store', 'Push Notifications'] - }, - { - name: 'Android', - icon: '🤖', - status: 'Bereit', - size: '~60 MB', - features: ['Play Store', 'AAB Format', 'Wide Device Support'] - }, - ] - - return ( -
- {platforms.map((platform) => ( -
-
- {platform.icon} -
-

{platform.name}

-

{platform.size}

-
- - {platform.status} - -
-
    - {platform.features.map((feature, i) => ( -
  • - {feature} -
  • - ))} -
-
- ))} -
- ) -} - -function WorkflowDiagram() { - const jobs = [ - { name: 'version', icon: '🏷️', runner: 'ubuntu' }, - { name: 'build-webgl', icon: '🌐', runner: 'ubuntu' }, - { name: 'build-ios', icon: '📱', runner: 'macos' }, - { name: 'build-android', icon: '🤖', runner: 'ubuntu' }, - { name: 'deploy', icon: '🚀', runner: 'ubuntu' }, - ] - - return ( -
-

Workflow Jobs

-
- {jobs.map((job, i) => ( -
-
- {job.icon} -

{job.name}

-

{job.runner}

-
- {i < jobs.length - 1 && ( - - )} -
- ))} -
-
- ) -} - -function SecretsChecklist() { - const secrets = [ - { name: 'UNITY_LICENSE', desc: 'Unity Personal/Pro License', required: true }, - { name: 'UNITY_EMAIL', desc: 'Unity Account Email', required: true }, - { name: 'UNITY_PASSWORD', desc: 'Unity Account Password', required: true }, - { name: 'IOS_BUILD_CERTIFICATE_BASE64', desc: 'Apple Distribution Certificate', required: false }, - { name: 'IOS_PROVISION_PROFILE_BASE64', desc: 'iOS Provisioning Profile', required: false }, - { name: 'ANDROID_KEYSTORE_BASE64', desc: 'Android Signing Keystore', required: false }, - { name: 'AWS_ACCESS_KEY_ID', desc: 'AWS fuer S3/CloudFront', required: false }, - ] - - return ( -
-
-

GitHub Secrets Checkliste

-
-
    - {secrets.map((secret) => ( -
  • -
    - {secret.name} -

    {secret.desc}

    -
    - - {secret.required ? 'Pflicht' : 'Optional'} - -
  • - ))} -
-
- ) -} - -// ======================================== -// Main Component -// ======================================== +import { STEPS, EDUCATION_CONTENT, WizardStep } from './_components/types' +import { WizardStepper, EducationCard, Sidebar } from './_components/WizardComponents' +import { PlatformCards, WorkflowDiagram, SecretsChecklist } from './_components/StepContent' export default function BuildPipelineWizardPage() { const [currentStep, setCurrentStep] = useState('welcome') @@ -541,61 +95,7 @@ export default function BuildPipelineWizardPage() {
{/* Sidebar */} -
- {/* Progress */} -
-

Fortschritt

-
-
- - Schritt {currentStepIndex + 1} von {STEPS.length} - - - {Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}% - -
-
-
-
-
-
- - {/* Pipeline Overview */} -
-

Pipeline Flow

-
-
Git Push/Tag
-
-
GitHub Actions
-
-
Unity Build
-
-
Deploy / Upload
-
-
- - {/* Quick Links */} -
-

Wichtige Dateien

-
    -
  • - YAML: ci/build-all-platforms.yml -
  • -
  • - C#: Assets/Editor/BuildScript.cs -
  • -
  • - JSON: Assets/Resources/version.json -
  • -
  • - Plist: ci/ios-export-options.plist -
  • -
-
-
+
) diff --git a/website/app/admin/communication/_components/MeetingsAndRooms.tsx b/website/app/admin/communication/_components/MeetingsAndRooms.tsx new file mode 100644 index 0000000..820fdec --- /dev/null +++ b/website/app/admin/communication/_components/MeetingsAndRooms.tsx @@ -0,0 +1,177 @@ +import { ActiveMeeting, RecentRoom, CommunicationStats } from './types' +import { formatDuration, formatTimeAgo, getRoomTypeBadge } from './helpers' + +export function ActiveMeetingsSection({ + activeMeetings, + loading, + onRefresh, +}: { + activeMeetings: ActiveMeeting[] + loading: boolean + onRefresh: () => void +}) { + return ( +
+
+

Aktive Meetings

+ +
+ + {activeMeetings.length === 0 ? ( +
+ + + +

Keine aktiven Meetings

+
+ ) : ( +
+ + + + + + + + + + + {activeMeetings.map((meeting, idx) => ( + + + + + + + ))} + +
MeetingTeilnehmerGestartetDauer
+
{meeting.display_name}
+
{meeting.room_name}
+
+ + + {meeting.participants} + + {formatTimeAgo(meeting.started_at)}{formatDuration(meeting.duration_minutes)}
+
+ )} +
+ ) +} + +export function ChatRoomsAndUsage({ + recentRooms, + stats, +}: { + recentRooms: RecentRoom[] + stats: CommunicationStats | null +}) { + return ( +
+
+

Aktive Chat-Räume

+ + {recentRooms.length === 0 ? ( +
+

Keine aktiven Räume

+
+ ) : ( +
+ {recentRooms.slice(0, 5).map((room, idx) => ( +
+
+
+ + + +
+
+
{room.name}
+
{room.member_count} Mitglieder
+
+
+
+ {room.room_type} + {formatTimeAgo(room.last_activity)} +
+
+ ))} +
+ )} +
+ + {/* Usage Statistics */} +
+

Nutzungsstatistiken

+
+
+
+ Call-Minuten heute + {stats?.jitsi.total_minutes_today || 0} Min. +
+
+
+
+
+
+
+ Aktive Chat-Räume + {stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0} +
+
+
+
+
+
+
+ Aktive Nutzer + {stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0} +
+
+
+
+
+
+ + {/* Quick Actions */} +
+

Schnellaktionen

+ +
+
+
+ ) +} diff --git a/website/app/admin/communication/_components/ServiceCards.tsx b/website/app/admin/communication/_components/ServiceCards.tsx new file mode 100644 index 0000000..5f0e888 --- /dev/null +++ b/website/app/admin/communication/_components/ServiceCards.tsx @@ -0,0 +1,96 @@ +import { CommunicationStats } from './types' +import { getStatusBadge, formatDuration } from './helpers' + +export function MatrixCard({ stats }: { stats: CommunicationStats | null }) { + return ( +
+
+
+
+ + + +
+
+

Matrix (Synapse)

+

E2EE Messaging

+
+
+ + {stats?.matrix.status || 'offline'} + +
+
+
+
{stats?.matrix.total_users || 0}
+
Benutzer
+
+
+
{stats?.matrix.active_users || 0}
+
Aktiv
+
+
+
{stats?.matrix.total_rooms || 0}
+
Räume
+
+
+
+
+ Nachrichten heute + {stats?.matrix.messages_today || 0} +
+
+ Diese Woche + {stats?.matrix.messages_this_week || 0} +
+
+
+ ) +} + +export function JitsiCard({ stats }: { stats: CommunicationStats | null }) { + return ( +
+
+
+
+ + + +
+
+

Jitsi Meet

+

Videokonferenzen

+
+
+ + {stats?.jitsi.status || 'offline'} + +
+
+
+
{stats?.jitsi.active_meetings || 0}
+
Live Calls
+
+
+
{stats?.jitsi.total_participants || 0}
+
Teilnehmer
+
+
+
{stats?.jitsi.meetings_today || 0}
+
Calls heute
+
+
+
+
+ Ø Dauer + {formatDuration(stats?.jitsi.average_duration_minutes || 0)} +
+
+ Peak gleichzeitig + {stats?.jitsi.peak_concurrent_users || 0} Nutzer +
+
+
+ ) +} diff --git a/website/app/admin/communication/_components/TrafficSection.tsx b/website/app/admin/communication/_components/TrafficSection.tsx new file mode 100644 index 0000000..1b08be4 --- /dev/null +++ b/website/app/admin/communication/_components/TrafficSection.tsx @@ -0,0 +1,116 @@ +import { CommunicationStats } from './types' +import { + calculateEstimatedTraffic, + calculateHourlyEstimate, + calculateMonthlyEstimate, + getResourceRecommendation, +} from './helpers' + +export function TrafficSection({ stats }: { stats: CommunicationStats | null }) { + return ( +
+
+
+
+ + + +
+
+

Traffic & Bandbreite

+

SysEleven Ressourcenplanung

+
+
+ + Live + +
+ +
+
+
Eingehend (heute)
+
+ {stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic(stats, 'in').toFixed(1)} MB +
+
+
+
Ausgehend (heute)
+
+ {stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic(stats, 'out').toFixed(1)} MB +
+
+
+
Geschätzt/Stunde
+
+ {stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate(stats).toFixed(2)} GB +
+
+
+
Geschätzt/Monat
+
+ {stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate(stats).toFixed(1)} GB +
+
+
+ +
+ {/* Matrix Traffic */} +
+
+
+ Matrix Messaging +
+
+
+ Nachrichten/Min + {stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)} +
+
+ Media Uploads heute + {stats?.traffic?.matrix?.media_uploads_today || 0} +
+
+ Media Größe + {stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB +
+
+
+ + {/* Jitsi Traffic */} +
+
+
+ Jitsi Video +
+
+
+ Video Streams aktiv + {stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)} +
+
+ Audio Streams aktiv + {stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)} +
+
+ Bitrate geschätzt + {((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps +
+
+
+
+ + {/* SysEleven Resource Recommendations */} +
+

SysEleven Empfehlung

+
+

Basierend auf aktuellem Traffic: {getResourceRecommendation(stats)}

+

+ Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} | + Ø Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. | + Calls heute: {stats?.jitsi?.meetings_today || 0} +

+
+
+
+ ) +} diff --git a/website/app/admin/communication/_components/helpers.ts b/website/app/admin/communication/_components/helpers.ts new file mode 100644 index 0000000..3abf177 --- /dev/null +++ b/website/app/admin/communication/_components/helpers.ts @@ -0,0 +1,91 @@ +import { CommunicationStats } from './types' + +export function getStatusBadge(status: string) { + const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase' + switch (status) { + case 'online': + return `${baseClasses} bg-green-100 text-green-800` + case 'degraded': + return `${baseClasses} bg-yellow-100 text-yellow-800` + case 'offline': + return `${baseClasses} bg-red-100 text-red-800` + default: + return `${baseClasses} bg-slate-100 text-slate-600` + } +} + +export function getRoomTypeBadge(type: string) { + const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium' + switch (type) { + case 'class': + return `${baseClasses} bg-blue-100 text-blue-700` + case 'parent': + return `${baseClasses} bg-purple-100 text-purple-700` + case 'staff': + return `${baseClasses} bg-orange-100 text-orange-700` + default: + return `${baseClasses} bg-slate-100 text-slate-600` + } +} + +export function formatDuration(minutes: number) { + if (minutes < 60) return `${Math.round(minutes)} Min.` + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + return `${hours}h ${mins}m` +} + +export function formatTimeAgo(dateStr: string) { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) return 'gerade eben' + if (diffMins < 60) return `vor ${diffMins} Min.` + if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.` + return `vor ${Math.floor(diffMins / 1440)} Tagen` +} + +// Traffic estimation helpers for SysEleven planning +export function calculateEstimatedTraffic(stats: CommunicationStats | null, direction: 'in' | 'out'): number { + const messages = stats?.matrix?.messages_today || 0 + const callMinutes = stats?.jitsi?.total_minutes_today || 0 + const participants = stats?.jitsi?.total_participants || 0 + + // Estimates: ~2KB per message, ~1.5 Mbps per video participant + const messageTrafficMB = messages * 0.002 + const videoTrafficMB = callMinutes * participants * 0.011 // ~660 KB/min per participant + + if (direction === 'in') { + return messageTrafficMB * 0.3 + videoTrafficMB * 0.4 + } + return messageTrafficMB * 0.7 + videoTrafficMB * 0.6 +} + +export function calculateHourlyEstimate(stats: CommunicationStats | null): number { + const activeParticipants = stats?.jitsi?.total_participants || 0 + return activeParticipants * 0.675 +} + +export function calculateMonthlyEstimate(stats: CommunicationStats | null): number { + const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0 + const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1 + const monthlyMinutes = dailyCallMinutes * 22 + return (monthlyMinutes * avgParticipants * 11) / 1024 +} + +export function getResourceRecommendation(stats: CommunicationStats | null): string { + const peakUsers = stats?.jitsi?.peak_concurrent_users || 0 + const monthlyGB = calculateMonthlyEstimate(stats) + + if (monthlyGB < 10 || peakUsers < 5) { + return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)' + } else if (monthlyGB < 50 || peakUsers < 20) { + return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)' + } else if (monthlyGB < 200 || peakUsers < 50) { + return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)' + } else { + return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)' + } +} diff --git a/website/app/admin/communication/_components/types.ts b/website/app/admin/communication/_components/types.ts new file mode 100644 index 0000000..38bb784 --- /dev/null +++ b/website/app/admin/communication/_components/types.ts @@ -0,0 +1,64 @@ +export interface MatrixStats { + total_users: number + active_users: number + total_rooms: number + active_rooms: number + messages_today: number + messages_this_week: number + status: 'online' | 'offline' | 'degraded' +} + +export interface JitsiStats { + active_meetings: number + total_participants: number + meetings_today: number + average_duration_minutes: number + peak_concurrent_users: number + total_minutes_today: number + status: 'online' | 'offline' | 'degraded' +} + +export interface TrafficStats { + matrix: { + bandwidth_in_mb: number + bandwidth_out_mb: number + messages_per_minute: number + media_uploads_today: number + media_size_mb: number + } + jitsi: { + bandwidth_in_mb: number + bandwidth_out_mb: number + video_streams_active: number + audio_streams_active: number + estimated_hourly_gb: number + } + total: { + bandwidth_in_mb: number + bandwidth_out_mb: number + estimated_monthly_gb: number + } +} + +export interface CommunicationStats { + matrix: MatrixStats + jitsi: JitsiStats + traffic?: TrafficStats + last_updated: string +} + +export interface ActiveMeeting { + room_name: string + display_name: string + participants: number + started_at: string + duration_minutes: number +} + +export interface RecentRoom { + room_id: string + name: string + member_count: number + last_activity: string + room_type: 'class' | 'parent' | 'staff' | 'general' +} diff --git a/website/app/admin/communication/_components/useCommunicationStats.ts b/website/app/admin/communication/_components/useCommunicationStats.ts new file mode 100644 index 0000000..562dcb4 --- /dev/null +++ b/website/app/admin/communication/_components/useCommunicationStats.ts @@ -0,0 +1,66 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { CommunicationStats, ActiveMeeting, RecentRoom } from './types' + +const API_BASE = '/api/admin/communication' + +export function useCommunicationStats() { + const [stats, setStats] = useState(null) + const [activeMeetings, setActiveMeetings] = useState([]) + const [recentRooms, setRecentRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchStats = useCallback(async () => { + try { + const response = await fetch(`${API_BASE}/stats`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const data = await response.json() + setStats(data) + setActiveMeetings(data.active_meetings || []) + setRecentRooms(data.recent_rooms || []) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Verbindungsfehler') + // Set mock data for display purposes when API unavailable + setStats({ + matrix: { + total_users: 0, + active_users: 0, + total_rooms: 0, + active_rooms: 0, + messages_today: 0, + messages_this_week: 0, + status: 'offline' + }, + jitsi: { + active_meetings: 0, + total_participants: 0, + meetings_today: 0, + average_duration_minutes: 0, + peak_concurrent_users: 0, + total_minutes_today: 0, + status: 'offline' + }, + last_updated: new Date().toISOString() + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchStats() + }, [fetchStats]) + + // Auto-refresh every 15 seconds + useEffect(() => { + const interval = setInterval(fetchStats, 15000) + return () => clearInterval(interval) + }, [fetchStats]) + + return { stats, activeMeetings, recentRooms, loading, error, fetchStats } +} diff --git a/website/app/admin/communication/page.tsx b/website/app/admin/communication/page.tsx index badf56f..f72c558 100644 --- a/website/app/admin/communication/page.tsx +++ b/website/app/admin/communication/page.tsx @@ -8,578 +8,34 @@ */ import AdminLayout from '@/components/admin/AdminLayout' -import { useEffect, useState, useCallback } from 'react' - -interface MatrixStats { - total_users: number - active_users: number - total_rooms: number - active_rooms: number - messages_today: number - messages_this_week: number - status: 'online' | 'offline' | 'degraded' -} - -interface JitsiStats { - active_meetings: number - total_participants: number - meetings_today: number - average_duration_minutes: number - peak_concurrent_users: number - total_minutes_today: number - status: 'online' | 'offline' | 'degraded' -} - -interface TrafficStats { - matrix: { - bandwidth_in_mb: number - bandwidth_out_mb: number - messages_per_minute: number - media_uploads_today: number - media_size_mb: number - } - jitsi: { - bandwidth_in_mb: number - bandwidth_out_mb: number - video_streams_active: number - audio_streams_active: number - estimated_hourly_gb: number - } - total: { - bandwidth_in_mb: number - bandwidth_out_mb: number - estimated_monthly_gb: number - } -} - -interface CommunicationStats { - matrix: MatrixStats - jitsi: JitsiStats - traffic?: TrafficStats - last_updated: string -} - -interface ActiveMeeting { - room_name: string - display_name: string - participants: number - started_at: string - duration_minutes: number -} - -interface RecentRoom { - room_id: string - name: string - member_count: number - last_activity: string - room_type: 'class' | 'parent' | 'staff' | 'general' -} +import { useCommunicationStats } from './_components/useCommunicationStats' +import { MatrixCard, JitsiCard } from './_components/ServiceCards' +import { TrafficSection } from './_components/TrafficSection' +import { ActiveMeetingsSection, ChatRoomsAndUsage } from './_components/MeetingsAndRooms' export default function CommunicationPage() { - const [stats, setStats] = useState(null) - const [activeMeetings, setActiveMeetings] = useState([]) - const [recentRooms, setRecentRooms] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const API_BASE = '/api/admin/communication' - - const fetchStats = useCallback(async () => { - try { - const response = await fetch(`${API_BASE}/stats`) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - const data = await response.json() - setStats(data) - setActiveMeetings(data.active_meetings || []) - setRecentRooms(data.recent_rooms || []) - setError(null) - } catch (err) { - setError(err instanceof Error ? err.message : 'Verbindungsfehler') - // Set mock data for display purposes when API unavailable - setStats({ - matrix: { - total_users: 0, - active_users: 0, - total_rooms: 0, - active_rooms: 0, - messages_today: 0, - messages_this_week: 0, - status: 'offline' - }, - jitsi: { - active_meetings: 0, - total_participants: 0, - meetings_today: 0, - average_duration_minutes: 0, - peak_concurrent_users: 0, - total_minutes_today: 0, - status: 'offline' - }, - last_updated: new Date().toISOString() - }) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchStats() - }, [fetchStats]) - - // Auto-refresh every 15 seconds - useEffect(() => { - const interval = setInterval(fetchStats, 15000) - return () => clearInterval(interval) - }, [fetchStats]) - - const getStatusBadge = (status: string) => { - const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase' - switch (status) { - case 'online': - return `${baseClasses} bg-green-100 text-green-800` - case 'degraded': - return `${baseClasses} bg-yellow-100 text-yellow-800` - case 'offline': - return `${baseClasses} bg-red-100 text-red-800` - default: - return `${baseClasses} bg-slate-100 text-slate-600` - } - } - - const getRoomTypeBadge = (type: string) => { - const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium' - switch (type) { - case 'class': - return `${baseClasses} bg-blue-100 text-blue-700` - case 'parent': - return `${baseClasses} bg-purple-100 text-purple-700` - case 'staff': - return `${baseClasses} bg-orange-100 text-orange-700` - default: - return `${baseClasses} bg-slate-100 text-slate-600` - } - } - - const formatDuration = (minutes: number) => { - if (minutes < 60) return `${Math.round(minutes)} Min.` - const hours = Math.floor(minutes / 60) - const mins = Math.round(minutes % 60) - return `${hours}h ${mins}m` - } - - const formatTimeAgo = (dateStr: string) => { - const date = new Date(dateStr) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - - if (diffMins < 1) return 'gerade eben' - if (diffMins < 60) return `vor ${diffMins} Min.` - if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.` - return `vor ${Math.floor(diffMins / 1440)} Tagen` - } - - // Traffic estimation helpers for SysEleven planning - const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => { - const messages = stats?.matrix?.messages_today || 0 - const callMinutes = stats?.jitsi?.total_minutes_today || 0 - const participants = stats?.jitsi?.total_participants || 0 - - // Estimates: ~2KB per message, ~1.5 Mbps per video participant - const messageTrafficMB = messages * 0.002 - const videoTrafficMB = callMinutes * participants * 0.011 // ~660 KB/min per participant - - if (direction === 'in') { - return messageTrafficMB * 0.3 + videoTrafficMB * 0.4 // Incoming is less (mostly receiving) - } - return messageTrafficMB * 0.7 + videoTrafficMB * 0.6 // Outgoing is more - } - - const calculateHourlyEstimate = (): number => { - const activeParticipants = stats?.jitsi?.total_participants || 0 - // ~1.5 Mbps per participant = ~0.675 GB/hour per participant - return activeParticipants * 0.675 - } - - const calculateMonthlyEstimate = (): number => { - const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0 - const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1 - // Extrapolate: assume 22 working days, similar usage pattern - const monthlyMinutes = dailyCallMinutes * 22 - // ~11 MB/min for video conference with participants - return (monthlyMinutes * avgParticipants * 11) / 1024 - } - - const getResourceRecommendation = (): string => { - const peakUsers = stats?.jitsi?.peak_concurrent_users || 0 - const monthlyGB = calculateMonthlyEstimate() - - if (monthlyGB < 10 || peakUsers < 5) { - return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)' - } else if (monthlyGB < 50 || peakUsers < 20) { - return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)' - } else if (monthlyGB < 200 || peakUsers < 50) { - return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)' - } else { - return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)' - } - } + const { stats, activeMeetings, recentRooms, loading, error, fetchStats } = useCommunicationStats() return ( {/* Service Status Overview */}
- {/* Matrix Status Card */} -
-
-
-
- - - -
-
-

Matrix (Synapse)

-

E2EE Messaging

-
-
- - {stats?.matrix.status || 'offline'} - -
-
-
-
{stats?.matrix.total_users || 0}
-
Benutzer
-
-
-
{stats?.matrix.active_users || 0}
-
Aktiv
-
-
-
{stats?.matrix.total_rooms || 0}
-
Räume
-
-
-
-
- Nachrichten heute - {stats?.matrix.messages_today || 0} -
-
- Diese Woche - {stats?.matrix.messages_this_week || 0} -
-
-
- - {/* Jitsi Status Card */} -
-
-
-
- - - -
-
-

Jitsi Meet

-

Videokonferenzen

-
-
- - {stats?.jitsi.status || 'offline'} - -
-
-
-
{stats?.jitsi.active_meetings || 0}
-
Live Calls
-
-
-
{stats?.jitsi.total_participants || 0}
-
Teilnehmer
-
-
-
{stats?.jitsi.meetings_today || 0}
-
Calls heute
-
-
-
-
- Ø Dauer - {formatDuration(stats?.jitsi.average_duration_minutes || 0)} -
-
- Peak gleichzeitig - {stats?.jitsi.peak_concurrent_users || 0} Nutzer -
-
-
+ +
{/* Traffic & Bandwidth Statistics for SysEleven Planning */} -
-
-
-
- - - -
-
-

Traffic & Bandbreite

-

SysEleven Ressourcenplanung

-
-
- - Live - -
- -
-
-
Eingehend (heute)
-
- {stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB -
-
-
-
Ausgehend (heute)
-
- {stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB -
-
-
-
Geschätzt/Stunde
-
- {stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB -
-
-
-
Geschätzt/Monat
-
- {stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB -
-
-
- -
- {/* Matrix Traffic */} -
-
-
- Matrix Messaging -
-
-
- Nachrichten/Min - {stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)} -
-
- Media Uploads heute - {stats?.traffic?.matrix?.media_uploads_today || 0} -
-
- Media Größe - {stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB -
-
-
- - {/* Jitsi Traffic */} -
-
-
- Jitsi Video -
-
-
- Video Streams aktiv - {stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)} -
-
- Audio Streams aktiv - {stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)} -
-
- Bitrate geschätzt - {((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps -
-
-
-
- - {/* SysEleven Resource Recommendations */} -
-

SysEleven Empfehlung

-
-

Basierend auf aktuellem Traffic: {getResourceRecommendation()}

-

- Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} | - Ø Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. | - Calls heute: {stats?.jitsi?.meetings_today || 0} -

-
-
-
+ {/* Active Meetings */} -
-
-

Aktive Meetings

- -
+ - {activeMeetings.length === 0 ? ( -
- - - -

Keine aktiven Meetings

-
- ) : ( -
- - - - - - - - - - - {activeMeetings.map((meeting, idx) => ( - - - - - - - ))} - -
MeetingTeilnehmerGestartetDauer
-
{meeting.display_name}
-
{meeting.room_name}
-
- - - {meeting.participants} - - {formatTimeAgo(meeting.started_at)}{formatDuration(meeting.duration_minutes)}
-
- )} -
- - {/* Recent Chat Rooms */} -
-
-

Aktive Chat-Räume

- - {recentRooms.length === 0 ? ( -
-

Keine aktiven Räume

-
- ) : ( -
- {recentRooms.slice(0, 5).map((room, idx) => ( -
-
-
- - - -
-
-
{room.name}
-
{room.member_count} Mitglieder
-
-
-
- {room.room_type} - {formatTimeAgo(room.last_activity)} -
-
- ))} -
- )} -
- - {/* Usage Statistics */} -
-

Nutzungsstatistiken

-
-
-
- Call-Minuten heute - {stats?.jitsi.total_minutes_today || 0} Min. -
-
-
-
-
-
-
- Aktive Chat-Räume - {stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0} -
-
-
-
-
-
-
- Aktive Nutzer - {stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0} -
-
-
-
-
-
- - {/* Quick Actions */} -
-

Schnellaktionen

- -
-
-
+ {/* Recent Chat Rooms & Usage */} + {/* Connection Info */}
diff --git a/website/app/admin/compliance/evidence/_components/EvidenceCard.tsx b/website/app/admin/compliance/evidence/_components/EvidenceCard.tsx new file mode 100644 index 0000000..07934cf --- /dev/null +++ b/website/app/admin/compliance/evidence/_components/EvidenceCard.tsx @@ -0,0 +1,77 @@ +interface Evidence { + id: string + control_id: string + evidence_type: string + title: string + description: string + artifact_url: string | null + file_size_bytes: number | null + status: string + collected_at: string +} + +const EVIDENCE_TYPE_ICONS: Record = { + scan_report: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', + policy_document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', + config_snapshot: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4', + test_result: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + screenshot: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', + external_link: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14', + manual_upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12', +} + +const STATUS_STYLES: Record = { + valid: 'bg-green-100 text-green-700', + expired: 'bg-red-100 text-red-700', + pending: 'bg-yellow-100 text-yellow-700', + failed: 'bg-red-100 text-red-700', +} + +function formatFileSize(bytes: number | null) { + if (!bytes) return '-' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +interface EvidenceCardProps { + evidence: Evidence + controlTitle: string +} + +export function EvidenceCard({ evidence: ev, controlTitle }: EvidenceCardProps) { + const defaultIcon = 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' + + return ( +
+
+
+
+ + + +
+ + {ev.status} + +
+ {controlTitle} +
+

{ev.title}

+ {ev.description &&

{ev.description}

} +
+ {ev.evidence_type.replace('_', ' ')} + {formatFileSize(ev.file_size_bytes)} +
+ {ev.artifact_url && ( + + {ev.artifact_url} + + )} +
+ Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')} +
+
+ ) +} diff --git a/website/app/admin/compliance/evidence/_components/EvidenceModals.tsx b/website/app/admin/compliance/evidence/_components/EvidenceModals.tsx new file mode 100644 index 0000000..b77d35a --- /dev/null +++ b/website/app/admin/compliance/evidence/_components/EvidenceModals.tsx @@ -0,0 +1,232 @@ +import { useRef, useState } from 'react' + +interface Control { + id: string + control_id: string + title: string +} + +interface NewEvidenceData { + control_id: string + evidence_type: string + title: string + description: string + artifact_url: string +} + +const EVIDENCE_TYPES = [ + { value: 'scan_report', label: 'Scan Report' }, + { value: 'policy_document', label: 'Policy Dokument' }, + { value: 'config_snapshot', label: 'Config Snapshot' }, + { value: 'test_result', label: 'Test Ergebnis' }, + { value: 'screenshot', label: 'Screenshot' }, + { value: 'external_link', label: 'Externer Link' }, + { value: 'manual_upload', label: 'Manueller Upload' }, +] + +function formatFileSize(bytes: number | null) { + if (!bytes) return '-' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +interface UploadModalProps { + controls: Control[] + newEvidence: NewEvidenceData + setNewEvidence: (data: NewEvidenceData) => void + uploading: boolean + onUpload: (file: File) => void + onClose: () => void +} + +export function UploadModal({ + controls, + newEvidence, + setNewEvidence, + uploading, + onUpload, + onClose, +}: UploadModalProps) { + const fileInputRef = useRef(null) + const [selectedFile, setSelectedFile] = useState(null) + + return ( +
+
+

Datei hochladen

+ +
+
+ + +
+ +
+ + +
+ +
+ + setNewEvidence({ ...newEvidence, title: e.target.value })} + placeholder="z.B. Semgrep Scan Report 2026-01" + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500" + /> +
+ +
+ +