[split-required] Split remaining 500-680 LOC files (final batch)
website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,29 @@ Stellt PostgreSQL-Anbindung für Alert-Persistenz bereit.
|
||||
Nutzt die gleiche Base wie classroom_engine für konsistente Migrationen.
|
||||
"""
|
||||
from .database import Base, SessionLocal, get_db, engine
|
||||
from .models import (
|
||||
AlertTopicDB,
|
||||
AlertItemDB,
|
||||
AlertRuleDB,
|
||||
AlertProfileDB,
|
||||
from .enums import (
|
||||
AlertSourceEnum,
|
||||
AlertStatusEnum,
|
||||
RelevanceDecisionEnum,
|
||||
FeedTypeEnum,
|
||||
RuleActionEnum,
|
||||
ImportanceLevelEnum,
|
||||
AlertModeEnum,
|
||||
MigrationModeEnum,
|
||||
DigestStatusEnum,
|
||||
UserRoleEnum,
|
||||
)
|
||||
from .models import (
|
||||
AlertTopicDB,
|
||||
AlertItemDB,
|
||||
AlertRuleDB,
|
||||
AlertProfileDB,
|
||||
)
|
||||
from .models_dual_mode import (
|
||||
AlertTemplateDB,
|
||||
AlertSourceDB,
|
||||
UserAlertSubscriptionDB,
|
||||
AlertDigestDB,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -31,4 +44,13 @@ __all__ = [
|
||||
"RelevanceDecisionEnum",
|
||||
"FeedTypeEnum",
|
||||
"RuleActionEnum",
|
||||
"ImportanceLevelEnum",
|
||||
"AlertModeEnum",
|
||||
"MigrationModeEnum",
|
||||
"DigestStatusEnum",
|
||||
"UserRoleEnum",
|
||||
"AlertTemplateDB",
|
||||
"AlertSourceDB",
|
||||
"UserAlertSubscriptionDB",
|
||||
"AlertDigestDB",
|
||||
]
|
||||
|
||||
84
backend-lehrer/alerts_agent/db/enums.py
Normal file
84
backend-lehrer/alerts_agent/db/enums.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Enum definitions for Alerts Agent database models.
|
||||
"""
|
||||
import enum
|
||||
|
||||
|
||||
class AlertSourceEnum(str, enum.Enum):
|
||||
"""Quelle des Alerts."""
|
||||
GOOGLE_ALERTS_RSS = "google_alerts_rss"
|
||||
GOOGLE_ALERTS_EMAIL = "google_alerts_email"
|
||||
RSS_FEED = "rss_feed"
|
||||
WEBHOOK = "webhook"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class AlertStatusEnum(str, enum.Enum):
|
||||
"""Verarbeitungsstatus des Alerts."""
|
||||
NEW = "new"
|
||||
PROCESSED = "processed"
|
||||
DUPLICATE = "duplicate"
|
||||
SCORED = "scored"
|
||||
REVIEWED = "reviewed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class RelevanceDecisionEnum(str, enum.Enum):
|
||||
"""Relevanz-Entscheidung."""
|
||||
KEEP = "KEEP"
|
||||
DROP = "DROP"
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class FeedTypeEnum(str, enum.Enum):
|
||||
"""Typ der Feed-Quelle."""
|
||||
RSS = "rss"
|
||||
EMAIL = "email"
|
||||
WEBHOOK = "webhook"
|
||||
|
||||
|
||||
class RuleActionEnum(str, enum.Enum):
|
||||
"""Aktionen fuer Regeln."""
|
||||
KEEP = "keep"
|
||||
DROP = "drop"
|
||||
TAG = "tag"
|
||||
EMAIL = "email"
|
||||
WEBHOOK = "webhook"
|
||||
SLACK = "slack"
|
||||
|
||||
|
||||
class ImportanceLevelEnum(str, enum.Enum):
|
||||
"""5-stufige Wichtigkeitsskala fuer Guided Mode."""
|
||||
INFO = "info"
|
||||
PRUEFEN = "pruefen"
|
||||
WICHTIG = "wichtig"
|
||||
DRINGEND = "dringend"
|
||||
KRITISCH = "kritisch"
|
||||
|
||||
|
||||
class AlertModeEnum(str, enum.Enum):
|
||||
"""Modus fuer Alert-Nutzung."""
|
||||
GUIDED = "guided"
|
||||
EXPERT = "expert"
|
||||
|
||||
|
||||
class MigrationModeEnum(str, enum.Enum):
|
||||
"""Wie wurden die Alerts migriert."""
|
||||
FORWARD = "forward"
|
||||
IMPORT = "import"
|
||||
RECONSTRUCTED = "reconstructed"
|
||||
|
||||
|
||||
class DigestStatusEnum(str, enum.Enum):
|
||||
"""Status der Digest-Generierung."""
|
||||
PENDING = "pending"
|
||||
GENERATING = "generating"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class UserRoleEnum(str, enum.Enum):
|
||||
"""Rolle des Nutzers fuer Template-Empfehlungen."""
|
||||
LEHRKRAFT = "lehrkraft"
|
||||
SCHULLEITUNG = "schulleitung"
|
||||
IT_BEAUFTRAGTE = "it_beauftragte"
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
SQLAlchemy Database Models für Alerts Agent.
|
||||
SQLAlchemy Database Models fuer Alerts Agent.
|
||||
|
||||
Persistiert Topics, Alerts, Rules und Profile in PostgreSQL.
|
||||
Nutzt die gleiche Base wie classroom_engine für konsistente Migrationen.
|
||||
Split into:
|
||||
- enums.py: All enum definitions
|
||||
- models.py (this file): Core ORM models (Topic, Item, Rule, Profile)
|
||||
- models_dual_mode.py: Dual-mode system (Template, Source, Subscription, Digest)
|
||||
|
||||
All symbols are re-exported here for backward compatibility.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
@@ -10,132 +14,56 @@ from sqlalchemy import (
|
||||
Boolean, Text, Enum as SQLEnum, ForeignKey, Index
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
# Import Base from classroom_engine for shared metadata
|
||||
from classroom_engine.database import Base
|
||||
|
||||
# Re-export all enums
|
||||
from .enums import (
|
||||
AlertSourceEnum,
|
||||
AlertStatusEnum,
|
||||
RelevanceDecisionEnum,
|
||||
FeedTypeEnum,
|
||||
RuleActionEnum,
|
||||
ImportanceLevelEnum,
|
||||
AlertModeEnum,
|
||||
MigrationModeEnum,
|
||||
DigestStatusEnum,
|
||||
UserRoleEnum,
|
||||
)
|
||||
|
||||
class AlertSourceEnum(str, enum.Enum):
|
||||
"""Quelle des Alerts."""
|
||||
GOOGLE_ALERTS_RSS = "google_alerts_rss"
|
||||
GOOGLE_ALERTS_EMAIL = "google_alerts_email"
|
||||
RSS_FEED = "rss_feed"
|
||||
WEBHOOK = "webhook"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class AlertStatusEnum(str, enum.Enum):
|
||||
"""Verarbeitungsstatus des Alerts."""
|
||||
NEW = "new"
|
||||
PROCESSED = "processed"
|
||||
DUPLICATE = "duplicate"
|
||||
SCORED = "scored"
|
||||
REVIEWED = "reviewed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class RelevanceDecisionEnum(str, enum.Enum):
|
||||
"""Relevanz-Entscheidung."""
|
||||
KEEP = "KEEP"
|
||||
DROP = "DROP"
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class FeedTypeEnum(str, enum.Enum):
|
||||
"""Typ der Feed-Quelle."""
|
||||
RSS = "rss"
|
||||
EMAIL = "email"
|
||||
WEBHOOK = "webhook"
|
||||
|
||||
|
||||
class RuleActionEnum(str, enum.Enum):
|
||||
"""Aktionen für Regeln."""
|
||||
KEEP = "keep"
|
||||
DROP = "drop"
|
||||
TAG = "tag"
|
||||
EMAIL = "email"
|
||||
WEBHOOK = "webhook"
|
||||
SLACK = "slack"
|
||||
|
||||
|
||||
class ImportanceLevelEnum(str, enum.Enum):
|
||||
"""5-stufige Wichtigkeitsskala für Guided Mode."""
|
||||
INFO = "info" # 0.0-0.4 - Informativ
|
||||
PRUEFEN = "pruefen" # 0.4-0.6 - Zu prüfen
|
||||
WICHTIG = "wichtig" # 0.6-0.75 - Wichtig
|
||||
DRINGEND = "dringend" # 0.75-0.9 - Dringend
|
||||
KRITISCH = "kritisch" # 0.9-1.0 - Kritisch
|
||||
|
||||
|
||||
class AlertModeEnum(str, enum.Enum):
|
||||
"""Modus für Alert-Nutzung."""
|
||||
GUIDED = "guided" # Geführter Modus für Lehrer/Schulleitungen
|
||||
EXPERT = "expert" # Experten-Modus für IT-affine Nutzer
|
||||
|
||||
|
||||
class MigrationModeEnum(str, enum.Enum):
|
||||
"""Wie wurden die Alerts migriert."""
|
||||
FORWARD = "forward" # E-Mail-Weiterleitung
|
||||
IMPORT = "import" # RSS-Import
|
||||
RECONSTRUCTED = "reconstructed" # Automatisch rekonstruiert
|
||||
|
||||
|
||||
class DigestStatusEnum(str, enum.Enum):
|
||||
"""Status der Digest-Generierung."""
|
||||
PENDING = "pending"
|
||||
GENERATING = "generating"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class UserRoleEnum(str, enum.Enum):
|
||||
"""Rolle des Nutzers für Template-Empfehlungen."""
|
||||
LEHRKRAFT = "lehrkraft"
|
||||
SCHULLEITUNG = "schulleitung"
|
||||
IT_BEAUFTRAGTE = "it_beauftragte"
|
||||
# Re-export dual-mode models
|
||||
from .models_dual_mode import (
|
||||
AlertTemplateDB,
|
||||
AlertSourceDB,
|
||||
UserAlertSubscriptionDB,
|
||||
AlertDigestDB,
|
||||
)
|
||||
|
||||
|
||||
class AlertTopicDB(Base):
|
||||
"""
|
||||
Alert Topic / Feed-Quelle.
|
||||
|
||||
Repräsentiert eine Google Alert-Konfiguration oder einen RSS-Feed.
|
||||
Repraesentiert eine Google Alert-Konfiguration oder einen RSS-Feed.
|
||||
"""
|
||||
__tablename__ = 'alert_topics'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), nullable=True, index=True) # Optional: Multi-User
|
||||
|
||||
# Topic-Details
|
||||
user_id = Column(String(36), nullable=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Feed-Konfiguration
|
||||
feed_url = Column(String(2000), nullable=True)
|
||||
feed_type = Column(
|
||||
SQLEnum(FeedTypeEnum),
|
||||
default=FeedTypeEnum.RSS,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Scheduling
|
||||
feed_type = Column(SQLEnum(FeedTypeEnum), default=FeedTypeEnum.RSS, nullable=False)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
fetch_interval_minutes = Column(Integer, default=60)
|
||||
last_fetched_at = Column(DateTime, nullable=True)
|
||||
last_fetch_error = Column(Text, nullable=True)
|
||||
|
||||
# Statistiken
|
||||
total_items_fetched = Column(Integer, default=0)
|
||||
items_kept = Column(Integer, default=0)
|
||||
items_dropped = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
alerts = relationship("AlertItemDB", back_populates="topic", cascade="all, delete-orphan")
|
||||
rules = relationship("AlertRuleDB", back_populates="topic", cascade="all, delete-orphan")
|
||||
|
||||
@@ -146,84 +74,47 @@ class AlertTopicDB(Base):
|
||||
class AlertItemDB(Base):
|
||||
"""
|
||||
Einzelner Alert-Eintrag.
|
||||
|
||||
Entspricht einem Artikel/Link aus Google Alerts oder RSS.
|
||||
"""
|
||||
__tablename__ = 'alert_items'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
# Content
|
||||
title = Column(Text, nullable=False)
|
||||
url = Column(String(2000), nullable=False)
|
||||
snippet = Column(Text, default="")
|
||||
article_text = Column(Text, nullable=True) # Volltext (optional)
|
||||
|
||||
# Metadaten
|
||||
article_text = Column(Text, nullable=True)
|
||||
lang = Column(String(10), default="de")
|
||||
published_at = Column(DateTime, nullable=True, index=True)
|
||||
fetched_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
processed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Source
|
||||
source = Column(
|
||||
SQLEnum(AlertSourceEnum),
|
||||
default=AlertSourceEnum.GOOGLE_ALERTS_RSS,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Deduplication
|
||||
source = Column(SQLEnum(AlertSourceEnum), default=AlertSourceEnum.GOOGLE_ALERTS_RSS, nullable=False)
|
||||
url_hash = Column(String(64), unique=True, nullable=False, index=True)
|
||||
content_hash = Column(String(64), nullable=True) # SimHash für Fuzzy-Matching
|
||||
content_hash = Column(String(64), nullable=True)
|
||||
canonical_url = Column(String(2000), nullable=True)
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
SQLEnum(AlertStatusEnum),
|
||||
default=AlertStatusEnum.NEW,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
cluster_id = Column(String(36), nullable=True) # Gruppierung ähnlicher Alerts
|
||||
|
||||
# Relevanz-Scoring
|
||||
status = Column(SQLEnum(AlertStatusEnum), default=AlertStatusEnum.NEW, nullable=False, index=True)
|
||||
cluster_id = Column(String(36), nullable=True)
|
||||
relevance_score = Column(Float, nullable=True)
|
||||
relevance_decision = Column(
|
||||
SQLEnum(RelevanceDecisionEnum),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
relevance_reasons = Column(JSON, default=list) # ["matches_priority", ...]
|
||||
relevance_decision = Column(SQLEnum(RelevanceDecisionEnum), nullable=True, index=True)
|
||||
relevance_reasons = Column(JSON, default=list)
|
||||
relevance_summary = Column(Text, nullable=True)
|
||||
scored_by_model = Column(String(100), nullable=True) # "llama3.1:8b"
|
||||
scored_by_model = Column(String(100), nullable=True)
|
||||
scored_at = Column(DateTime, nullable=True)
|
||||
|
||||
# User Actions
|
||||
user_marked_relevant = Column(Boolean, nullable=True) # Explizites Feedback
|
||||
user_tags = Column(JSON, default=list) # ["wichtig", "später lesen"]
|
||||
user_marked_relevant = Column(Boolean, nullable=True)
|
||||
user_tags = Column(JSON, default=list)
|
||||
user_notes = Column(Text, nullable=True)
|
||||
|
||||
# Guided Mode Fields (NEU)
|
||||
importance_level = Column(
|
||||
SQLEnum(ImportanceLevelEnum),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
why_relevant = Column(Text, nullable=True) # "Warum relevant?" Erklärung
|
||||
next_steps = Column(JSON, default=list) # ["Schulleitung informieren", "Frist beachten"]
|
||||
action_deadline = Column(DateTime, nullable=True) # Falls es eine Frist gibt
|
||||
source_name = Column(String(255), nullable=True) # "Kultusministerium NRW"
|
||||
source_credibility = Column(String(50), default="official") # official, news, blog
|
||||
|
||||
# Timestamps
|
||||
# Guided Mode Fields
|
||||
importance_level = Column(SQLEnum(ImportanceLevelEnum), nullable=True, index=True)
|
||||
why_relevant = Column(Text, nullable=True)
|
||||
next_steps = Column(JSON, default=list)
|
||||
action_deadline = Column(DateTime, nullable=True)
|
||||
source_name = Column(String(255), nullable=True)
|
||||
source_credibility = Column(String(50), default="official")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
topic = relationship("AlertTopicDB", back_populates="alerts")
|
||||
|
||||
# Composite Index für häufige Queries
|
||||
__table_args__ = (
|
||||
Index('ix_alert_items_topic_status', 'topic_id', 'status'),
|
||||
Index('ix_alert_items_topic_decision', 'topic_id', 'relevance_decision'),
|
||||
@@ -234,46 +125,24 @@ class AlertItemDB(Base):
|
||||
|
||||
|
||||
class AlertRuleDB(Base):
|
||||
"""
|
||||
Filterregel für Alerts.
|
||||
|
||||
Definiert Bedingungen und Aktionen für automatische Verarbeitung.
|
||||
"""
|
||||
"""Filterregel fuer Alerts."""
|
||||
__tablename__ = 'alert_rules'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='CASCADE'), nullable=True, index=True)
|
||||
user_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# Rule-Details
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
|
||||
# Bedingungen (als JSON)
|
||||
# Format: [{"field": "title", "op": "contains", "value": "..."}]
|
||||
conditions = Column(JSON, nullable=False, default=list)
|
||||
|
||||
# Aktion
|
||||
action_type = Column(
|
||||
SQLEnum(RuleActionEnum),
|
||||
default=RuleActionEnum.KEEP,
|
||||
nullable=False
|
||||
)
|
||||
action_config = Column(JSON, default=dict) # {"email": "x@y.z", "tags": [...]}
|
||||
|
||||
# Priorisierung (höher = wird zuerst ausgeführt)
|
||||
action_type = Column(SQLEnum(RuleActionEnum), default=RuleActionEnum.KEEP, nullable=False)
|
||||
action_config = Column(JSON, default=dict)
|
||||
priority = Column(Integer, default=0, index=True)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
|
||||
# Statistiken
|
||||
match_count = Column(Integer, default=0)
|
||||
last_matched_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
topic = relationship("AlertTopicDB", back_populates="rules")
|
||||
|
||||
def __repr__(self):
|
||||
@@ -281,42 +150,21 @@ class AlertRuleDB(Base):
|
||||
|
||||
|
||||
class AlertProfileDB(Base):
|
||||
"""
|
||||
Nutzer-Profil für Relevanz-Scoring.
|
||||
|
||||
Speichert Prioritäten, Ausschlüsse und Lern-Beispiele.
|
||||
"""
|
||||
"""Nutzer-Profil fuer Relevanz-Scoring."""
|
||||
__tablename__ = 'alert_profiles'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), unique=True, nullable=True, index=True)
|
||||
|
||||
# Name für Anzeige (falls mehrere Profile pro User)
|
||||
name = Column(String(255), default="Default")
|
||||
|
||||
# Relevanz-Kriterien
|
||||
# Format: [{"label": "Inklusion", "weight": 0.9, "keywords": [...], "description": "..."}]
|
||||
priorities = Column(JSON, default=list)
|
||||
|
||||
# Ausschluss-Keywords
|
||||
exclusions = Column(JSON, default=list) # ["Stellenanzeige", "Werbung"]
|
||||
|
||||
# Few-Shot Beispiele für LLM
|
||||
# Format: [{"title": "...", "url": "...", "reason": "...", "added_at": "..."}]
|
||||
exclusions = Column(JSON, default=list)
|
||||
positive_examples = Column(JSON, default=list)
|
||||
negative_examples = Column(JSON, default=list)
|
||||
|
||||
# Policies
|
||||
# Format: {"prefer_german_sources": true, "max_age_days": 30}
|
||||
policies = Column(JSON, default=dict)
|
||||
|
||||
# Statistiken
|
||||
total_scored = Column(Integer, default=0)
|
||||
total_kept = Column(Integer, default=0)
|
||||
total_dropped = Column(Integer, default=0)
|
||||
accuracy_estimate = Column(Float, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -324,16 +172,11 @@ class AlertProfileDB(Base):
|
||||
return f"<AlertProfile {self.name} (user={self.user_id})>"
|
||||
|
||||
def get_prompt_context(self) -> str:
|
||||
"""
|
||||
Generiere Kontext für LLM-Prompt.
|
||||
|
||||
Dieser Text wird in den System-Prompt des Relevanz-Scorers eingefügt.
|
||||
"""
|
||||
"""Generiere Kontext fuer LLM-Prompt."""
|
||||
lines = ["## Relevanzprofil des Nutzers\n"]
|
||||
|
||||
# Prioritäten
|
||||
if self.priorities:
|
||||
lines.append("### Prioritäten (Themen von Interesse):")
|
||||
lines.append("### Prioritaeten (Themen von Interesse):")
|
||||
for p in self.priorities:
|
||||
weight = p.get("weight", 0.5)
|
||||
weight_label = "Sehr wichtig" if weight > 0.7 else "Wichtig" if weight > 0.4 else "Interessant"
|
||||
@@ -344,33 +187,29 @@ class AlertProfileDB(Base):
|
||||
lines.append(f" Keywords: {', '.join(p['keywords'])}")
|
||||
lines.append("")
|
||||
|
||||
# Ausschlüsse
|
||||
if self.exclusions:
|
||||
lines.append("### Ausschlüsse (ignorieren):")
|
||||
lines.append("### Ausschluesse (ignorieren):")
|
||||
lines.append(f"Themen mit diesen Keywords: {', '.join(self.exclusions)}")
|
||||
lines.append("")
|
||||
|
||||
# Positive Beispiele (letzte 5)
|
||||
if self.positive_examples:
|
||||
lines.append("### Beispiele für relevante Alerts:")
|
||||
lines.append("### Beispiele fuer relevante Alerts:")
|
||||
for ex in self.positive_examples[-5:]:
|
||||
lines.append(f"- \"{ex.get('title', '')}\"")
|
||||
if ex.get("reason"):
|
||||
lines.append(f" Grund: {ex['reason']}")
|
||||
lines.append("")
|
||||
|
||||
# Negative Beispiele (letzte 5)
|
||||
if self.negative_examples:
|
||||
lines.append("### Beispiele für irrelevante Alerts:")
|
||||
lines.append("### Beispiele fuer irrelevante Alerts:")
|
||||
for ex in self.negative_examples[-5:]:
|
||||
lines.append(f"- \"{ex.get('title', '')}\"")
|
||||
if ex.get("reason"):
|
||||
lines.append(f" Grund: {ex['reason']}")
|
||||
lines.append("")
|
||||
|
||||
# Policies
|
||||
if self.policies:
|
||||
lines.append("### Zusätzliche Regeln:")
|
||||
lines.append("### Zusaetzliche Regeln:")
|
||||
for key, value in self.policies.items():
|
||||
lines.append(f"- {key}: {value}")
|
||||
|
||||
@@ -378,33 +217,27 @@ class AlertProfileDB(Base):
|
||||
|
||||
@classmethod
|
||||
def create_default_education_profile(cls) -> "AlertProfileDB":
|
||||
"""
|
||||
Erstelle ein Standard-Profil für Bildungsthemen.
|
||||
"""
|
||||
"""Erstelle ein Standard-Profil fuer Bildungsthemen."""
|
||||
return cls(
|
||||
name="Bildung Default",
|
||||
priorities=[
|
||||
{
|
||||
"label": "Inklusion",
|
||||
"weight": 0.9,
|
||||
"keywords": ["inklusiv", "Förderbedarf", "Behinderung", "Barrierefreiheit"],
|
||||
"description": "Inklusive Bildung, Förderschulen, Nachteilsausgleich"
|
||||
"label": "Inklusion", "weight": 0.9,
|
||||
"keywords": ["inklusiv", "Foerderbedarf", "Behinderung", "Barrierefreiheit"],
|
||||
"description": "Inklusive Bildung, Foerderschulen, Nachteilsausgleich"
|
||||
},
|
||||
{
|
||||
"label": "Datenschutz Schule",
|
||||
"weight": 0.85,
|
||||
"keywords": ["DSGVO", "Schülerfotos", "Einwilligung", "personenbezogene Daten"],
|
||||
"label": "Datenschutz Schule", "weight": 0.85,
|
||||
"keywords": ["DSGVO", "Schuelerfotos", "Einwilligung", "personenbezogene Daten"],
|
||||
"description": "DSGVO in Schulen, Datenschutz bei Klassenfotos"
|
||||
},
|
||||
{
|
||||
"label": "Schulrecht Bayern",
|
||||
"weight": 0.8,
|
||||
"label": "Schulrecht Bayern", "weight": 0.8,
|
||||
"keywords": ["BayEUG", "Schulordnung", "Kultusministerium", "Bayern"],
|
||||
"description": "Bayerisches Schulrecht, Verordnungen"
|
||||
},
|
||||
{
|
||||
"label": "Digitalisierung Schule",
|
||||
"weight": 0.7,
|
||||
"label": "Digitalisierung Schule", "weight": 0.7,
|
||||
"keywords": ["DigitalPakt", "Tablet-Klasse", "Lernplattform"],
|
||||
"description": "Digitale Medien im Unterricht"
|
||||
},
|
||||
@@ -416,221 +249,3 @@ class AlertProfileDB(Base):
|
||||
"min_content_length": 100,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DUAL-MODE SYSTEM: Templates, Subscriptions, Sources, Digests
|
||||
# ============================================================================
|
||||
|
||||
class AlertTemplateDB(Base):
|
||||
"""
|
||||
Vorkonfigurierte Alert-Templates (Playbooks).
|
||||
|
||||
Für Guided Mode: Lehrer wählen 1-3 Templates statt RSS-Feeds zu konfigurieren.
|
||||
"""
|
||||
__tablename__ = 'alert_templates'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Template-Identität
|
||||
slug = Column(String(100), unique=True, nullable=False) # "foerderprogramme", "abitur-updates"
|
||||
name = Column(String(255), nullable=False) # "Förderprogramme & Fristen"
|
||||
description = Column(Text, default="") # B1/B2 Deutsch, 1-2 Sätze
|
||||
icon = Column(String(50), default="") # Emoji: "💰", "📝", "⚖️"
|
||||
category = Column(String(100), default="") # "administration", "teaching", "it"
|
||||
|
||||
# Zielgruppen (welche Rollen profitieren)
|
||||
target_roles = Column(JSON, default=list) # ["schulleitung", "lehrkraft"]
|
||||
|
||||
# Template-Konfiguration
|
||||
topics_config = Column(JSON, default=list) # Vorkonfigurierte RSS-Feeds
|
||||
rules_config = Column(JSON, default=list) # Vorkonfigurierte Regeln
|
||||
profile_config = Column(JSON, default=dict) # Prioritäten/Ausschlüsse
|
||||
|
||||
# Importance-Mapping (Score → 5 Stufen)
|
||||
importance_config = Column(JSON, default=dict) # {"critical": 0.90, "urgent": 0.75, ...}
|
||||
|
||||
# Ausgabe-Einstellungen
|
||||
max_cards_per_day = Column(Integer, default=10)
|
||||
digest_enabled = Column(Boolean, default=True)
|
||||
digest_day = Column(String(20), default="monday") # Tag für wöchentlichen Digest
|
||||
|
||||
# Lokalisierung
|
||||
language = Column(String(10), default="de")
|
||||
|
||||
# Metadata
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_premium = Column(Boolean, default=False) # Für kostenpflichtige Templates
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subscriptions = relationship("UserAlertSubscriptionDB", back_populates="template")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertTemplate {self.slug}: {self.name}>"
|
||||
|
||||
|
||||
class AlertSourceDB(Base):
|
||||
"""
|
||||
Alert-Quelle für Migration bestehender Alerts.
|
||||
|
||||
Unterstützt: E-Mail-Weiterleitung, RSS-Import, Rekonstruktion.
|
||||
"""
|
||||
__tablename__ = 'alert_sources'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id = Column(String(36), nullable=True, index=True) # Für Multi-Tenant
|
||||
user_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# Quellen-Typ
|
||||
source_type = Column(
|
||||
SQLEnum(FeedTypeEnum),
|
||||
default=FeedTypeEnum.RSS,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Original-Bezeichnung (vom Kunden)
|
||||
original_label = Column(String(255), nullable=True) # "EU IT Ausschreibungen"
|
||||
|
||||
# E-Mail-Weiterleitung
|
||||
inbound_address = Column(String(255), nullable=True, unique=True) # alerts+tenant123@breakpilot.app
|
||||
|
||||
# RSS-Import
|
||||
rss_url = Column(String(2000), nullable=True)
|
||||
|
||||
# Migration-Modus
|
||||
migration_mode = Column(
|
||||
SQLEnum(MigrationModeEnum),
|
||||
default=MigrationModeEnum.IMPORT,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Verknüpfung zu erstelltem Topic
|
||||
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
items_received = Column(Integer, default=0)
|
||||
last_item_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertSource {self.source_type.value}: {self.original_label}>"
|
||||
|
||||
|
||||
class UserAlertSubscriptionDB(Base):
|
||||
"""
|
||||
User-Subscription für Alert-Templates oder Expert-Profile.
|
||||
|
||||
Speichert Modus-Wahl, Template-Verknüpfung und Wizard-Zustand.
|
||||
"""
|
||||
__tablename__ = 'user_alert_subscriptions'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
school_id = Column(String(36), nullable=True, index=True) # Optional: Schulkontext
|
||||
|
||||
# Modus-Auswahl
|
||||
mode = Column(
|
||||
SQLEnum(AlertModeEnum),
|
||||
default=AlertModeEnum.GUIDED,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Nutzer-Rolle (für Guided Mode)
|
||||
user_role = Column(
|
||||
SQLEnum(UserRoleEnum),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Template-Verknüpfung (Guided Mode) - kann mehrere sein
|
||||
template_id = Column(String(36), ForeignKey('alert_templates.id', ondelete='SET NULL'), nullable=True)
|
||||
selected_template_ids = Column(JSON, default=list) # Bis zu 3 Templates
|
||||
|
||||
# Profil-Verknüpfung (Expert Mode)
|
||||
profile_id = Column(String(36), ForeignKey('alert_profiles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Subscription-Einstellungen
|
||||
is_active = Column(Boolean, default=True)
|
||||
notification_email = Column(String(255), nullable=True)
|
||||
|
||||
# Digest-Präferenzen
|
||||
digest_enabled = Column(Boolean, default=True)
|
||||
digest_frequency = Column(String(20), default="weekly") # weekly, daily
|
||||
digest_day = Column(String(20), default="monday")
|
||||
last_digest_sent_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Wizard-Zustand (für unvollständige Setups)
|
||||
wizard_step = Column(Integer, default=0)
|
||||
wizard_completed = Column(Boolean, default=False)
|
||||
wizard_state = Column(JSON, default=dict) # Zwischenspeicher für Wizard-Daten
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
template = relationship("AlertTemplateDB", back_populates="subscriptions")
|
||||
profile = relationship("AlertProfileDB")
|
||||
digests = relationship("AlertDigestDB", back_populates="subscription", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserAlertSubscription {self.user_id} ({self.mode.value})>"
|
||||
|
||||
|
||||
class AlertDigestDB(Base):
|
||||
"""
|
||||
Wöchentliche Digest-Zusammenfassung.
|
||||
|
||||
Enthält gerenderte Zusammenfassung + Statistiken.
|
||||
"""
|
||||
__tablename__ = 'alert_digests'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
subscription_id = Column(String(36), ForeignKey('user_alert_subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
|
||||
# Zeitraum
|
||||
period_start = Column(DateTime, nullable=False)
|
||||
period_end = Column(DateTime, nullable=False)
|
||||
|
||||
# Content
|
||||
title = Column(String(255), default="") # "KW 3/2026 - Ihre Bildungs-Alerts"
|
||||
summary_html = Column(Text, default="") # Gerenderte HTML-Zusammenfassung
|
||||
summary_pdf_url = Column(String(500), nullable=True) # Link zum PDF-Export
|
||||
|
||||
# Statistiken
|
||||
total_alerts = Column(Integer, default=0)
|
||||
kritisch_count = Column(Integer, default=0)
|
||||
dringend_count = Column(Integer, default=0)
|
||||
wichtig_count = Column(Integer, default=0)
|
||||
pruefen_count = Column(Integer, default=0)
|
||||
info_count = Column(Integer, default=0)
|
||||
|
||||
# Enthaltene Alert-IDs
|
||||
alert_ids = Column(JSON, default=list)
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
SQLEnum(DigestStatusEnum),
|
||||
default=DigestStatusEnum.PENDING,
|
||||
nullable=False
|
||||
)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subscription = relationship("UserAlertSubscriptionDB", back_populates="digests")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertDigest {self.title} ({self.status.value})>"
|
||||
|
||||
149
backend-lehrer/alerts_agent/db/models_dual_mode.py
Normal file
149
backend-lehrer/alerts_agent/db/models_dual_mode.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Dual-Mode System Models: Templates, Subscriptions, Sources, Digests.
|
||||
|
||||
These are additional ORM models for the Guided/Expert dual-mode alert system.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, DateTime, JSON,
|
||||
Boolean, Text, Enum as SQLEnum, ForeignKey,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from classroom_engine.database import Base
|
||||
from .enums import (
|
||||
FeedTypeEnum,
|
||||
MigrationModeEnum,
|
||||
AlertModeEnum,
|
||||
UserRoleEnum,
|
||||
DigestStatusEnum,
|
||||
)
|
||||
|
||||
|
||||
class AlertTemplateDB(Base):
|
||||
"""
|
||||
Vorkonfigurierte Alert-Templates (Playbooks).
|
||||
Fuer Guided Mode: Lehrer waehlen 1-3 Templates statt RSS-Feeds zu konfigurieren.
|
||||
"""
|
||||
__tablename__ = 'alert_templates'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
slug = Column(String(100), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
icon = Column(String(50), default="")
|
||||
category = Column(String(100), default="")
|
||||
target_roles = Column(JSON, default=list)
|
||||
topics_config = Column(JSON, default=list)
|
||||
rules_config = Column(JSON, default=list)
|
||||
profile_config = Column(JSON, default=dict)
|
||||
importance_config = Column(JSON, default=dict)
|
||||
max_cards_per_day = Column(Integer, default=10)
|
||||
digest_enabled = Column(Boolean, default=True)
|
||||
digest_day = Column(String(20), default="monday")
|
||||
language = Column(String(10), default="de")
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_premium = Column(Boolean, default=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
subscriptions = relationship("UserAlertSubscriptionDB", back_populates="template")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertTemplate {self.slug}: {self.name}>"
|
||||
|
||||
|
||||
class AlertSourceDB(Base):
|
||||
"""
|
||||
Alert-Quelle fuer Migration bestehender Alerts.
|
||||
Unterstuetzt: E-Mail-Weiterleitung, RSS-Import, Rekonstruktion.
|
||||
"""
|
||||
__tablename__ = 'alert_sources'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id = Column(String(36), nullable=True, index=True)
|
||||
user_id = Column(String(36), nullable=True, index=True)
|
||||
source_type = Column(SQLEnum(FeedTypeEnum), default=FeedTypeEnum.RSS, nullable=False)
|
||||
original_label = Column(String(255), nullable=True)
|
||||
inbound_address = Column(String(255), nullable=True, unique=True)
|
||||
rss_url = Column(String(2000), nullable=True)
|
||||
migration_mode = Column(SQLEnum(MigrationModeEnum), default=MigrationModeEnum.IMPORT, nullable=False)
|
||||
topic_id = Column(String(36), ForeignKey('alert_topics.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
items_received = Column(Integer, default=0)
|
||||
last_item_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertSource {self.source_type.value}: {self.original_label}>"
|
||||
|
||||
|
||||
class UserAlertSubscriptionDB(Base):
|
||||
"""
|
||||
User-Subscription fuer Alert-Templates oder Expert-Profile.
|
||||
Speichert Modus-Wahl, Template-Verknuepfung und Wizard-Zustand.
|
||||
"""
|
||||
__tablename__ = 'user_alert_subscriptions'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
school_id = Column(String(36), nullable=True, index=True)
|
||||
mode = Column(SQLEnum(AlertModeEnum), default=AlertModeEnum.GUIDED, nullable=False)
|
||||
user_role = Column(SQLEnum(UserRoleEnum), nullable=True)
|
||||
template_id = Column(String(36), ForeignKey('alert_templates.id', ondelete='SET NULL'), nullable=True)
|
||||
selected_template_ids = Column(JSON, default=list)
|
||||
profile_id = Column(String(36), ForeignKey('alert_profiles.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
notification_email = Column(String(255), nullable=True)
|
||||
digest_enabled = Column(Boolean, default=True)
|
||||
digest_frequency = Column(String(20), default="weekly")
|
||||
digest_day = Column(String(20), default="monday")
|
||||
last_digest_sent_at = Column(DateTime, nullable=True)
|
||||
wizard_step = Column(Integer, default=0)
|
||||
wizard_completed = Column(Boolean, default=False)
|
||||
wizard_state = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
template = relationship("AlertTemplateDB", back_populates="subscriptions")
|
||||
profile = relationship("AlertProfileDB")
|
||||
digests = relationship("AlertDigestDB", back_populates="subscription", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserAlertSubscription {self.user_id} ({self.mode.value})>"
|
||||
|
||||
|
||||
class AlertDigestDB(Base):
|
||||
"""
|
||||
Woechentliche Digest-Zusammenfassung.
|
||||
Enthaelt gerenderte Zusammenfassung + Statistiken.
|
||||
"""
|
||||
__tablename__ = 'alert_digests'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
subscription_id = Column(String(36), ForeignKey('user_alert_subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
period_start = Column(DateTime, nullable=False)
|
||||
period_end = Column(DateTime, nullable=False)
|
||||
title = Column(String(255), default="")
|
||||
summary_html = Column(Text, default="")
|
||||
summary_pdf_url = Column(String(500), nullable=True)
|
||||
total_alerts = Column(Integer, default=0)
|
||||
kritisch_count = Column(Integer, default=0)
|
||||
dringend_count = Column(Integer, default=0)
|
||||
wichtig_count = Column(Integer, default=0)
|
||||
pruefen_count = Column(Integer, default=0)
|
||||
info_count = Column(Integer, default=0)
|
||||
alert_ids = Column(JSON, default=list)
|
||||
status = Column(SQLEnum(DigestStatusEnum), default=DigestStatusEnum.PENDING, nullable=False)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
subscription = relationship("UserAlertSubscriptionDB", back_populates="digests")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertDigest {self.title} ({self.status.value})>"
|
||||
@@ -1,25 +1,17 @@
|
||||
"""
|
||||
Certificates API - Zeugnisverwaltung für BreakPilot.
|
||||
Certificates API - Zeugnisverwaltung fuer BreakPilot.
|
||||
|
||||
Bietet Endpoints für:
|
||||
- Erstellen und Verwalten von Zeugnissen
|
||||
- PDF-Export von Zeugnissen
|
||||
- Notenübersicht und Statistiken
|
||||
- Archivierung in DSMS
|
||||
|
||||
Arbeitet zusammen mit:
|
||||
- services/pdf_service.py für PDF-Generierung
|
||||
- Gradebook für Notenverwaltung
|
||||
Split into:
|
||||
- certificates_models.py: Enums, Pydantic models, helper functions
|
||||
- certificates_api.py (this file): API endpoints and in-memory store
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# PDF service requires WeasyPrint with system libraries - make optional for CI
|
||||
try:
|
||||
@@ -30,157 +22,26 @@ except (ImportError, OSError):
|
||||
SchoolInfo = None # type: ignore
|
||||
_pdf_available = False
|
||||
|
||||
from certificates_models import (
|
||||
CertificateType,
|
||||
CertificateStatus,
|
||||
BehaviorGrade,
|
||||
CertificateCreateRequest,
|
||||
CertificateUpdateRequest,
|
||||
CertificateResponse,
|
||||
CertificateListResponse,
|
||||
GradeStatistics,
|
||||
get_type_label as _get_type_label,
|
||||
calculate_average as _calculate_average,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/certificates", tags=["certificates"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums
|
||||
# =============================================================================
|
||||
|
||||
class CertificateType(str, Enum):
|
||||
"""Typen von Zeugnissen."""
|
||||
HALBJAHR = "halbjahr" # Halbjahreszeugnis
|
||||
JAHRES = "jahres" # Jahreszeugnis
|
||||
ABSCHLUSS = "abschluss" # Abschlusszeugnis
|
||||
ABGANG = "abgang" # Abgangszeugnis
|
||||
UEBERGANG = "uebergang" # Übergangszeugnis
|
||||
|
||||
|
||||
class CertificateStatus(str, Enum):
|
||||
"""Status eines Zeugnisses."""
|
||||
DRAFT = "draft" # Entwurf - noch in Bearbeitung
|
||||
REVIEW = "review" # Zur Prüfung
|
||||
APPROVED = "approved" # Genehmigt
|
||||
ISSUED = "issued" # Ausgestellt
|
||||
ARCHIVED = "archived" # Archiviert
|
||||
|
||||
|
||||
class GradeType(str, Enum):
|
||||
"""Notentyp."""
|
||||
NUMERIC = "numeric" # 1-6
|
||||
POINTS = "points" # 0-15 (Oberstufe)
|
||||
TEXT = "text" # Verbal (Grundschule)
|
||||
|
||||
|
||||
class BehaviorGrade(str, Enum):
|
||||
"""Verhaltens-/Arbeitsnoten."""
|
||||
A = "A" # Sehr gut
|
||||
B = "B" # Gut
|
||||
C = "C" # Befriedigend
|
||||
D = "D" # Verbesserungswürdig
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class SchoolInfoModel(BaseModel):
|
||||
"""Schulinformationen für Zeugnis."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
|
||||
|
||||
class SubjectGrade(BaseModel):
|
||||
"""Note für ein Fach."""
|
||||
name: str = Field(..., description="Fachname")
|
||||
grade: str = Field(..., description="Note (1-6 oder A-D)")
|
||||
points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)")
|
||||
note: Optional[str] = Field(None, description="Bemerkung zum Fach")
|
||||
|
||||
|
||||
class AttendanceInfo(BaseModel):
|
||||
"""Anwesenheitsinformationen."""
|
||||
days_absent: int = Field(0, description="Fehlende Tage gesamt")
|
||||
days_excused: int = Field(0, description="Entschuldigte Tage")
|
||||
days_unexcused: int = Field(0, description="Unentschuldigte Tage")
|
||||
hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt")
|
||||
|
||||
|
||||
class CertificateCreateRequest(BaseModel):
|
||||
"""Request zum Erstellen eines neuen Zeugnisses."""
|
||||
student_id: str = Field(..., description="ID des Schülers")
|
||||
student_name: str = Field(..., description="Name des Schülers")
|
||||
student_birthdate: str = Field(..., description="Geburtsdatum")
|
||||
student_class: str = Field(..., description="Klasse")
|
||||
school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')")
|
||||
certificate_type: CertificateType = Field(..., description="Art des Zeugnisses")
|
||||
subjects: List[SubjectGrade] = Field(..., description="Fachnoten")
|
||||
attendance: AttendanceInfo = Field(default_factory=AttendanceInfo, description="Anwesenheit")
|
||||
remarks: Optional[str] = Field(None, description="Bemerkungen")
|
||||
class_teacher: str = Field(..., description="Klassenlehrer/in")
|
||||
principal: str = Field(..., description="Schulleiter/in")
|
||||
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen")
|
||||
issue_date: Optional[str] = Field(None, description="Ausstellungsdatum")
|
||||
social_behavior: Optional[BehaviorGrade] = Field(None, description="Sozialverhalten")
|
||||
work_behavior: Optional[BehaviorGrade] = Field(None, description="Arbeitsverhalten")
|
||||
|
||||
|
||||
class CertificateUpdateRequest(BaseModel):
|
||||
"""Request zum Aktualisieren eines Zeugnisses."""
|
||||
subjects: Optional[List[SubjectGrade]] = None
|
||||
attendance: Optional[AttendanceInfo] = None
|
||||
remarks: Optional[str] = None
|
||||
class_teacher: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
social_behavior: Optional[BehaviorGrade] = None
|
||||
work_behavior: Optional[BehaviorGrade] = None
|
||||
status: Optional[CertificateStatus] = None
|
||||
|
||||
|
||||
class CertificateResponse(BaseModel):
|
||||
"""Response mit Zeugnisdaten."""
|
||||
id: str
|
||||
student_id: str
|
||||
student_name: str
|
||||
student_birthdate: str
|
||||
student_class: str
|
||||
school_year: str
|
||||
certificate_type: CertificateType
|
||||
subjects: List[SubjectGrade]
|
||||
attendance: AttendanceInfo
|
||||
remarks: Optional[str]
|
||||
class_teacher: str
|
||||
principal: str
|
||||
school_info: Optional[SchoolInfoModel]
|
||||
issue_date: Optional[str]
|
||||
social_behavior: Optional[BehaviorGrade]
|
||||
work_behavior: Optional[BehaviorGrade]
|
||||
status: CertificateStatus
|
||||
average_grade: Optional[float]
|
||||
pdf_path: Optional[str]
|
||||
dsms_cid: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CertificateListResponse(BaseModel):
|
||||
"""Response mit Liste von Zeugnissen."""
|
||||
certificates: List[CertificateResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class GradeStatistics(BaseModel):
|
||||
"""Notenstatistiken für eine Klasse."""
|
||||
class_name: str
|
||||
school_year: str
|
||||
certificate_type: CertificateType
|
||||
student_count: int
|
||||
average_grade: float
|
||||
grade_distribution: Dict[str, int]
|
||||
subject_averages: Dict[str, float]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# In-Memory Storage (Prototyp - später durch DB ersetzen)
|
||||
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
|
||||
# =============================================================================
|
||||
|
||||
_certificates_store: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -194,7 +55,7 @@ def _get_certificate(cert_id: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _save_certificate(cert_data: Dict[str, Any]) -> str:
|
||||
"""Speichert Zeugnis und gibt ID zurück."""
|
||||
"""Speichert Zeugnis und gibt ID zurueck."""
|
||||
cert_id = cert_data.get("id") or str(uuid.uuid4())
|
||||
cert_data["id"] = cert_id
|
||||
cert_data["updated_at"] = datetime.now()
|
||||
@@ -204,35 +65,13 @@ def _save_certificate(cert_data: Dict[str, Any]) -> str:
|
||||
return cert_id
|
||||
|
||||
|
||||
def _calculate_average(subjects: List[Dict[str, Any]]) -> Optional[float]:
|
||||
"""Berechnet Notendurchschnitt."""
|
||||
numeric_grades = []
|
||||
for subject in subjects:
|
||||
grade = subject.get("grade", "")
|
||||
try:
|
||||
numeric = float(grade)
|
||||
if 1 <= numeric <= 6:
|
||||
numeric_grades.append(numeric)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if numeric_grades:
|
||||
return round(sum(numeric_grades) / len(numeric_grades), 2)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/", response_model=CertificateResponse)
|
||||
async def create_certificate(request: CertificateCreateRequest):
|
||||
"""
|
||||
Erstellt ein neues Zeugnis.
|
||||
|
||||
Das Zeugnis wird als Entwurf gespeichert und kann später
|
||||
bearbeitet, genehmigt und als PDF exportiert werden.
|
||||
"""
|
||||
"""Erstellt ein neues Zeugnis."""
|
||||
logger.info(f"Creating new certificate for student: {request.student_name}")
|
||||
|
||||
subjects_list = [s.model_dump() for s in request.subjects]
|
||||
@@ -261,74 +100,48 @@ async def create_certificate(request: CertificateCreateRequest):
|
||||
|
||||
cert_id = _save_certificate(cert_data)
|
||||
cert_data["id"] = cert_id
|
||||
|
||||
logger.info(f"Certificate created with ID: {cert_id}")
|
||||
return CertificateResponse(**cert_data)
|
||||
|
||||
|
||||
# IMPORTANT: Static routes must be defined BEFORE dynamic /{cert_id} route
|
||||
# to prevent "types" or "behavior-grades" being matched as cert_id
|
||||
|
||||
@router.get("/types")
|
||||
async def get_certificate_types():
|
||||
"""
|
||||
Gibt alle verfügbaren Zeugnistypen zurück.
|
||||
"""
|
||||
return {
|
||||
"types": [
|
||||
{"value": t.value, "label": _get_type_label(t)}
|
||||
for t in CertificateType
|
||||
]
|
||||
}
|
||||
"""Gibt alle verfuegbaren Zeugnistypen zurueck."""
|
||||
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in CertificateType]}
|
||||
|
||||
|
||||
@router.get("/behavior-grades")
|
||||
async def get_behavior_grades():
|
||||
"""
|
||||
Gibt alle verfügbaren Verhaltensnoten zurück.
|
||||
"""
|
||||
"""Gibt alle verfuegbaren Verhaltensnoten zurueck."""
|
||||
labels = {
|
||||
BehaviorGrade.A: "A - Sehr gut",
|
||||
BehaviorGrade.B: "B - Gut",
|
||||
BehaviorGrade.C: "C - Befriedigend",
|
||||
BehaviorGrade.D: "D - Verbesserungswürdig"
|
||||
}
|
||||
return {
|
||||
"grades": [
|
||||
{"value": g.value, "label": labels[g]}
|
||||
for g in BehaviorGrade
|
||||
]
|
||||
BehaviorGrade.A: "A - Sehr gut", BehaviorGrade.B: "B - Gut",
|
||||
BehaviorGrade.C: "C - Befriedigend", BehaviorGrade.D: "D - Verbesserungswuerdig"
|
||||
}
|
||||
return {"grades": [{"value": g.value, "label": labels[g]} for g in BehaviorGrade]}
|
||||
|
||||
|
||||
@router.get("/{cert_id}", response_model=CertificateResponse)
|
||||
async def get_certificate(cert_id: str):
|
||||
"""
|
||||
Lädt ein gespeichertes Zeugnis.
|
||||
"""
|
||||
"""Laedt ein gespeichertes Zeugnis."""
|
||||
logger.info(f"Getting certificate: {cert_id}")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
return CertificateResponse(**cert_data)
|
||||
return CertificateResponse(**_get_certificate(cert_id))
|
||||
|
||||
|
||||
@router.get("/", response_model=CertificateListResponse)
|
||||
async def list_certificates(
|
||||
student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"),
|
||||
class_name: Optional[str] = Query(None, description="Filter nach Klasse"),
|
||||
school_year: Optional[str] = Query(None, description="Filter nach Schuljahr"),
|
||||
certificate_type: Optional[CertificateType] = Query(None, description="Filter nach Zeugnistyp"),
|
||||
status: Optional[CertificateStatus] = Query(None, description="Filter nach Status"),
|
||||
page: int = Query(1, ge=1, description="Seitennummer"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite")
|
||||
student_id: Optional[str] = Query(None),
|
||||
class_name: Optional[str] = Query(None),
|
||||
school_year: Optional[str] = Query(None),
|
||||
certificate_type: Optional[CertificateType] = Query(None),
|
||||
status: Optional[CertificateStatus] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""
|
||||
Listet alle gespeicherten Zeugnisse mit optionalen Filtern.
|
||||
"""
|
||||
"""Listet alle gespeicherten Zeugnisse mit optionalen Filtern."""
|
||||
logger.info("Listing certificates with filters")
|
||||
|
||||
# Filter anwenden
|
||||
filtered_certs = list(_certificates_store.values())
|
||||
|
||||
if student_id:
|
||||
filtered_certs = [c for c in filtered_certs if c.get("student_id") == student_id]
|
||||
if class_name:
|
||||
@@ -340,39 +153,26 @@ async def list_certificates(
|
||||
if status:
|
||||
filtered_certs = [c for c in filtered_certs if c.get("status") == status]
|
||||
|
||||
# Sortieren nach Erstelldatum (neueste zuerst)
|
||||
filtered_certs.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
|
||||
|
||||
# Paginierung
|
||||
total = len(filtered_certs)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
paginated_certs = filtered_certs[start:end]
|
||||
paginated_certs = filtered_certs[start:start + page_size]
|
||||
|
||||
return CertificateListResponse(
|
||||
certificates=[CertificateResponse(**c) for c in paginated_certs],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
total=total, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{cert_id}", response_model=CertificateResponse)
|
||||
async def update_certificate(cert_id: str, request: CertificateUpdateRequest):
|
||||
"""
|
||||
Aktualisiert ein bestehendes Zeugnis.
|
||||
"""
|
||||
"""Aktualisiert ein bestehendes Zeugnis."""
|
||||
logger.info(f"Updating certificate: {cert_id}")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
# Prüfen ob Zeugnis noch bearbeitbar ist
|
||||
if cert_data.get("status") in [CertificateStatus.ISSUED, CertificateStatus.ARCHIVED]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden")
|
||||
|
||||
# Nur übergebene Felder aktualisieren
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if value is not None:
|
||||
@@ -385,61 +185,43 @@ async def update_certificate(cert_id: str, request: CertificateUpdateRequest):
|
||||
cert_data[key] = value
|
||||
|
||||
_save_certificate(cert_data)
|
||||
|
||||
return CertificateResponse(**cert_data)
|
||||
|
||||
|
||||
@router.delete("/{cert_id}")
|
||||
async def delete_certificate(cert_id: str):
|
||||
"""
|
||||
Löscht ein Zeugnis.
|
||||
|
||||
Nur Entwürfe können gelöscht werden.
|
||||
"""
|
||||
"""Loescht ein Zeugnis. Nur Entwuerfe koennen geloescht werden."""
|
||||
logger.info(f"Deleting certificate: {cert_id}")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
if cert_data.get("status") != CertificateStatus.DRAFT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur Zeugnis-Entwürfe können gelöscht werden"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Nur Zeugnis-Entwuerfe koennen geloescht werden")
|
||||
del _certificates_store[cert_id]
|
||||
return {"message": f"Zeugnis {cert_id} wurde gelöscht"}
|
||||
return {"message": f"Zeugnis {cert_id} wurde geloescht"}
|
||||
|
||||
|
||||
@router.post("/{cert_id}/export-pdf")
|
||||
async def export_certificate_pdf(cert_id: str):
|
||||
"""
|
||||
Exportiert ein Zeugnis als PDF.
|
||||
"""
|
||||
"""Exportiert ein Zeugnis als PDF."""
|
||||
logger.info(f"Exporting certificate {cert_id} as PDF")
|
||||
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
# PDF generieren
|
||||
try:
|
||||
pdf_bytes = generate_certificate_pdf(cert_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating PDF: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}")
|
||||
|
||||
# Dateiname erstellen (ASCII-safe für HTTP Header)
|
||||
student_name = cert_data.get("student_name", "Zeugnis").replace(" ", "_")
|
||||
school_year = cert_data.get("school_year", "").replace("/", "-")
|
||||
cert_type = cert_data.get("certificate_type", "zeugnis")
|
||||
filename = f"Zeugnis_{student_name}_{cert_type}_{school_year}.pdf"
|
||||
|
||||
# Für HTTP Header: ASCII-Fallback und UTF-8 encoded filename (RFC 5987)
|
||||
from urllib.parse import quote
|
||||
filename_ascii = filename.encode('ascii', 'replace').decode('ascii')
|
||||
filename_encoded = quote(filename, safe='')
|
||||
|
||||
# PDF als Download zurückgeben
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
content=pdf_bytes, media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
|
||||
"Content-Length": str(len(pdf_bytes))
|
||||
@@ -449,106 +231,57 @@ async def export_certificate_pdf(cert_id: str):
|
||||
|
||||
@router.post("/{cert_id}/submit-review")
|
||||
async def submit_for_review(cert_id: str):
|
||||
"""
|
||||
Reicht Zeugnis zur Prüfung ein.
|
||||
"""
|
||||
"""Reicht Zeugnis zur Pruefung ein."""
|
||||
logger.info(f"Submitting certificate {cert_id} for review")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
if cert_data.get("status") != CertificateStatus.DRAFT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur Entwürfe können zur Prüfung eingereicht werden"
|
||||
)
|
||||
|
||||
# Prüfen ob alle Pflichtfelder ausgefüllt sind
|
||||
raise HTTPException(status_code=400, detail="Nur Entwuerfe koennen zur Pruefung eingereicht werden")
|
||||
if not cert_data.get("subjects"):
|
||||
raise HTTPException(status_code=400, detail="Keine Fachnoten eingetragen")
|
||||
|
||||
cert_data["status"] = CertificateStatus.REVIEW
|
||||
_save_certificate(cert_data)
|
||||
|
||||
return {"message": "Zeugnis wurde zur Prüfung eingereicht", "status": CertificateStatus.REVIEW}
|
||||
return {"message": "Zeugnis wurde zur Pruefung eingereicht", "status": CertificateStatus.REVIEW}
|
||||
|
||||
|
||||
@router.post("/{cert_id}/approve")
|
||||
async def approve_certificate(cert_id: str):
|
||||
"""
|
||||
Genehmigt ein Zeugnis.
|
||||
|
||||
Erfordert Schulleiter-Rechte (in Produktion).
|
||||
"""
|
||||
"""Genehmigt ein Zeugnis."""
|
||||
logger.info(f"Approving certificate {cert_id}")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
if cert_data.get("status") != CertificateStatus.REVIEW:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur Zeugnisse in Prüfung können genehmigt werden"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Nur Zeugnisse in Pruefung koennen genehmigt werden")
|
||||
cert_data["status"] = CertificateStatus.APPROVED
|
||||
_save_certificate(cert_data)
|
||||
|
||||
return {"message": "Zeugnis wurde genehmigt", "status": CertificateStatus.APPROVED}
|
||||
|
||||
|
||||
@router.post("/{cert_id}/issue")
|
||||
async def issue_certificate(cert_id: str):
|
||||
"""
|
||||
Stellt ein Zeugnis offiziell aus.
|
||||
|
||||
Nach Ausstellung kann das Zeugnis nicht mehr bearbeitet werden.
|
||||
"""
|
||||
"""Stellt ein Zeugnis offiziell aus."""
|
||||
logger.info(f"Issuing certificate {cert_id}")
|
||||
cert_data = _get_certificate(cert_id)
|
||||
|
||||
if cert_data.get("status") != CertificateStatus.APPROVED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur genehmigte Zeugnisse können ausgestellt werden"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Nur genehmigte Zeugnisse koennen ausgestellt werden")
|
||||
cert_data["status"] = CertificateStatus.ISSUED
|
||||
cert_data["issue_date"] = datetime.now().strftime("%d.%m.%Y")
|
||||
_save_certificate(cert_data)
|
||||
|
||||
return {
|
||||
"message": "Zeugnis wurde ausgestellt",
|
||||
"status": CertificateStatus.ISSUED,
|
||||
"issue_date": cert_data["issue_date"]
|
||||
}
|
||||
return {"message": "Zeugnis wurde ausgestellt", "status": CertificateStatus.ISSUED, "issue_date": cert_data["issue_date"]}
|
||||
|
||||
|
||||
@router.get("/student/{student_id}", response_model=CertificateListResponse)
|
||||
async def get_certificates_for_student(
|
||||
student_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100)
|
||||
student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""
|
||||
Lädt alle Zeugnisse für einen bestimmten Schüler.
|
||||
"""
|
||||
"""Laedt alle Zeugnisse fuer einen bestimmten Schueler."""
|
||||
logger.info(f"Getting certificates for student: {student_id}")
|
||||
|
||||
filtered_certs = [
|
||||
c for c in _certificates_store.values()
|
||||
if c.get("student_id") == student_id
|
||||
]
|
||||
|
||||
# Sortieren nach Schuljahr und Typ
|
||||
filtered_certs = [c for c in _certificates_store.values() if c.get("student_id") == student_id]
|
||||
filtered_certs.sort(key=lambda x: (x.get("school_year", ""), x.get("certificate_type", "")), reverse=True)
|
||||
|
||||
total = len(filtered_certs)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
paginated_certs = filtered_certs[start:end]
|
||||
|
||||
paginated_certs = filtered_certs[start:start + page_size]
|
||||
return CertificateListResponse(
|
||||
certificates=[CertificateResponse(**c) for c in paginated_certs],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
total=total, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@@ -556,14 +289,11 @@ async def get_certificates_for_student(
|
||||
async def get_class_statistics(
|
||||
class_name: str,
|
||||
school_year: str = Query(..., description="Schuljahr"),
|
||||
certificate_type: CertificateType = Query(CertificateType.HALBJAHR, description="Zeugnistyp")
|
||||
certificate_type: CertificateType = Query(CertificateType.HALBJAHR)
|
||||
):
|
||||
"""
|
||||
Berechnet Notenstatistiken für eine Klasse.
|
||||
"""
|
||||
"""Berechnet Notenstatistiken fuer eine Klasse."""
|
||||
logger.info(f"Calculating statistics for class {class_name}")
|
||||
|
||||
# Filter Zeugnisse
|
||||
class_certs = [
|
||||
c for c in _certificates_store.values()
|
||||
if c.get("student_class") == class_name
|
||||
@@ -572,13 +302,9 @@ async def get_class_statistics(
|
||||
]
|
||||
|
||||
if not class_certs:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Keine Zeugnisse für Klasse {class_name} im Schuljahr {school_year} gefunden"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Keine Zeugnisse fuer Klasse {class_name} im Schuljahr {school_year} gefunden")
|
||||
|
||||
# Statistiken berechnen
|
||||
all_grades = []
|
||||
all_grades: List[float] = []
|
||||
subject_grades: Dict[str, List[float]] = {}
|
||||
grade_counts = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}
|
||||
|
||||
@@ -586,7 +312,6 @@ async def get_class_statistics(
|
||||
avg = cert.get("average_grade")
|
||||
if avg:
|
||||
all_grades.append(avg)
|
||||
# Runde für Verteilung
|
||||
rounded = str(round(avg))
|
||||
if rounded in grade_counts:
|
||||
grade_counts[rounded] += 1
|
||||
@@ -602,35 +327,14 @@ async def get_class_statistics(
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Fachdurchschnitte berechnen
|
||||
subject_averages = {
|
||||
name: round(sum(grades) / len(grades), 2)
|
||||
for name, grades in subject_grades.items()
|
||||
if grades
|
||||
for name, grades in subject_grades.items() if grades
|
||||
}
|
||||
|
||||
return GradeStatistics(
|
||||
class_name=class_name,
|
||||
school_year=school_year,
|
||||
certificate_type=certificate_type,
|
||||
student_count=len(class_certs),
|
||||
class_name=class_name, school_year=school_year,
|
||||
certificate_type=certificate_type, student_count=len(class_certs),
|
||||
average_grade=round(sum(all_grades) / len(all_grades), 2) if all_grades else 0.0,
|
||||
grade_distribution=grade_counts,
|
||||
subject_averages=subject_averages
|
||||
grade_distribution=grade_counts, subject_averages=subject_averages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _get_type_label(cert_type: CertificateType) -> str:
|
||||
"""Gibt menschenlesbare Labels für Zeugnistypen zurück."""
|
||||
labels = {
|
||||
CertificateType.HALBJAHR: "Halbjahreszeugnis",
|
||||
CertificateType.JAHRES: "Jahreszeugnis",
|
||||
CertificateType.ABSCHLUSS: "Abschlusszeugnis",
|
||||
CertificateType.ABGANG: "Abgangszeugnis",
|
||||
CertificateType.UEBERGANG: "Übergangszeugnis",
|
||||
}
|
||||
return labels.get(cert_type, cert_type.value)
|
||||
|
||||
184
backend-lehrer/certificates_models.py
Normal file
184
backend-lehrer/certificates_models.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Certificates Models - Pydantic models and enums for Zeugnisverwaltung.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums
|
||||
# =============================================================================
|
||||
|
||||
class CertificateType(str, Enum):
|
||||
"""Typen von Zeugnissen."""
|
||||
HALBJAHR = "halbjahr"
|
||||
JAHRES = "jahres"
|
||||
ABSCHLUSS = "abschluss"
|
||||
ABGANG = "abgang"
|
||||
UEBERGANG = "uebergang"
|
||||
|
||||
|
||||
class CertificateStatus(str, Enum):
|
||||
"""Status eines Zeugnisses."""
|
||||
DRAFT = "draft"
|
||||
REVIEW = "review"
|
||||
APPROVED = "approved"
|
||||
ISSUED = "issued"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class GradeType(str, Enum):
|
||||
"""Notentyp."""
|
||||
NUMERIC = "numeric"
|
||||
POINTS = "points"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class BehaviorGrade(str, Enum):
|
||||
"""Verhaltens-/Arbeitsnoten."""
|
||||
A = "A"
|
||||
B = "B"
|
||||
C = "C"
|
||||
D = "D"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class SchoolInfoModel(BaseModel):
|
||||
"""Schulinformationen fuer Zeugnis."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
|
||||
|
||||
class SubjectGrade(BaseModel):
|
||||
"""Note fuer ein Fach."""
|
||||
name: str = Field(..., description="Fachname")
|
||||
grade: str = Field(..., description="Note (1-6 oder A-D)")
|
||||
points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)")
|
||||
note: Optional[str] = Field(None, description="Bemerkung zum Fach")
|
||||
|
||||
|
||||
class AttendanceInfo(BaseModel):
|
||||
"""Anwesenheitsinformationen."""
|
||||
days_absent: int = Field(0, description="Fehlende Tage gesamt")
|
||||
days_excused: int = Field(0, description="Entschuldigte Tage")
|
||||
days_unexcused: int = Field(0, description="Unentschuldigte Tage")
|
||||
hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt")
|
||||
|
||||
|
||||
class CertificateCreateRequest(BaseModel):
|
||||
"""Request zum Erstellen eines neuen Zeugnisses."""
|
||||
student_id: str = Field(..., description="ID des Schuelers")
|
||||
student_name: str = Field(..., description="Name des Schuelers")
|
||||
student_birthdate: str = Field(..., description="Geburtsdatum")
|
||||
student_class: str = Field(..., description="Klasse")
|
||||
school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')")
|
||||
certificate_type: CertificateType = Field(..., description="Art des Zeugnisses")
|
||||
subjects: List[SubjectGrade] = Field(..., description="Fachnoten")
|
||||
attendance: AttendanceInfo = Field(default_factory=AttendanceInfo)
|
||||
remarks: Optional[str] = Field(None, description="Bemerkungen")
|
||||
class_teacher: str = Field(..., description="Klassenlehrer/in")
|
||||
principal: str = Field(..., description="Schulleiter/in")
|
||||
school_info: Optional[SchoolInfoModel] = Field(None)
|
||||
issue_date: Optional[str] = Field(None, description="Ausstellungsdatum")
|
||||
social_behavior: Optional[BehaviorGrade] = Field(None)
|
||||
work_behavior: Optional[BehaviorGrade] = Field(None)
|
||||
|
||||
|
||||
class CertificateUpdateRequest(BaseModel):
|
||||
"""Request zum Aktualisieren eines Zeugnisses."""
|
||||
subjects: Optional[List[SubjectGrade]] = None
|
||||
attendance: Optional[AttendanceInfo] = None
|
||||
remarks: Optional[str] = None
|
||||
class_teacher: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
social_behavior: Optional[BehaviorGrade] = None
|
||||
work_behavior: Optional[BehaviorGrade] = None
|
||||
status: Optional[CertificateStatus] = None
|
||||
|
||||
|
||||
class CertificateResponse(BaseModel):
|
||||
"""Response mit Zeugnisdaten."""
|
||||
id: str
|
||||
student_id: str
|
||||
student_name: str
|
||||
student_birthdate: str
|
||||
student_class: str
|
||||
school_year: str
|
||||
certificate_type: CertificateType
|
||||
subjects: List[SubjectGrade]
|
||||
attendance: AttendanceInfo
|
||||
remarks: Optional[str]
|
||||
class_teacher: str
|
||||
principal: str
|
||||
school_info: Optional[SchoolInfoModel]
|
||||
issue_date: Optional[str]
|
||||
social_behavior: Optional[BehaviorGrade]
|
||||
work_behavior: Optional[BehaviorGrade]
|
||||
status: CertificateStatus
|
||||
average_grade: Optional[float]
|
||||
pdf_path: Optional[str]
|
||||
dsms_cid: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CertificateListResponse(BaseModel):
|
||||
"""Response mit Liste von Zeugnissen."""
|
||||
certificates: List[CertificateResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class GradeStatistics(BaseModel):
|
||||
"""Notenstatistiken fuer eine Klasse."""
|
||||
class_name: str
|
||||
school_year: str
|
||||
certificate_type: CertificateType
|
||||
student_count: int
|
||||
average_grade: float
|
||||
grade_distribution: Dict[str, int]
|
||||
subject_averages: Dict[str, float]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_type_label(cert_type: CertificateType) -> str:
|
||||
"""Gibt menschenlesbare Labels fuer Zeugnistypen zurueck."""
|
||||
labels = {
|
||||
CertificateType.HALBJAHR: "Halbjahreszeugnis",
|
||||
CertificateType.JAHRES: "Jahreszeugnis",
|
||||
CertificateType.ABSCHLUSS: "Abschlusszeugnis",
|
||||
CertificateType.ABGANG: "Abgangszeugnis",
|
||||
CertificateType.UEBERGANG: "Uebergangszeugnis",
|
||||
}
|
||||
return labels.get(cert_type, cert_type.value)
|
||||
|
||||
|
||||
def calculate_average(subjects: List[Dict]) -> Optional[float]:
|
||||
"""Berechnet Notendurchschnitt."""
|
||||
numeric_grades = []
|
||||
for subject in subjects:
|
||||
grade = subject.get("grade", "")
|
||||
try:
|
||||
numeric = float(grade)
|
||||
if 1 <= numeric <= 6:
|
||||
numeric_grades.append(numeric)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if numeric_grades:
|
||||
return round(sum(numeric_grades) / len(numeric_grades), 2)
|
||||
return None
|
||||
@@ -1,29 +1,25 @@
|
||||
"""
|
||||
Letters API - Elternbrief-Verwaltung für BreakPilot.
|
||||
Letters API - Elternbrief-Verwaltung fuer BreakPilot.
|
||||
|
||||
Bietet Endpoints für:
|
||||
Bietet Endpoints fuer:
|
||||
- Speichern und Laden von Elternbriefen
|
||||
- PDF-Export von Briefen
|
||||
- Versenden per Email
|
||||
- GFK-Integration für Textverbesserung
|
||||
- GFK-Integration fuer Textverbesserung
|
||||
|
||||
Arbeitet zusammen mit:
|
||||
- services/pdf_service.py für PDF-Generierung
|
||||
- llm_gateway/services/communication_service.py für GFK-Verbesserungen
|
||||
Split into:
|
||||
- letters_models.py: Enums, Pydantic models, helper functions
|
||||
- letters_api.py (this file): API endpoints and in-memory store
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import httpx
|
||||
import io
|
||||
|
||||
# PDF service requires WeasyPrint with system libraries - make optional for CI
|
||||
try:
|
||||
@@ -34,171 +30,30 @@ except (ImportError, OSError):
|
||||
SchoolInfo = None # type: ignore
|
||||
_pdf_available = False
|
||||
|
||||
from letters_models import (
|
||||
LetterType,
|
||||
LetterTone,
|
||||
LetterStatus,
|
||||
LetterCreateRequest,
|
||||
LetterUpdateRequest,
|
||||
LetterResponse,
|
||||
LetterListResponse,
|
||||
ExportPDFRequest,
|
||||
ImproveRequest,
|
||||
ImproveResponse,
|
||||
SendEmailRequest,
|
||||
SendEmailResponse,
|
||||
get_type_label as _get_type_label,
|
||||
get_tone_label as _get_tone_label,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/letters", tags=["letters"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums
|
||||
# =============================================================================
|
||||
|
||||
class LetterType(str, Enum):
|
||||
"""Typen von Elternbriefen."""
|
||||
GENERAL = "general" # Allgemeine Information
|
||||
HALBJAHR = "halbjahr" # Halbjahresinformation
|
||||
FEHLZEITEN = "fehlzeiten" # Fehlzeiten-Mitteilung
|
||||
ELTERNABEND = "elternabend" # Einladung Elternabend
|
||||
LOB = "lob" # Positives Feedback
|
||||
CUSTOM = "custom" # Benutzerdefiniert
|
||||
|
||||
|
||||
class LetterTone(str, Enum):
|
||||
"""Tonalität der Briefe."""
|
||||
FORMAL = "formal"
|
||||
PROFESSIONAL = "professional"
|
||||
WARM = "warm"
|
||||
CONCERNED = "concerned"
|
||||
APPRECIATIVE = "appreciative"
|
||||
|
||||
|
||||
class LetterStatus(str, Enum):
|
||||
"""Status eines Briefes."""
|
||||
DRAFT = "draft"
|
||||
SENT = "sent"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class SchoolInfoModel(BaseModel):
|
||||
"""Schulinformationen für Briefkopf."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
|
||||
|
||||
class LegalReferenceModel(BaseModel):
|
||||
"""Rechtliche Referenz."""
|
||||
law: str
|
||||
paragraph: str
|
||||
title: str
|
||||
summary: Optional[str] = None
|
||||
relevance: Optional[str] = None
|
||||
|
||||
|
||||
class LetterCreateRequest(BaseModel):
|
||||
"""Request zum Erstellen eines neuen Briefes."""
|
||||
recipient_name: str = Field(..., description="Name des Empfängers (z.B. 'Familie Müller')")
|
||||
recipient_address: str = Field(..., description="Adresse des Empfängers")
|
||||
student_name: str = Field(..., description="Name des Schülers")
|
||||
student_class: str = Field(..., description="Klasse des Schülers")
|
||||
subject: str = Field(..., description="Betreff des Briefes")
|
||||
content: str = Field(..., description="Inhalt des Briefes")
|
||||
letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes")
|
||||
tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalität des Briefes")
|
||||
teacher_name: str = Field(..., description="Name des Lehrers")
|
||||
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers (z.B. 'Klassenlehrerin')")
|
||||
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen für Briefkopf")
|
||||
legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen")
|
||||
gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien")
|
||||
|
||||
|
||||
class LetterUpdateRequest(BaseModel):
|
||||
"""Request zum Aktualisieren eines Briefes."""
|
||||
recipient_name: Optional[str] = None
|
||||
recipient_address: Optional[str] = None
|
||||
student_name: Optional[str] = None
|
||||
student_class: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
letter_type: Optional[LetterType] = None
|
||||
tone: Optional[LetterTone] = None
|
||||
teacher_name: Optional[str] = None
|
||||
teacher_title: Optional[str] = None
|
||||
school_info: Optional[SchoolInfoModel] = None
|
||||
legal_references: Optional[List[LegalReferenceModel]] = None
|
||||
gfk_principles_applied: Optional[List[str]] = None
|
||||
status: Optional[LetterStatus] = None
|
||||
|
||||
|
||||
class LetterResponse(BaseModel):
|
||||
"""Response mit Briefdaten."""
|
||||
id: str
|
||||
recipient_name: str
|
||||
recipient_address: str
|
||||
student_name: str
|
||||
student_class: str
|
||||
subject: str
|
||||
content: str
|
||||
letter_type: LetterType
|
||||
tone: LetterTone
|
||||
teacher_name: str
|
||||
teacher_title: Optional[str]
|
||||
school_info: Optional[SchoolInfoModel]
|
||||
legal_references: Optional[List[LegalReferenceModel]]
|
||||
gfk_principles_applied: Optional[List[str]]
|
||||
gfk_score: Optional[float]
|
||||
status: LetterStatus
|
||||
pdf_path: Optional[str]
|
||||
dsms_cid: Optional[str]
|
||||
sent_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LetterListResponse(BaseModel):
|
||||
"""Response mit Liste von Briefen."""
|
||||
letters: List[LetterResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ExportPDFRequest(BaseModel):
|
||||
"""Request zum PDF-Export."""
|
||||
letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes")
|
||||
letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten")
|
||||
|
||||
|
||||
class ImproveRequest(BaseModel):
|
||||
"""Request zur GFK-Verbesserung."""
|
||||
content: str = Field(..., description="Text zur Verbesserung")
|
||||
communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation")
|
||||
tone: Optional[str] = Field("professional", description="Gewünschte Tonalität")
|
||||
|
||||
|
||||
class ImproveResponse(BaseModel):
|
||||
"""Response mit verbessertem Text."""
|
||||
improved_content: str
|
||||
changes: List[str]
|
||||
gfk_score: float
|
||||
gfk_principles_applied: List[str]
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
"""Request zum Email-Versand."""
|
||||
letter_id: str
|
||||
recipient_email: str
|
||||
cc_emails: Optional[List[str]] = None
|
||||
include_pdf: bool = True
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
"""Response nach Email-Versand."""
|
||||
success: bool
|
||||
message: str
|
||||
sent_at: Optional[datetime]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# In-Memory Storage (Prototyp - später durch DB ersetzen)
|
||||
# In-Memory Storage (Prototyp - spaeter durch DB ersetzen)
|
||||
# =============================================================================
|
||||
|
||||
_letters_store: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -212,7 +67,7 @@ def _get_letter(letter_id: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _save_letter(letter_data: Dict[str, Any]) -> str:
|
||||
"""Speichert Brief und gibt ID zurück."""
|
||||
"""Speichert Brief und gibt ID zurueck."""
|
||||
letter_id = letter_data.get("id") or str(uuid.uuid4())
|
||||
letter_data["id"] = letter_id
|
||||
letter_data["updated_at"] = datetime.now()
|
||||
@@ -228,12 +83,7 @@ def _save_letter(letter_data: Dict[str, Any]) -> str:
|
||||
|
||||
@router.post("/", response_model=LetterResponse)
|
||||
async def create_letter(request: LetterCreateRequest):
|
||||
"""
|
||||
Erstellt einen neuen Elternbrief.
|
||||
|
||||
Der Brief wird als Entwurf gespeichert und kann später bearbeitet,
|
||||
als PDF exportiert oder per Email versendet werden.
|
||||
"""
|
||||
"""Erstellt einen neuen Elternbrief."""
|
||||
logger.info(f"Creating new letter for student: {request.student_name}")
|
||||
|
||||
letter_data = {
|
||||
@@ -259,7 +109,6 @@ async def create_letter(request: LetterCreateRequest):
|
||||
|
||||
letter_id = _save_letter(letter_data)
|
||||
letter_data["id"] = letter_id
|
||||
|
||||
logger.info(f"Letter created with ID: {letter_id}")
|
||||
return LetterResponse(**letter_data)
|
||||
|
||||
@@ -267,35 +116,19 @@ async def create_letter(request: LetterCreateRequest):
|
||||
# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id}
|
||||
@router.get("/types")
|
||||
async def get_letter_types():
|
||||
"""
|
||||
Gibt alle verfügbaren Brieftypen zurück.
|
||||
"""
|
||||
return {
|
||||
"types": [
|
||||
{"value": t.value, "label": _get_type_label(t)}
|
||||
for t in LetterType
|
||||
]
|
||||
}
|
||||
"""Gibt alle verfuegbaren Brieftypen zurueck."""
|
||||
return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]}
|
||||
|
||||
|
||||
@router.get("/tones")
|
||||
async def get_letter_tones():
|
||||
"""
|
||||
Gibt alle verfügbaren Tonalitäten zurück.
|
||||
"""
|
||||
return {
|
||||
"tones": [
|
||||
{"value": t.value, "label": _get_tone_label(t)}
|
||||
for t in LetterTone
|
||||
]
|
||||
}
|
||||
"""Gibt alle verfuegbaren Tonalitaeten zurueck."""
|
||||
return {"tones": [{"value": t.value, "label": _get_tone_label(t)} for t in LetterTone]}
|
||||
|
||||
|
||||
@router.get("/{letter_id}", response_model=LetterResponse)
|
||||
async def get_letter(letter_id: str):
|
||||
"""
|
||||
Lädt einen gespeicherten Brief.
|
||||
"""
|
||||
"""Laedt einen gespeicherten Brief."""
|
||||
logger.info(f"Getting letter: {letter_id}")
|
||||
letter_data = _get_letter(letter_id)
|
||||
return LetterResponse(**letter_data)
|
||||
@@ -303,21 +136,17 @@ async def get_letter(letter_id: str):
|
||||
|
||||
@router.get("/", response_model=LetterListResponse)
|
||||
async def list_letters(
|
||||
student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"),
|
||||
class_name: Optional[str] = Query(None, description="Filter nach Klasse"),
|
||||
letter_type: Optional[LetterType] = Query(None, description="Filter nach Brief-Typ"),
|
||||
status: Optional[LetterStatus] = Query(None, description="Filter nach Status"),
|
||||
page: int = Query(1, ge=1, description="Seitennummer"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite")
|
||||
student_id: Optional[str] = Query(None),
|
||||
class_name: Optional[str] = Query(None),
|
||||
letter_type: Optional[LetterType] = Query(None),
|
||||
status: Optional[LetterStatus] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""
|
||||
Listet alle gespeicherten Briefe mit optionalen Filtern.
|
||||
"""
|
||||
"""Listet alle gespeicherten Briefe mit optionalen Filtern."""
|
||||
logger.info("Listing letters with filters")
|
||||
|
||||
# Filter anwenden
|
||||
filtered_letters = list(_letters_store.values())
|
||||
|
||||
if class_name:
|
||||
filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name]
|
||||
if letter_type:
|
||||
@@ -325,32 +154,23 @@ async def list_letters(
|
||||
if status:
|
||||
filtered_letters = [l for l in filtered_letters if l.get("status") == status]
|
||||
|
||||
# Sortieren nach Erstelldatum (neueste zuerst)
|
||||
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
|
||||
|
||||
# Paginierung
|
||||
total = len(filtered_letters)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
paginated_letters = filtered_letters[start:end]
|
||||
paginated_letters = filtered_letters[start:start + page_size]
|
||||
|
||||
return LetterListResponse(
|
||||
letters=[LetterResponse(**l) for l in paginated_letters],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
total=total, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{letter_id}", response_model=LetterResponse)
|
||||
async def update_letter(letter_id: str, request: LetterUpdateRequest):
|
||||
"""
|
||||
Aktualisiert einen bestehenden Brief.
|
||||
"""
|
||||
"""Aktualisiert einen bestehenden Brief."""
|
||||
logger.info(f"Updating letter: {letter_id}")
|
||||
letter_data = _get_letter(letter_id)
|
||||
|
||||
# Nur übergebene Felder aktualisieren
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if value is not None:
|
||||
@@ -362,118 +182,80 @@ async def update_letter(letter_id: str, request: LetterUpdateRequest):
|
||||
letter_data[key] = value
|
||||
|
||||
_save_letter(letter_data)
|
||||
|
||||
return LetterResponse(**letter_data)
|
||||
|
||||
|
||||
@router.delete("/{letter_id}")
|
||||
async def delete_letter(letter_id: str):
|
||||
"""
|
||||
Löscht einen Brief.
|
||||
"""
|
||||
"""Loescht einen Brief."""
|
||||
logger.info(f"Deleting letter: {letter_id}")
|
||||
if letter_id not in _letters_store:
|
||||
raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden")
|
||||
|
||||
del _letters_store[letter_id]
|
||||
return {"message": f"Brief {letter_id} wurde gelöscht"}
|
||||
return {"message": f"Brief {letter_id} wurde geloescht"}
|
||||
|
||||
|
||||
@router.post("/export-pdf")
|
||||
async def export_letter_pdf(request: ExportPDFRequest):
|
||||
"""
|
||||
Exportiert einen Brief als PDF.
|
||||
|
||||
Kann entweder einen gespeicherten Brief (per letter_id) oder
|
||||
direkte Briefdaten (per letter_data) als PDF exportieren.
|
||||
|
||||
Gibt das PDF als Download zurück.
|
||||
"""
|
||||
"""Exportiert einen Brief als PDF."""
|
||||
logger.info("Exporting letter as PDF")
|
||||
|
||||
# Briefdaten ermitteln
|
||||
if request.letter_id:
|
||||
letter_data = _get_letter(request.letter_id)
|
||||
elif request.letter_data:
|
||||
letter_data = request.letter_data.model_dump()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Entweder letter_id oder letter_data muss angegeben werden"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden")
|
||||
|
||||
# Datum hinzufügen falls nicht vorhanden
|
||||
if "date" not in letter_data:
|
||||
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
|
||||
|
||||
# PDF generieren
|
||||
try:
|
||||
pdf_bytes = generate_letter_pdf(letter_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating PDF: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}")
|
||||
|
||||
# Dateiname erstellen
|
||||
student_name = letter_data.get("student_name", "Brief").replace(" ", "_")
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
filename = f"Elternbrief_{student_name}_{date_str}.pdf"
|
||||
|
||||
# PDF als Download zurückgeben
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes))
|
||||
}
|
||||
content=pdf_bytes, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes))}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{letter_id}/export-pdf")
|
||||
async def export_saved_letter_pdf(letter_id: str):
|
||||
"""
|
||||
Exportiert einen gespeicherten Brief als PDF (Kurzform).
|
||||
"""
|
||||
"""Exportiert einen gespeicherten Brief als PDF (Kurzform)."""
|
||||
return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id))
|
||||
|
||||
|
||||
@router.post("/improve", response_model=ImproveResponse)
|
||||
async def improve_letter_content(request: ImproveRequest):
|
||||
"""
|
||||
Verbessert den Briefinhalt nach GFK-Prinzipien.
|
||||
|
||||
Nutzt die Communication Service API für KI-gestützte Verbesserungen.
|
||||
"""
|
||||
"""Verbessert den Briefinhalt nach GFK-Prinzipien."""
|
||||
logger.info("Improving letter content with GFK principles")
|
||||
|
||||
# Communication Service URL (läuft im gleichen Backend)
|
||||
comm_service_url = os.getenv(
|
||||
"COMMUNICATION_SERVICE_URL",
|
||||
"http://localhost:8000/v1/communication"
|
||||
)
|
||||
comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Validierung des aktuellen Textes
|
||||
validate_response = await client.post(
|
||||
f"{comm_service_url}/validate",
|
||||
json={"text": request.content},
|
||||
timeout=30.0
|
||||
json={"text": request.content}, timeout=30.0
|
||||
)
|
||||
|
||||
if validate_response.status_code != 200:
|
||||
logger.warning(f"Validation service returned {validate_response.status_code}")
|
||||
# Fallback: Original-Text zurückgeben
|
||||
return ImproveResponse(
|
||||
improved_content=request.content,
|
||||
changes=["Verbesserungsservice nicht verfügbar"],
|
||||
gfk_score=0.5,
|
||||
gfk_principles_applied=[]
|
||||
changes=["Verbesserungsservice nicht verfuegbar"],
|
||||
gfk_score=0.5, gfk_principles_applied=[]
|
||||
)
|
||||
|
||||
validation_data = validate_response.json()
|
||||
|
||||
# Falls Text schon gut ist, keine Änderungen
|
||||
if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8:
|
||||
return ImproveResponse(
|
||||
improved_content=request.content,
|
||||
@@ -482,84 +264,48 @@ async def improve_letter_content(request: ImproveRequest):
|
||||
gfk_principles_applied=validation_data.get("positive_elements", [])
|
||||
)
|
||||
|
||||
# Verbesserungsvorschläge als Änderungen
|
||||
changes = validation_data.get("suggestions", [])
|
||||
gfk_score = validation_data.get("gfk_score", 0.5)
|
||||
gfk_principles = validation_data.get("positive_elements", [])
|
||||
|
||||
# TODO: Hier könnte ein LLM den Text basierend auf den Vorschlägen verbessern
|
||||
# Für jetzt geben wir den Original-Text mit den Verbesserungsvorschlägen zurück
|
||||
return ImproveResponse(
|
||||
improved_content=request.content,
|
||||
changes=changes,
|
||||
gfk_score=gfk_score,
|
||||
gfk_principles_applied=gfk_principles
|
||||
changes=validation_data.get("suggestions", []),
|
||||
gfk_score=validation_data.get("gfk_score", 0.5),
|
||||
gfk_principles_applied=validation_data.get("positive_elements", [])
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while calling communication service")
|
||||
return ImproveResponse(
|
||||
improved_content=request.content,
|
||||
changes=["Zeitüberschreitung beim Verbesserungsservice"],
|
||||
gfk_score=0.5,
|
||||
gfk_principles_applied=[]
|
||||
changes=["Zeitueberschreitung beim Verbesserungsservice"],
|
||||
gfk_score=0.5, gfk_principles_applied=[]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error improving content: {e}")
|
||||
return ImproveResponse(
|
||||
improved_content=request.content,
|
||||
changes=[f"Fehler: {str(e)}"],
|
||||
gfk_score=0.5,
|
||||
gfk_principles_applied=[]
|
||||
gfk_score=0.5, gfk_principles_applied=[]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{letter_id}/send", response_model=SendEmailResponse)
|
||||
async def send_letter_email(letter_id: str, request: SendEmailRequest):
|
||||
"""
|
||||
Versendet einen Brief per Email.
|
||||
|
||||
Der Brief wird als PDF angehängt (wenn include_pdf=True)
|
||||
und der Status wird auf 'sent' gesetzt.
|
||||
"""
|
||||
"""Versendet einen Brief per Email."""
|
||||
logger.info(f"Sending letter {letter_id} to {request.recipient_email}")
|
||||
|
||||
# Brief laden
|
||||
letter_data = _get_letter(letter_id)
|
||||
|
||||
# Email-Service URL (Mailpit oder SMTP)
|
||||
email_service_url = os.getenv(
|
||||
"EMAIL_SERVICE_URL",
|
||||
"http://localhost:8025/api/v1/send" # Mailpit default
|
||||
)
|
||||
|
||||
try:
|
||||
# PDF generieren falls gewünscht
|
||||
pdf_attachment = None
|
||||
if request.include_pdf:
|
||||
letter_data["date"] = datetime.now().strftime("%d.%m.%Y")
|
||||
pdf_bytes = generate_letter_pdf(letter_data)
|
||||
pdf_attachment = {
|
||||
"filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf",
|
||||
"content": pdf_bytes.hex(), # Hex-encoded für JSON
|
||||
"content": pdf_bytes.hex(),
|
||||
"content_type": "application/pdf"
|
||||
}
|
||||
|
||||
# Email senden (vereinfachte Implementierung)
|
||||
# In der Praxis würde hier ein richtiger Email-Service aufgerufen
|
||||
async with httpx.AsyncClient() as client:
|
||||
email_data = {
|
||||
"to": request.recipient_email,
|
||||
"cc": request.cc_emails or [],
|
||||
"subject": letter_data.get("subject", "Elternbrief"),
|
||||
"body": letter_data.get("content", ""),
|
||||
"attachments": [pdf_attachment] if pdf_attachment else []
|
||||
}
|
||||
|
||||
# Für Prototyp: Nur loggen, nicht wirklich senden
|
||||
logger.info(f"Would send email: {email_data['subject']} to {email_data['to']}")
|
||||
|
||||
# Status aktualisieren
|
||||
logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}")
|
||||
letter_data["status"] = LetterStatus.SENT
|
||||
letter_data["sent_at"] = datetime.now()
|
||||
_save_letter(letter_data)
|
||||
@@ -572,11 +318,7 @@ async def send_letter_email(letter_id: str, request: SendEmailRequest):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email: {e}")
|
||||
return SendEmailResponse(
|
||||
success=False,
|
||||
message=f"Fehler beim Versenden: {str(e)}",
|
||||
sent_at=None
|
||||
)
|
||||
return SendEmailResponse(success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None)
|
||||
|
||||
|
||||
@router.get("/student/{student_id}", response_model=LetterListResponse)
|
||||
@@ -585,57 +327,20 @@ async def get_letters_for_student(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""
|
||||
Lädt alle Briefe für einen bestimmten Schüler.
|
||||
"""
|
||||
"""Laedt alle Briefe fuer einen bestimmten Schueler."""
|
||||
logger.info(f"Getting letters for student: {student_id}")
|
||||
|
||||
# In einem echten System würde hier nach student_id gefiltert
|
||||
# Für Prototyp filtern wir nach student_name
|
||||
filtered_letters = [
|
||||
l for l in _letters_store.values()
|
||||
if student_id.lower() in l.get("student_name", "").lower()
|
||||
]
|
||||
|
||||
# Sortieren und Paginierung
|
||||
filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True)
|
||||
total = len(filtered_letters)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
paginated_letters = filtered_letters[start:end]
|
||||
paginated_letters = filtered_letters[start:start + page_size]
|
||||
|
||||
return LetterListResponse(
|
||||
letters=[LetterResponse(**l) for l in paginated_letters],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
total=total, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _get_type_label(letter_type: LetterType) -> str:
|
||||
"""Gibt menschenlesbare Labels für Brieftypen zurück."""
|
||||
labels = {
|
||||
LetterType.GENERAL: "Allgemeine Information",
|
||||
LetterType.HALBJAHR: "Halbjahresinformation",
|
||||
LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung",
|
||||
LetterType.ELTERNABEND: "Einladung Elternabend",
|
||||
LetterType.LOB: "Positives Feedback",
|
||||
LetterType.CUSTOM: "Benutzerdefiniert",
|
||||
}
|
||||
return labels.get(letter_type, letter_type.value)
|
||||
|
||||
|
||||
def _get_tone_label(tone: LetterTone) -> str:
|
||||
"""Gibt menschenlesbare Labels für Tonalitäten zurück."""
|
||||
labels = {
|
||||
LetterTone.FORMAL: "Sehr förmlich",
|
||||
LetterTone.PROFESSIONAL: "Professionell-freundlich",
|
||||
LetterTone.WARM: "Warmherzig",
|
||||
LetterTone.CONCERNED: "Besorgt",
|
||||
LetterTone.APPRECIATIVE: "Wertschätzend",
|
||||
}
|
||||
return labels.get(tone, tone.value)
|
||||
|
||||
195
backend-lehrer/letters_models.py
Normal file
195
backend-lehrer/letters_models.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Letters Models - Pydantic models and enums for Elternbrief-Verwaltung.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums
|
||||
# =============================================================================
|
||||
|
||||
class LetterType(str, Enum):
|
||||
"""Typen von Elternbriefen."""
|
||||
GENERAL = "general"
|
||||
HALBJAHR = "halbjahr"
|
||||
FEHLZEITEN = "fehlzeiten"
|
||||
ELTERNABEND = "elternabend"
|
||||
LOB = "lob"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class LetterTone(str, Enum):
|
||||
"""Tonalitaet der Briefe."""
|
||||
FORMAL = "formal"
|
||||
PROFESSIONAL = "professional"
|
||||
WARM = "warm"
|
||||
CONCERNED = "concerned"
|
||||
APPRECIATIVE = "appreciative"
|
||||
|
||||
|
||||
class LetterStatus(str, Enum):
|
||||
"""Status eines Briefes."""
|
||||
DRAFT = "draft"
|
||||
SENT = "sent"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class SchoolInfoModel(BaseModel):
|
||||
"""Schulinformationen fuer Briefkopf."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
|
||||
|
||||
class LegalReferenceModel(BaseModel):
|
||||
"""Rechtliche Referenz."""
|
||||
law: str
|
||||
paragraph: str
|
||||
title: str
|
||||
summary: Optional[str] = None
|
||||
relevance: Optional[str] = None
|
||||
|
||||
|
||||
class LetterCreateRequest(BaseModel):
|
||||
"""Request zum Erstellen eines neuen Briefes."""
|
||||
recipient_name: str = Field(..., description="Name des Empfaengers")
|
||||
recipient_address: str = Field(..., description="Adresse des Empfaengers")
|
||||
student_name: str = Field(..., description="Name des Schuelers")
|
||||
student_class: str = Field(..., description="Klasse des Schuelers")
|
||||
subject: str = Field(..., description="Betreff des Briefes")
|
||||
content: str = Field(..., description="Inhalt des Briefes")
|
||||
letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes")
|
||||
tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalitaet des Briefes")
|
||||
teacher_name: str = Field(..., description="Name des Lehrers")
|
||||
teacher_title: Optional[str] = Field(None, description="Titel des Lehrers")
|
||||
school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen")
|
||||
legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen")
|
||||
gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien")
|
||||
|
||||
|
||||
class LetterUpdateRequest(BaseModel):
|
||||
"""Request zum Aktualisieren eines Briefes."""
|
||||
recipient_name: Optional[str] = None
|
||||
recipient_address: Optional[str] = None
|
||||
student_name: Optional[str] = None
|
||||
student_class: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
letter_type: Optional[LetterType] = None
|
||||
tone: Optional[LetterTone] = None
|
||||
teacher_name: Optional[str] = None
|
||||
teacher_title: Optional[str] = None
|
||||
school_info: Optional[SchoolInfoModel] = None
|
||||
legal_references: Optional[List[LegalReferenceModel]] = None
|
||||
gfk_principles_applied: Optional[List[str]] = None
|
||||
status: Optional[LetterStatus] = None
|
||||
|
||||
|
||||
class LetterResponse(BaseModel):
|
||||
"""Response mit Briefdaten."""
|
||||
id: str
|
||||
recipient_name: str
|
||||
recipient_address: str
|
||||
student_name: str
|
||||
student_class: str
|
||||
subject: str
|
||||
content: str
|
||||
letter_type: LetterType
|
||||
tone: LetterTone
|
||||
teacher_name: str
|
||||
teacher_title: Optional[str]
|
||||
school_info: Optional[SchoolInfoModel]
|
||||
legal_references: Optional[List[LegalReferenceModel]]
|
||||
gfk_principles_applied: Optional[List[str]]
|
||||
gfk_score: Optional[float]
|
||||
status: LetterStatus
|
||||
pdf_path: Optional[str]
|
||||
dsms_cid: Optional[str]
|
||||
sent_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LetterListResponse(BaseModel):
|
||||
"""Response mit Liste von Briefen."""
|
||||
letters: List[LetterResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ExportPDFRequest(BaseModel):
|
||||
"""Request zum PDF-Export."""
|
||||
letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes")
|
||||
letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten")
|
||||
|
||||
|
||||
class ImproveRequest(BaseModel):
|
||||
"""Request zur GFK-Verbesserung."""
|
||||
content: str = Field(..., description="Text zur Verbesserung")
|
||||
communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation")
|
||||
tone: Optional[str] = Field("professional", description="Gewuenschte Tonalitaet")
|
||||
|
||||
|
||||
class ImproveResponse(BaseModel):
|
||||
"""Response mit verbessertem Text."""
|
||||
improved_content: str
|
||||
changes: List[str]
|
||||
gfk_score: float
|
||||
gfk_principles_applied: List[str]
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
"""Request zum Email-Versand."""
|
||||
letter_id: str
|
||||
recipient_email: str
|
||||
cc_emails: Optional[List[str]] = None
|
||||
include_pdf: bool = True
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
"""Response nach Email-Versand."""
|
||||
success: bool
|
||||
message: str
|
||||
sent_at: Optional[datetime]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_type_label(letter_type: LetterType) -> str:
|
||||
"""Gibt menschenlesbare Labels fuer Brieftypen zurueck."""
|
||||
labels = {
|
||||
LetterType.GENERAL: "Allgemeine Information",
|
||||
LetterType.HALBJAHR: "Halbjahresinformation",
|
||||
LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung",
|
||||
LetterType.ELTERNABEND: "Einladung Elternabend",
|
||||
LetterType.LOB: "Positives Feedback",
|
||||
LetterType.CUSTOM: "Benutzerdefiniert",
|
||||
}
|
||||
return labels.get(letter_type, letter_type.value)
|
||||
|
||||
|
||||
def get_tone_label(tone: LetterTone) -> str:
|
||||
"""Gibt menschenlesbare Labels fuer Tonalitaeten zurueck."""
|
||||
labels = {
|
||||
LetterTone.FORMAL: "Sehr foermlich",
|
||||
LetterTone.PROFESSIONAL: "Professionell-freundlich",
|
||||
LetterTone.WARM: "Warmherzig",
|
||||
LetterTone.CONCERNED: "Besorgt",
|
||||
LetterTone.APPRECIATIVE: "Wertschaetzend",
|
||||
}
|
||||
return labels.get(tone, tone.value)
|
||||
@@ -6,6 +6,8 @@ from .inference import InferenceService, get_inference_service
|
||||
from .playbook_service import PlaybookService
|
||||
from .pii_detector import PIIDetector, get_pii_detector, PIIType, RedactionResult
|
||||
from .tool_gateway import ToolGateway, get_tool_gateway, SearchDepth
|
||||
from .communication_service import CommunicationService, get_communication_service
|
||||
from .communication_types import CommunicationType, CommunicationTone, LegalReference, GFKPrinciple
|
||||
|
||||
__all__ = [
|
||||
"InferenceService",
|
||||
@@ -18,4 +20,10 @@ __all__ = [
|
||||
"ToolGateway",
|
||||
"get_tool_gateway",
|
||||
"SearchDepth",
|
||||
"CommunicationService",
|
||||
"get_communication_service",
|
||||
"CommunicationType",
|
||||
"CommunicationTone",
|
||||
"LegalReference",
|
||||
"GFKPrinciple",
|
||||
]
|
||||
|
||||
@@ -1,371 +1,95 @@
|
||||
"""
|
||||
Communication Service - KI-gestützte Lehrer-Eltern-Kommunikation.
|
||||
Communication Service - KI-gestuetzte Lehrer-Eltern-Kommunikation.
|
||||
|
||||
Unterstützt Lehrkräfte bei der Erstellung professioneller, rechtlich fundierter
|
||||
Kommunikation mit Eltern. Basiert auf den Prinzipien der gewaltfreien Kommunikation
|
||||
(GFK nach Marshall Rosenberg) und deutschen Schulgesetzen.
|
||||
Split into:
|
||||
- communication_types.py: Enums, data classes, templates, legal references
|
||||
- communication_service.py (this file): CommunicationService class
|
||||
|
||||
Die rechtlichen Referenzen werden dynamisch aus der Datenbank geladen
|
||||
(edu_search_documents Tabelle), nicht mehr hardcoded.
|
||||
All symbols are re-exported here for backward compatibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum, auto
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Legal Crawler API URL (für dynamische Rechtsinhalte)
|
||||
LEGAL_CRAWLER_API_URL = os.getenv(
|
||||
"LEGAL_CRAWLER_API_URL",
|
||||
"http://localhost:8000/v1/legal-crawler"
|
||||
from .communication_types import (
|
||||
CommunicationType,
|
||||
CommunicationTone,
|
||||
LegalReference,
|
||||
GFKPrinciple,
|
||||
FALLBACK_LEGAL_REFERENCES,
|
||||
GFK_PRINCIPLES,
|
||||
COMMUNICATION_TEMPLATES,
|
||||
fetch_legal_references_from_db,
|
||||
parse_db_references_to_legal_refs,
|
||||
)
|
||||
|
||||
|
||||
class CommunicationType(str, Enum):
|
||||
"""Arten von Eltern-Kommunikation."""
|
||||
GENERAL_INFO = "general_info" # Allgemeine Information
|
||||
BEHAVIOR = "behavior" # Verhalten/Disziplin
|
||||
ACADEMIC = "academic" # Schulleistungen
|
||||
ATTENDANCE = "attendance" # Anwesenheit/Fehlzeiten
|
||||
MEETING_INVITE = "meeting_invite" # Einladung zum Gespräch
|
||||
POSITIVE_FEEDBACK = "positive_feedback" # Positives Feedback
|
||||
CONCERN = "concern" # Bedenken äußern
|
||||
CONFLICT = "conflict" # Konfliktlösung
|
||||
SPECIAL_NEEDS = "special_needs" # Förderbedarf
|
||||
|
||||
|
||||
class CommunicationTone(str, Enum):
|
||||
"""Tonalität der Kommunikation."""
|
||||
FORMAL = "formal" # Sehr förmlich
|
||||
PROFESSIONAL = "professional" # Professionell-freundlich
|
||||
WARM = "warm" # Warmherzig
|
||||
CONCERNED = "concerned" # Besorgt
|
||||
APPRECIATIVE = "appreciative" # Wertschätzend
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegalReference:
|
||||
"""Rechtliche Referenz für Kommunikation."""
|
||||
law: str # z.B. "SchulG NRW"
|
||||
paragraph: str # z.B. "§ 42"
|
||||
title: str # z.B. "Pflichten der Eltern"
|
||||
summary: str # Kurzzusammenfassung
|
||||
relevance: str # Warum relevant für diesen Fall
|
||||
|
||||
|
||||
@dataclass
|
||||
class GFKPrinciple:
|
||||
"""Prinzip der Gewaltfreien Kommunikation."""
|
||||
principle: str # z.B. "Beobachtung"
|
||||
description: str # Erklärung
|
||||
example: str # Beispiel im Kontext
|
||||
|
||||
|
||||
# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer)
|
||||
# Die primäre Quelle sind gecrawlte Dokumente in der edu_search_documents Tabelle
|
||||
FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = {
|
||||
"DEFAULT": {
|
||||
"elternpflichten": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Pflichten der Eltern",
|
||||
summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstützen.",
|
||||
relevance="Grundlage für Kooperationsaufforderungen"
|
||||
),
|
||||
"schulpflicht": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Schulpflicht",
|
||||
summary="Kinder sind schulpflichtig. Eltern sind verantwortlich für regelmäßigen Schulbesuch.",
|
||||
relevance="Bei Fehlzeiten und Anwesenheitsproblemen"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Lädt rechtliche Referenzen aus der Datenbank (via Legal Crawler API).
|
||||
|
||||
Args:
|
||||
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
|
||||
|
||||
Returns:
|
||||
Liste von Rechtsdokumenten mit Paragraphen
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{LEGAL_CRAWLER_API_URL}/references/{state}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("documents", [])
|
||||
else:
|
||||
logger.warning(f"Legal API returned {response.status_code} for state {state}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden rechtlicher Referenzen für {state}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def parse_db_references_to_legal_refs(
|
||||
db_docs: List[Dict[str, Any]],
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Konvertiert DB-Dokumente in LegalReference-Objekte.
|
||||
|
||||
Filtert nach relevanten Paragraphen basierend auf dem Topic.
|
||||
"""
|
||||
references = []
|
||||
|
||||
# Topic zu relevanten Paragraph-Nummern mapping
|
||||
topic_keywords = {
|
||||
"elternpflichten": ["42", "76", "85", "eltern", "pflicht"],
|
||||
"schulpflicht": ["41", "35", "schulpflicht", "pflicht"],
|
||||
"ordnungsmassnahmen": ["53", "ordnung", "erzieh", "maßnahm"],
|
||||
"datenschutz": ["120", "daten", "schutz"],
|
||||
"foerderung": ["2", "förder", "bildung", "auftrag"],
|
||||
}
|
||||
|
||||
keywords = topic_keywords.get(topic, ["eltern"])
|
||||
|
||||
for doc in db_docs:
|
||||
law_name = doc.get("law_name", doc.get("title", "Schulgesetz"))
|
||||
paragraphs = doc.get("paragraphs", [])
|
||||
|
||||
if not paragraphs:
|
||||
# Wenn keine Paragraphen extrahiert, allgemeine Referenz erstellen
|
||||
references.append(LegalReference(
|
||||
law=law_name,
|
||||
paragraph="(siehe Gesetzestext)",
|
||||
title=doc.get("title", "Schulgesetz"),
|
||||
summary=f"Rechtliche Grundlage aus {law_name}",
|
||||
relevance=f"Relevant für {topic}"
|
||||
))
|
||||
continue
|
||||
|
||||
# Relevante Paragraphen finden
|
||||
for para in paragraphs[:10]: # Max 10 Paragraphen prüfen
|
||||
para_nr = para.get("nr", "")
|
||||
para_title = para.get("title", "")
|
||||
|
||||
# Prüfen ob Paragraph relevant ist
|
||||
is_relevant = False
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in para_nr.lower() or keyword.lower() in para_title.lower():
|
||||
is_relevant = True
|
||||
break
|
||||
|
||||
if is_relevant:
|
||||
references.append(LegalReference(
|
||||
law=law_name,
|
||||
paragraph=para_nr,
|
||||
title=para_title[:100],
|
||||
summary=f"{para_title[:150]}",
|
||||
relevance=f"Relevant für {topic}"
|
||||
))
|
||||
|
||||
return references
|
||||
|
||||
# GFK-Prinzipien
|
||||
GFK_PRINCIPLES = [
|
||||
GFKPrinciple(
|
||||
principle="Beobachtung",
|
||||
description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation",
|
||||
example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Gefühle",
|
||||
description="Eigene Gefühle ausdrücken (Ich-Botschaften)",
|
||||
example="'Ich mache mir Sorgen...' statt 'Sie müssen endlich...'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Bedürfnisse",
|
||||
description="Dahinterliegende Bedürfnisse benennen",
|
||||
example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie müssen mehr kontrollieren.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Bitten",
|
||||
description="Konkrete, erfüllbare Bitten formulieren",
|
||||
example="'Wären Sie bereit, täglich die Hausaufgaben zu prüfen?' statt 'Tun Sie endlich etwas!'"
|
||||
),
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
"CommunicationType",
|
||||
"CommunicationTone",
|
||||
"LegalReference",
|
||||
"GFKPrinciple",
|
||||
"CommunicationService",
|
||||
"get_communication_service",
|
||||
"fetch_legal_references_from_db",
|
||||
"parse_db_references_to_legal_refs",
|
||||
]
|
||||
|
||||
|
||||
# Kommunikationsvorlagen
|
||||
COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = {
|
||||
CommunicationType.GENERAL_INFO: {
|
||||
"subject": "Information: {topic}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über folgendes informieren:",
|
||||
"closing": "Bei Fragen stehe ich Ihnen gerne zur Verfügung.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.BEHAVIOR: {
|
||||
"subject": "Gesprächswunsch: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.",
|
||||
"closing": "Ich bin überzeugt, dass wir gemeinsam eine gute Lösung finden können. Ich würde mich über ein Gespräch freuen.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.ACADEMIC: {
|
||||
"subject": "Schulische Entwicklung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über die schulische Entwicklung von {student_name} informieren.",
|
||||
"closing": "Ich würde mich freuen, wenn wir gemeinsam überlegen könnten, wie wir {student_name} optimal unterstützen können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.ATTENDANCE: {
|
||||
"subject": "Fehlzeiten: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezüglich der Anwesenheit von {student_name}.",
|
||||
"closing": "Gemäß {legal_reference} sind regelmäßige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Lösung finden.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.MEETING_INVITE: {
|
||||
"subject": "Einladung zum Elterngespräch",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich würde mich freuen, Sie zu einem persönlichen Gespräch einzuladen.",
|
||||
"closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine für Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.POSITIVE_FEEDBACK: {
|
||||
"subject": "Positive Rückmeldung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu können.",
|
||||
"closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu dürfen.\n\nMit herzlichen Grüßen",
|
||||
},
|
||||
CommunicationType.CONCERN: {
|
||||
"subject": "Gemeinsame Sorge: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen würde.",
|
||||
"closing": "Ich bin überzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Lösung finden werden.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.CONFLICT: {
|
||||
"subject": "Bitte um ein klärendes Gespräch",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte das Gespräch mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.",
|
||||
"closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin überzeugt, dass wir im Dialog eine für alle Seiten gute Lösung finden können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.SPECIAL_NEEDS: {
|
||||
"subject": "Förderung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte mit Ihnen über die individuelle Förderung von {student_name} sprechen.",
|
||||
"closing": "Gemäß dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu fördern. Lassen Sie uns gemeinsam überlegen, wie wir {student_name} bestmöglich unterstützen können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommunicationService:
|
||||
"""
|
||||
Service zur Unterstützung von Lehrer-Eltern-Kommunikation.
|
||||
Service zur Unterstuetzung von Lehrer-Eltern-Kommunikation.
|
||||
|
||||
Generiert professionelle, rechtlich fundierte und empathische Nachrichten
|
||||
basierend auf den Prinzipien der gewaltfreien Kommunikation.
|
||||
|
||||
Rechtliche Referenzen werden dynamisch aus der DB geladen (via Legal Crawler API).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.fallback_references = FALLBACK_LEGAL_REFERENCES
|
||||
self.gfk_principles = GFK_PRINCIPLES
|
||||
self.templates = COMMUNICATION_TEMPLATES
|
||||
# Cache für DB-Referenzen (um wiederholte API-Calls zu vermeiden)
|
||||
self._cached_references: Dict[str, List[LegalReference]] = {}
|
||||
|
||||
async def get_legal_references_async(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
self, state: str, topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Gibt relevante rechtliche Referenzen für ein Bundesland und Thema zurück.
|
||||
Lädt aus DB via Legal Crawler API.
|
||||
|
||||
Args:
|
||||
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
|
||||
topic: Themenbereich (z.B. "elternpflichten", "schulpflicht")
|
||||
|
||||
Returns:
|
||||
Liste relevanter LegalReference-Objekte
|
||||
"""
|
||||
"""Gibt relevante rechtliche Referenzen fuer ein Bundesland und Thema zurueck."""
|
||||
cache_key = f"{state}:{topic}"
|
||||
|
||||
# Cache prüfen
|
||||
if cache_key in self._cached_references:
|
||||
return self._cached_references[cache_key]
|
||||
|
||||
# Aus DB laden
|
||||
db_docs = await fetch_legal_references_from_db(state)
|
||||
|
||||
if db_docs:
|
||||
# DB-Dokumente in LegalReference konvertieren
|
||||
references = parse_db_references_to_legal_refs(db_docs, topic)
|
||||
if references:
|
||||
self._cached_references[cache_key] = references
|
||||
return references
|
||||
|
||||
# Fallback wenn DB leer
|
||||
logger.info(f"Keine DB-Referenzen für {state}/{topic}, nutze Fallback")
|
||||
logger.info(f"Keine DB-Referenzen fuer {state}/{topic}, nutze Fallback")
|
||||
return self._get_fallback_references(state, topic)
|
||||
|
||||
def get_legal_references(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Synchrone Methode für Rückwärtskompatibilität.
|
||||
Nutzt nur Fallback-Referenzen (für non-async Kontexte).
|
||||
|
||||
Für dynamische DB-Referenzen bitte get_legal_references_async() verwenden.
|
||||
"""
|
||||
def get_legal_references(self, state: str, topic: str) -> List[LegalReference]:
|
||||
"""Synchrone Methode fuer Rueckwaertskompatibilitaet."""
|
||||
return self._get_fallback_references(state, topic)
|
||||
|
||||
def _get_fallback_references(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""Gibt Fallback-Referenzen zurück."""
|
||||
def _get_fallback_references(self, state: str, topic: str) -> List[LegalReference]:
|
||||
"""Gibt Fallback-Referenzen zurueck."""
|
||||
state_refs = self.fallback_references.get("DEFAULT", {})
|
||||
|
||||
if topic in state_refs:
|
||||
return [state_refs[topic]]
|
||||
|
||||
return list(state_refs.values())
|
||||
|
||||
def get_gfk_guidance(
|
||||
self,
|
||||
comm_type: CommunicationType
|
||||
) -> List[GFKPrinciple]:
|
||||
"""
|
||||
Gibt GFK-Leitlinien für einen Kommunikationstyp zurück.
|
||||
"""
|
||||
def get_gfk_guidance(self, comm_type: CommunicationType) -> List[GFKPrinciple]:
|
||||
return self.gfk_principles
|
||||
|
||||
def get_template(
|
||||
self,
|
||||
comm_type: CommunicationType
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Gibt die Vorlage für einen Kommunikationstyp zurück.
|
||||
"""
|
||||
def get_template(self, comm_type: CommunicationType) -> Dict[str, str]:
|
||||
return self.templates.get(comm_type, self.templates[CommunicationType.GENERAL_INFO])
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
comm_type: CommunicationType,
|
||||
state: str,
|
||||
tone: CommunicationTone
|
||||
self, comm_type: CommunicationType, state: str, tone: CommunicationTone
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt den System-Prompt für die KI-gestützte Nachrichtengenerierung.
|
||||
|
||||
Args:
|
||||
comm_type: Art der Kommunikation
|
||||
state: Bundesland für rechtliche Referenzen
|
||||
tone: Gewünschte Tonalität
|
||||
|
||||
Returns:
|
||||
System-Prompt für LLM
|
||||
"""
|
||||
# Rechtliche Referenzen sammeln
|
||||
"""Erstellt den System-Prompt fuer die KI-gestuetzte Nachrichtengenerierung."""
|
||||
topic_map = {
|
||||
CommunicationType.ATTENDANCE: "schulpflicht",
|
||||
CommunicationType.BEHAVIOR: "ordnungsmassnahmen",
|
||||
@@ -383,17 +107,16 @@ class CommunicationService:
|
||||
for ref in legal_refs:
|
||||
legal_context += f"- {ref.law} {ref.paragraph} ({ref.title}): {ref.summary}\n"
|
||||
|
||||
# Tonalität beschreiben
|
||||
tone_descriptions = {
|
||||
CommunicationTone.FORMAL: "Verwende eine sehr formelle, sachliche Sprache.",
|
||||
CommunicationTone.PROFESSIONAL: "Verwende eine professionelle, aber freundliche Sprache.",
|
||||
CommunicationTone.WARM: "Verwende eine warmherzige, einladende Sprache.",
|
||||
CommunicationTone.CONCERNED: "Drücke aufrichtige Sorge und Empathie aus.",
|
||||
CommunicationTone.APPRECIATIVE: "Betone Wertschätzung und positives Feedback.",
|
||||
CommunicationTone.CONCERNED: "Druecke aufrichtige Sorge und Empathie aus.",
|
||||
CommunicationTone.APPRECIATIVE: "Betone Wertschaetzung und positives Feedback.",
|
||||
}
|
||||
tone_desc = tone_descriptions.get(tone, tone_descriptions[CommunicationTone.PROFESSIONAL])
|
||||
|
||||
system_prompt = f"""Du bist ein erfahrener Kommunikationsberater für Lehrkräfte im deutschen Schulsystem.
|
||||
return f"""Du bist ein erfahrener Kommunikationsberater fuer Lehrkraefte im deutschen Schulsystem.
|
||||
Deine Aufgabe ist es, professionelle, empathische und rechtlich fundierte Elternbriefe zu verfassen.
|
||||
|
||||
GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg):
|
||||
@@ -401,55 +124,42 @@ GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg):
|
||||
1. BEOBACHTUNG: Beschreibe konkrete Handlungen ohne Bewertung
|
||||
Beispiel: "Ich habe bemerkt, dass..." statt "Das Kind ist..."
|
||||
|
||||
2. GEFÜHLE: Drücke Gefühle als Ich-Botschaften aus
|
||||
Beispiel: "Ich mache mir Sorgen..." statt "Sie müssen..."
|
||||
2. GEFUEHLE: Druecke Gefuehle als Ich-Botschaften aus
|
||||
Beispiel: "Ich mache mir Sorgen..." statt "Sie muessen..."
|
||||
|
||||
3. BEDÜRFNISSE: Benenne dahinterliegende Bedürfnisse
|
||||
3. BEDUERFNISSE: Benenne dahinterliegende Beduerfnisse
|
||||
Beispiel: "Mir ist wichtig, dass..." statt "Sie sollten..."
|
||||
|
||||
4. BITTEN: Formuliere konkrete, erfüllbare Bitten
|
||||
Beispiel: "Wären Sie bereit, ...?" statt "Tun Sie endlich...!"
|
||||
4. BITTEN: Formuliere konkrete, erfuellbare Bitten
|
||||
Beispiel: "Waeren Sie bereit, ...?" statt "Tun Sie endlich...!"
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Immer die Würde aller Beteiligten wahren
|
||||
- Keine Schuldzuweisungen oder Vorwürfe
|
||||
- Lösungsorientiert statt problemfokussiert
|
||||
- Auf Augenhöhe kommunizieren
|
||||
- Immer die Wuerde aller Beteiligten wahren
|
||||
- Keine Schuldzuweisungen oder Vorwuerfe
|
||||
- Loesungsorientiert statt problemfokussiert
|
||||
- Auf Augenhoehe kommunizieren
|
||||
- Kooperation statt Konfrontation
|
||||
- Deutsche Sprache, förmliche Anrede (Sie)
|
||||
- Deutsche Sprache, foermliche Anrede (Sie)
|
||||
- Sachlich, aber empathisch
|
||||
{legal_context}
|
||||
|
||||
TONALITÄT:
|
||||
TONALITAET:
|
||||
{tone_desc}
|
||||
|
||||
FORMAT:
|
||||
- Verfasse den Brief als vollständigen, versandfertigen Text
|
||||
- Verfasse den Brief als vollstaendigen, versandfertigen Text
|
||||
- Beginne mit der Anrede
|
||||
- Strukturiere den Inhalt klar und verständlich
|
||||
- Schließe mit einer freundlichen Grußformel
|
||||
- Die Signatur (Name der Lehrkraft) wird später hinzugefügt
|
||||
- Strukturiere den Inhalt klar und verstaendlich
|
||||
- Schliesse mit einer freundlichen Grussformel
|
||||
- Die Signatur (Name der Lehrkraft) wird spaeter hinzugefuegt
|
||||
|
||||
WICHTIG: Der Brief soll professionell und rechtlich einwandfrei sein, aber gleichzeitig
|
||||
menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit."""
|
||||
|
||||
return system_prompt
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
comm_type: CommunicationType,
|
||||
context: Dict[str, Any]
|
||||
self, comm_type: CommunicationType, context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt den User-Prompt aus dem Kontext.
|
||||
|
||||
Args:
|
||||
comm_type: Art der Kommunikation
|
||||
context: Kontextinformationen (student_name, parent_name, situation, etc.)
|
||||
|
||||
Returns:
|
||||
User-Prompt für LLM
|
||||
"""
|
||||
"""Erstellt den User-Prompt aus dem Kontext."""
|
||||
student_name = context.get("student_name", "das Kind")
|
||||
parent_name = context.get("parent_name", "Frau/Herr")
|
||||
situation = context.get("situation", "")
|
||||
@@ -460,59 +170,48 @@ menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit
|
||||
CommunicationType.BEHAVIOR: "ein Verhalten, das besprochen werden sollte",
|
||||
CommunicationType.ACADEMIC: "die schulische Entwicklung",
|
||||
CommunicationType.ATTENDANCE: "Fehlzeiten oder Anwesenheitsprobleme",
|
||||
CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespräch",
|
||||
CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespraech",
|
||||
CommunicationType.POSITIVE_FEEDBACK: "positives Feedback",
|
||||
CommunicationType.CONCERN: "eine Sorge oder ein Anliegen",
|
||||
CommunicationType.CONFLICT: "eine konflikthafte Situation",
|
||||
CommunicationType.SPECIAL_NEEDS: "Förderbedarf oder besondere Unterstützung",
|
||||
CommunicationType.SPECIAL_NEEDS: "Foerderbedarf oder besondere Unterstuetzung",
|
||||
}
|
||||
type_desc = type_descriptions.get(comm_type, "ein Anliegen")
|
||||
|
||||
user_prompt = f"""Schreibe einen Elternbrief zu folgendem Anlass: {type_desc}
|
||||
|
||||
Schülername: {student_name}
|
||||
Schuelername: {student_name}
|
||||
Elternname: {parent_name}
|
||||
|
||||
Situation:
|
||||
{situation}
|
||||
"""
|
||||
|
||||
if additional_info:
|
||||
user_prompt += f"\nZusätzliche Informationen:\n{additional_info}\n"
|
||||
user_prompt += f"\nZusaetzliche Informationen:\n{additional_info}\n"
|
||||
|
||||
user_prompt += """
|
||||
Bitte verfasse einen professionellen, empathischen Brief nach den GFK-Prinzipien.
|
||||
Der Brief sollte:
|
||||
- Die Situation sachlich beschreiben (Beobachtung)
|
||||
- Verständnis und Sorge ausdrücken (Gefühle)
|
||||
- Das gemeinsame Ziel betonen (Bedürfnisse)
|
||||
- Verstaendnis und Sorge ausdruecken (Gefuehle)
|
||||
- Das gemeinsame Ziel betonen (Beduerfnisse)
|
||||
- Einen konstruktiven Vorschlag machen (Bitte)
|
||||
"""
|
||||
|
||||
return user_prompt
|
||||
|
||||
def validate_communication(self, text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validiert eine generierte Kommunikation auf GFK-Konformität.
|
||||
|
||||
Args:
|
||||
text: Der zu prüfende Text
|
||||
|
||||
Returns:
|
||||
Validierungsergebnis mit Verbesserungsvorschlägen
|
||||
"""
|
||||
"""Validiert eine generierte Kommunikation auf GFK-Konformitaet."""
|
||||
issues = []
|
||||
suggestions = []
|
||||
|
||||
# Prüfe auf problematische Formulierungen
|
||||
problematic_patterns = [
|
||||
("Sie müssen", "Vorschlag: 'Wären Sie bereit, ...' oder 'Ich bitte Sie, ...'"),
|
||||
("Sie sollten", "Vorschlag: 'Ich würde mir wünschen, ...'"),
|
||||
("Sie muessen", "Vorschlag: 'Waeren Sie bereit, ...' oder 'Ich bitte Sie, ...'"),
|
||||
("Sie sollten", "Vorschlag: 'Ich wuerde mir wuenschen, ...'"),
|
||||
("Das Kind ist", "Vorschlag: 'Ich habe beobachtet, dass ...'"),
|
||||
("immer", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
|
||||
("nie", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
|
||||
("faul", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
("unverschämt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
("unverschaemt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
("respektlos", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
]
|
||||
|
||||
@@ -521,15 +220,14 @@ Der Brief sollte:
|
||||
issues.append(f"Problematische Formulierung gefunden: '{pattern}'")
|
||||
suggestions.append(suggestion)
|
||||
|
||||
# Prüfe auf positive Elemente
|
||||
positive_elements = []
|
||||
positive_patterns = [
|
||||
("Ich habe bemerkt", "Gute Beobachtung"),
|
||||
("Ich möchte", "Gute Ich-Botschaft"),
|
||||
("Ich moechte", "Gute Ich-Botschaft"),
|
||||
("gemeinsam", "Gute Kooperationsorientierung"),
|
||||
("wichtig", "Gutes Bedürfnis-Statement"),
|
||||
("freuen", "Positive Tonalität"),
|
||||
("Wären Sie bereit", "Gute Bitte-Formulierung"),
|
||||
("wichtig", "Gutes Beduerfnis-Statement"),
|
||||
("freuen", "Positive Tonalitaet"),
|
||||
("Waeren Sie bereit", "Gute Bitte-Formulierung"),
|
||||
]
|
||||
|
||||
for pattern, feedback in positive_patterns:
|
||||
@@ -545,47 +243,37 @@ Der Brief sollte:
|
||||
}
|
||||
|
||||
def get_all_communication_types(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Kommunikationstypen zurück."""
|
||||
return [
|
||||
{"value": ct.value, "label": self._get_type_label(ct)}
|
||||
for ct in CommunicationType
|
||||
]
|
||||
return [{"value": ct.value, "label": self._get_type_label(ct)} for ct in CommunicationType]
|
||||
|
||||
def _get_type_label(self, ct: CommunicationType) -> str:
|
||||
"""Gibt das deutsche Label für einen Kommunikationstyp zurück."""
|
||||
labels = {
|
||||
CommunicationType.GENERAL_INFO: "Allgemeine Information",
|
||||
CommunicationType.BEHAVIOR: "Verhalten/Disziplin",
|
||||
CommunicationType.ACADEMIC: "Schulleistungen",
|
||||
CommunicationType.ATTENDANCE: "Fehlzeiten",
|
||||
CommunicationType.MEETING_INVITE: "Einladung zum Gespräch",
|
||||
CommunicationType.MEETING_INVITE: "Einladung zum Gespraech",
|
||||
CommunicationType.POSITIVE_FEEDBACK: "Positives Feedback",
|
||||
CommunicationType.CONCERN: "Bedenken äußern",
|
||||
CommunicationType.CONFLICT: "Konfliktlösung",
|
||||
CommunicationType.SPECIAL_NEEDS: "Förderbedarf",
|
||||
CommunicationType.CONCERN: "Bedenken aeussern",
|
||||
CommunicationType.CONFLICT: "Konfliktloesung",
|
||||
CommunicationType.SPECIAL_NEEDS: "Foerderbedarf",
|
||||
}
|
||||
return labels.get(ct, ct.value)
|
||||
|
||||
def get_all_tones(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Tonalitäten zurück."""
|
||||
labels = {
|
||||
CommunicationTone.FORMAL: "Sehr förmlich",
|
||||
CommunicationTone.FORMAL: "Sehr foermlich",
|
||||
CommunicationTone.PROFESSIONAL: "Professionell-freundlich",
|
||||
CommunicationTone.WARM: "Warmherzig",
|
||||
CommunicationTone.CONCERNED: "Besorgt",
|
||||
CommunicationTone.APPRECIATIVE: "Wertschätzend",
|
||||
CommunicationTone.APPRECIATIVE: "Wertschaetzend",
|
||||
}
|
||||
return [
|
||||
{"value": t.value, "label": labels.get(t, t.value)}
|
||||
for t in CommunicationTone
|
||||
]
|
||||
return [{"value": t.value, "label": labels.get(t, t.value)} for t in CommunicationTone]
|
||||
|
||||
def get_states(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Bundesländer zurück."""
|
||||
return [
|
||||
{"value": "NRW", "label": "Nordrhein-Westfalen"},
|
||||
{"value": "BY", "label": "Bayern"},
|
||||
{"value": "BW", "label": "Baden-Württemberg"},
|
||||
{"value": "BW", "label": "Baden-Wuerttemberg"},
|
||||
{"value": "NI", "label": "Niedersachsen"},
|
||||
{"value": "HE", "label": "Hessen"},
|
||||
{"value": "SN", "label": "Sachsen"},
|
||||
@@ -595,19 +283,18 @@ Der Brief sollte:
|
||||
{"value": "BB", "label": "Brandenburg"},
|
||||
{"value": "MV", "label": "Mecklenburg-Vorpommern"},
|
||||
{"value": "ST", "label": "Sachsen-Anhalt"},
|
||||
{"value": "TH", "label": "Thüringen"},
|
||||
{"value": "TH", "label": "Thueringen"},
|
||||
{"value": "HH", "label": "Hamburg"},
|
||||
{"value": "HB", "label": "Bremen"},
|
||||
{"value": "SL", "label": "Saarland"},
|
||||
]
|
||||
|
||||
|
||||
# Singleton-Instanz
|
||||
_communication_service: Optional[CommunicationService] = None
|
||||
|
||||
|
||||
def get_communication_service() -> CommunicationService:
|
||||
"""Gibt die Singleton-Instanz des CommunicationService zurück."""
|
||||
"""Gibt die Singleton-Instanz des CommunicationService zurueck."""
|
||||
global _communication_service
|
||||
if _communication_service is None:
|
||||
_communication_service = CommunicationService()
|
||||
|
||||
209
backend-lehrer/llm_gateway/services/communication_types.py
Normal file
209
backend-lehrer/llm_gateway/services/communication_types.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Communication Types - Enums, data classes, templates, and legal references.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LEGAL_CRAWLER_API_URL = os.getenv(
|
||||
"LEGAL_CRAWLER_API_URL",
|
||||
"http://localhost:8000/v1/legal-crawler"
|
||||
)
|
||||
|
||||
|
||||
class CommunicationType(str, Enum):
|
||||
"""Arten von Eltern-Kommunikation."""
|
||||
GENERAL_INFO = "general_info"
|
||||
BEHAVIOR = "behavior"
|
||||
ACADEMIC = "academic"
|
||||
ATTENDANCE = "attendance"
|
||||
MEETING_INVITE = "meeting_invite"
|
||||
POSITIVE_FEEDBACK = "positive_feedback"
|
||||
CONCERN = "concern"
|
||||
CONFLICT = "conflict"
|
||||
SPECIAL_NEEDS = "special_needs"
|
||||
|
||||
|
||||
class CommunicationTone(str, Enum):
|
||||
"""Tonalitaet der Kommunikation."""
|
||||
FORMAL = "formal"
|
||||
PROFESSIONAL = "professional"
|
||||
WARM = "warm"
|
||||
CONCERNED = "concerned"
|
||||
APPRECIATIVE = "appreciative"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegalReference:
|
||||
"""Rechtliche Referenz fuer Kommunikation."""
|
||||
law: str
|
||||
paragraph: str
|
||||
title: str
|
||||
summary: str
|
||||
relevance: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GFKPrinciple:
|
||||
"""Prinzip der Gewaltfreien Kommunikation."""
|
||||
principle: str
|
||||
description: str
|
||||
example: str
|
||||
|
||||
|
||||
# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer)
|
||||
FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = {
|
||||
"DEFAULT": {
|
||||
"elternpflichten": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Pflichten der Eltern",
|
||||
summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstuetzen.",
|
||||
relevance="Grundlage fuer Kooperationsaufforderungen"
|
||||
),
|
||||
"schulpflicht": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Schulpflicht",
|
||||
summary="Kinder sind schulpflichtig. Eltern sind verantwortlich fuer regelmaessigen Schulbesuch.",
|
||||
relevance="Bei Fehlzeiten und Anwesenheitsproblemen"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
GFK_PRINCIPLES = [
|
||||
GFKPrinciple(
|
||||
principle="Beobachtung",
|
||||
description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation",
|
||||
example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Gefuehle",
|
||||
description="Eigene Gefuehle ausdruecken (Ich-Botschaften)",
|
||||
example="'Ich mache mir Sorgen...' statt 'Sie muessen endlich...'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Beduerfnisse",
|
||||
description="Dahinterliegende Beduerfnisse benennen",
|
||||
example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie muessen mehr kontrollieren.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Bitten",
|
||||
description="Konkrete, erfuellbare Bitten formulieren",
|
||||
example="'Waeren Sie bereit, taeglich die Hausaufgaben zu pruefen?' statt 'Tun Sie endlich etwas!'"
|
||||
),
|
||||
]
|
||||
|
||||
COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = {
|
||||
CommunicationType.GENERAL_INFO: {
|
||||
"subject": "Information: {topic}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte Sie ueber folgendes informieren:",
|
||||
"closing": "Bei Fragen stehe ich Ihnen gerne zur Verfuegung.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.BEHAVIOR: {
|
||||
"subject": "Gespraechswunsch: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.",
|
||||
"closing": "Ich bin ueberzeugt, dass wir gemeinsam eine gute Loesung finden koennen. Ich wuerde mich ueber ein Gespraech freuen.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.ACADEMIC: {
|
||||
"subject": "Schulische Entwicklung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte Sie ueber die schulische Entwicklung von {student_name} informieren.",
|
||||
"closing": "Ich wuerde mich freuen, wenn wir gemeinsam ueberlegen koennten, wie wir {student_name} optimal unterstuetzen koennen.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.ATTENDANCE: {
|
||||
"subject": "Fehlzeiten: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezueglich der Anwesenheit von {student_name}.",
|
||||
"closing": "Gemaess {legal_reference} sind regelmaessige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Loesung finden.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.MEETING_INVITE: {
|
||||
"subject": "Einladung zum Elterngespraech",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wuerde mich freuen, Sie zu einem persoenlichen Gespraech einzuladen.",
|
||||
"closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine fuer Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.POSITIVE_FEEDBACK: {
|
||||
"subject": "Positive Rueckmeldung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu koennen.",
|
||||
"closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu duerfen.\n\nMit herzlichen Gruessen",
|
||||
},
|
||||
CommunicationType.CONCERN: {
|
||||
"subject": "Gemeinsame Sorge: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen wuerde.",
|
||||
"closing": "Ich bin ueberzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Loesung finden werden.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.CONFLICT: {
|
||||
"subject": "Bitte um ein klaerendes Gespraech",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte das Gespraech mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.",
|
||||
"closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin ueberzeugt, dass wir im Dialog eine fuer alle Seiten gute Loesung finden koennen.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
CommunicationType.SPECIAL_NEEDS: {
|
||||
"subject": "Foerderung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich moechte mit Ihnen ueber die individuelle Foerderung von {student_name} sprechen.",
|
||||
"closing": "Gemaess dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu foerdern. Lassen Sie uns gemeinsam ueberlegen, wie wir {student_name} bestmoeglich unterstuetzen koennen.\n\nMit freundlichen Gruessen",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]:
|
||||
"""Laedt rechtliche Referenzen aus der Datenbank (via Legal Crawler API)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(f"{LEGAL_CRAWLER_API_URL}/references/{state}")
|
||||
if response.status_code == 200:
|
||||
return response.json().get("documents", [])
|
||||
else:
|
||||
logger.warning(f"Legal API returned {response.status_code} for state {state}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden rechtlicher Referenzen fuer {state}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def parse_db_references_to_legal_refs(
|
||||
db_docs: List[Dict[str, Any]], topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""Konvertiert DB-Dokumente in LegalReference-Objekte."""
|
||||
references = []
|
||||
topic_keywords = {
|
||||
"elternpflichten": ["42", "76", "85", "eltern", "pflicht"],
|
||||
"schulpflicht": ["41", "35", "schulpflicht", "pflicht"],
|
||||
"ordnungsmassnahmen": ["53", "ordnung", "erzieh", "massnahm"],
|
||||
"datenschutz": ["120", "daten", "schutz"],
|
||||
"foerderung": ["2", "foerder", "bildung", "auftrag"],
|
||||
}
|
||||
keywords = topic_keywords.get(topic, ["eltern"])
|
||||
|
||||
for doc in db_docs:
|
||||
law_name = doc.get("law_name", doc.get("title", "Schulgesetz"))
|
||||
paragraphs = doc.get("paragraphs", [])
|
||||
|
||||
if not paragraphs:
|
||||
references.append(LegalReference(
|
||||
law=law_name, paragraph="(siehe Gesetzestext)",
|
||||
title=doc.get("title", "Schulgesetz"),
|
||||
summary=f"Rechtliche Grundlage aus {law_name}",
|
||||
relevance=f"Relevant fuer {topic}"
|
||||
))
|
||||
continue
|
||||
|
||||
for para in paragraphs[:10]:
|
||||
para_nr = para.get("nr", "")
|
||||
para_title = para.get("title", "")
|
||||
is_relevant = any(
|
||||
kw.lower() in para_nr.lower() or kw.lower() in para_title.lower()
|
||||
for kw in keywords
|
||||
)
|
||||
if is_relevant:
|
||||
references.append(LegalReference(
|
||||
law=law_name, paragraph=para_nr,
|
||||
title=para_title[:100],
|
||||
summary=f"{para_title[:150]}",
|
||||
relevance=f"Relevant fuer {topic}"
|
||||
))
|
||||
|
||||
return references
|
||||
@@ -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")
|
||||
|
||||
300
klausur-service/backend/hybrid_vocab_ocr.py
Normal file
300
klausur-service/backend/hybrid_vocab_ocr.py
Normal file
@@ -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)
|
||||
@@ -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<WizardStep, string> = { file: 'Datei', metadata: 'Metadaten', rights: 'Rechte', encryption: 'Verschluesselung', summary: 'Zusammenfassung' }
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
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<WizardStep>('file')
|
||||
|
||||
// File step
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
// Metadata step
|
||||
const [metadata, setMetadata] = useState<EHMetadata>({
|
||||
title: '',
|
||||
subject: effectiveSubject,
|
||||
niveau: 'eA',
|
||||
year: effectiveYear,
|
||||
aufgaben_nummer: ''
|
||||
})
|
||||
|
||||
// Rights step
|
||||
const [metadata, setMetadata] = useState<EHMetadata>({ 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<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Erwartungshorizont hochladen</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus.
|
||||
Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.
|
||||
</p>
|
||||
|
||||
<div className="eh-file-drop">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileSelect}
|
||||
id="eh-file-input"
|
||||
/>
|
||||
<label htmlFor="eh-file-input" className="eh-file-label">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<span className="eh-file-icon">📄</span>
|
||||
<span className="eh-file-name">{selectedFile.name}</span>
|
||||
<span className="eh-file-size">
|
||||
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="eh-file-icon">📂</span>
|
||||
<span>PDF-Datei hier ablegen oder klicken</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fileError && <p className="eh-error">{fileError}</p>}
|
||||
|
||||
{!encryptionSupported && (
|
||||
<p className="eh-warning">
|
||||
Ihr Browser unterstuetzt keine Verschluesselung.
|
||||
Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metadata':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Metadaten</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Geben Sie Informationen zum Erwartungshorizont ein.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-title">Titel *</label>
|
||||
<input
|
||||
id="eh-title"
|
||||
type="text"
|
||||
value={metadata.title}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-subject">Fach *</label>
|
||||
<select
|
||||
id="eh-subject"
|
||||
value={metadata.subject}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, subject: e.target.value }))}
|
||||
>
|
||||
{SUBJECTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-niveau">Niveau *</label>
|
||||
<select
|
||||
id="eh-niveau"
|
||||
value={metadata.niveau}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, niveau: e.target.value as 'eA' | 'gA' }))}
|
||||
>
|
||||
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
|
||||
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-year">Jahr *</label>
|
||||
<input
|
||||
id="eh-year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2050}
|
||||
value={metadata.year}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
|
||||
<input
|
||||
id="eh-aufgabe"
|
||||
type="text"
|
||||
value={metadata.aufgaben_nummer || ''}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))}
|
||||
placeholder="z.B. 1a, 2.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rights':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Rechte-Bestaetigung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Bitte lesen und bestaetigen Sie die folgenden Bedingungen.
|
||||
</p>
|
||||
|
||||
<div className="eh-rights-box">
|
||||
<pre>{RIGHTS_TEXT}</pre>
|
||||
</div>
|
||||
|
||||
<div className="eh-checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="eh-rights-confirm"
|
||||
checked={rightsConfirmed}
|
||||
onChange={e => setRightsConfirmed(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="eh-rights-confirm">
|
||||
Ich habe die Bedingungen gelesen und stimme ihnen zu.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="eh-info-box">
|
||||
<strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer
|
||||
KI-Training verwendet. Er dient ausschliesslich als Referenz fuer
|
||||
Ihre persoenlichen Korrekturvorschlaege.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'encryption':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Verschluesselung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont.
|
||||
Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase">Passwort *</label>
|
||||
<div className="eh-password-input">
|
||||
<input
|
||||
id="eh-passphrase"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphrase}
|
||||
onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="eh-toggle-password"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? '👁' : '👀'}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>
|
||||
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
|
||||
<input
|
||||
id="eh-passphrase-confirm"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphraseConfirm}
|
||||
onChange={e => setPassphraseConfirm(e.target.value)}
|
||||
placeholder="Passwort wiederholen"
|
||||
/>
|
||||
{passphraseConfirm && passphrase !== passphraseConfirm && (
|
||||
<p className="eh-error">Passwoerter stimmen nicht ueberein</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="eh-warning-box">
|
||||
<strong>Achtung:</strong> Merken Sie sich dieses Passwort gut!
|
||||
Ohne das Passwort kann der Erwartungshorizont nicht fuer
|
||||
Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr
|
||||
Passwort nicht wiederherstellen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'summary':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Pruefen Sie Ihre Eingaben und starten Sie den Upload.
|
||||
</p>
|
||||
|
||||
<div className="eh-summary-table">
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Datei:</span>
|
||||
<span className="eh-summary-value">{selectedFile?.name}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Titel:</span>
|
||||
<span className="eh-summary-value">{metadata.title}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Fach:</span>
|
||||
<span className="eh-summary-value">
|
||||
{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Niveau:</span>
|
||||
<span className="eh-summary-value">{metadata.niveau}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Jahr:</span>
|
||||
<span className="eh-summary-value">{metadata.year}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Verschluesselung:</span>
|
||||
<span className="eh-summary-value">AES-256-GCM</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Rechte bestaetigt:</span>
|
||||
<span className="eh-summary-value">Ja</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="eh-upload-progress">
|
||||
<div
|
||||
className="eh-progress-bar"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="eh-error">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
case 'file': return <FileStep selectedFile={selectedFile} fileError={fileError} encryptionSupported={encryptionSupported} onFileSelect={handleFileSelect} />
|
||||
case 'metadata': return <MetadataStep metadata={metadata} onMetadataChange={setMetadata} />
|
||||
case 'rights': return <RightsStep rightsConfirmed={rightsConfirmed} onRightsConfirmedChange={setRightsConfirmed} />
|
||||
case 'encryption': return <EncryptionStep passphrase={passphrase} passphraseConfirm={passphraseConfirm} showPassphrase={showPassphrase} passphraseStrength={passphraseStrength} onPassphraseChange={setPassphrase} onPassphraseConfirmChange={setPassphraseConfirm} onToggleShow={() => setShowPassphrase(!showPassphrase)} />
|
||||
case 'summary': return <SummaryStep selectedFile={selectedFile} metadata={metadata} uploading={uploading} uploadProgress={uploadProgress} uploadError={uploadError} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="eh-wizard-overlay">
|
||||
<div className="eh-wizard-modal">
|
||||
{/* Header */}
|
||||
<div className="eh-wizard-header">
|
||||
<h2>Erwartungshorizont hochladen</h2>
|
||||
<button className="eh-wizard-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="eh-wizard-progress">
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`eh-progress-step ${
|
||||
index < currentStepIndex ? 'completed' :
|
||||
index === currentStepIndex ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="eh-progress-dot">
|
||||
{index < currentStepIndex ? '\u2713' : index + 1}
|
||||
</div>
|
||||
<div key={step} className={`eh-progress-step ${index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'active' : ''}`}>
|
||||
<div className="eh-progress-dot">{index < currentStepIndex ? '\u2713' : index + 1}</div>
|
||||
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="eh-wizard-content">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="eh-wizard-content">{renderStepContent()}</div>
|
||||
<div className="eh-wizard-footer">
|
||||
<button
|
||||
className="eh-btn eh-btn-secondary"
|
||||
onClick={isFirstStep ? onClose : goBack}
|
||||
disabled={uploading}
|
||||
>
|
||||
{isFirstStep ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
<button className="eh-btn eh-btn-secondary" onClick={isFirstStep ? onClose : goBack} disabled={uploading}>{isFirstStep ? 'Abbrechen' : 'Zurueck'}</button>
|
||||
{isLastStep ? (
|
||||
<button
|
||||
className="eh-btn eh-btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !isStepValid(currentStep)}
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</button>
|
||||
<button className="eh-btn eh-btn-primary" onClick={handleUpload} disabled={uploading || !isStepValid(currentStep)}>{uploading ? 'Wird hochgeladen...' : 'Hochladen'}</button>
|
||||
) : (
|
||||
<button
|
||||
className="eh-btn eh-btn-primary"
|
||||
onClick={goNext}
|
||||
disabled={!isStepValid(currentStep)}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
<button className="eh-btn eh-btn-primary" onClick={goNext} disabled={!isStepValid(currentStep)}>Weiter</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Erwartungshorizont hochladen</h3>
|
||||
<p className="eh-wizard-description">Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.</p>
|
||||
<div className="eh-file-drop">
|
||||
<input type="file" accept=".pdf" onChange={onFileSelect} id="eh-file-input" />
|
||||
<label htmlFor="eh-file-input" className="eh-file-label">
|
||||
{selectedFile ? (<><span className="eh-file-icon">📄</span><span className="eh-file-name">{selectedFile.name}</span><span className="eh-file-size">({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)</span></>) : (<><span className="eh-file-icon">📂</span><span>PDF-Datei hier ablegen oder klicken</span></>)}
|
||||
</label>
|
||||
</div>
|
||||
{fileError && <p className="eh-error">{fileError}</p>}
|
||||
{!encryptionSupported && <p className="eh-warning">Ihr Browser unterstuetzt keine Verschluesselung. Bitte verwenden Sie einen modernen Browser.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Metadata
|
||||
export function MetadataStep({ metadata, onMetadataChange }: {
|
||||
metadata: EHMetadata; onMetadataChange: (metadata: EHMetadata) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Metadaten</h3>
|
||||
<p className="eh-wizard-description">Geben Sie Informationen zum Erwartungshorizont ein.</p>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-title">Titel *</label>
|
||||
<input id="eh-title" type="text" value={metadata.title} onChange={e => onMetadataChange({ ...metadata, title: e.target.value })} placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" />
|
||||
</div>
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-subject">Fach *</label>
|
||||
<select id="eh-subject" value={metadata.subject} onChange={e => onMetadataChange({ ...metadata, subject: e.target.value })}>
|
||||
{SUBJECTS.map(s => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-niveau">Niveau *</label>
|
||||
<select id="eh-niveau" value={metadata.niveau} onChange={e => onMetadataChange({ ...metadata, niveau: e.target.value as 'eA' | 'gA' })}>
|
||||
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
|
||||
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-year">Jahr *</label>
|
||||
<input id="eh-year" type="number" min={2000} max={2050} value={metadata.year} onChange={e => onMetadataChange({ ...metadata, year: parseInt(e.target.value) })} />
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
|
||||
<input id="eh-aufgabe" type="text" value={metadata.aufgaben_nummer || ''} onChange={e => onMetadataChange({ ...metadata, aufgaben_nummer: e.target.value })} placeholder="z.B. 1a, 2.1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: Rights Confirmation
|
||||
export function RightsStep({ rightsConfirmed, onRightsConfirmedChange }: {
|
||||
rightsConfirmed: boolean; onRightsConfirmedChange: (confirmed: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Rechte-Bestaetigung</h3>
|
||||
<p className="eh-wizard-description">Bitte lesen und bestaetigen Sie die folgenden Bedingungen.</p>
|
||||
<div className="eh-rights-box"><pre>{RIGHTS_TEXT}</pre></div>
|
||||
<div className="eh-checkbox-group">
|
||||
<input type="checkbox" id="eh-rights-confirm" checked={rightsConfirmed} onChange={e => onRightsConfirmedChange(e.target.checked)} />
|
||||
<label htmlFor="eh-rights-confirm">Ich habe die Bedingungen gelesen und stimme ihnen zu.</label>
|
||||
</div>
|
||||
<div className="eh-info-box"><strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer KI-Training verwendet. Er dient ausschliesslich als Referenz fuer Ihre persoenlichen Korrekturvorschlaege.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Verschluesselung</h3>
|
||||
<p className="eh-wizard-description">Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.</p>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase">Passwort *</label>
|
||||
<div className="eh-password-input">
|
||||
<input id="eh-passphrase" type={showPassphrase ? 'text' : 'password'} value={passphrase} onChange={e => onPassphraseChange(e.target.value)} placeholder="Mindestens 8 Zeichen" />
|
||||
<button type="button" className="eh-toggle-password" onClick={onToggleShow}>{showPassphrase ? '👁' : '👀'}</button>
|
||||
</div>
|
||||
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}</div>
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
|
||||
<input id="eh-passphrase-confirm" type={showPassphrase ? 'text' : 'password'} value={passphraseConfirm} onChange={e => onPassphraseConfirmChange(e.target.value)} placeholder="Passwort wiederholen" />
|
||||
{passphraseConfirm && passphrase !== passphraseConfirm && <p className="eh-error">Passwoerter stimmen nicht ueberein</p>}
|
||||
</div>
|
||||
<div className="eh-warning-box"><strong>Achtung:</strong> Merken Sie sich dieses Passwort gut! Ohne das Passwort kann der Erwartungshorizont nicht fuer Korrekturvorschlaege verwendet werden.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Summary
|
||||
export function SummaryStep({ selectedFile, metadata, uploading, uploadProgress, uploadError }: {
|
||||
selectedFile: File | null; metadata: EHMetadata; uploading: boolean;
|
||||
uploadProgress: number; uploadError: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p className="eh-wizard-description">Pruefen Sie Ihre Eingaben und starten Sie den Upload.</p>
|
||||
<div className="eh-summary-table">
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Datei:</span><span className="eh-summary-value">{selectedFile?.name}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Titel:</span><span className="eh-summary-value">{metadata.title}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Fach:</span><span className="eh-summary-value">{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Niveau:</span><span className="eh-summary-value">{metadata.niveau}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Jahr:</span><span className="eh-summary-value">{metadata.year}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Verschluesselung:</span><span className="eh-summary-value">AES-256-GCM</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Rechte bestaetigt:</span><span className="eh-summary-value">Ja</span></div>
|
||||
</div>
|
||||
{uploading && (<div className="eh-upload-progress"><div className="eh-progress-bar" style={{ width: `${uploadProgress}%` }} /><span>{uploadProgress}%</span></div>)}
|
||||
{uploadError && <p className="eh-error">{uploadError}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SUBJECTS, RIGHTS_TEXT }
|
||||
export type { EHMetadata }
|
||||
92
klausur-service/frontend/src/services/api-eh-types.ts
Normal file
92
klausur-service/frontend/src/services/api-eh-types.ts
Normal file
@@ -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<string, unknown> | 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[]
|
||||
}
|
||||
98
klausur-service/frontend/src/services/api-eh.ts
Normal file
98
klausur-service/frontend/src/services/api-eh.ts
Normal file
@@ -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<Erwartungshorizont[]> => {
|
||||
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<Erwartungshorizont> => apiCall(`/eh/${id}`),
|
||||
|
||||
uploadEH: async (formData: FormData): Promise<Erwartungshorizont> => {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {}
|
||||
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<EHRAGResult> =>
|
||||
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<EHAuditEntry[]> => {
|
||||
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<EHKeyShare[]> => apiCall(`/eh/${ehId}/shares`),
|
||||
|
||||
revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> =>
|
||||
apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }),
|
||||
|
||||
getSharedWithMe: (): Promise<SharedEHInfo[]> => 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<PendingInvitationInfo[]> => apiCall('/eh/invitations/pending'),
|
||||
|
||||
getSentInvitations: (): Promise<SentInvitationInfo[]> => 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<EHAccessChain> => apiCall(`/eh/${ehId}/access-chain`),
|
||||
}
|
||||
|
||||
export const klausurEHApi = {
|
||||
getLinkedEH: (klausurId: string): Promise<LinkedEHInfo[]> => apiCall(`/klausuren/${klausurId}/linked-eh`),
|
||||
}
|
||||
@@ -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<string, { score: number; annotations: string[] }>
|
||||
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<string, { score: number; annotations: string[] }>;
|
||||
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<string, unknown> | 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<string, unknown> | null; student_count: number;
|
||||
students: StudentKlausur[]; created_at: string; teacher_id: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
thresholds: Record<number, number>; labels: Record<number, string>;
|
||||
criteria: Record<string, { weight: number; label: string }>
|
||||
}
|
||||
|
||||
// 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<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
export async function apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getAuthToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {})
|
||||
}
|
||||
|
||||
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<Klausur[]> =>
|
||||
apiCall('/klausuren'),
|
||||
listKlausuren: (): Promise<Klausur[]> => apiCall('/klausuren'),
|
||||
getKlausur: (id: string): Promise<Klausur> => apiCall(`/klausuren/${id}`),
|
||||
createKlausur: (data: Partial<Klausur>): Promise<Klausur> => apiCall('/klausuren', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateKlausur: (id: string, data: Partial<Klausur>): Promise<Klausur> => apiCall(`/klausuren/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteKlausur: (id: string): Promise<{ success: boolean }> => apiCall(`/klausuren/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getKlausur: (id: string): Promise<Klausur> =>
|
||||
apiCall(`/klausuren/${id}`),
|
||||
listStudents: (klausurId: string): Promise<StudentKlausur[]> => apiCall(`/klausuren/${klausurId}/students`),
|
||||
deleteStudent: (studentId: string): Promise<{ success: boolean }> => apiCall(`/students/${studentId}`, { method: 'DELETE' }),
|
||||
|
||||
createKlausur: (data: Partial<Klausur>): Promise<Klausur> =>
|
||||
apiCall('/klausuren', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
updateCriteria: (studentId: string, criterion: string, score: number, annotations?: string[]): Promise<StudentKlausur> =>
|
||||
apiCall(`/students/${studentId}/criteria`, { method: 'PUT', body: JSON.stringify({ criterion, score, annotations }) }),
|
||||
|
||||
updateKlausur: (id: string, data: Partial<Klausur>): Promise<Klausur> =>
|
||||
apiCall(`/klausuren/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
updateGutachten: (studentId: string, gutachten: { einleitung: string; hauptteil: string; fazit: string; staerken?: string[]; schwaechen?: string[] }): Promise<StudentKlausur> =>
|
||||
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<StudentKlausur> => apiCall(`/students/${studentId}/finalize`, { method: 'POST' }),
|
||||
|
||||
// Students
|
||||
listStudents: (klausurId: string): Promise<StudentKlausur[]> =>
|
||||
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<StudentKlausur> =>
|
||||
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<StudentKlausur> =>
|
||||
apiCall(`/students/${studentId}/gutachten`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(gutachten)
|
||||
}),
|
||||
|
||||
finalizeStudent: (studentId: string): Promise<StudentKlausur> =>
|
||||
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<string, { average: number; min: number; max: number; count: number }>
|
||||
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<string, { average: number; min: number; max: number; count: number }>; 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<Array<{
|
||||
id: string
|
||||
timestamp: string
|
||||
user_id: string
|
||||
action: string
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
field: string | null
|
||||
old_value: string | null
|
||||
new_value: string | null
|
||||
details: Record<string, unknown> | null
|
||||
}>> =>
|
||||
getStudentAuditLog: (studentId: string): Promise<Array<{ id: string; timestamp: string; user_id: string; action: string; entity_type: string; entity_id: string; field: string | null; old_value: string | null; new_value: string | null; details: Record<string, unknown> | null }>> =>
|
||||
apiCall(`/students/${studentId}/audit-log`),
|
||||
|
||||
// Utilities
|
||||
getGradeInfo: (): Promise<GradeInfo> =>
|
||||
apiCall('/grade-info')
|
||||
getGradeInfo: (): Promise<GradeInfo> => apiCall('/grade-info'),
|
||||
}
|
||||
|
||||
// File upload (special handling for multipart)
|
||||
export async function uploadStudentWork(
|
||||
klausurId: string,
|
||||
studentName: string,
|
||||
file: File
|
||||
): Promise<StudentKlausur> {
|
||||
const token = getAuthToken()
|
||||
// ============================================================================
|
||||
// File Upload
|
||||
// ============================================================================
|
||||
|
||||
export async function uploadStudentWork(klausurId: string, studentName: string, file: File): Promise<StudentKlausur> {
|
||||
const token = getAuthToken()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('student_name', studentName)
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
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<string, unknown> | 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<Erwartungshorizont[]> => {
|
||||
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<Erwartungshorizont> =>
|
||||
apiCall(`/eh/${id}`),
|
||||
|
||||
// Upload encrypted EH (special handling for FormData)
|
||||
uploadEH: async (formData: FormData): Promise<Erwartungshorizont> => {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = {}
|
||||
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<EHRAGResult> =>
|
||||
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<EHAuditEntry[]> => {
|
||||
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<EHKeyShare[]> =>
|
||||
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<SharedEHInfo[]> =>
|
||||
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<PendingInvitationInfo[]> =>
|
||||
apiCall('/eh/invitations/pending'),
|
||||
|
||||
// Get sent invitations (as inviter)
|
||||
getSentInvitations: (): Promise<SentInvitationInfo[]> =>
|
||||
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<EHAccessChain> =>
|
||||
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<LinkedEHInfo[]> =>
|
||||
apiCall(`/klausuren/${klausurId}/linked-eh`)
|
||||
}
|
||||
|
||||
113
studio-v2/app/geo-lernwelt/GeoSettings.tsx
Normal file
113
studio-v2/app/geo-lernwelt/GeoSettings.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { AOITheme, AOIQuality, Difficulty, GeoJSONPolygon, AOIResponse } from './types'
|
||||
|
||||
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Theme Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Lernthema</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
|
||||
const config = THEME_CONFIG[theme]
|
||||
return (
|
||||
<button key={theme} onClick={() => onThemeChange(theme)}
|
||||
className={`p-3 rounded-xl text-left transition-all ${selectedTheme === theme ? 'bg-white/15 border border-white/30' : 'bg-white/5 border border-transparent hover:bg-white/10'}`}>
|
||||
<span className="text-2xl">{config.icon}</span>
|
||||
<div className="text-sm text-white mt-1">{config.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
|
||||
<button key={q} onClick={() => onQualityChange(q)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm transition-all ${quality === q ? 'bg-white/15 text-white border border-white/30' : 'bg-white/5 text-white/60 hover:bg-white/10'}`}>
|
||||
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
|
||||
<button key={d} onClick={() => onDifficultyChange(d)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${difficulty === d ? 'bg-white/15 text-white border border-white/30' : 'bg-white/5 text-white/60 hover:bg-white/10'}`}>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area Info */}
|
||||
{drawnPolygon && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
|
||||
<div className="text-sm text-white/60">
|
||||
<p>Polygon gezeichnet ✓</p>
|
||||
<p className="text-white/40 text-xs mt-1">Klicke "Lernwelt erstellen" um fortzufahren</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
<button onClick={onCreateAOI} disabled={!drawnPolygon || isLoading}
|
||||
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${drawnPolygon && !isLoading ? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600' : 'bg-white/10 text-white/40 cursor-not-allowed'}`}>
|
||||
{isLoading ? (<span className="flex items-center justify-center gap-2"><span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />Wird erstellt...</span>) : '🚀 Lernwelt erstellen'}
|
||||
</button>
|
||||
|
||||
{/* AOI Status */}
|
||||
{currentAOI && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Status</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${currentAOI.status === 'completed' ? 'bg-green-500' : currentAOI.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'}`} />
|
||||
<span className="text-sm text-white/80 capitalize">
|
||||
{currentAOI.status === 'queued' ? 'In Warteschlange...' : currentAOI.status === 'processing' ? 'Wird verarbeitet...' : currentAOI.status === 'completed' ? 'Fertig!' : 'Fehlgeschlagen'}
|
||||
</span>
|
||||
</div>
|
||||
{currentAOI.area_km2 > 0 && (<p className="text-xs text-white/50">Flaeche: {currentAOI.area_km2.toFixed(2)} km²</p>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { THEME_CONFIG }
|
||||
@@ -39,15 +39,7 @@ function MapLoadingPlaceholder() {
|
||||
)
|
||||
}
|
||||
|
||||
// Theme icons and colors
|
||||
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
|
||||
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() {
|
||||
</div>
|
||||
|
||||
{/* Settings Panel (1/3) */}
|
||||
<div className="space-y-4">
|
||||
{/* Theme Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Lernthema</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
|
||||
const config = THEME_CONFIG[theme]
|
||||
return (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSelectedTheme(theme)}
|
||||
className={`p-3 rounded-xl text-left transition-all ${
|
||||
selectedTheme === theme
|
||||
? 'bg-white/15 border border-white/30'
|
||||
: 'bg-white/5 border border-transparent hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{config.icon}</span>
|
||||
<div className="text-sm text-white mt-1">{config.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setQuality(q)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm transition-all ${
|
||||
quality === q
|
||||
? 'bg-white/15 text-white border border-white/30'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDifficulty(d)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${
|
||||
difficulty === d
|
||||
? 'bg-white/15 text-white border border-white/30'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area Info */}
|
||||
{drawnPolygon && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
|
||||
<div className="text-sm text-white/60">
|
||||
<p>Polygon gezeichnet ✓</p>
|
||||
<p className="text-white/40 text-xs mt-1">
|
||||
Klicke "Lernwelt erstellen" um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
<button
|
||||
onClick={handleCreateAOI}
|
||||
disabled={!drawnPolygon || isLoading}
|
||||
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${
|
||||
drawnPolygon && !isLoading
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Wird erstellt...
|
||||
</span>
|
||||
) : (
|
||||
'🚀 Lernwelt erstellen'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AOI Status */}
|
||||
{currentAOI && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Status</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
currentAOI.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: currentAOI.status === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-yellow-500 animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-white/80 capitalize">
|
||||
{currentAOI.status === 'queued'
|
||||
? 'In Warteschlange...'
|
||||
: currentAOI.status === 'processing'
|
||||
? 'Wird verarbeitet...'
|
||||
: currentAOI.status === 'completed'
|
||||
? 'Fertig!'
|
||||
: 'Fehlgeschlagen'}
|
||||
</span>
|
||||
</div>
|
||||
{currentAOI.area_km2 > 0 && (
|
||||
<p className="text-xs text-white/50">
|
||||
Flaeche: {currentAOI.area_km2.toFixed(2)} km²
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GeoSettings
|
||||
selectedTheme={selectedTheme}
|
||||
onThemeChange={setSelectedTheme}
|
||||
quality={quality}
|
||||
onQualityChange={setQuality}
|
||||
difficulty={difficulty}
|
||||
onDifficultyChange={setDifficulty}
|
||||
drawnPolygon={drawnPolygon}
|
||||
currentAOI={currentAOI}
|
||||
isLoading={isLoading}
|
||||
onCreateAOI={handleCreateAOI}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Unity 3D Viewer Tab */
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GlassCard onClick={onClick} delay={delay} size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Index/Number */}
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-medium ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{student.anonym_id}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{hasGrade && student.grade_points > 0 && (
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{student.grade_points} P ({getGradeLabel(student.grade_points)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
152
studio-v2/app/korrektur/[klausurId]/_components/UploadModal.tsx
Normal file
152
studio-v2/app/korrektur/[klausurId]/_components/UploadModal.tsx
Normal file
@@ -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<File[]>([])
|
||||
const [anonymIds, setAnonymIds] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-2xl max-h-[80vh] overflow-hidden" size="lg" delay={0}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-white">Arbeiten hochladen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 mb-6"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
<p className="text-white font-medium">Dateien hierher ziehen</p>
|
||||
<p className="text-white/50 text-sm mt-1">oder klicken zum Auswaehlen</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto space-y-2 mb-6">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 rounded-xl bg-white/5">
|
||||
<span className="text-lg">{file.type.startsWith('image/') ? '\uD83D\uDDBC\uFE0F' : '\uD83D\uDCC4'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{file.name}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={anonymIds[index] || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-2 rounded-lg hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading || files.length === 0}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Hochladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{files.length} Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`rounded-3xl p-5 ${className}`}
|
||||
style={{
|
||||
background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HISTOGRAM
|
||||
// =============================================================================
|
||||
|
||||
export function Histogram({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) {
|
||||
const distribution = useMemo(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
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 (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{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 (
|
||||
<div key={grade} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{count || ''}</span>
|
||||
<div className="w-full rounded-t transition-all hover:opacity-80" style={{ height: `${height}%`, minHeight: count > 0 ? '8px' : '0', backgroundColor: color }} title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`} />
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{grade}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className={`text-xs text-center mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Punkte</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CRITERIA HEATMAP
|
||||
// =============================================================================
|
||||
|
||||
export function CriteriaHeatmap({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) {
|
||||
const criteriaAverages = useMemo(() => {
|
||||
const sums: Record<string, { sum: number; count: number }> = {}
|
||||
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<string, number> = {}
|
||||
for (const [criterion, data] of Object.entries(sums)) averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
|
||||
return averages
|
||||
}, [students])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Kriterien-Durchschnitt</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
||||
const average = criteriaAverages[criterion] || 0
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
return (
|
||||
<div key={criterion} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} /><span className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{config.name}</span></div>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{average}%</span>
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}><div className="h-full rounded-full transition-all" style={{ width: `${average}%`, backgroundColor: color }} /></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-3"><svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg></div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Keine Ausreisser erkannt</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Alle Bewertungen sind konsistent</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Ausreisser ({fairness.outliers.length})</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{fairness.outliers.map((outlier) => (
|
||||
<button key={outlier.student_id} onClick={() => onStudentClick(outlier.student_id)} className={`w-full p-3 rounded-xl border transition-colors text-left ${isDark ? 'bg-white/5 border-white/10 hover:bg-white/10' : 'bg-slate-100 border-slate-200 hover:bg-slate-200'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{outlier.anonym_id}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${outlier.deviation > 0 ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{outlier.reason}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div className={`text-center ${className}`}>
|
||||
<div className="relative inline-block" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'} strokeWidth={strokeWidth} />
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round" style={{ transition: 'stroke-dashoffset 1s ease-out' }} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{percentage}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`font-medium mt-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>{label}</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Fairness-Score</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`rounded-3xl p-5 ${className}`}
|
||||
style={{
|
||||
background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<number, number> = {}
|
||||
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 (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{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 (
|
||||
<div
|
||||
key={grade}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{count || ''}</span>
|
||||
<div
|
||||
className="w-full rounded-t transition-all hover:opacity-80"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
minHeight: count > 0 ? '8px' : '0',
|
||||
backgroundColor: color,
|
||||
}}
|
||||
title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{grade}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className={`text-xs text-center mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Punkte</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<string, { sum: number; count: number }> = {}
|
||||
|
||||
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<string, number> = {}
|
||||
for (const [criterion, data] of Object.entries(sums)) {
|
||||
averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
|
||||
}
|
||||
|
||||
return averages
|
||||
}, [students])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Kriterien-Durchschnitt</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
||||
const average = criteriaAverages[criterion] || 0
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
|
||||
return (
|
||||
<div key={criterion} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{config.name}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{average}%</span>
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${average}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Keine Ausreisser erkannt</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Alle Bewertungen sind konsistent</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ausreisser ({fairness.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{fairness.outliers.map((outlier) => (
|
||||
<button
|
||||
key={outlier.student_id}
|
||||
onClick={() => onStudentClick(outlier.student_id)}
|
||||
className={`w-full p-3 rounded-xl border transition-colors text-left ${isDark ? 'bg-white/5 border-white/10 hover:bg-white/10' : 'bg-slate-100 border-slate-200 hover:bg-slate-200'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{outlier.anonym_id}</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
outlier.deviation > 0
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{outlier.reason}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div className={`text-center ${className}`}>
|
||||
<div className="relative inline-block" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{percentage}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`font-medium mt-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>{label}</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Fairness-Score</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [fairness, setFairness] = useState<FairnessAnalysis | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="relative z-10 p-4"><Sidebar /></div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}`)}
|
||||
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<button onClick={() => router.push(`/korrektur/${klausurId}`)} className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Fairness-Analyse</h1>
|
||||
@@ -418,149 +74,49 @@ export default function FairnessPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={korrekturApi.getOverviewExportUrl(klausurId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`px-4 py-2 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
PDF Export
|
||||
<a href={korrekturApi.getOverviewExportUrl(klausurId)} target="_blank" rel="noopener noreferrer" className={`px-4 py-2 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>PDF Export
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
<ThemeToggle /><LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
{error && (<GlassCard className="mb-6" isDark={isDark}><div className="flex items-center gap-3 text-red-400"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span>{error}</span><button onClick={loadData} className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}>Erneut versuchen</button></div></GlassCard>)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (<div className="flex-1 flex items-center justify-center"><div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /></div>)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && fairness && (
|
||||
<>
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<GlassCard delay={100} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.studentCount}</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={150} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Durchschnitt</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{stats?.average.toFixed(1)} P
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={200} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Standardabw.</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{stats?.stdDev.toFixed(2)}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={250} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Spannweite</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.spread} P</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={300} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Ausreisser</p>
|
||||
<p className={`text-2xl font-bold ${stats?.outlierCount ? 'text-amber-400' : 'text-green-400'}`}>
|
||||
{stats?.outlierCount}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={350} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Warnungen</p>
|
||||
<p className={`text-2xl font-bold ${stats?.warningCount ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{stats?.warningCount}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={100} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.studentCount}</p></GlassCard>
|
||||
<GlassCard delay={150} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Durchschnitt</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.average.toFixed(1)} P</p></GlassCard>
|
||||
<GlassCard delay={200} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Standardabw.</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.stdDev.toFixed(2)}</p></GlassCard>
|
||||
<GlassCard delay={250} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Spannweite</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.spread} P</p></GlassCard>
|
||||
<GlassCard delay={300} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Ausreisser</p><p className={`text-2xl font-bold ${stats?.outlierCount ? 'text-amber-400' : 'text-green-400'}`}>{stats?.outlierCount}</p></GlassCard>
|
||||
<GlassCard delay={350} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Warnungen</p><p className={`text-2xl font-bold ${stats?.warningCount ? 'text-red-400' : 'text-green-400'}`}>{stats?.warningCount}</p></GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{fairness.warnings.length > 0 && (
|
||||
<GlassCard className="mb-6" delay={400} isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Warnungen
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{fairness.warnings.map((warning, index) => (
|
||||
<li key={index} className={`text-sm flex items-start gap-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
<span className="text-amber-400 mt-1">-</span>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3 className={`font-semibold mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}><svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>Warnungen</h3>
|
||||
<ul className="space-y-2">{fairness.warnings.map((warning, index) => (<li key={index} className={`text-sm flex items-start gap-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}><span className="text-amber-400 mt-1">-</span>{warning}</li>))}</ul>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Fairness Score */}
|
||||
<GlassCard delay={450} isDark={isDark}>
|
||||
<FairnessScore fairness={fairness} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Histogram */}
|
||||
<GlassCard className="lg:col-span-2" delay={500} isDark={isDark}>
|
||||
<Histogram students={students} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Criteria Heatmap */}
|
||||
<GlassCard delay={550} isDark={isDark}>
|
||||
<CriteriaHeatmap students={students} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Outlier List */}
|
||||
<GlassCard className="lg:col-span-2" delay={600} isDark={isDark}>
|
||||
<OutlierList
|
||||
fairness={fairness}
|
||||
onStudentClick={(studentId) =>
|
||||
router.push(`/korrektur/${klausurId}/${studentId}`)
|
||||
}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</GlassCard>
|
||||
<GlassCard delay={450} isDark={isDark}><FairnessScore fairness={fairness} isDark={isDark} /></GlassCard>
|
||||
<GlassCard className="lg:col-span-2" delay={500} isDark={isDark}><Histogram students={students} isDark={isDark} /></GlassCard>
|
||||
<GlassCard delay={550} isDark={isDark}><CriteriaHeatmap students={students} isDark={isDark} /></GlassCard>
|
||||
<GlassCard className="lg:col-span-2" delay={600} isDark={isDark}><OutlierList fairness={fairness} onStudentClick={(studentId) => router.push(`/korrektur/${klausurId}/${studentId}`)} isDark={isDark} /></GlassCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No Data */}
|
||||
{!isLoading && !fairness && !error && (
|
||||
<GlassCard className="text-center py-12" isDark={isDark}>
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<svg className={`w-8 h-8 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}><svg className={`w-8 h-8 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg></div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Daten verfuegbar</h3>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
|
||||
Die Fairness-Analyse erfordert korrigierte Arbeiten.
|
||||
</p>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Die Fairness-Analyse erfordert korrigierte Arbeiten.</p>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<GlassCard onClick={onClick} delay={delay} size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Index/Number */}
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-medium ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{student.anonym_id}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{hasGrade && student.grade_points > 0 && (
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{student.grade_points} P ({getGradeLabel(student.grade_points)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<File[]>([])
|
||||
const [anonymIds, setAnonymIds] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-2xl max-h-[80vh] overflow-hidden" size="lg" delay={0}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-white">Arbeiten hochladen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 mb-6"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
<p className="text-white font-medium">Dateien hierher ziehen</p>
|
||||
<p className="text-white/50 text-sm mt-1">oder klicken zum Auswaehlen</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto space-y-2 mb-6">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-white/5"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{file.type.startsWith('image/') ? '🖼️' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{file.name}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={anonymIds[index] || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-2 rounded-lg hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading || files.length === 0}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Hochladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{files.length} Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="relative z-10 p-4"><Sidebar /></div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/korrektur')}
|
||||
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<button onClick={() => router.push('/korrektur')} className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{klausur?.title || 'Klausur'}
|
||||
</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
|
||||
{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}
|
||||
</p>
|
||||
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur?.title || 'Klausur'}</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
{!isLoading && klausur && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-8">
|
||||
<GlassCard size="sm" delay={100} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{students.length}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={150} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{completedCount}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Abgeschlossen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={200} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-orange-400">{students.length - completedCount}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Offen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={250} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-400">{progress}%</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fortschritt</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={100} isDark={isDark}><div className="text-center"><p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{students.length}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p></div></GlassCard>
|
||||
<GlassCard size="sm" delay={150} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-green-400">{completedCount}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Abgeschlossen</p></div></GlassCard>
|
||||
<GlassCard size="sm" delay={200} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-orange-400">{students.length - completedCount}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Offen</p></div></GlassCard>
|
||||
<GlassCard size="sm" delay={250} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-purple-400">{progress}%</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fortschritt</p></div></GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{!isLoading && students.length > 0 && (
|
||||
<GlassCard size="sm" className="mb-6" delay={300} isDark={isDark}>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
@@ -446,130 +117,68 @@ export default function KlausurDetailPage() {
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>{completedCount}/{students.length} korrigiert</span>
|
||||
</div>
|
||||
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-green-500 to-emerald-400"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-green-500 to-emerald-400" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button onClick={loadData} className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}>Erneut versuchen</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (<div className="flex-1 flex items-center justify-center"><div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /></div>)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isLoading && (
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<button onClick={() => setShowUploadModal(true)} className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
|
||||
Arbeiten hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<span className="text-xl">📱</span>
|
||||
QR Upload
|
||||
<button onClick={() => setShowQRModal(true)} className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
<span className="text-xl">📱</span>QR Upload
|
||||
</button>
|
||||
{students.length > 0 && (
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}/fairness`)}
|
||||
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<button onClick={() => router.push(`/korrektur/${klausurId}/fairness`)} className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
||||
Fairness-Analyse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Students List */}
|
||||
{!isLoading && students.length === 0 && (
|
||||
<GlassCard className="text-center py-12" delay={350} isDark={isDark}>
|
||||
<div className={`w-20 h-20 mx-auto mb-4 rounded-2xl flex items-center justify-center ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<svg className={`w-10 h-10 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
<svg className={`w-10 h-10 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" /></svg>
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Arbeiten vorhanden</h3>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Laden Sie Schuelerarbeiten hoch, um mit der Korrektur zu beginnen.</p>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
|
||||
>
|
||||
Arbeiten hochladen
|
||||
</button>
|
||||
<button onClick={() => setShowUploadModal(true)} className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all">Arbeiten hochladen</button>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{!isLoading && students.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{students.map((student, index) => (
|
||||
<StudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
index={index}
|
||||
onClick={() => router.push(`/korrektur/${klausurId}/${student.id}`)}
|
||||
delay={350 + index * 30}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StudentCard key={student.id} student={student} index={index} onClick={() => router.push(`/korrektur/${klausurId}/${student.id}`)} delay={350 + index * 30} isDark={isDark} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<UploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
<UploadModal isOpen={showUploadModal} onClose={() => setShowUploadModal(false)} onUpload={handleUpload} isUploading={isUploading} />
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
// Handle mobile uploaded files
|
||||
if (files.length > 0) {
|
||||
// Could auto-process the files here
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<QRCodeUpload sessionId={uploadSessionId} onClose={() => setShowQRModal(false)} onFilesChanged={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string[]>([])
|
||||
@@ -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 (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background */}
|
||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-amber-500 opacity-50' : 'bg-amber-300 opacity-30'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-orange-500 opacity-50' : 'bg-orange-300 opacity-30'
|
||||
}`} style={{ animationDelay: '1s' }} />
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${isDark ? 'bg-amber-500 opacity-50' : 'bg-amber-300 opacity-30'}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${isDark ? 'bg-orange-500 opacity-50' : 'bg-orange-300 opacity-30'}`} style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Logo & Titel */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-3xl shadow-lg">
|
||||
🔔
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-3xl shadow-lg">🔔</div>
|
||||
<div className="text-left">
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Google Alerts einrichten
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Bleiben Sie informiert ueber Bildungsthemen
|
||||
</p>
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Google Alerts einrichten</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Bleiben Sie informiert ueber Bildungsthemen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
s === step
|
||||
? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white scale-110 shadow-lg'
|
||||
: s < step
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40'
|
||||
: 'bg-slate-200 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<div key={s} className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${s === step ? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white scale-110 shadow-lg' : s < step ? (isDark ? 'bg-green-500/30 text-green-300' : 'bg-green-100 text-green-700') : (isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400')}`}>
|
||||
{s < step ? '✓' : s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500"
|
||||
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
<div className="h-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500" style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/80 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Step 1: Themen waehlen */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Welche Themen interessieren Sie?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie Themen, ueber die Sie informiert werden moechten
|
||||
</p>
|
||||
|
||||
{/* Vordefinierte Themen */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
||||
{lehrerThemen.map((topic) => {
|
||||
const isSelected = selectedTopics.includes(topic.name)
|
||||
return (
|
||||
<button
|
||||
key={topic.name}
|
||||
onClick={() => toggleTopic(topic.name)}
|
||||
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 text-left ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{topic.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${
|
||||
isSelected
|
||||
? isDark ? 'text-amber-300' : 'text-amber-700'
|
||||
: isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}>
|
||||
{topic.name}
|
||||
</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{topic.keywords.slice(0, 2).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom Topic */}
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<h4 className={`font-medium mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>📌</span> Eigenes Thema hinzufuegen
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Themenname (z.B. 'Mathematik Didaktik')"
|
||||
value={customTopic.name}
|
||||
onChange={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Stichwoerter (kommagetrennt)"
|
||||
value={customTopic.keywords}
|
||||
onChange={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Google Alerts Anleitung */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Google Alerts einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie
|
||||
</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>
|
||||
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
|
||||
Sie richten einfach eine Weiterleitung ein - wir uebernehmen die
|
||||
Auswertung, Filterung und Zusammenfassung.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
<StepBox step={1} title="Google Alerts oeffnen" isActive>
|
||||
<p className="mb-2">
|
||||
Besuchen Sie <a
|
||||
href="https://www.google.de/alerts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-500 hover:underline font-medium"
|
||||
>
|
||||
google.de/alerts
|
||||
</a> und melden Sie sich mit Ihrem Google-Konto an.
|
||||
</p>
|
||||
</StepBox>
|
||||
|
||||
<StepBox step={2} title="Alerts erstellen">
|
||||
<p>
|
||||
Geben Sie Suchbegriffe ein (z.B. "{selectedTopics[0] || 'Bildungspolitik'}")
|
||||
und erstellen Sie Alerts. Die Alerts werden an Ihre E-Mail-Adresse gesendet.
|
||||
</p>
|
||||
</StepBox>
|
||||
|
||||
<StepBox step={3} title="E-Mail-Weiterleitung einrichten">
|
||||
<p>
|
||||
Im naechsten Schritt richten Sie eine automatische Weiterleitung
|
||||
der Google Alert E-Mails an uns ein. So verarbeiten wir Ihre Alerts
|
||||
automatisch.
|
||||
</p>
|
||||
</StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Tipp: Mehrere Alerts kombinieren" icon="💡" className="mt-6">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: E-Mail Weiterleitung einrichten */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Empfohlene Methode: E-Mail Weiterleitung */}
|
||||
<div className={`p-5 rounded-xl border-2 ${isDark ? 'border-green-500/50 bg-green-500/10' : 'border-green-500 bg-green-50'}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-600">
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Ihre Weiterleitungsadresse:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className={`flex-1 px-3 py-2 rounded-lg text-sm font-mono ${
|
||||
isDark ? 'bg-white/10 text-amber-300' : 'bg-slate-100 text-amber-600'
|
||||
}`}>
|
||||
alerts@breakpilot.de
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText('alerts@breakpilot.de')}
|
||||
className="px-3 py-2 rounded-lg bg-amber-500 text-white text-sm hover:bg-amber-600 transition-all"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
So richten Sie die Weiterleitung in Gmail ein:
|
||||
</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie Gmail → Einstellungen → Filter</li>
|
||||
<li>2. Neuer Filter: Von "googlealerts-noreply@google.com"</li>
|
||||
<li>3. Aktion: Weiterleiten an "alerts@breakpilot.de"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alternative: RSS (mit Warnung) */}
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'border-white/10 bg-white/5' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📡</span>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium mb-1 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Alternativ: RSS-Feed (eingeschraenkt verfuegbar)
|
||||
</h4>
|
||||
<p className={`text-sm mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie RSS noch sehen,
|
||||
koennen Sie die Feed-URL hier eingeben:
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.google.de/alerts/feeds/... (falls verfuegbar)"
|
||||
value={rssFeedUrl}
|
||||
onChange={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
|
||||
⚠️ Die meisten Nutzer sehen keine RSS-Option mehr in Google Alerts.
|
||||
Verwenden Sie in diesem Fall die E-Mail-Weiterleitung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten.
|
||||
Die Demo-Alerts werden weiterhin angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Benachrichtigungs-Einstellungen */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Benachrichtigungen einstellen
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie moechten Sie informiert werden?
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Frequenz */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Wie oft moechten Sie Alerts erhalten?
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={freq.id}
|
||||
onClick={() => setNotificationFrequency(freq.id as any)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center ${
|
||||
notificationFrequency === freq.id
|
||||
? 'border-amber-500 bg-amber-500/20'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl block mb-1">{freq.icon}</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{freq.label}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{freq.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mindest-Wichtigkeit */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Mindest-Wichtigkeit fuer Benachrichtigungen
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={imp.id}
|
||||
onClick={() => setMinImportance(imp.id as AlertImportance)}
|
||||
className={`p-2 rounded-lg border-2 transition-all text-center text-xs ${
|
||||
minImportance === imp.id
|
||||
? `border-${imp.color}-500 bg-${imp.color}-500/20`
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{imp.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zusammenfassung */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ihre Einstellungen
|
||||
</h4>
|
||||
<ul className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>• {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt</li>
|
||||
<li>• Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}</li>
|
||||
<li>• Mindest-Wichtigkeit: {minImportance}</li>
|
||||
{rssFeedUrl && <li>• RSS-Feed verbunden</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10 shadow-xl'}`}>
|
||||
{step === 1 && <Step1TopicSelection selectedTopics={selectedTopics} onToggleTopic={toggleTopic} customTopic={customTopic} onCustomTopicChange={setCustomTopic} isDark={isDark} />}
|
||||
{step === 2 && <Step2Instructions selectedTopics={selectedTopics} isDark={isDark} />}
|
||||
{step === 3 && <Step3Forwarding rssFeedUrl={rssFeedUrl} onRssFeedUrlChange={setRssFeedUrl} isDark={isDark} />}
|
||||
{step === 4 && <Step4Settings notificationFrequency={notificationFrequency} onFrequencyChange={setNotificationFrequency} minImportance={minImportance} onMinImportanceChange={setMinImportance} selectedTopics={selectedTopics} customTopic={customTopic} rssFeedUrl={rssFeedUrl} isDark={isDark} />}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center gap-4 mt-8">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`px-8 py-3 rounded-xl font-medium transition-all ${
|
||||
canProceed()
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white hover:shadow-xl hover:shadow-orange-500/30 hover:scale-105'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{step > 1 && (<button onClick={handleBack} className={`px-6 py-3 rounded-xl font-medium transition-all ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>← Zurueck</button>)}
|
||||
<button onClick={handleNext} disabled={!canProceed()} className={`px-8 py-3 rounded-xl font-medium transition-all ${canProceed() ? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white hover:shadow-xl hover:shadow-orange-500/30 hover:scale-105' : isDark ? 'bg-white/10 text-white/30 cursor-not-allowed' : 'bg-slate-200 text-slate-400 cursor-not-allowed'}`}>
|
||||
{step === totalSteps ? 'Fertig! →' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Option */}
|
||||
{onSkip && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
Ueberspringen (spaeter einrichten)
|
||||
</button>
|
||||
)}
|
||||
{onSkip && (<button onClick={onSkip} className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}>Ueberspringen (spaeter einrichten)</button>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
199
studio-v2/components/alerts-wizard/AlertsWizardSteps.tsx
Normal file
199
studio-v2/components/alerts-wizard/AlertsWizardSteps.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>Welche Themen interessieren Sie?</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Waehlen Sie Themen, ueber die Sie informiert werden moechten</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
||||
{lehrerThemen.map((topic) => {
|
||||
const isSelected = selectedTopics.includes(topic.name)
|
||||
return (
|
||||
<button key={topic.name} onClick={() => onToggleTopic(topic.name)}
|
||||
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 text-left ${isSelected ? 'border-amber-500 bg-amber-500/20 shadow-lg' : isDark ? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20' : 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{topic.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${isSelected ? (isDark ? 'text-amber-300' : 'text-amber-700') : (isDark ? 'text-white' : 'text-slate-900')}`}>{topic.name}</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{topic.keywords.slice(0, 2).join(', ')}</p>
|
||||
</div>
|
||||
{isSelected && (<div className="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center"><svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg></div>)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<h4 className={`font-medium mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}><span>📌</span> Eigenes Thema hinzufuegen</h4>
|
||||
<div className="space-y-3">
|
||||
<input type="text" placeholder="Themenname (z.B. 'Mathematik Didaktik')" value={customTopic.name} onChange={(e) => 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'}`} />
|
||||
<input type="text" placeholder="Stichwoerter (kommagetrennt)" value={customTopic.keywords} onChange={(e) => 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'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 2: Google Alerts Instructions
|
||||
// =============================================================================
|
||||
|
||||
export function Step2Instructions({ selectedTopics, isDark }: { selectedTopics: string[]; isDark: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>Google Alerts einrichten</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto. Sie richten einfach eine Weiterleitung ein - wir uebernehmen die Auswertung, Filterung und Zusammenfassung.</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
<StepBox step={1} title="Google Alerts oeffnen" isActive><p className="mb-2">Besuchen Sie <a href="https://www.google.de/alerts" target="_blank" rel="noopener noreferrer" className="text-amber-500 hover:underline font-medium">google.de/alerts</a> und melden Sie sich mit Ihrem Google-Konto an.</p></StepBox>
|
||||
<StepBox step={2} title="Alerts erstellen"><p>Geben Sie Suchbegriffe ein (z.B. "{selectedTopics[0] || 'Bildungspolitik'}") und erstellen Sie Alerts.</p></StepBox>
|
||||
<StepBox step={3} title="E-Mail-Weiterleitung einrichten"><p>Im naechsten Schritt richten Sie eine automatische Weiterleitung der Google Alert E-Mails an uns ein.</p></StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Tipp: Mehrere Alerts kombinieren" icon="💡" className="mt-6"><p>Sie koennen beliebig viele Google Alerts erstellen. Alle werden per E-Mail an Sie gesendet und durch die Weiterleitung automatisch verarbeitet.</p></TipBox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 3: Email Forwarding
|
||||
// =============================================================================
|
||||
|
||||
export function Step3Forwarding({ rssFeedUrl, onRssFeedUrlChange, isDark }: { rssFeedUrl: string; onRssFeedUrlChange: (url: string) => void; isDark: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>E-Mail Weiterleitung einrichten</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className={`p-5 rounded-xl border-2 ${isDark ? 'border-green-500/50 bg-green-500/10' : 'border-green-500 bg-green-50'}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>E-Mail Weiterleitung</h4>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-600">Empfohlen</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>Ihre Weiterleitungsadresse:</p>
|
||||
<div className="flex gap-2">
|
||||
<code className={`flex-1 px-3 py-2 rounded-lg text-sm font-mono ${isDark ? 'bg-white/10 text-amber-300' : 'bg-slate-100 text-amber-600'}`}>alerts@breakpilot.de</code>
|
||||
<button onClick={() => navigator.clipboard.writeText('alerts@breakpilot.de')} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-sm hover:bg-amber-600 transition-all">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>So richten Sie die Weiterleitung in Gmail ein:</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie Gmail → Einstellungen → Filter</li>
|
||||
<li>2. Neuer Filter: Von "googlealerts-noreply@google.com"</li>
|
||||
<li>3. Aktion: Weiterleiten an "alerts@breakpilot.de"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'border-white/10 bg-white/5' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📡</span>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium mb-1 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>Alternativ: RSS-Feed (eingeschraenkt verfuegbar)</h4>
|
||||
<p className={`text-sm mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Google hat die RSS-Option fuer viele Konten entfernt. Falls verfuegbar:</p>
|
||||
<input type="url" placeholder="https://www.google.de/alerts/feeds/... (falls verfuegbar)" value={rssFeedUrl} onChange={(e) => 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'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>Benachrichtigungen einstellen</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Wie moechten Sie informiert werden?</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>Wie oft moechten Sie Alerts erhalten?</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([{ 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) => (
|
||||
<button key={freq.id} onClick={() => onFrequencyChange(freq.id)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center ${notificationFrequency === freq.id ? 'border-amber-500 bg-amber-500/20' : isDark ? 'border-white/10 bg-white/5 hover:bg-white/10' : 'border-slate-200 bg-white hover:bg-slate-50'}`}>
|
||||
<span className="text-2xl block mb-1">{freq.icon}</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{freq.label}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{freq.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>Mindest-Wichtigkeit fuer Benachrichtigungen</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{([{ id: 'KRITISCH', label: 'Kritisch' }, { id: 'DRINGEND', label: 'Dringend' }, { id: 'WICHTIG', label: 'Wichtig' }, { id: 'PRUEFEN', label: 'Pruefen' }, { id: 'INFO', label: 'Info' }] as const).map((imp) => (
|
||||
<button key={imp.id} onClick={() => onMinImportanceChange(imp.id as AlertImportance)}
|
||||
className={`p-2 rounded-lg border-2 transition-all text-center text-xs ${minImportance === imp.id ? 'border-amber-500 bg-amber-500/20' : isDark ? 'border-white/10 bg-white/5 hover:bg-white/10' : 'border-slate-200 bg-white hover:bg-slate-50'}`}>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{imp.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher.</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Ihre Einstellungen</h4>
|
||||
<ul className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>- {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt</li>
|
||||
<li>- Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}</li>
|
||||
<li>- Mindest-Wichtigkeit: {minImportance}</li>
|
||||
{rssFeedUrl && <li>- RSS-Feed verbunden</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
studio-v2/components/onboarding-wizard/schulformen.ts
Normal file
37
studio-v2/components/onboarding-wizard/schulformen.ts
Normal file
@@ -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: '🌍' },
|
||||
]
|
||||
137
studio-v2/lib/korrektur/api-archiv.ts
Normal file
137
studio-v2/lib/korrektur/api-archiv.ts
Normal file
@@ -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<ArchivSearchResponse> {
|
||||
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<ArchivSearchResponse>(`/api/v1/archiv${queryString ? `?${queryString}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getArchivDocument(docId: string): Promise<ArchivDokument & { text_preview?: string }> {
|
||||
return apiFetch<ArchivDokument & { text_preview?: string }>(`/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<Array<{ id: string; score: number; text: string; year: number; subject: string; niveau: string; task_number?: number; doc_type: string }>> {
|
||||
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<Array<{ label: string; type: string }>> {
|
||||
return apiFetch<Array<{ label: string; type: string }>>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
export async function getArchivStats(): Promise<{
|
||||
total_documents: number; total_chunks: number;
|
||||
by_year: Record<string, number>; by_subject: Record<string, number>; by_niveau: Record<string, number>;
|
||||
}> {
|
||||
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<KorrekturStats> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
139
studio-v2/lib/korrektur/api-core.ts
Normal file
139
studio-v2/lib/korrektur/api-core.ts
Normal file
@@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<Klausur[]> {
|
||||
const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren')
|
||||
return data.klausuren || []
|
||||
}
|
||||
|
||||
export async function getKlausur(id: string): Promise<Klausur> {
|
||||
return apiFetch<Klausur>(`/api/v1/klausuren/${id}`)
|
||||
}
|
||||
|
||||
export async function createKlausur(data: CreateKlausurData): Promise<Klausur> {
|
||||
return apiFetch<Klausur>('/api/v1/klausuren', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
|
||||
export async function deleteKlausur(id: string): Promise<void> {
|
||||
await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Students
|
||||
export async function getStudents(klausurId: string): Promise<StudentWork[]> {
|
||||
const data = await apiFetch<{ students: StudentWork[] }>(`/api/v1/klausuren/${klausurId}/students`)
|
||||
return data.students || []
|
||||
}
|
||||
|
||||
export async function getStudent(studentId: string): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/api/v1/students/${studentId}`)
|
||||
}
|
||||
|
||||
export async function uploadStudentWork(klausurId: string, file: File, anonymId: string): Promise<StudentWork> {
|
||||
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<void> {
|
||||
await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Criteria & Gutachten
|
||||
export async function updateCriteria(studentId: string, criteria: CriteriaScores): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/criteria`, { method: 'PUT', body: JSON.stringify({ criteria_scores: criteria }) })
|
||||
}
|
||||
|
||||
export async function updateGutachten(studentId: string, gutachten: string): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/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<Annotation[]> {
|
||||
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<Annotation> {
|
||||
return apiFetch<Annotation>(`/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<Annotation> {
|
||||
return apiFetch<Annotation>(`/api/v1/annotations/${annotationId}`, { method: 'PUT', body: JSON.stringify(updates) })
|
||||
}
|
||||
|
||||
export async function deleteAnnotation(annotationId: string): Promise<void> {
|
||||
await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// EH/RAG
|
||||
export async function getEHSuggestions(studentId: string, criterion?: string): Promise<EHSuggestion[]> {
|
||||
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<FairnessAnalysis> {
|
||||
return apiFetch<FairnessAnalysis>(`/api/v1/klausuren/${klausurId}/fairness`)
|
||||
}
|
||||
|
||||
export async function getGradeInfo(): Promise<GradeInfo> {
|
||||
return apiFetch<GradeInfo>('/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` }
|
||||
@@ -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<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
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<Klausur[]> {
|
||||
const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren')
|
||||
return data.klausuren || []
|
||||
}
|
||||
|
||||
export async function getKlausur(id: string): Promise<Klausur> {
|
||||
return apiFetch<Klausur>(`/api/v1/klausuren/${id}`)
|
||||
}
|
||||
|
||||
export async function createKlausur(data: CreateKlausurData): Promise<Klausur> {
|
||||
return apiFetch<Klausur>('/api/v1/klausuren', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteKlausur(id: string): Promise<void> {
|
||||
await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STUDENTS API
|
||||
// ============================================================================
|
||||
|
||||
export async function getStudents(klausurId: string): Promise<StudentWork[]> {
|
||||
const data = await apiFetch<{ students: StudentWork[] }>(
|
||||
`/api/v1/klausuren/${klausurId}/students`
|
||||
)
|
||||
return data.students || []
|
||||
}
|
||||
|
||||
export async function getStudent(studentId: string): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/api/v1/students/${studentId}`)
|
||||
}
|
||||
|
||||
export async function uploadStudentWork(
|
||||
klausurId: string,
|
||||
file: File,
|
||||
anonymId: string
|
||||
): Promise<StudentWork> {
|
||||
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<void> {
|
||||
await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRITERIA & GUTACHTEN API
|
||||
// ============================================================================
|
||||
|
||||
export async function updateCriteria(
|
||||
studentId: string,
|
||||
criteria: CriteriaScores
|
||||
): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/criteria`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ criteria_scores: criteria }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateGutachten(
|
||||
studentId: string,
|
||||
gutachten: string
|
||||
): Promise<StudentWork> {
|
||||
return apiFetch<StudentWork>(`/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<Annotation[]> {
|
||||
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<Annotation> {
|
||||
return apiFetch<Annotation>(`/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<Annotation> {
|
||||
return apiFetch<Annotation>(`/api/v1/annotations/${annotationId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAnnotation(annotationId: string): Promise<void> {
|
||||
await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EH/RAG API (500+ NiBiS Dokumente)
|
||||
// ============================================================================
|
||||
|
||||
export async function getEHSuggestions(
|
||||
studentId: string,
|
||||
criterion?: string
|
||||
): Promise<EHSuggestion[]> {
|
||||
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<FairnessAnalysis> {
|
||||
return apiFetch<FairnessAnalysis>(`/api/v1/klausuren/${klausurId}/fairness`)
|
||||
}
|
||||
|
||||
export async function getGradeInfo(): Promise<GradeInfo> {
|
||||
return apiFetch<GradeInfo>('/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<ArchivSearchResponse> {
|
||||
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<ArchivSearchResponse>(endpoint)
|
||||
}
|
||||
|
||||
export async function getArchivDocument(docId: string): Promise<ArchivDokument & { text_preview?: string }> {
|
||||
return apiFetch<ArchivDokument & { text_preview?: string }>(`/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<Array<{
|
||||
id: string
|
||||
score: number
|
||||
text: string
|
||||
year: number
|
||||
subject: string
|
||||
niveau: string
|
||||
task_number?: number
|
||||
doc_type: string
|
||||
}>> {
|
||||
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<Array<{ label: string; type: string }>> {
|
||||
return apiFetch<Array<{ label: string; type: string }>>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
export async function getArchivStats(): Promise<{
|
||||
total_documents: number
|
||||
total_chunks: number
|
||||
by_year: Record<string, number>
|
||||
by_subject: Record<string, number>
|
||||
by_niveau: Record<string, number>
|
||||
}> {
|
||||
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<KorrekturStats> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
397
voice-service/bqas/rag_judge_evaluators.py
Normal file
397
voice-service/bqas/rag_judge_evaluators.py
Normal file
@@ -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,
|
||||
)
|
||||
60
voice-service/bqas/rag_judge_types.py
Normal file
60
voice-service/bqas/rag_judge_types.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
162
voice-service/bqas/runner_golden.py
Normal file
162
voice-service/bqas/runner_golden.py
Normal file
@@ -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}
|
||||
141
voice-service/services/enhanced_orchestrator_session.py
Normal file
141
voice-service/services/enhanced_orchestrator_session.py
Normal file
@@ -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
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
116
website/app/admin/builds/wizard/_components/StepContent.tsx
Normal file
116
website/app/admin/builds/wizard/_components/StepContent.tsx
Normal file
@@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
{platforms.map((platform) => (
|
||||
<div key={platform.name} className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border border-green-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-3xl">{platform.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{platform.name}</h4>
|
||||
<p className="text-sm text-gray-500">{platform.size}</p>
|
||||
</div>
|
||||
<span className="ml-auto px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{platform.status}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{platform.features.map((feature, i) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span> {feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 bg-gray-900 rounded-lg p-6">
|
||||
<h3 className="text-white font-semibold mb-4">Workflow Jobs</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{jobs.map((job, i) => (
|
||||
<div key={job.name} className="flex items-center gap-2">
|
||||
<div className="bg-gray-800 rounded-lg p-3 text-center min-w-[100px]">
|
||||
<span className="text-2xl">{job.icon}</span>
|
||||
<p className="text-white text-sm font-medium mt-1">{job.name}</p>
|
||||
<p className="text-gray-500 text-xs">{job.runner}</p>
|
||||
</div>
|
||||
{i < jobs.length - 1 && (
|
||||
<span className="text-gray-600 text-xl">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b">
|
||||
<h3 className="font-semibold text-gray-800">GitHub Secrets Checkliste</h3>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{secrets.map((secret) => (
|
||||
<li key={secret.name} className="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded">{secret.name}</code>
|
||||
<p className="text-sm text-gray-500 mt-1">{secret.desc}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
secret.required ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{secret.required ? 'Pflicht' : 'Optional'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
website/app/admin/builds/wizard/_components/WizardComponents.tsx
Normal file
123
website/app/admin/builds/wizard/_components/WizardComponents.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isCompleted = index < currentIndex
|
||||
const isClickable = index <= currentIndex + 1
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => isClickable && onStepClick(step.id)}
|
||||
disabled={!isClickable}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-green-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: isClickable
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
|
||||
isActive ? 'bg-white/20' : isCompleted ? 'bg-green-200' : 'bg-gray-200'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
|
||||
<div className="prose prose-sm max-w-none mb-6">
|
||||
{content.split('\n\n').map((paragraph, i) => (
|
||||
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
|
||||
{paragraph.split('**').map((part, j) =>
|
||||
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-green-800 mb-2">Tipps:</h3>
|
||||
<ul className="space-y-1">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-green-700">
|
||||
<span className="text-green-500 mt-0.5">•</span>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({ currentStepIndex }: { currentStepIndex: number }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<span className="text-xs font-semibold text-green-600">
|
||||
Schritt {currentStepIndex + 1} von {STEPS.length}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-green-600">
|
||||
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-green-100">
|
||||
<div
|
||||
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-green-600 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Overview */}
|
||||
<div className="bg-gradient-to-br from-green-500 to-emerald-600 rounded-xl shadow p-6 text-white">
|
||||
<h3 className="font-semibold mb-4">Pipeline Flow</h3>
|
||||
<div className="text-sm space-y-2 font-mono">
|
||||
<div className="bg-white/10 rounded px-2 py-1">Git Push/Tag</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">GitHub Actions</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Unity Build</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Deploy / Upload</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-gray-600"><span className="text-green-600">YAML:</span> ci/build-all-platforms.yml</li>
|
||||
<li className="text-gray-600"><span className="text-green-600">C#:</span> Assets/Editor/BuildScript.cs</li>
|
||||
<li className="text-gray-600"><span className="text-green-600">JSON:</span> Assets/Resources/version.json</li>
|
||||
<li className="text-gray-600"><span className="text-green-600">Plist:</span> ci/ios-export-options.plist</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
website/app/admin/builds/wizard/_components/types.ts
Normal file
205
website/app/admin/builds/wizard/_components/types.ts
Normal file
@@ -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<WizardStep, { title: string; content: string; tips: string[] }> = {
|
||||
'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']
|
||||
},
|
||||
}
|
||||
@@ -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<WizardStep, { title: string; content: string; tips: string[] }> = {
|
||||
'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 (
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isCompleted = index < currentIndex
|
||||
const isClickable = index <= currentIndex + 1
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => isClickable && onStepClick(step.id)}
|
||||
disabled={!isClickable}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-green-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: isClickable
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
|
||||
isActive ? 'bg-white/20' : isCompleted ? 'bg-green-200' : 'bg-gray-200'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
|
||||
<div className="prose prose-sm max-w-none mb-6">
|
||||
{content.split('\n\n').map((paragraph, i) => (
|
||||
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
|
||||
{paragraph.split('**').map((part, j) =>
|
||||
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-green-800 mb-2">Tipps:</h3>
|
||||
<ul className="space-y-1">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-green-700">
|
||||
<span className="text-green-500 mt-0.5">•</span>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
{platforms.map((platform) => (
|
||||
<div key={platform.name} className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border border-green-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-3xl">{platform.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{platform.name}</h4>
|
||||
<p className="text-sm text-gray-500">{platform.size}</p>
|
||||
</div>
|
||||
<span className="ml-auto px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{platform.status}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{platform.features.map((feature, i) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span> {feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 bg-gray-900 rounded-lg p-6">
|
||||
<h3 className="text-white font-semibold mb-4">Workflow Jobs</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{jobs.map((job, i) => (
|
||||
<div key={job.name} className="flex items-center gap-2">
|
||||
<div className="bg-gray-800 rounded-lg p-3 text-center min-w-[100px]">
|
||||
<span className="text-2xl">{job.icon}</span>
|
||||
<p className="text-white text-sm font-medium mt-1">{job.name}</p>
|
||||
<p className="text-gray-500 text-xs">{job.runner}</p>
|
||||
</div>
|
||||
{i < jobs.length - 1 && (
|
||||
<span className="text-gray-600 text-xl">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b">
|
||||
<h3 className="font-semibold text-gray-800">GitHub Secrets Checkliste</h3>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{secrets.map((secret) => (
|
||||
<li key={secret.name} className="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded">{secret.name}</code>
|
||||
<p className="text-sm text-gray-500 mt-1">{secret.desc}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
secret.required ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{secret.required ? 'Pflicht' : 'Optional'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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<WizardStep>('welcome')
|
||||
@@ -541,61 +95,7 @@ export default function BuildPipelineWizardPage() {
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<span className="text-xs font-semibold text-green-600">
|
||||
Schritt {currentStepIndex + 1} von {STEPS.length}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-green-600">
|
||||
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-green-100">
|
||||
<div
|
||||
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-green-600 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Overview */}
|
||||
<div className="bg-gradient-to-br from-green-500 to-emerald-600 rounded-xl shadow p-6 text-white">
|
||||
<h3 className="font-semibold mb-4">Pipeline Flow</h3>
|
||||
<div className="text-sm space-y-2 font-mono">
|
||||
<div className="bg-white/10 rounded px-2 py-1">Git Push/Tag</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">GitHub Actions</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Unity Build</div>
|
||||
<div className="text-center text-green-200">↓</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Deploy / Upload</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-gray-600">
|
||||
<span className="text-green-600">YAML:</span> ci/build-all-platforms.yml
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-green-600">C#:</span> Assets/Editor/BuildScript.cs
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-green-600">JSON:</span> Assets/Resources/version.json
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-green-600">Plist:</span> ci/ios-export-options.plist
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar currentStepIndex={currentStepIndex} />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
|
||||
177
website/app/admin/communication/_components/MeetingsAndRooms.tsx
Normal file
177
website/app/admin/communication/_components/MeetingsAndRooms.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeMeetings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>Keine aktiven Meetings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
||||
<th className="pb-3 pr-4">Meeting</th>
|
||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
||||
<th className="pb-3 pr-4">Gestartet</th>
|
||||
<th className="pb-3">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{activeMeetings.map((meeting, idx) => (
|
||||
<tr key={idx} className="text-sm">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{meeting.participants}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatRoomsAndUsage({
|
||||
recentRooms,
|
||||
stats,
|
||||
}: {
|
||||
recentRooms: RecentRoom[]
|
||||
stats: CommunicationStats | null
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Räume</h3>
|
||||
|
||||
{recentRooms.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-500">
|
||||
<p>Keine aktiven Räume</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Call-Minuten heute</span>
|
||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Chat-Räume</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Nutzer</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="http://localhost:8448/_synapse/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Synapse Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8443"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Jitsi Meet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
website/app/admin/communication/_components/ServiceCards.tsx
Normal file
96
website/app/admin/communication/_components/ServiceCards.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CommunicationStats } from './types'
|
||||
import { getStatusBadge, formatDuration } from './helpers'
|
||||
|
||||
export function MatrixCard({ stats }: { stats: CommunicationStats | null }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
||||
{stats?.matrix.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Benutzer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
||||
<div className="text-xs text-slate-500">Räume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Nachrichten heute</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Diese Woche</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JitsiCard({ stats }: { stats: CommunicationStats | null }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
||||
{stats?.jitsi.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
||||
<div className="text-xs text-slate-500">Live Calls</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
||||
<div className="text-xs text-slate-500">Calls heute</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Ø Dauer</span>
|
||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
website/app/admin/communication/_components/TrafficSection.tsx
Normal file
116
website/app/admin/communication/_components/TrafficSection.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { CommunicationStats } from './types'
|
||||
import {
|
||||
calculateEstimatedTraffic,
|
||||
calculateHourlyEstimate,
|
||||
calculateMonthlyEstimate,
|
||||
getResourceRecommendation,
|
||||
} from './helpers'
|
||||
|
||||
export function TrafficSection({ stats }: { stats: CommunicationStats | null }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic(stats, 'in').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic(stats, 'out').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschätzt/Stunde</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate(stats).toFixed(2)} GB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschätzt/Monat</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate(stats).toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Nachrichten/Min</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Uploads heute</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Größe</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Video Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Bitrate geschätzt</span>
|
||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SysEleven Resource Recommendations */}
|
||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
||||
<div className="text-sm text-emerald-700">
|
||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation(stats)}</strong></p>
|
||||
<p className="mt-1 text-xs text-emerald-600">
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
website/app/admin/communication/_components/helpers.ts
Normal file
91
website/app/admin/communication/_components/helpers.ts
Normal file
@@ -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)'
|
||||
}
|
||||
}
|
||||
64
website/app/admin/communication/_components/types.ts
Normal file
64
website/app/admin/communication/_components/types.ts
Normal file
@@ -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'
|
||||
}
|
||||
@@ -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<CommunicationStats | null>(null)
|
||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 }
|
||||
}
|
||||
@@ -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<CommunicationStats | null>(null)
|
||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AdminLayout title="Kommunikation" description="Matrix & Jitsi Monitoring">
|
||||
{/* Service Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Matrix Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
||||
{stats?.matrix.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Benutzer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
||||
<div className="text-xs text-slate-500">Räume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Nachrichten heute</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Diese Woche</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
||||
{stats?.jitsi.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
||||
<div className="text-xs text-slate-500">Live Calls</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
||||
<div className="text-xs text-slate-500">Calls heute</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Ø Dauer</span>
|
||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MatrixCard stats={stats} />
|
||||
<JitsiCard stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* Traffic & Bandwidth Statistics for SysEleven Planning */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschätzt/Stunde</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschätzt/Monat</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Nachrichten/Min</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Uploads heute</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Größe</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Video Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Bitrate geschätzt</span>
|
||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SysEleven Resource Recommendations */}
|
||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
||||
<div className="text-sm text-emerald-700">
|
||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
|
||||
<p className="mt-1 text-xs text-emerald-600">
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficSection stats={stats} />
|
||||
|
||||
{/* Active Meetings */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
<ActiveMeetingsSection
|
||||
activeMeetings={activeMeetings}
|
||||
loading={loading}
|
||||
onRefresh={fetchStats}
|
||||
/>
|
||||
|
||||
{activeMeetings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>Keine aktiven Meetings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
||||
<th className="pb-3 pr-4">Meeting</th>
|
||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
||||
<th className="pb-3 pr-4">Gestartet</th>
|
||||
<th className="pb-3">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{activeMeetings.map((meeting, idx) => (
|
||||
<tr key={idx} className="text-sm">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{meeting.participants}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Chat Rooms */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Räume</h3>
|
||||
|
||||
{recentRooms.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-500">
|
||||
<p>Keine aktiven Räume</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Call-Minuten heute</span>
|
||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Chat-Räume</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Nutzer</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="http://localhost:8448/_synapse/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Synapse Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8443"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Jitsi Meet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Recent Chat Rooms & Usage */}
|
||||
<ChatRoomsAndUsage recentRooms={recentRooms} stats={stats} />
|
||||
|
||||
{/* Connection Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPE_ICONS[ev.evidence_type] || defaultIcon} />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_STYLES[ev.status] || 'bg-slate-100 text-slate-700'}`}>
|
||||
{ev.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{controlTitle}</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
|
||||
{ev.description && <p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
|
||||
<span>{ev.evidence_type.replace('_', ' ')}</span>
|
||||
<span>{formatFileSize(ev.file_size_bytes)}</span>
|
||||
</div>
|
||||
{ev.artifact_url && (
|
||||
<a href={ev.artifact_url} target="_blank" rel="noopener noreferrer"
|
||||
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate">
|
||||
{ev.artifact_url}
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-slate-400">
|
||||
Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datei hochladen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
|
||||
<select
|
||||
value={newEvidence.control_id}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Control auswaehlen...</option>
|
||||
{controls.map((c) => (
|
||||
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newEvidence.evidence_type}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEvidence.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={newEvidence.description}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedFile && onUpload(selectedFile)}
|
||||
disabled={uploading || !selectedFile}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Hochladen...' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkModalProps {
|
||||
controls: Control[]
|
||||
newEvidence: NewEvidenceData
|
||||
setNewEvidence: (data: NewEvidenceData) => void
|
||||
uploading: boolean
|
||||
onSubmit: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LinkModal({
|
||||
controls,
|
||||
newEvidence,
|
||||
setNewEvidence,
|
||||
uploading,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: LinkModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Link/Quelle hinzufuegen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
|
||||
<select
|
||||
value={newEvidence.control_id}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Control auswaehlen...</option>
|
||||
{controls.map((c) => (
|
||||
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEvidence.title}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
|
||||
placeholder="z.B. GitHub Branch Protection Settings"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newEvidence.artifact_url}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
|
||||
placeholder="https://github.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={newEvidence.description}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Speichern...' : 'Hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
/**
|
||||
* Evidence Management Page
|
||||
*
|
||||
* Features:
|
||||
* - List evidence by control
|
||||
* - File upload
|
||||
* - URL/Link adding
|
||||
* - Evidence status tracking
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, Suspense } from 'react'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import { UploadModal, LinkModal } from './_components/EvidenceModals'
|
||||
import { EvidenceCard } from './_components/EvidenceCard'
|
||||
|
||||
interface Evidence {
|
||||
id: string
|
||||
@@ -34,56 +30,32 @@ interface Evidence {
|
||||
collected_at: string
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
control_id: string
|
||||
title: string
|
||||
}
|
||||
interface Control { id: string; control_id: string; title: string }
|
||||
|
||||
const EVIDENCE_TYPES = [
|
||||
{ value: 'scan_report', label: 'Scan Report', icon: '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' },
|
||||
{ value: 'policy_document', label: 'Policy Dokument', icon: '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' },
|
||||
{ value: 'config_snapshot', label: 'Config Snapshot', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
|
||||
{ value: 'test_result', label: 'Test Ergebnis', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ value: 'screenshot', label: 'Screenshot', icon: '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' },
|
||||
{ value: 'external_link', label: 'Externer Link', icon: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' },
|
||||
{ value: 'manual_upload', label: 'Manueller Upload', icon: '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 EVIDENCE_TYPE_OPTIONS = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
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 EvidencePageContent({ initialControlId }: { initialControlId: string | null }) {
|
||||
const [evidence, setEvidence] = useState<Evidence[]>([])
|
||||
const [controls, setControls] = useState<Control[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterControlId, setFilterControlId] = useState(initialControlId || '')
|
||||
const [filterType, setFilterType] = useState('')
|
||||
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const [newEvidence, setNewEvidence] = useState({
|
||||
control_id: initialControlId || '',
|
||||
evidence_type: 'manual_upload',
|
||||
title: '',
|
||||
description: '',
|
||||
artifact_url: '',
|
||||
})
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [newEvidence, setNewEvidence] = useState({ control_id: initialControlId || '', evidence_type: 'manual_upload', title: '', description: '', artifact_url: '' })
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [filterControlId, filterType])
|
||||
useEffect(() => { loadData() }, [filterControlId, filterType])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
@@ -91,431 +63,98 @@ function EvidencePageContent({ initialControlId }: { initialControlId: string |
|
||||
const params = new URLSearchParams()
|
||||
if (filterControlId) params.append('control_id', filterControlId)
|
||||
if (filterType) params.append('evidence_type', filterType)
|
||||
|
||||
const [evidenceRes, controlsRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/v1/compliance/evidence?${params}`),
|
||||
fetch(`${BACKEND_URL}/api/v1/compliance/controls`),
|
||||
])
|
||||
|
||||
if (evidenceRes.ok) {
|
||||
const data = await evidenceRes.json()
|
||||
setEvidence(data.evidence || [])
|
||||
}
|
||||
if (controlsRes.ok) {
|
||||
const data = await controlsRes.json()
|
||||
setControls(data.controls || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
if (evidenceRes.ok) { const data = await evidenceRes.json(); setEvidence(data.evidence || []) }
|
||||
if (controlsRes.ok) { const data = await controlsRes.json(); setControls(data.controls || []) }
|
||||
} catch (error) { console.error('Failed to load data:', error) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!selectedFile || !newEvidence.control_id || !newEvidence.title) {
|
||||
alert('Bitte alle Pflichtfelder ausfuellen')
|
||||
return
|
||||
}
|
||||
const resetForm = () => { setNewEvidence({ control_id: filterControlId || '', evidence_type: 'manual_upload', title: '', description: '', artifact_url: '' }) }
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (!newEvidence.control_id || !newEvidence.title) { alert('Bitte alle Pflichtfelder ausfuellen'); return }
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
control_id: newEvidence.control_id,
|
||||
evidence_type: newEvidence.evidence_type,
|
||||
title: newEvidence.title,
|
||||
})
|
||||
if (newEvidence.description) {
|
||||
params.append('description', newEvidence.description)
|
||||
}
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence/upload?${params}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setUploadModalOpen(false)
|
||||
resetForm()
|
||||
loadData()
|
||||
} else {
|
||||
const error = await res.text()
|
||||
alert(`Upload fehlgeschlagen: ${error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
alert('Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
const formData = new FormData(); formData.append('file', file)
|
||||
const params = new URLSearchParams({ control_id: newEvidence.control_id, evidence_type: newEvidence.evidence_type, title: newEvidence.title })
|
||||
if (newEvidence.description) params.append('description', newEvidence.description)
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence/upload?${params}`, { method: 'POST', body: formData })
|
||||
if (res.ok) { setUploadModalOpen(false); resetForm(); loadData() } else { alert(`Upload fehlgeschlagen: ${await res.text()}`) }
|
||||
} catch { alert('Upload fehlgeschlagen') } finally { setUploading(false) }
|
||||
}
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) {
|
||||
alert('Bitte alle Pflichtfelder ausfuellen')
|
||||
return
|
||||
}
|
||||
|
||||
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) { alert('Bitte alle Pflichtfelder ausfuellen'); return }
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
control_id: newEvidence.control_id,
|
||||
evidence_type: 'external_link',
|
||||
title: newEvidence.title,
|
||||
description: newEvidence.description,
|
||||
artifact_url: newEvidence.artifact_url,
|
||||
source: 'manual',
|
||||
}),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ control_id: newEvidence.control_id, evidence_type: 'external_link', title: newEvidence.title, description: newEvidence.description, artifact_url: newEvidence.artifact_url, source: 'manual' }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setLinkModalOpen(false)
|
||||
resetForm()
|
||||
loadData()
|
||||
} else {
|
||||
const error = await res.text()
|
||||
alert(`Fehler: ${error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add link:', error)
|
||||
alert('Fehler beim Hinzufuegen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
if (res.ok) { setLinkModalOpen(false); resetForm(); loadData() } else { alert(`Fehler: ${await res.text()}`) }
|
||||
} catch { alert('Fehler beim Hinzufuegen') } finally { setUploading(false) }
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setNewEvidence({
|
||||
control_id: filterControlId || '',
|
||||
evidence_type: 'manual_upload',
|
||||
title: '',
|
||||
description: '',
|
||||
artifact_url: '',
|
||||
})
|
||||
setSelectedFile(null)
|
||||
}
|
||||
|
||||
const 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`
|
||||
}
|
||||
|
||||
const getControlTitle = (controlUuid: string) => {
|
||||
const control = controls.find((c) => c.id === controlUuid)
|
||||
return control?.control_id || controlUuid
|
||||
}
|
||||
const getControlTitle = (controlUuid: string) => controls.find((c) => c.id === controlUuid)?.control_id || controlUuid
|
||||
|
||||
return (
|
||||
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/admin/compliance"
|
||||
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<Link href="/admin/compliance" className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Zurueck
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => { resetForm(); setLinkModalOpen(true) }}
|
||||
className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50"
|
||||
>
|
||||
Link hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { resetForm(); setUploadModalOpen(true) }}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Datei hochladen
|
||||
</button>
|
||||
<button onClick={() => { resetForm(); setLinkModalOpen(true) }} className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50">Link hinzufuegen</button>
|
||||
<button onClick={() => { resetForm(); setUploadModalOpen(true) }} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Datei hochladen</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filterControlId}
|
||||
onChange={(e) => setFilterControlId(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<select value={filterControlId} onChange={(e) => setFilterControlId(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500">
|
||||
<option value="">Alle Controls</option>
|
||||
{controls.map((c) => (
|
||||
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
|
||||
))}
|
||||
{controls.map((c) => <option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500">
|
||||
<option value="">Alle Typen</option>
|
||||
{EVIDENCE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
{EVIDENCE_TYPE_OPTIONS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
|
||||
<span className="text-sm text-slate-500">{evidence.length} Nachweise</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" /></div>
|
||||
) : evidence.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
|
||||
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||
</svg>
|
||||
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" /></svg>
|
||||
<p className="text-slate-500 mb-4">Keine Nachweise gefunden</p>
|
||||
<button
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Ersten Nachweis hinzufuegen
|
||||
</button>
|
||||
<button onClick={() => setUploadModalOpen(true)} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Ersten Nachweis hinzufuegen</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{evidence.map((ev) => (
|
||||
<div key={ev.id} className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPES.find((t) => t.value === ev.evidence_type)?.icon || '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'} />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_STYLES[ev.status] || 'bg-slate-100 text-slate-700'}`}>
|
||||
{ev.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{getControlTitle(ev.control_id)}</span>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
|
||||
{ev.description && (
|
||||
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
|
||||
<span>{ev.evidence_type.replace('_', ' ')}</span>
|
||||
<span>{formatFileSize(ev.file_size_bytes)}</span>
|
||||
</div>
|
||||
|
||||
{ev.artifact_url && (
|
||||
<a
|
||||
href={ev.artifact_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate"
|
||||
>
|
||||
{ev.artifact_url}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs text-slate-400">
|
||||
Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{evidence.map((ev) => <EvidenceCard key={ev.id} evidence={ev} controlTitle={getControlTitle(ev.control_id)} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
{uploadModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datei hochladen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
|
||||
<select
|
||||
value={newEvidence.control_id}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Control auswaehlen...</option>
|
||||
{controls.map((c) => (
|
||||
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newEvidence.evidence_type}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEvidence.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={newEvidence.description}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setUploadModalOpen(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Hochladen...' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link Modal */}
|
||||
{linkModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Link/Quelle hinzufuegen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
|
||||
<select
|
||||
value={newEvidence.control_id}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Control auswaehlen...</option>
|
||||
{controls.map((c) => (
|
||||
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEvidence.title}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
|
||||
placeholder="z.B. GitHub Branch Protection Settings"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newEvidence.artifact_url}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
|
||||
placeholder="https://github.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={newEvidence.description}
|
||||
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setLinkModalOpen(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Speichern...' : 'Hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadModalOpen && <UploadModal controls={controls} newEvidence={newEvidence} setNewEvidence={setNewEvidence} uploading={uploading} onUpload={handleFileUpload} onClose={() => setUploadModalOpen(false)} />}
|
||||
{linkModalOpen && <LinkModal controls={controls} newEvidence={newEvidence} setNewEvidence={setNewEvidence} uploading={uploading} onSubmit={handleLinkSubmit} onClose={() => setLinkModalOpen(false)} />}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function EvidencePageWithParams() {
|
||||
const searchParams = useSearchParams()
|
||||
const initialControlId = searchParams.get('control')
|
||||
return <EvidencePageContent initialControlId={initialControlId} />
|
||||
return <EvidencePageContent initialControlId={searchParams.get('control')} />
|
||||
}
|
||||
|
||||
export default function EvidencePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
}>
|
||||
<Suspense fallback={<AdminLayout title="Evidence Management" description="Nachweise & Artefakte"><div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" /></div></AdminLayout>}>
|
||||
<EvidencePageWithParams />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { TestResult, FullTestResults, EDUCATION_CONTENT } from './types'
|
||||
|
||||
export function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
|
||||
<span className="mr-2">💡</span>
|
||||
Warum ist das wichtig?
|
||||
</h3>
|
||||
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
|
||||
<div className="space-y-2 text-blue-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestResultCard({ result }: { result: TestResult }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 border-green-300 text-green-800',
|
||||
failed: 'bg-red-100 border-red-300 text-red-800',
|
||||
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
passed: '✓',
|
||||
failed: '✗',
|
||||
pending: '○',
|
||||
skipped: '−',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<span className="mr-2">{statusIcons[result.status]}</span>
|
||||
{result.name}
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">{result.description}</p>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Erwartet:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.expected}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Erhalten:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.actual}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
|
||||
Fehler: {result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestSummaryCard({ results }: { results: FullTestResults }) {
|
||||
const passRate = results.total_tests > 0
|
||||
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
|
||||
<div className="text-sm text-green-600">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
|
||||
<div className="text-sm text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 text-center ${
|
||||
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className={`text-3xl font-bold ${
|
||||
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>{passRate}%</div>
|
||||
<div className="text-sm text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Results */}
|
||||
<div className="space-y-4">
|
||||
{results.categories.map((category) => (
|
||||
<div key={category.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{category.display_name}</h4>
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{category.passed}/{category.total} bestanden
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{category.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mt-6 text-sm text-gray-500 text-right">
|
||||
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WizardStep } from './types'
|
||||
|
||||
export function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: step.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
{step.status === 'failed' && <span className="text-xs text-red-600">✗</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
website/app/admin/middleware/test-wizard/_components/types.ts
Normal file
152
website/app/admin/middleware/test-wizard/_components/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
export interface TestResult {
|
||||
name: string
|
||||
description: string
|
||||
expected: string
|
||||
actual: string
|
||||
status: 'passed' | 'failed' | 'pending' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface TestCategoryResult {
|
||||
category: string
|
||||
display_name: string
|
||||
description: string
|
||||
why_important: string
|
||||
tests: TestResult[]
|
||||
passed: number
|
||||
failed: number
|
||||
total: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export interface FullTestResults {
|
||||
timestamp: string
|
||||
categories: TestCategoryResult[]
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_tests: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
category?: string
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export const INITIAL_STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
|
||||
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
|
||||
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
|
||||
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
|
||||
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
|
||||
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Middleware-Test-Wizard',
|
||||
content: [
|
||||
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
|
||||
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
|
||||
'• Warum jede Komponente wichtig ist',
|
||||
'• Welche Angriffe sie verhindert',
|
||||
'• Wie Sie Probleme erkennen und beheben',
|
||||
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
|
||||
],
|
||||
},
|
||||
'request-id': {
|
||||
title: 'Request-ID & Distributed Tracing',
|
||||
content: [
|
||||
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
|
||||
'Request-IDs sind essentiell fuer:',
|
||||
'• Fehlersuche in verteilten Systemen',
|
||||
'• Performance-Analyse',
|
||||
'• Audit-Trails fuer Compliance',
|
||||
],
|
||||
},
|
||||
'security-headers': {
|
||||
title: 'Security Headers - Erste Verteidigungslinie',
|
||||
content: [
|
||||
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
|
||||
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
|
||||
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
|
||||
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
|
||||
'• Strict-Transport-Security - Erzwingt HTTPS',
|
||||
'OWASP empfiehlt diese Headers als Mindeststandard.',
|
||||
],
|
||||
},
|
||||
'rate-limiter': {
|
||||
title: 'Rate Limiting - Schutz vor Ueberflutung',
|
||||
content: [
|
||||
'Ohne Rate Limiting kann ein Angreifer:',
|
||||
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
|
||||
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
|
||||
'• Teure API-Aufrufe missbrauchen',
|
||||
'BreakPilot limitiert:',
|
||||
'• 100 Anfragen/Minute pro IP (allgemein)',
|
||||
'• 20 Anfragen/Minute fuer Auth-Endpoints',
|
||||
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
|
||||
],
|
||||
},
|
||||
'pii-redactor': {
|
||||
title: 'PII Redaktion - DSGVO Pflicht',
|
||||
content: [
|
||||
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
|
||||
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
|
||||
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
|
||||
'• Telefonnummern: Direkter Personenbezug',
|
||||
'Der PII Redactor erkennt automatisch:',
|
||||
'• Email-Adressen → [EMAIL_REDACTED]',
|
||||
'• IP-Adressen → [IP_REDACTED]',
|
||||
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
|
||||
],
|
||||
},
|
||||
'input-gate': {
|
||||
title: 'Input Gate - Der Tuersteher',
|
||||
content: [
|
||||
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
|
||||
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
|
||||
'• Content-Type: Erlaubt nur erwartete Formate',
|
||||
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
|
||||
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
|
||||
],
|
||||
},
|
||||
'cors': {
|
||||
title: 'CORS - Kontrollierte Zugriffe',
|
||||
content: [
|
||||
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
|
||||
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
|
||||
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
|
||||
'BreakPilot erlaubt nur:',
|
||||
'• https://breakpilot.app (Produktion)',
|
||||
'• http://localhost:3000 (Development)',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Anzahl bestandener Tests',
|
||||
'• Fehlgeschlagene Tests mit Details',
|
||||
'• Empfehlungen zur Behebung',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
WizardStep,
|
||||
TestCategoryResult,
|
||||
FullTestResults,
|
||||
BACKEND_URL,
|
||||
INITIAL_STEPS,
|
||||
} from './types'
|
||||
|
||||
export function useTestWizard() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
// Update step status
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
// Update all step statuses
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
// Store category results
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepData,
|
||||
categoryResults,
|
||||
fullResults,
|
||||
isLoading,
|
||||
error,
|
||||
isTestStep,
|
||||
isWelcome,
|
||||
isSummary,
|
||||
runCategoryTest,
|
||||
runAllTests,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
handleStepClick,
|
||||
}
|
||||
}
|
||||
@@ -1,451 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
interface TestResult {
|
||||
name: string
|
||||
description: string
|
||||
expected: string
|
||||
actual: string
|
||||
status: 'passed' | 'failed' | 'pending' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
interface TestCategoryResult {
|
||||
category: string
|
||||
display_name: string
|
||||
description: string
|
||||
why_important: string
|
||||
tests: TestResult[]
|
||||
passed: number
|
||||
failed: number
|
||||
total: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
interface FullTestResults {
|
||||
timestamp: string
|
||||
categories: TestCategoryResult[]
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_tests: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
|
||||
|
||||
interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
category?: string
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
|
||||
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
|
||||
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
|
||||
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
|
||||
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
|
||||
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Middleware-Test-Wizard',
|
||||
content: [
|
||||
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
|
||||
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
|
||||
'• Warum jede Komponente wichtig ist',
|
||||
'• Welche Angriffe sie verhindert',
|
||||
'• Wie Sie Probleme erkennen und beheben',
|
||||
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
|
||||
],
|
||||
},
|
||||
'request-id': {
|
||||
title: 'Request-ID & Distributed Tracing',
|
||||
content: [
|
||||
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
|
||||
'Request-IDs sind essentiell fuer:',
|
||||
'• Fehlersuche in verteilten Systemen',
|
||||
'• Performance-Analyse',
|
||||
'• Audit-Trails fuer Compliance',
|
||||
],
|
||||
},
|
||||
'security-headers': {
|
||||
title: 'Security Headers - Erste Verteidigungslinie',
|
||||
content: [
|
||||
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
|
||||
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
|
||||
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
|
||||
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
|
||||
'• Strict-Transport-Security - Erzwingt HTTPS',
|
||||
'OWASP empfiehlt diese Headers als Mindeststandard.',
|
||||
],
|
||||
},
|
||||
'rate-limiter': {
|
||||
title: 'Rate Limiting - Schutz vor Ueberflutung',
|
||||
content: [
|
||||
'Ohne Rate Limiting kann ein Angreifer:',
|
||||
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
|
||||
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
|
||||
'• Teure API-Aufrufe missbrauchen',
|
||||
'BreakPilot limitiert:',
|
||||
'• 100 Anfragen/Minute pro IP (allgemein)',
|
||||
'• 20 Anfragen/Minute fuer Auth-Endpoints',
|
||||
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
|
||||
],
|
||||
},
|
||||
'pii-redactor': {
|
||||
title: 'PII Redaktion - DSGVO Pflicht',
|
||||
content: [
|
||||
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
|
||||
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
|
||||
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
|
||||
'• Telefonnummern: Direkter Personenbezug',
|
||||
'Der PII Redactor erkennt automatisch:',
|
||||
'• Email-Adressen → [EMAIL_REDACTED]',
|
||||
'• IP-Adressen → [IP_REDACTED]',
|
||||
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
|
||||
],
|
||||
},
|
||||
'input-gate': {
|
||||
title: 'Input Gate - Der Tuersteher',
|
||||
content: [
|
||||
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
|
||||
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
|
||||
'• Content-Type: Erlaubt nur erwartete Formate',
|
||||
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
|
||||
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
|
||||
],
|
||||
},
|
||||
'cors': {
|
||||
title: 'CORS - Kontrollierte Zugriffe',
|
||||
content: [
|
||||
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
|
||||
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
|
||||
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
|
||||
'BreakPilot erlaubt nur:',
|
||||
'• https://breakpilot.app (Produktion)',
|
||||
'• http://localhost:3000 (Development)',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Anzahl bestandener Tests',
|
||||
'• Fehlgeschlagene Tests mit Details',
|
||||
'• Empfehlungen zur Behebung',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Components
|
||||
// ==============================================
|
||||
|
||||
function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: step.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
{step.status === 'failed' && <span className="text-xs text-red-600">✗</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
|
||||
<span className="mr-2">💡</span>
|
||||
Warum ist das wichtig?
|
||||
</h3>
|
||||
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
|
||||
<div className="space-y-2 text-blue-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestResultCard({ result }: { result: TestResult }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 border-green-300 text-green-800',
|
||||
failed: 'bg-red-100 border-red-300 text-red-800',
|
||||
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
passed: '✓',
|
||||
failed: '✗',
|
||||
pending: '○',
|
||||
skipped: '−',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<span className="mr-2">{statusIcons[result.status]}</span>
|
||||
{result.name}
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">{result.description}</p>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Erwartet:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.expected}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Erhalten:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.actual}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
|
||||
Fehler: {result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestSummaryCard({ results }: { results: FullTestResults }) {
|
||||
const passRate = results.total_tests > 0
|
||||
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
|
||||
<div className="text-sm text-green-600">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
|
||||
<div className="text-sm text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 text-center ${
|
||||
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className={`text-3xl font-bold ${
|
||||
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>{passRate}%</div>
|
||||
<div className="text-sm text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Results */}
|
||||
<div className="space-y-4">
|
||||
{results.categories.map((category) => (
|
||||
<div key={category.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{category.display_name}</h4>
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{category.passed}/{category.total} bestanden
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{category.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mt-6 text-sm text-gray-500 text-right">
|
||||
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
import { useTestWizard } from './_components/useTestWizard'
|
||||
import { WizardStepper } from './_components/WizardStepper'
|
||||
import { EducationCard, TestResultCard, TestSummaryCard } from './_components/TestCards'
|
||||
|
||||
export default function TestWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
// Update step status
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
// Update all step statuses
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
// Store category results
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
// Allow clicking on completed steps or the next available step
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
const {
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepData,
|
||||
categoryResults,
|
||||
fullResults,
|
||||
isLoading,
|
||||
error,
|
||||
isTestStep,
|
||||
isWelcome,
|
||||
isSummary,
|
||||
runCategoryTest,
|
||||
runAllTests,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
handleStepClick,
|
||||
} = useTestWizard()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
|
||||
131
website/app/admin/middleware/wizard/_components/TestCards.tsx
Normal file
131
website/app/admin/middleware/wizard/_components/TestCards.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { TestResult, FullTestResults, EDUCATION_CONTENT } from './types'
|
||||
|
||||
export function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
|
||||
<span className="mr-2">💡</span>
|
||||
Warum ist das wichtig?
|
||||
</h3>
|
||||
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
|
||||
<div className="space-y-2 text-blue-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestResultCard({ result }: { result: TestResult }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 border-green-300 text-green-800',
|
||||
failed: 'bg-red-100 border-red-300 text-red-800',
|
||||
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
passed: '✓',
|
||||
failed: '✗',
|
||||
pending: '○',
|
||||
skipped: '−',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<span className="mr-2">{statusIcons[result.status]}</span>
|
||||
{result.name}
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">{result.description}</p>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Erwartet:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.expected}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Erhalten:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.actual}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
|
||||
Fehler: {result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestSummaryCard({ results }: { results: FullTestResults }) {
|
||||
const passRate = results.total_tests > 0
|
||||
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
|
||||
<div className="text-sm text-green-600">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
|
||||
<div className="text-sm text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 text-center ${
|
||||
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className={`text-3xl font-bold ${
|
||||
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>{passRate}%</div>
|
||||
<div className="text-sm text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Results */}
|
||||
<div className="space-y-4">
|
||||
{results.categories.map((category) => (
|
||||
<div key={category.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{category.display_name}</h4>
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{category.passed}/{category.total} bestanden
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{category.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mt-6 text-sm text-gray-500 text-right">
|
||||
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WizardStep } from './types'
|
||||
|
||||
export function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: step.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
{step.status === 'failed' && <span className="text-xs text-red-600">✗</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
website/app/admin/middleware/wizard/_components/types.ts
Normal file
152
website/app/admin/middleware/wizard/_components/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
export interface TestResult {
|
||||
name: string
|
||||
description: string
|
||||
expected: string
|
||||
actual: string
|
||||
status: 'passed' | 'failed' | 'pending' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface TestCategoryResult {
|
||||
category: string
|
||||
display_name: string
|
||||
description: string
|
||||
why_important: string
|
||||
tests: TestResult[]
|
||||
passed: number
|
||||
failed: number
|
||||
total: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export interface FullTestResults {
|
||||
timestamp: string
|
||||
categories: TestCategoryResult[]
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_tests: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
category?: string
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export const INITIAL_STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
|
||||
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
|
||||
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
|
||||
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
|
||||
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
|
||||
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Middleware-Test-Wizard',
|
||||
content: [
|
||||
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
|
||||
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
|
||||
'• Warum jede Komponente wichtig ist',
|
||||
'• Welche Angriffe sie verhindert',
|
||||
'• Wie Sie Probleme erkennen und beheben',
|
||||
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
|
||||
],
|
||||
},
|
||||
'request-id': {
|
||||
title: 'Request-ID & Distributed Tracing',
|
||||
content: [
|
||||
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
|
||||
'Request-IDs sind essentiell fuer:',
|
||||
'• Fehlersuche in verteilten Systemen',
|
||||
'• Performance-Analyse',
|
||||
'• Audit-Trails fuer Compliance',
|
||||
],
|
||||
},
|
||||
'security-headers': {
|
||||
title: 'Security Headers - Erste Verteidigungslinie',
|
||||
content: [
|
||||
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
|
||||
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
|
||||
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
|
||||
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
|
||||
'• Strict-Transport-Security - Erzwingt HTTPS',
|
||||
'OWASP empfiehlt diese Headers als Mindeststandard.',
|
||||
],
|
||||
},
|
||||
'rate-limiter': {
|
||||
title: 'Rate Limiting - Schutz vor Ueberflutung',
|
||||
content: [
|
||||
'Ohne Rate Limiting kann ein Angreifer:',
|
||||
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
|
||||
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
|
||||
'• Teure API-Aufrufe missbrauchen',
|
||||
'BreakPilot limitiert:',
|
||||
'• 100 Anfragen/Minute pro IP (allgemein)',
|
||||
'• 20 Anfragen/Minute fuer Auth-Endpoints',
|
||||
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
|
||||
],
|
||||
},
|
||||
'pii-redactor': {
|
||||
title: 'PII Redaktion - DSGVO Pflicht',
|
||||
content: [
|
||||
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
|
||||
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
|
||||
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
|
||||
'• Telefonnummern: Direkter Personenbezug',
|
||||
'Der PII Redactor erkennt automatisch:',
|
||||
'• Email-Adressen → [EMAIL_REDACTED]',
|
||||
'• IP-Adressen → [IP_REDACTED]',
|
||||
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
|
||||
],
|
||||
},
|
||||
'input-gate': {
|
||||
title: 'Input Gate - Der Tuersteher',
|
||||
content: [
|
||||
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
|
||||
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
|
||||
'• Content-Type: Erlaubt nur erwartete Formate',
|
||||
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
|
||||
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
|
||||
],
|
||||
},
|
||||
'cors': {
|
||||
title: 'CORS - Kontrollierte Zugriffe',
|
||||
content: [
|
||||
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
|
||||
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
|
||||
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
|
||||
'BreakPilot erlaubt nur:',
|
||||
'• https://breakpilot.app (Produktion)',
|
||||
'• http://localhost:3000 (Development)',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Anzahl bestandener Tests',
|
||||
'• Fehlgeschlagene Tests mit Details',
|
||||
'• Empfehlungen zur Behebung',
|
||||
],
|
||||
},
|
||||
}
|
||||
140
website/app/admin/middleware/wizard/_components/useTestWizard.ts
Normal file
140
website/app/admin/middleware/wizard/_components/useTestWizard.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
WizardStep,
|
||||
TestCategoryResult,
|
||||
FullTestResults,
|
||||
BACKEND_URL,
|
||||
INITIAL_STEPS,
|
||||
} from './types'
|
||||
|
||||
export function useTestWizard() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
// Update step status
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
// Update all step statuses
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
// Store category results
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepData,
|
||||
categoryResults,
|
||||
fullResults,
|
||||
isLoading,
|
||||
error,
|
||||
isTestStep,
|
||||
isWelcome,
|
||||
isSummary,
|
||||
runCategoryTest,
|
||||
runAllTests,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
handleStepClick,
|
||||
}
|
||||
}
|
||||
@@ -1,451 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
interface TestResult {
|
||||
name: string
|
||||
description: string
|
||||
expected: string
|
||||
actual: string
|
||||
status: 'passed' | 'failed' | 'pending' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
interface TestCategoryResult {
|
||||
category: string
|
||||
display_name: string
|
||||
description: string
|
||||
why_important: string
|
||||
tests: TestResult[]
|
||||
passed: number
|
||||
failed: number
|
||||
total: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
interface FullTestResults {
|
||||
timestamp: string
|
||||
categories: TestCategoryResult[]
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_tests: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
|
||||
|
||||
interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
category?: string
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'request-id', name: 'Request-ID', icon: '🔑', status: 'pending', category: 'request-id' },
|
||||
{ id: 'security-headers', name: 'Security Headers', icon: '🛡️', status: 'pending', category: 'security-headers' },
|
||||
{ id: 'rate-limiter', name: 'Rate Limiting', icon: '⏱️', status: 'pending', category: 'rate-limiter' },
|
||||
{ id: 'pii-redactor', name: 'PII Redaktion', icon: '🔒', status: 'pending', category: 'pii-redactor' },
|
||||
{ id: 'input-gate', name: 'Input Validierung', icon: '🚧', status: 'pending', category: 'input-gate' },
|
||||
{ id: 'cors', name: 'CORS', icon: '🌐', status: 'pending', category: 'cors' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, { title: string; content: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Middleware-Test-Wizard',
|
||||
content: [
|
||||
'Middleware ist die unsichtbare Schutzschicht Ihrer Anwendung. Sie verarbeitet jede Anfrage bevor sie Ihren Code erreicht - und jede Antwort bevor sie den Benutzer erreicht.',
|
||||
'In diesem Wizard testen wir alle Middleware-Komponenten und Sie lernen dabei:',
|
||||
'• Warum jede Komponente wichtig ist',
|
||||
'• Welche Angriffe sie verhindert',
|
||||
'• Wie Sie Probleme erkennen und beheben',
|
||||
'Klicken Sie auf "Starten" um den Test-Wizard zu beginnen.',
|
||||
],
|
||||
},
|
||||
'request-id': {
|
||||
title: 'Request-ID & Distributed Tracing',
|
||||
content: [
|
||||
'Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen Pfad der Anfrage durch alle Microservices in Sekunden.',
|
||||
'Request-IDs sind essentiell fuer:',
|
||||
'• Fehlersuche in verteilten Systemen',
|
||||
'• Performance-Analyse',
|
||||
'• Audit-Trails fuer Compliance',
|
||||
],
|
||||
},
|
||||
'security-headers': {
|
||||
title: 'Security Headers - Erste Verteidigungslinie',
|
||||
content: [
|
||||
'Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll:',
|
||||
'• X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe',
|
||||
'• X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe',
|
||||
'• Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen',
|
||||
'• Strict-Transport-Security - Erzwingt HTTPS',
|
||||
'OWASP empfiehlt diese Headers als Mindeststandard.',
|
||||
],
|
||||
},
|
||||
'rate-limiter': {
|
||||
title: 'Rate Limiting - Schutz vor Ueberflutung',
|
||||
content: [
|
||||
'Ohne Rate Limiting kann ein Angreifer:',
|
||||
'• Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute)',
|
||||
'• Ihre Server mit Anfragen ueberfluten (DDoS)',
|
||||
'• Teure API-Aufrufe missbrauchen',
|
||||
'BreakPilot limitiert:',
|
||||
'• 100 Anfragen/Minute pro IP (allgemein)',
|
||||
'• 20 Anfragen/Minute fuer Auth-Endpoints',
|
||||
'• 500 Anfragen/Minute pro authentifiziertem Benutzer',
|
||||
],
|
||||
},
|
||||
'pii-redactor': {
|
||||
title: 'PII Redaktion - DSGVO Pflicht',
|
||||
content: [
|
||||
'Personenbezogene Daten in Logs sind ein DSGVO-Verstoss:',
|
||||
'• Email-Adressen: Bussgelder bis 20 Mio. EUR',
|
||||
'• IP-Adressen: Gelten als personenbezogen (EuGH-Urteil)',
|
||||
'• Telefonnummern: Direkter Personenbezug',
|
||||
'Der PII Redactor erkennt automatisch:',
|
||||
'• Email-Adressen → [EMAIL_REDACTED]',
|
||||
'• IP-Adressen → [IP_REDACTED]',
|
||||
'• Deutsche Telefonnummern → [PHONE_REDACTED]',
|
||||
],
|
||||
},
|
||||
'input-gate': {
|
||||
title: 'Input Gate - Der Tuersteher',
|
||||
content: [
|
||||
'Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht:',
|
||||
'• Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz)',
|
||||
'• Content-Type: Erlaubt nur erwartete Formate',
|
||||
'• Dateiendungen: Blockiert .exe, .bat, .sh Uploads',
|
||||
'Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt.',
|
||||
],
|
||||
},
|
||||
'cors': {
|
||||
title: 'CORS - Kontrollierte Zugriffe',
|
||||
content: [
|
||||
'CORS bestimmt, welche Websites Ihre API aufrufen duerfen:',
|
||||
'• Zu offen (*): Jede Website kann Ihre API missbrauchen',
|
||||
'• Zu streng: Ihre eigene Frontend-App wird blockiert',
|
||||
'BreakPilot erlaubt nur:',
|
||||
'• https://breakpilot.app (Produktion)',
|
||||
'• http://localhost:3000 (Development)',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Anzahl bestandener Tests',
|
||||
'• Fehlgeschlagene Tests mit Details',
|
||||
'• Empfehlungen zur Behebung',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Components
|
||||
// ==============================================
|
||||
|
||||
function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: step.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
{step.status === 'failed' && <span className="text-xs text-red-600">✗</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
|
||||
<span className="mr-2">💡</span>
|
||||
Warum ist das wichtig?
|
||||
</h3>
|
||||
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
|
||||
<div className="space-y-2 text-blue-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestResultCard({ result }: { result: TestResult }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 border-green-300 text-green-800',
|
||||
failed: 'bg-red-100 border-red-300 text-red-800',
|
||||
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
passed: '✓',
|
||||
failed: '✗',
|
||||
pending: '○',
|
||||
skipped: '−',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<span className="mr-2">{statusIcons[result.status]}</span>
|
||||
{result.name}
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">{result.description}</p>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Erwartet:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.expected}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Erhalten:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.actual}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
|
||||
Fehler: {result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestSummaryCard({ results }: { results: FullTestResults }) {
|
||||
const passRate = results.total_tests > 0
|
||||
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
|
||||
<div className="text-sm text-green-600">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
|
||||
<div className="text-sm text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 text-center ${
|
||||
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className={`text-3xl font-bold ${
|
||||
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>{passRate}%</div>
|
||||
<div className="text-sm text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Results */}
|
||||
<div className="space-y-4">
|
||||
{results.categories.map((category) => (
|
||||
<div key={category.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{category.display_name}</h4>
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{category.passed}/{category.total} bestanden
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{category.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mt-6 text-sm text-gray-500 text-right">
|
||||
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
import { useTestWizard } from './_components/useTestWizard'
|
||||
import { WizardStepper } from './_components/WizardStepper'
|
||||
import { EducationCard, TestResultCard, TestSummaryCard } from './_components/TestCards'
|
||||
|
||||
export default function TestWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
// Update step status
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/ui-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
// Update all step statuses
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
// Store category results
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
// Allow clicking on completed steps or the next available step
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
const {
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepData,
|
||||
categoryResults,
|
||||
fullResults,
|
||||
isLoading,
|
||||
error,
|
||||
isTestStep,
|
||||
isWelcome,
|
||||
isSummary,
|
||||
runCategoryTest,
|
||||
runAllTests,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
handleStepClick,
|
||||
} = useTestWizard()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
|
||||
<div className="prose prose-sm max-w-none mb-6">
|
||||
{content.split('\n\n').map((paragraph, i) => (
|
||||
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
|
||||
{paragraph.split('**').map((part, j) =>
|
||||
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-indigo-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-indigo-800 mb-2">Tipps:</h3>
|
||||
<ul className="space-y-1">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-indigo-700">
|
||||
<span className="text-indigo-500 mt-0.5">•</span>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
website/app/admin/multiplayer/wizard/_components/Sidebar.tsx
Normal file
59
website/app/admin/multiplayer/wizard/_components/Sidebar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { STEPS, WizardStep } from './types'
|
||||
|
||||
export function Sidebar({ currentStepIndex }: { currentStepIndex: number }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<span className="text-xs font-semibold text-indigo-600">
|
||||
Schritt {currentStepIndex + 1} von {STEPS.length}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-indigo-600">
|
||||
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-indigo-100">
|
||||
<div
|
||||
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-600 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Overview */}
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow p-6 text-white">
|
||||
<h3 className="font-semibold mb-4">Architektur</h3>
|
||||
<div className="text-sm space-y-2 font-mono">
|
||||
<div className="bg-white/10 rounded px-2 py-1">Unity WebGL</div>
|
||||
<div className="text-center text-indigo-200">↓ JS Bridge</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Matrix + Jitsi</div>
|
||||
<div className="text-center text-indigo-200">↓ WebSocket/API</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Go Backend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">Go:</span> consent-service/.../matrix/game_rooms.go
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">Go:</span> consent-service/.../jitsi/game_meetings.go
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">C#:</span> Assets/Scripts/Network/MultiplayerManager.cs
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">JS:</span> Assets/Plugins/WebGL/MultiplayerPlugin.jslib
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
website/app/admin/multiplayer/wizard/_components/StepContent.tsx
Normal file
137
website/app/admin/multiplayer/wizard/_components/StepContent.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function GameModeDemo() {
|
||||
const modes = [
|
||||
{ name: 'Solo', icon: '🎮', players: '1', features: ['Einzelspieler', 'Offline moeglich', 'Eigenes Tempo'] },
|
||||
{ name: 'Co-Op', icon: '🤝', players: '2-4', features: ['Team-Chat', 'Gemeinsame Strecke', 'Video optional'] },
|
||||
{ name: 'Challenge', icon: '⚔️', players: '2', features: ['1v1 Wettbewerb', 'Live-Score', 'Video empfohlen'] },
|
||||
{ name: 'Klassenrennen', icon: '🏁', players: '∞', features: ['Alle gegen alle', 'Lehrer-Moderation', 'Leaderboard'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
{modes.map((mode) => (
|
||||
<div key={mode.name} className="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-3xl">{mode.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{mode.name}</h4>
|
||||
<p className="text-sm text-gray-500">{mode.players} Spieler</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{mode.features.map((feature, i) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="text-indigo-500">✓</span> {feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceStatusDemo() {
|
||||
const [matrixStatus, setMatrixStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
|
||||
const [jitsiStatus, setJitsiStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
|
||||
|
||||
const checkMatrix = async () => {
|
||||
setMatrixStatus('checking')
|
||||
try {
|
||||
// In production, this would check the actual Matrix server
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setMatrixStatus('online') // Mock - assume online
|
||||
} catch {
|
||||
setMatrixStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
const checkJitsi = async () => {
|
||||
setJitsiStatus('checking')
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setJitsiStatus('online') // Mock
|
||||
} catch {
|
||||
setJitsiStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-800">Service-Status pruefen:</h3>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">💬</span>
|
||||
<span className="font-medium">Matrix Synapse</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
matrixStatus === 'online' ? 'bg-green-100 text-green-700' :
|
||||
matrixStatus === 'offline' ? 'bg-red-100 text-red-700' :
|
||||
matrixStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{matrixStatus === 'online' ? 'Online' :
|
||||
matrixStatus === 'offline' ? 'Offline' :
|
||||
matrixStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">Port 8008</p>
|
||||
<button
|
||||
onClick={checkMatrix}
|
||||
disabled={matrixStatus === 'checking'}
|
||||
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">📹</span>
|
||||
<span className="font-medium">Jitsi Meet</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
jitsiStatus === 'online' ? 'bg-green-100 text-green-700' :
|
||||
jitsiStatus === 'offline' ? 'bg-red-100 text-red-700' :
|
||||
jitsiStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{jitsiStatus === 'online' ? 'Online' :
|
||||
jitsiStatus === 'offline' ? 'Offline' :
|
||||
jitsiStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">Port 8443</p>
|
||||
<button
|
||||
onClick={checkJitsi}
|
||||
disabled={jitsiStatus === 'checking'}
|
||||
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodePreview({ title, code, language }: { title: string; code: string; language: string }) {
|
||||
return (
|
||||
<div className="mt-6 bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-800">
|
||||
<span className="text-sm text-gray-400">{title}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded">{language}</span>
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { StepInfo, WizardStep } 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 (
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isCompleted = index < currentIndex
|
||||
const isClickable = index <= currentIndex + 1
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => isClickable && onStepClick(step.id)}
|
||||
disabled={!isClickable}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
||||
: isClickable
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
|
||||
isActive ? 'bg-white/20' : isCompleted ? 'bg-indigo-200' : 'bg-gray-200'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const CODE_EXAMPLES = {
|
||||
matrixChat: {
|
||||
title: 'game_rooms.go - Raum erstellen',
|
||||
language: 'Go',
|
||||
code: `func (s *MatrixService) CreateGameTeamRoom(
|
||||
ctx context.Context,
|
||||
config GameRoomConfig,
|
||||
) (*CreateRoomResponse, error) {
|
||||
roomName := fmt.Sprintf("Breakpilot Drive - Team %s",
|
||||
config.SessionID[:8])
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: roomName,
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
// ... power levels, encryption
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}`,
|
||||
},
|
||||
jitsiVideo: {
|
||||
title: 'game_meetings.go - Meeting erstellen',
|
||||
language: 'Go',
|
||||
code: `func (s *JitsiService) CreateChallengeMeeting(
|
||||
ctx context.Context,
|
||||
config GameMeetingConfig,
|
||||
challengerName string,
|
||||
opponentName string,
|
||||
) (*GameMeetingLink, error) {
|
||||
meeting := Meeting{
|
||||
RoomName: fmt.Sprintf("bp-challenge-%s",
|
||||
config.SessionID[:8]),
|
||||
Subject: fmt.Sprintf("Challenge: %s vs %s",
|
||||
challengerName, opponentName),
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false,
|
||||
RequireDisplayName: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}`,
|
||||
},
|
||||
unityIntegration: {
|
||||
title: 'MultiplayerManager.cs - Session erstellen',
|
||||
language: 'C#',
|
||||
code: `public void CreateSession(
|
||||
GameMode mode,
|
||||
string displayName,
|
||||
Action<MultiplayerSession> onSuccess,
|
||||
Action<string> onError
|
||||
) {
|
||||
localPlayer = new Player {
|
||||
id = Guid.NewGuid().ToString(),
|
||||
displayName = displayName,
|
||||
isHost = true,
|
||||
isReady = false
|
||||
};
|
||||
|
||||
state = MultiplayerState.Connecting;
|
||||
StartCoroutine(CreateSessionCoroutine(
|
||||
mode, onSuccess, onError));
|
||||
}`,
|
||||
},
|
||||
} as const
|
||||
226
website/app/admin/multiplayer/wizard/_components/types.ts
Normal file
226
website/app/admin/multiplayer/wizard/_components/types.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export type WizardStep =
|
||||
| 'welcome'
|
||||
| 'game-modes'
|
||||
| 'matrix-chat'
|
||||
| 'jitsi-video'
|
||||
| 'go-services'
|
||||
| 'unity-integration'
|
||||
| 'demo'
|
||||
| 'summary'
|
||||
|
||||
export interface StepInfo {
|
||||
id: WizardStep
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Step Configuration
|
||||
// ========================================
|
||||
|
||||
export const STEPS: StepInfo[] = [
|
||||
{ id: 'welcome', title: 'Willkommen', description: 'Multiplayer-Uebersicht' },
|
||||
{ id: 'game-modes', title: 'Spielmodi', description: 'Co-Op, Challenge, Klasse' },
|
||||
{ id: 'matrix-chat', title: 'Matrix Chat', description: 'Echtzeit-Kommunikation' },
|
||||
{ id: 'jitsi-video', title: 'Jitsi Video', description: 'Video-Konferenzen' },
|
||||
{ id: 'go-services', title: 'Go Services', description: 'Backend-Integration' },
|
||||
{ id: 'unity-integration', title: 'Unity', description: 'WebGL Bridge' },
|
||||
{ id: 'demo', title: 'Demo', description: 'Live-Test' },
|
||||
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
|
||||
]
|
||||
|
||||
// ========================================
|
||||
// Educational Content
|
||||
// ========================================
|
||||
|
||||
export const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Multiplayer fuer Breakpilot Drive',
|
||||
content: `Das Multiplayer-System ermoeglicht kooperatives und kompetitives Spielen
|
||||
zwischen Schuelern. Es basiert auf zwei bewaehrten Open-Source-Technologien:
|
||||
|
||||
- **Matrix Synapse** fuer Echtzeit-Chat
|
||||
- **Jitsi Meet** fuer Video-Kommunikation
|
||||
|
||||
Diese Integration ermoeglicht verschiedene Spielmodi, von 1v1-Challenges
|
||||
bis hin zu klassenweiten Wettbewerben.`,
|
||||
tips: [
|
||||
'Matrix ist ein dezentrales Chat-Protokoll mit End-to-End-Verschluesselung',
|
||||
'Jitsi ist eine Open-Source-Alternative zu Zoom/Teams',
|
||||
'Beide Systeme sind DSGVO-konform und selbst-gehostet'
|
||||
]
|
||||
},
|
||||
'game-modes': {
|
||||
title: 'Multiplayer-Spielmodi',
|
||||
content: `Breakpilot Drive unterstuetzt vier verschiedene Multiplayer-Modi:
|
||||
|
||||
**Solo** - Einzelspieler ohne Netzwerk
|
||||
|
||||
**Co-Op** - 2-4 Spieler arbeiten zusammen
|
||||
- Gemeinsame Strecke
|
||||
- Team-Chat
|
||||
- Optionales Video
|
||||
|
||||
**Challenge** - 1v1 Wettbewerb
|
||||
- Gleiche Quiz-Fragen
|
||||
- Live-Punktestand
|
||||
- Video-Chat empfohlen
|
||||
|
||||
**Klassenrennen** - Alle gegen alle
|
||||
- Lehrer als Moderator
|
||||
- Klassen-Chat
|
||||
- Live-Leaderboard`,
|
||||
tips: [
|
||||
'Co-Op ist ideal fuer Lerngruppen und Foerderunterricht',
|
||||
'Challenges motivieren durch direkten Wettbewerb',
|
||||
'Klassenrennen eignen sich als Abschluss einer Lerneinheit'
|
||||
]
|
||||
},
|
||||
'matrix-chat': {
|
||||
title: 'Matrix Chat Integration',
|
||||
content: `Matrix wird fuer die Echtzeit-Kommunikation verwendet:
|
||||
|
||||
**Raum-Typen:**
|
||||
- Team-Raeume (Co-Op, privat)
|
||||
- Challenge-Raeume (1v1, temporaer)
|
||||
- Klassen-Raeume (alle Schueler, Lehrer moderiert)
|
||||
|
||||
**Features:**
|
||||
- Spieler-Beitritt/Austritt Benachrichtigungen
|
||||
- Score-Updates in Echtzeit
|
||||
- Achievement-Ankuendigungen
|
||||
- End-to-End-Verschluesselung optional
|
||||
|
||||
**Game Events:**
|
||||
- player_joined, player_left
|
||||
- game_started, game_ended
|
||||
- score_update, quiz_answered
|
||||
- achievement, challenge_won`,
|
||||
tips: [
|
||||
'Matrix-Raeume werden automatisch erstellt und archiviert',
|
||||
'Power Levels kontrollieren wer schreiben darf',
|
||||
'Custom Events (breakpilot.game.*) fuer Spiellogik'
|
||||
]
|
||||
},
|
||||
'jitsi-video': {
|
||||
title: 'Jitsi Video Integration',
|
||||
content: `Jitsi ermoeglicht Video-Kommunikation waehrend des Spiels:
|
||||
|
||||
**Konfiguration pro Modus:**
|
||||
- Co-Op: Audio an, Video optional
|
||||
- Challenge: Audio/Video empfohlen
|
||||
- Klassenrennen: Lehrer-Video, Schueler stumm
|
||||
|
||||
**Sicherheit:**
|
||||
- JWT-basierte Authentifizierung
|
||||
- Lobby fuer Klassenraeume
|
||||
- Kein Recording fuer Minderjaehrige
|
||||
|
||||
**Unity-Embedding:**
|
||||
- Kompaktes Overlay (320x240px)
|
||||
- Minimale UI (nur Mikro, Kamera, Auflegen)
|
||||
- Automatisches Verbinden bei Spielstart`,
|
||||
tips: [
|
||||
'Jitsi-Container erscheint als Overlay im Spiel',
|
||||
'Audio hat Prioritaet - Video ist optional',
|
||||
'Lehrer koennen Schueler stummschalten'
|
||||
]
|
||||
},
|
||||
'go-services': {
|
||||
title: 'Backend Go Services',
|
||||
content: `Die Multiplayer-Logik ist in Go implementiert:
|
||||
|
||||
**Matrix Service** (game_rooms.go)
|
||||
- CreateGameTeamRoom() - Co-Op Raeume
|
||||
- CreateGameChallengeRoom() - 1v1 Raeume
|
||||
- CreateGameClassRaceRoom() - Klassen-Raeume
|
||||
- SendGameEvent() - Event Broadcasting
|
||||
|
||||
**Jitsi Service** (game_meetings.go)
|
||||
- CreateCoopMeeting() - Team Video
|
||||
- CreateChallengeMeeting() - 1v1 Video
|
||||
- CreateClassRaceMeeting() - Klassen-Video
|
||||
- JWT-Token Generierung
|
||||
|
||||
**Pfade:**
|
||||
- consent-service/internal/services/matrix/game_rooms.go
|
||||
- consent-service/internal/services/jitsi/game_meetings.go`,
|
||||
tips: [
|
||||
'Services erweitern bestehende Matrix/Jitsi Integration',
|
||||
'Validierung erfolgt vor Raum-Erstellung',
|
||||
'Cleanup wird automatisch bei Spielende ausgefuehrt'
|
||||
]
|
||||
},
|
||||
'unity-integration': {
|
||||
title: 'Unity WebGL Integration',
|
||||
content: `Die Unity-seitige Integration besteht aus zwei Teilen:
|
||||
|
||||
**MultiplayerManager.cs**
|
||||
- Singleton fuer Session-Verwaltung
|
||||
- Events fuer UI-Updates
|
||||
- Score/Achievement Broadcasting
|
||||
- Editor-Simulation fuer Entwicklung
|
||||
|
||||
**MultiplayerPlugin.jslib**
|
||||
- JavaScript Bridge fuer WebGL
|
||||
- WebSocket-Verbindung zu Backend
|
||||
- Jitsi External API Integration
|
||||
- Automatisches Container-Management
|
||||
|
||||
**Pfade:**
|
||||
- Assets/Scripts/Network/MultiplayerManager.cs
|
||||
- Assets/Plugins/WebGL/MultiplayerPlugin.jslib`,
|
||||
tips: [
|
||||
'Im Editor werden Multiplayer-Events simuliert',
|
||||
'jslib-Funktionen nur im WebGL Build verfuegbar',
|
||||
'Jitsi-Container wird dynamisch erstellt'
|
||||
]
|
||||
},
|
||||
'demo': {
|
||||
title: 'Live-Demo',
|
||||
content: `Teste die Multiplayer-Komponenten:
|
||||
|
||||
**Matrix-Verbindung:**
|
||||
- Pruefe ob Matrix Synapse erreichbar ist
|
||||
- Teste Raum-Erstellung
|
||||
- Sende Test-Nachricht
|
||||
|
||||
**Jitsi-Verbindung:**
|
||||
- Pruefe ob Jitsi Meet erreichbar ist
|
||||
- Teste Meeting-Link Generierung
|
||||
- Pruefe JWT-Validierung
|
||||
|
||||
**Hinweis:** Fuer vollstaendige Tests muss das
|
||||
Unity WebGL Build laufen und mit dem Backend verbunden sein.`,
|
||||
tips: [
|
||||
'Matrix laeuft auf Port 8008',
|
||||
'Jitsi laeuft auf Port 8443',
|
||||
'Beide Services muessen in Docker laufen'
|
||||
]
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung & Naechste Schritte',
|
||||
content: `Du hast gelernt:
|
||||
|
||||
✓ Vier Multiplayer-Modi (Solo, Co-Op, Challenge, Klasse)
|
||||
✓ Matrix fuer Chat und Game Events
|
||||
✓ Jitsi fuer Video-Kommunikation
|
||||
✓ Go Backend Services
|
||||
✓ Unity WebGL Integration
|
||||
|
||||
**Naechste Schritte:**
|
||||
1. Backend-Services starten (docker-compose up)
|
||||
2. Unity WebGL Build erstellen
|
||||
3. Multiplayer im Admin Panel testen
|
||||
4. Phase 9: Mobile App Delivery`,
|
||||
tips: [
|
||||
'Dokumentation in docs/breakpilot-drive/multiplayer.md',
|
||||
'Tests in consent-service/*_test.go',
|
||||
'Unity Tests ueber Test Runner'
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -10,445 +10,12 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
type WizardStep =
|
||||
| 'welcome'
|
||||
| 'game-modes'
|
||||
| 'matrix-chat'
|
||||
| 'jitsi-video'
|
||||
| 'go-services'
|
||||
| 'unity-integration'
|
||||
| 'demo'
|
||||
| 'summary'
|
||||
|
||||
interface StepInfo {
|
||||
id: WizardStep
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Step Configuration
|
||||
// ========================================
|
||||
|
||||
const STEPS: StepInfo[] = [
|
||||
{ id: 'welcome', title: 'Willkommen', description: 'Multiplayer-Uebersicht' },
|
||||
{ id: 'game-modes', title: 'Spielmodi', description: 'Co-Op, Challenge, Klasse' },
|
||||
{ id: 'matrix-chat', title: 'Matrix Chat', description: 'Echtzeit-Kommunikation' },
|
||||
{ id: 'jitsi-video', title: 'Jitsi Video', description: 'Video-Konferenzen' },
|
||||
{ id: 'go-services', title: 'Go Services', description: 'Backend-Integration' },
|
||||
{ id: 'unity-integration', title: 'Unity', description: 'WebGL Bridge' },
|
||||
{ id: 'demo', title: 'Demo', description: 'Live-Test' },
|
||||
{ id: 'summary', title: 'Zusammenfassung', description: 'Naechste Schritte' },
|
||||
]
|
||||
|
||||
// ========================================
|
||||
// Educational Content
|
||||
// ========================================
|
||||
|
||||
const EDUCATION_CONTENT: Record<WizardStep, { title: string; content: string; tips: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Multiplayer fuer Breakpilot Drive',
|
||||
content: `Das Multiplayer-System ermoeglicht kooperatives und kompetitives Spielen
|
||||
zwischen Schuelern. Es basiert auf zwei bewaehrten Open-Source-Technologien:
|
||||
|
||||
- **Matrix Synapse** fuer Echtzeit-Chat
|
||||
- **Jitsi Meet** fuer Video-Kommunikation
|
||||
|
||||
Diese Integration ermoeglicht verschiedene Spielmodi, von 1v1-Challenges
|
||||
bis hin zu klassenweiten Wettbewerben.`,
|
||||
tips: [
|
||||
'Matrix ist ein dezentrales Chat-Protokoll mit End-to-End-Verschluesselung',
|
||||
'Jitsi ist eine Open-Source-Alternative zu Zoom/Teams',
|
||||
'Beide Systeme sind DSGVO-konform und selbst-gehostet'
|
||||
]
|
||||
},
|
||||
'game-modes': {
|
||||
title: 'Multiplayer-Spielmodi',
|
||||
content: `Breakpilot Drive unterstuetzt vier verschiedene Multiplayer-Modi:
|
||||
|
||||
**Solo** - Einzelspieler ohne Netzwerk
|
||||
|
||||
**Co-Op** - 2-4 Spieler arbeiten zusammen
|
||||
- Gemeinsame Strecke
|
||||
- Team-Chat
|
||||
- Optionales Video
|
||||
|
||||
**Challenge** - 1v1 Wettbewerb
|
||||
- Gleiche Quiz-Fragen
|
||||
- Live-Punktestand
|
||||
- Video-Chat empfohlen
|
||||
|
||||
**Klassenrennen** - Alle gegen alle
|
||||
- Lehrer als Moderator
|
||||
- Klassen-Chat
|
||||
- Live-Leaderboard`,
|
||||
tips: [
|
||||
'Co-Op ist ideal fuer Lerngruppen und Foerderunterricht',
|
||||
'Challenges motivieren durch direkten Wettbewerb',
|
||||
'Klassenrennen eignen sich als Abschluss einer Lerneinheit'
|
||||
]
|
||||
},
|
||||
'matrix-chat': {
|
||||
title: 'Matrix Chat Integration',
|
||||
content: `Matrix wird fuer die Echtzeit-Kommunikation verwendet:
|
||||
|
||||
**Raum-Typen:**
|
||||
- Team-Raeume (Co-Op, privat)
|
||||
- Challenge-Raeume (1v1, temporaer)
|
||||
- Klassen-Raeume (alle Schueler, Lehrer moderiert)
|
||||
|
||||
**Features:**
|
||||
- Spieler-Beitritt/Austritt Benachrichtigungen
|
||||
- Score-Updates in Echtzeit
|
||||
- Achievement-Ankuendigungen
|
||||
- End-to-End-Verschluesselung optional
|
||||
|
||||
**Game Events:**
|
||||
- player_joined, player_left
|
||||
- game_started, game_ended
|
||||
- score_update, quiz_answered
|
||||
- achievement, challenge_won`,
|
||||
tips: [
|
||||
'Matrix-Raeume werden automatisch erstellt und archiviert',
|
||||
'Power Levels kontrollieren wer schreiben darf',
|
||||
'Custom Events (breakpilot.game.*) fuer Spiellogik'
|
||||
]
|
||||
},
|
||||
'jitsi-video': {
|
||||
title: 'Jitsi Video Integration',
|
||||
content: `Jitsi ermoeglicht Video-Kommunikation waehrend des Spiels:
|
||||
|
||||
**Konfiguration pro Modus:**
|
||||
- Co-Op: Audio an, Video optional
|
||||
- Challenge: Audio/Video empfohlen
|
||||
- Klassenrennen: Lehrer-Video, Schueler stumm
|
||||
|
||||
**Sicherheit:**
|
||||
- JWT-basierte Authentifizierung
|
||||
- Lobby fuer Klassenraeume
|
||||
- Kein Recording fuer Minderjaehrige
|
||||
|
||||
**Unity-Embedding:**
|
||||
- Kompaktes Overlay (320x240px)
|
||||
- Minimale UI (nur Mikro, Kamera, Auflegen)
|
||||
- Automatisches Verbinden bei Spielstart`,
|
||||
tips: [
|
||||
'Jitsi-Container erscheint als Overlay im Spiel',
|
||||
'Audio hat Prioritaet - Video ist optional',
|
||||
'Lehrer koennen Schueler stummschalten'
|
||||
]
|
||||
},
|
||||
'go-services': {
|
||||
title: 'Backend Go Services',
|
||||
content: `Die Multiplayer-Logik ist in Go implementiert:
|
||||
|
||||
**Matrix Service** (game_rooms.go)
|
||||
- CreateGameTeamRoom() - Co-Op Raeume
|
||||
- CreateGameChallengeRoom() - 1v1 Raeume
|
||||
- CreateGameClassRaceRoom() - Klassen-Raeume
|
||||
- SendGameEvent() - Event Broadcasting
|
||||
|
||||
**Jitsi Service** (game_meetings.go)
|
||||
- CreateCoopMeeting() - Team Video
|
||||
- CreateChallengeMeeting() - 1v1 Video
|
||||
- CreateClassRaceMeeting() - Klassen-Video
|
||||
- JWT-Token Generierung
|
||||
|
||||
**Pfade:**
|
||||
- consent-service/internal/services/matrix/game_rooms.go
|
||||
- consent-service/internal/services/jitsi/game_meetings.go`,
|
||||
tips: [
|
||||
'Services erweitern bestehende Matrix/Jitsi Integration',
|
||||
'Validierung erfolgt vor Raum-Erstellung',
|
||||
'Cleanup wird automatisch bei Spielende ausgefuehrt'
|
||||
]
|
||||
},
|
||||
'unity-integration': {
|
||||
title: 'Unity WebGL Integration',
|
||||
content: `Die Unity-seitige Integration besteht aus zwei Teilen:
|
||||
|
||||
**MultiplayerManager.cs**
|
||||
- Singleton fuer Session-Verwaltung
|
||||
- Events fuer UI-Updates
|
||||
- Score/Achievement Broadcasting
|
||||
- Editor-Simulation fuer Entwicklung
|
||||
|
||||
**MultiplayerPlugin.jslib**
|
||||
- JavaScript Bridge fuer WebGL
|
||||
- WebSocket-Verbindung zu Backend
|
||||
- Jitsi External API Integration
|
||||
- Automatisches Container-Management
|
||||
|
||||
**Pfade:**
|
||||
- Assets/Scripts/Network/MultiplayerManager.cs
|
||||
- Assets/Plugins/WebGL/MultiplayerPlugin.jslib`,
|
||||
tips: [
|
||||
'Im Editor werden Multiplayer-Events simuliert',
|
||||
'jslib-Funktionen nur im WebGL Build verfuegbar',
|
||||
'Jitsi-Container wird dynamisch erstellt'
|
||||
]
|
||||
},
|
||||
'demo': {
|
||||
title: 'Live-Demo',
|
||||
content: `Teste die Multiplayer-Komponenten:
|
||||
|
||||
**Matrix-Verbindung:**
|
||||
- Pruefe ob Matrix Synapse erreichbar ist
|
||||
- Teste Raum-Erstellung
|
||||
- Sende Test-Nachricht
|
||||
|
||||
**Jitsi-Verbindung:**
|
||||
- Pruefe ob Jitsi Meet erreichbar ist
|
||||
- Teste Meeting-Link Generierung
|
||||
- Pruefe JWT-Validierung
|
||||
|
||||
**Hinweis:** Fuer vollstaendige Tests muss das
|
||||
Unity WebGL Build laufen und mit dem Backend verbunden sein.`,
|
||||
tips: [
|
||||
'Matrix laeuft auf Port 8008',
|
||||
'Jitsi laeuft auf Port 8443',
|
||||
'Beide Services muessen in Docker laufen'
|
||||
]
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung & Naechste Schritte',
|
||||
content: `Du hast gelernt:
|
||||
|
||||
✓ Vier Multiplayer-Modi (Solo, Co-Op, Challenge, Klasse)
|
||||
✓ Matrix fuer Chat und Game Events
|
||||
✓ Jitsi fuer Video-Kommunikation
|
||||
✓ Go Backend Services
|
||||
✓ Unity WebGL Integration
|
||||
|
||||
**Naechste Schritte:**
|
||||
1. Backend-Services starten (docker-compose up)
|
||||
2. Unity WebGL Build erstellen
|
||||
3. Multiplayer im Admin Panel testen
|
||||
4. Phase 9: Mobile App Delivery`,
|
||||
tips: [
|
||||
'Dokumentation in docs/breakpilot-drive/multiplayer.md',
|
||||
'Tests in consent-service/*_test.go',
|
||||
'Unity Tests ueber Test Runner'
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Components
|
||||
// ========================================
|
||||
|
||||
function WizardStepper({ steps, currentStep, onStepClick }: {
|
||||
steps: StepInfo[]
|
||||
currentStep: WizardStep
|
||||
onStepClick: (step: WizardStep) => void
|
||||
}) {
|
||||
const currentIndex = steps.findIndex(s => s.id === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isCompleted = index < currentIndex
|
||||
const isClickable = index <= currentIndex + 1
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => isClickable && onStepClick(step.id)}
|
||||
disabled={!isClickable}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
||||
: isClickable
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
|
||||
isActive ? 'bg-white/20' : isCompleted ? 'bg-indigo-200' : 'bg-gray-200'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ title, content, tips }: { title: string; content: string; tips: string[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{title}</h2>
|
||||
<div className="prose prose-sm max-w-none mb-6">
|
||||
{content.split('\n\n').map((paragraph, i) => (
|
||||
<p key={i} className="text-gray-700 whitespace-pre-line mb-3">
|
||||
{paragraph.split('**').map((part, j) =>
|
||||
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-indigo-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-indigo-800 mb-2">Tipps:</h3>
|
||||
<ul className="space-y-1">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-indigo-700">
|
||||
<span className="text-indigo-500 mt-0.5">•</span>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GameModeDemo() {
|
||||
const modes = [
|
||||
{ name: 'Solo', icon: '🎮', players: '1', features: ['Einzelspieler', 'Offline moeglich', 'Eigenes Tempo'] },
|
||||
{ name: 'Co-Op', icon: '🤝', players: '2-4', features: ['Team-Chat', 'Gemeinsame Strecke', 'Video optional'] },
|
||||
{ name: 'Challenge', icon: '⚔️', players: '2', features: ['1v1 Wettbewerb', 'Live-Score', 'Video empfohlen'] },
|
||||
{ name: 'Klassenrennen', icon: '🏁', players: '∞', features: ['Alle gegen alle', 'Lehrer-Moderation', 'Leaderboard'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
{modes.map((mode) => (
|
||||
<div key={mode.name} className="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-3xl">{mode.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{mode.name}</h4>
|
||||
<p className="text-sm text-gray-500">{mode.players} Spieler</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{mode.features.map((feature, i) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="text-indigo-500">✓</span> {feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceStatusDemo() {
|
||||
const [matrixStatus, setMatrixStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
|
||||
const [jitsiStatus, setJitsiStatus] = useState<'idle' | 'checking' | 'online' | 'offline'>('idle')
|
||||
|
||||
const checkMatrix = async () => {
|
||||
setMatrixStatus('checking')
|
||||
try {
|
||||
// In production, this would check the actual Matrix server
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setMatrixStatus('online') // Mock - assume online
|
||||
} catch {
|
||||
setMatrixStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
const checkJitsi = async () => {
|
||||
setJitsiStatus('checking')
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setJitsiStatus('online') // Mock
|
||||
} catch {
|
||||
setJitsiStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-800">Service-Status pruefen:</h3>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">💬</span>
|
||||
<span className="font-medium">Matrix Synapse</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
matrixStatus === 'online' ? 'bg-green-100 text-green-700' :
|
||||
matrixStatus === 'offline' ? 'bg-red-100 text-red-700' :
|
||||
matrixStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{matrixStatus === 'online' ? 'Online' :
|
||||
matrixStatus === 'offline' ? 'Offline' :
|
||||
matrixStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">Port 8008</p>
|
||||
<button
|
||||
onClick={checkMatrix}
|
||||
disabled={matrixStatus === 'checking'}
|
||||
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">📹</span>
|
||||
<span className="font-medium">Jitsi Meet</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
jitsiStatus === 'online' ? 'bg-green-100 text-green-700' :
|
||||
jitsiStatus === 'offline' ? 'bg-red-100 text-red-700' :
|
||||
jitsiStatus === 'checking' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{jitsiStatus === 'online' ? 'Online' :
|
||||
jitsiStatus === 'offline' ? 'Offline' :
|
||||
jitsiStatus === 'checking' ? 'Pruefe...' : 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">Port 8443</p>
|
||||
<button
|
||||
onClick={checkJitsi}
|
||||
disabled={jitsiStatus === 'checking'}
|
||||
className="w-full px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodePreview({ title, code, language }: { title: string; code: string; language: string }) {
|
||||
return (
|
||||
<div className="mt-6 bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-800">
|
||||
<span className="text-sm text-gray-400">{title}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded">{language}</span>
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Main Component
|
||||
// ========================================
|
||||
import { STEPS, EDUCATION_CONTENT, WizardStep } from './_components/types'
|
||||
import { WizardStepper } from './_components/WizardStepper'
|
||||
import { EducationCard } from './_components/EducationCard'
|
||||
import { GameModeDemo, ServiceStatusDemo, CodePreview } from './_components/StepContent'
|
||||
import { Sidebar } from './_components/Sidebar'
|
||||
import { CODE_EXAMPLES } from './_components/codeExamples'
|
||||
|
||||
export default function MultiplayerWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('welcome')
|
||||
@@ -508,80 +75,15 @@ export default function MultiplayerWizardPage() {
|
||||
|
||||
{/* Step-specific content */}
|
||||
{currentStep === 'game-modes' && <GameModeDemo />}
|
||||
|
||||
{currentStep === 'demo' && <ServiceStatusDemo />}
|
||||
|
||||
{currentStep === 'matrix-chat' && (
|
||||
<CodePreview
|
||||
title="game_rooms.go - Raum erstellen"
|
||||
language="Go"
|
||||
code={`func (s *MatrixService) CreateGameTeamRoom(
|
||||
ctx context.Context,
|
||||
config GameRoomConfig,
|
||||
) (*CreateRoomResponse, error) {
|
||||
roomName := fmt.Sprintf("Breakpilot Drive - Team %s",
|
||||
config.SessionID[:8])
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: roomName,
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
// ... power levels, encryption
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}`}
|
||||
/>
|
||||
<CodePreview {...CODE_EXAMPLES.matrixChat} />
|
||||
)}
|
||||
|
||||
{currentStep === 'jitsi-video' && (
|
||||
<CodePreview
|
||||
title="game_meetings.go - Meeting erstellen"
|
||||
language="Go"
|
||||
code={`func (s *JitsiService) CreateChallengeMeeting(
|
||||
ctx context.Context,
|
||||
config GameMeetingConfig,
|
||||
challengerName string,
|
||||
opponentName string,
|
||||
) (*GameMeetingLink, error) {
|
||||
meeting := Meeting{
|
||||
RoomName: fmt.Sprintf("bp-challenge-%s",
|
||||
config.SessionID[:8]),
|
||||
Subject: fmt.Sprintf("Challenge: %s vs %s",
|
||||
challengerName, opponentName),
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false,
|
||||
RequireDisplayName: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}`}
|
||||
/>
|
||||
<CodePreview {...CODE_EXAMPLES.jitsiVideo} />
|
||||
)}
|
||||
|
||||
{currentStep === 'unity-integration' && (
|
||||
<CodePreview
|
||||
title="MultiplayerManager.cs - Session erstellen"
|
||||
language="C#"
|
||||
code={`public void CreateSession(
|
||||
GameMode mode,
|
||||
string displayName,
|
||||
Action<MultiplayerSession> onSuccess,
|
||||
Action<string> onError
|
||||
) {
|
||||
localPlayer = new Player {
|
||||
id = Guid.NewGuid().ToString(),
|
||||
displayName = displayName,
|
||||
isHost = true,
|
||||
isReady = false
|
||||
};
|
||||
|
||||
state = MultiplayerState.Connecting;
|
||||
StartCoroutine(CreateSessionCoroutine(
|
||||
mode, onSuccess, onError));
|
||||
}`}
|
||||
/>
|
||||
<CodePreview {...CODE_EXAMPLES.unityIntegration} />
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -604,59 +106,7 @@ export default function MultiplayerWizardPage() {
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fortschritt</h3>
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<span className="text-xs font-semibold text-indigo-600">
|
||||
Schritt {currentStepIndex + 1} von {STEPS.length}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-indigo-600">
|
||||
{Math.round(((currentStepIndex + 1) / STEPS.length) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-indigo-100">
|
||||
<div
|
||||
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-indigo-600 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Overview */}
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow p-6 text-white">
|
||||
<h3 className="font-semibold mb-4">Architektur</h3>
|
||||
<div className="text-sm space-y-2 font-mono">
|
||||
<div className="bg-white/10 rounded px-2 py-1">Unity WebGL</div>
|
||||
<div className="text-center text-indigo-200">↓ JS Bridge</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Matrix + Jitsi</div>
|
||||
<div className="text-center text-indigo-200">↓ WebSocket/API</div>
|
||||
<div className="bg-white/10 rounded px-2 py-1">Go Backend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Wichtige Dateien</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">Go:</span> consent-service/.../matrix/game_rooms.go
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">Go:</span> consent-service/.../jitsi/game_meetings.go
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">C#:</span> Assets/Scripts/Network/MultiplayerManager.cs
|
||||
</li>
|
||||
<li className="text-gray-600">
|
||||
<span className="text-indigo-600">JS:</span> Assets/Plugins/WebGL/MultiplayerPlugin.jslib
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar currentStepIndex={currentStepIndex} />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
|
||||
246
website/app/admin/rag/components/CollectionCard.tsx
Normal file
246
website/app/admin/rag/components/CollectionCard.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Collection } from '../types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
const statusColors = {
|
||||
ready: 'bg-green-100 text-green-800',
|
||||
indexing: 'bg-yellow-100 text-yellow-800',
|
||||
empty: 'bg-slate-100 text-slate-800',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
ready: 'Bereit',
|
||||
indexing: 'Indexierung...',
|
||||
empty: 'Leer',
|
||||
}
|
||||
|
||||
const useCaseLabels: Record<string, string> = {
|
||||
klausur: 'Klausurkorrektur',
|
||||
zeugnis: 'Zeugniserstellung',
|
||||
material: 'Unterrichtsmaterial',
|
||||
curriculum: 'Lehrplan',
|
||||
other: 'Sonstiges',
|
||||
unknown: 'Unbekannt',
|
||||
}
|
||||
|
||||
export function CollectionCard({ collection }: { collection: Collection }) {
|
||||
const [ingesting, setIngesting] = useState(false)
|
||||
const [reindexing, setReindexing] = useState(false)
|
||||
const [ingestMessage, setIngestMessage] = useState<string | null>(null)
|
||||
const [showReindexConfirm, setShowReindexConfirm] = useState(false)
|
||||
const [chunkingStrategy, setChunkingStrategy] = useState<'semantic' | 'recursive'>('semantic')
|
||||
|
||||
const handleIngest = async () => {
|
||||
setIngesting(true)
|
||||
setIngestMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/ingest?incremental=true`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIngestMessage(data.message || 'Indexierung gestartet')
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setIngestMessage(error.detail || 'Fehler beim Starten')
|
||||
}
|
||||
} catch (err) {
|
||||
setIngestMessage('Netzwerkfehler')
|
||||
} finally {
|
||||
setIngesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReindex = async () => {
|
||||
setShowReindexConfirm(false)
|
||||
setReindexing(true)
|
||||
setIngestMessage('Starte Re-Indexierung...')
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/reindex?chunking_strategy=${chunkingStrategy}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
setIngestMessage(error.detail || 'Fehler beim Starten')
|
||||
setReindexing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressRes = await fetch(`${API_BASE}/api/v1/admin/rag/reindex/progress`)
|
||||
if (progressRes.ok) {
|
||||
const progress = await progressRes.json()
|
||||
|
||||
if (progress.phase === 'deleting') {
|
||||
setIngestMessage('Loesche alte Chunks...')
|
||||
} else if (progress.phase === 'indexing') {
|
||||
const pct = progress.total_docs > 0
|
||||
? Math.round((progress.current_doc / progress.total_docs) * 100)
|
||||
: 0
|
||||
setIngestMessage(
|
||||
`Indexiere: ${progress.current_doc}/${progress.total_docs} (${pct}%) - ${progress.current_filename}`
|
||||
)
|
||||
} else if (progress.phase === 'complete') {
|
||||
setIngestMessage(
|
||||
`Fertig: ${progress.documents_processed} Dokumente, ` +
|
||||
`${progress.chunks_created} neue Chunks (${progress.old_chunks_deleted} alte geloescht)`
|
||||
)
|
||||
setReindexing(false)
|
||||
return
|
||||
} else if (progress.phase === 'failed') {
|
||||
setIngestMessage(`Fehler: ${progress.error || 'Unbekannter Fehler'}`)
|
||||
setReindexing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (progress.running) {
|
||||
setTimeout(pollProgress, 1000)
|
||||
} else {
|
||||
setReindexing(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setTimeout(pollProgress, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(pollProgress, 500)
|
||||
} catch (err) {
|
||||
setIngestMessage('Netzwerkfehler bei Re-Indexierung')
|
||||
setReindexing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
|
||||
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
|
||||
{collection.description && (
|
||||
<p className="text-sm text-slate-600 mt-1">{collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
|
||||
{statusLabels[collection.status]}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded">
|
||||
{useCaseLabels[collection.useCase] || collection.useCase}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{collection.chunkCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.years?.length > 0
|
||||
? `${Math.min(...collection.years)}-${Math.max(...collection.years)}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Faecher</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.subjects?.length > 0 ? collection.subjects.length : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{collection.bundesland}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collection.subjects.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{collection.subjects.slice(0, 8).map((subject) => (
|
||||
<span key={subject} className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md">{subject}</span>
|
||||
))}
|
||||
{collection.subjects.length > 8 && (
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">+{collection.subjects.length - 8} weitere</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingestion Buttons */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleIngest}
|
||||
disabled={ingesting || reindexing}
|
||||
className="px-4 py-2 text-sm font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-lg hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{ingesting ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>Wird gestartet...</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>Neue indexieren</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowReindexConfirm(true)}
|
||||
disabled={ingesting || reindexing || collection.chunkCount === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Alle Dokumente mit neuem Chunking-Algorithmus neu indexieren"
|
||||
>
|
||||
{reindexing ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600"></div>Re-Indexierung...</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>Neu-Chunking</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{ingestMessage && <p className="mt-2 text-sm text-slate-600">{ingestMessage}</p>}
|
||||
</div>
|
||||
|
||||
{/* Re-Index Confirmation Modal */}
|
||||
{showReindexConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Collection neu indexieren?</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Dies loescht alle {collection.chunkCount.toLocaleString()} bestehenden Chunks
|
||||
und erstellt sie mit dem gewaehlten Chunking-Algorithmus neu.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Chunking-Strategie</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="chunkingStrategy" value="semantic" checked={chunkingStrategy === 'semantic'} onChange={() => setChunkingStrategy('semantic')} className="text-primary-600" />
|
||||
<span className="text-sm"><strong>Semantisch</strong><span className="text-slate-500 ml-1">(empfohlen)</span></span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="chunkingStrategy" value="recursive" checked={chunkingStrategy === 'recursive'} onChange={() => setChunkingStrategy('recursive')} className="text-primary-600" />
|
||||
<span className="text-sm">Rekursiv (legacy)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">Semantisches Chunking respektiert Satzgrenzen und verbessert die Suchqualitaet.</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setShowReindexConfirm(false)} className="px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200">Abbrechen</button>
|
||||
<button onClick={handleReindex} className="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700">Neu indexieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Collection, CreateCollectionData } from '../types'
|
||||
import { CollectionCard } from './CollectionCard'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
@@ -11,11 +12,24 @@ interface CollectionsTabProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
function CollectionsTab({
|
||||
collections,
|
||||
loading,
|
||||
onRefresh
|
||||
}: CollectionsTabProps) {
|
||||
const useCaseOptions = [
|
||||
{ value: 'klausur', label: 'Klausurkorrektur' },
|
||||
{ value: 'zeugnis', label: 'Zeugniserstellung' },
|
||||
{ value: 'material', label: 'Unterrichtsmaterial' },
|
||||
{ value: 'curriculum', label: 'Lehrplan' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
const bundeslandOptions = [
|
||||
{ value: 'NI', label: 'Niedersachsen' },
|
||||
{ value: 'NW', label: 'Nordrhein-Westfalen' },
|
||||
{ value: 'BY', label: 'Bayern' },
|
||||
{ value: 'BW', label: 'Baden-Wuerttemberg' },
|
||||
{ value: 'HE', label: 'Hessen' },
|
||||
{ value: 'DE', label: 'Bundesweit' },
|
||||
]
|
||||
|
||||
function CollectionsTab({ collections, loading, onRefresh }: CollectionsTabProps) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
@@ -30,23 +44,15 @@ function CollectionsTab({
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false)
|
||||
setFormData({
|
||||
name: 'bp_',
|
||||
display_name: '',
|
||||
bundesland: 'NI',
|
||||
use_case: '',
|
||||
description: '',
|
||||
})
|
||||
setFormData({ name: 'bp_', display_name: '', bundesland: 'NI', use_case: '', description: '' })
|
||||
onRefresh()
|
||||
} else {
|
||||
const error = await res.json()
|
||||
@@ -59,23 +65,6 @@ function CollectionsTab({
|
||||
}
|
||||
}
|
||||
|
||||
const useCaseOptions = [
|
||||
{ value: 'klausur', label: 'Klausurkorrektur' },
|
||||
{ value: 'zeugnis', label: 'Zeugniserstellung' },
|
||||
{ value: 'material', label: 'Unterrichtsmaterial' },
|
||||
{ value: 'curriculum', label: 'Lehrplan' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
const bundeslandOptions = [
|
||||
{ value: 'NI', label: 'Niedersachsen' },
|
||||
{ value: 'NW', label: 'Nordrhein-Westfalen' },
|
||||
{ value: 'BY', label: 'Bayern' },
|
||||
{ value: 'BW', label: 'Baden-Württemberg' },
|
||||
{ value: 'HE', label: 'Hessen' },
|
||||
{ value: 'DE', label: 'Bundesweit' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -85,32 +74,20 @@ function CollectionsTab({
|
||||
<p className="text-sm text-slate-500">Verwaltung der indexierten Dokumentensammlungen</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<button onClick={onRefresh} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Aktualisieren</button>
|
||||
<button onClick={() => setShowCreateModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
Neue Sammlung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
{!loading && (
|
||||
<div className="grid gap-4">
|
||||
{collections.length === 0 ? (
|
||||
@@ -120,28 +97,14 @@ function CollectionsTab({
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Sammlungen vorhanden</h3>
|
||||
<p className="text-slate-500 mb-4">Erstellen Sie eine neue Sammlung, um Dokumente zu indexieren.</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Erste Sammlung erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowCreateModal(true)} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">Erste Sammlung erstellen</button>
|
||||
</div>
|
||||
) : (
|
||||
collections.map((col) => (
|
||||
<CollectionCard key={col.name} collection={col} />
|
||||
))
|
||||
collections.map((col) => <CollectionCard key={col.name} collection={col} />)
|
||||
)}
|
||||
|
||||
{/* Add new collection card */}
|
||||
{collections.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<button onClick={() => setShowCreateModal(true)} className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer">
|
||||
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
<span className="text-sm font-medium text-slate-600">Neue Sammlung erstellen</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -154,115 +117,43 @@ function CollectionsTab({
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Neue RAG-Sammlung erstellen</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellen Sie eine neue Sammlung für einen spezifischen Anwendungsfall
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Erstellen Sie eine neue Sammlung fuer einen spezifischen Anwendungsfall</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{createError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createError && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{createError}</div>}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Anzeigename *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="z.B. Niedersachsen - Zeugniserstellung"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename *</label>
|
||||
<input type="text" value={formData.display_name} onChange={(e) => setFormData(prev => ({ ...prev, display_name: e.target.value }))} placeholder="z.B. Niedersachsen - Zeugniserstellung" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Bundesland
|
||||
</label>
|
||||
<select
|
||||
value={formData.bundesland}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, bundesland: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{bundeslandOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Bundesland</label>
|
||||
<select value={formData.bundesland} onChange={(e) => setFormData(prev => ({ ...prev, bundesland: e.target.value }))} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
|
||||
{bundeslandOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Anwendungsfall *
|
||||
</label>
|
||||
<select
|
||||
value={formData.use_case}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, use_case: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Auswählen...</option>
|
||||
{useCaseOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anwendungsfall *</label>
|
||||
<select value={formData.use_case} onChange={(e) => setFormData(prev => ({ ...prev, use_case: e.target.value }))} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
|
||||
<option value="">Auswaehlen...</option>
|
||||
{useCaseOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Technischer Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="bp_ni_zeugnis"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Technischer Name *</label>
|
||||
<input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="bp_ni_zeugnis" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm" />
|
||||
<p className="text-xs text-slate-500 mt-1">Muss mit "bp_" beginnen. Nur Kleinbuchstaben und Unterstriche.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Wofür wird diese Sammlung verwendet?"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<textarea value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="Wofuer wird diese Sammlung verwendet?" rows={3} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false)
|
||||
setCreateError(null)
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !formData.name || !formData.display_name || !formData.use_case}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
'Sammlung erstellen'
|
||||
)}
|
||||
<button onClick={() => { setShowCreateModal(false); setCreateError(null) }} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Abbrechen</button>
|
||||
<button onClick={handleCreate} disabled={creating || !formData.name || !formData.display_name || !formData.use_case} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
||||
{creating ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Wird erstellt...</>) : 'Sammlung erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,322 +163,4 @@ function CollectionsTab({
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionCard({ collection }: { collection: Collection }) {
|
||||
const [ingesting, setIngesting] = useState(false)
|
||||
const [reindexing, setReindexing] = useState(false)
|
||||
const [ingestMessage, setIngestMessage] = useState<string | null>(null)
|
||||
const [showReindexConfirm, setShowReindexConfirm] = useState(false)
|
||||
const [chunkingStrategy, setChunkingStrategy] = useState<'semantic' | 'recursive'>('semantic')
|
||||
|
||||
const statusColors = {
|
||||
ready: 'bg-green-100 text-green-800',
|
||||
indexing: 'bg-yellow-100 text-yellow-800',
|
||||
empty: 'bg-slate-100 text-slate-800',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
ready: 'Bereit',
|
||||
indexing: 'Indexierung...',
|
||||
empty: 'Leer',
|
||||
}
|
||||
|
||||
const useCaseLabels: Record<string, string> = {
|
||||
klausur: 'Klausurkorrektur',
|
||||
zeugnis: 'Zeugniserstellung',
|
||||
material: 'Unterrichtsmaterial',
|
||||
curriculum: 'Lehrplan',
|
||||
other: 'Sonstiges',
|
||||
unknown: 'Unbekannt',
|
||||
}
|
||||
|
||||
const handleIngest = async () => {
|
||||
setIngesting(true)
|
||||
setIngestMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/ingest?incremental=true`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIngestMessage(data.message || 'Indexierung gestartet')
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setIngestMessage(error.detail || 'Fehler beim Starten')
|
||||
}
|
||||
} catch (err) {
|
||||
setIngestMessage('Netzwerkfehler')
|
||||
} finally {
|
||||
setIngesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReindex = async () => {
|
||||
setShowReindexConfirm(false)
|
||||
setReindexing(true)
|
||||
setIngestMessage('Starte Re-Indexierung...')
|
||||
|
||||
try {
|
||||
// Start the reindex
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/admin/rag/collections/${collection.name}/reindex?chunking_strategy=${chunkingStrategy}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
setIngestMessage(error.detail || 'Fehler beim Starten')
|
||||
setReindexing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for progress
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressRes = await fetch(`${API_BASE}/api/v1/admin/rag/reindex/progress`)
|
||||
if (progressRes.ok) {
|
||||
const progress = await progressRes.json()
|
||||
|
||||
if (progress.phase === 'deleting') {
|
||||
setIngestMessage('Lösche alte Chunks...')
|
||||
} else if (progress.phase === 'indexing') {
|
||||
const pct = progress.total_docs > 0
|
||||
? Math.round((progress.current_doc / progress.total_docs) * 100)
|
||||
: 0
|
||||
setIngestMessage(
|
||||
`Indexiere: ${progress.current_doc}/${progress.total_docs} (${pct}%) - ${progress.current_filename}`
|
||||
)
|
||||
} else if (progress.phase === 'complete') {
|
||||
setIngestMessage(
|
||||
`Fertig: ${progress.documents_processed} Dokumente, ` +
|
||||
`${progress.chunks_created} neue Chunks (${progress.old_chunks_deleted} alte gelöscht)`
|
||||
)
|
||||
setReindexing(false)
|
||||
return // Stop polling
|
||||
} else if (progress.phase === 'failed') {
|
||||
setIngestMessage(`Fehler: ${progress.error || 'Unbekannter Fehler'}`)
|
||||
setReindexing(false)
|
||||
return // Stop polling
|
||||
}
|
||||
|
||||
// Continue polling if still running
|
||||
if (progress.running) {
|
||||
setTimeout(pollProgress, 1000)
|
||||
} else {
|
||||
setReindexing(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore polling errors, will retry
|
||||
setTimeout(pollProgress, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling after a short delay
|
||||
setTimeout(pollProgress, 500)
|
||||
|
||||
} catch (err) {
|
||||
setIngestMessage('Netzwerkfehler bei Re-Indexierung')
|
||||
setReindexing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
|
||||
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
|
||||
{collection.description && (
|
||||
<p className="text-sm text-slate-600 mt-1">{collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
|
||||
{statusLabels[collection.status]}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded">
|
||||
{useCaseLabels[collection.useCase] || collection.useCase}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.chunkCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.years?.length > 0
|
||||
? `${Math.min(...collection.years)}-${Math.max(...collection.years)}`
|
||||
: '-'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Fächer</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.subjects?.length > 0 ? collection.subjects.length : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{collection.bundesland}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collection.subjects.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{collection.subjects.slice(0, 8).map((subject) => (
|
||||
<span
|
||||
key={subject}
|
||||
className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md"
|
||||
>
|
||||
{subject}
|
||||
</span>
|
||||
))}
|
||||
{collection.subjects.length > 8 && (
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">
|
||||
+{collection.subjects.length - 8} weitere
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingestion Buttons */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleIngest}
|
||||
disabled={ingesting || reindexing}
|
||||
className="px-4 py-2 text-sm font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-lg hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{ingesting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
||||
Wird gestartet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue indexieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowReindexConfirm(true)}
|
||||
disabled={ingesting || reindexing || collection.chunkCount === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Alle Dokumente mit neuem Chunking-Algorithmus neu indexieren"
|
||||
>
|
||||
{reindexing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-amber-600"></div>
|
||||
Re-Indexierung...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Neu-Chunking
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ingestMessage && (
|
||||
<p className="mt-2 text-sm text-slate-600">{ingestMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Re-Index Confirmation Modal */}
|
||||
{showReindexConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">
|
||||
Collection neu indexieren?
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Dies löscht alle {collection.chunkCount.toLocaleString()} bestehenden Chunks
|
||||
und erstellt sie mit dem gewählten Chunking-Algorithmus neu.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Chunking-Strategie
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="chunkingStrategy"
|
||||
value="semantic"
|
||||
checked={chunkingStrategy === 'semantic'}
|
||||
onChange={() => setChunkingStrategy('semantic')}
|
||||
className="text-primary-600"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<strong>Semantisch</strong>
|
||||
<span className="text-slate-500 ml-1">(empfohlen)</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="chunkingStrategy"
|
||||
value="recursive"
|
||||
checked={chunkingStrategy === 'recursive'}
|
||||
onChange={() => setChunkingStrategy('recursive')}
|
||||
className="text-primary-600"
|
||||
/>
|
||||
<span className="text-sm">Rekursiv (legacy)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Semantisches Chunking respektiert Satzgrenzen und verbessert die Suchqualität.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowReindexConfirm(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReindex}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700"
|
||||
>
|
||||
Neu indexieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Upload Tab
|
||||
// ============================================================================
|
||||
|
||||
|
||||
export { CollectionsTab, CollectionCard }
|
||||
|
||||
106
website/app/admin/rag/components/IngestionHistory.tsx
Normal file
106
website/app/admin/rag/components/IngestionHistory.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { IngestionHistoryEntry } from '../types'
|
||||
|
||||
interface IngestionHistoryProps {
|
||||
history: IngestionHistoryEntry[]
|
||||
}
|
||||
|
||||
export function IngestionHistory({ history }: IngestionHistoryProps) {
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Indexierungs-Historie</h3>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 font-medium"
|
||||
>
|
||||
{showHistory ? 'Ausblenden' : `Alle anzeigen (${history.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Noch keine Indexierungslaeufe durchgefuehrt.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(showHistory ? history : history.slice(0, 3)).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`rounded-lg p-4 ${
|
||||
entry.status === 'success'
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.status === 'success' ? (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium text-slate-900">
|
||||
{new Date(entry.started_at || entry.startedAt).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{entry.collection}</span>
|
||||
</div>
|
||||
|
||||
{entry.stats && (
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Gefunden:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.documents_found}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Indexiert:</span>{' '}
|
||||
<span className="font-medium text-green-700">{entry.stats.documents_indexed}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Uebersprungen:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.documents_skipped}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Chunks:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.chunks_created}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.filters && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{entry.filters.year && (
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
|
||||
Jahr: {entry.filters.year}
|
||||
</span>
|
||||
)}
|
||||
{entry.filters.subject && (
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
|
||||
Fach: {entry.filters.subject}
|
||||
</span>
|
||||
)}
|
||||
{entry.filters.incremental && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
|
||||
Inkrementell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<p className="mt-2 text-sm text-red-700">{entry.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type {
|
||||
Collection,
|
||||
IngestionStatus,
|
||||
LiveProgress,
|
||||
IndexedStats,
|
||||
PendingFile,
|
||||
PendingFilesData,
|
||||
IngestionHistoryEntry
|
||||
} from '../types'
|
||||
import { IngestionHistory } from './IngestionHistory'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
@@ -18,117 +17,66 @@ interface IngestionTabProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
function IngestionTab({
|
||||
status,
|
||||
onRefresh
|
||||
}: IngestionTabProps) {
|
||||
function IngestionTab({ status, onRefresh }: IngestionTabProps) {
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [liveProgress, setLiveProgress] = useState<LiveProgress | null>(null)
|
||||
const [indexedStats, setIndexedStats] = useState<IndexedStats | null>(null)
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFilesData | null>(null)
|
||||
const [ingestionHistory, setIngestionHistory] = useState<IngestionHistoryEntry[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showPending, setShowPending] = useState(false)
|
||||
|
||||
// Fetch indexed stats, pending files, and history on mount
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIndexedStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`); if (res.ok) setIndexedStats(await res.json()) }
|
||||
catch (err) { console.error('Failed to fetch stats:', err) }
|
||||
}
|
||||
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/rag/files/pending`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPendingFiles(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending files:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}/api/v1/admin/rag/files/pending`); if (res.ok) setPendingFiles(await res.json()) }
|
||||
catch (err) { console.error('Failed to fetch pending files:', err) }
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/rag/ingestion/history?limit=20`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIngestionHistory(data.history || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch history:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}/api/v1/admin/rag/ingestion/history?limit=20`); if (res.ok) { const data = await res.json(); setIngestionHistory(data.history || []) } }
|
||||
catch (err) { console.error('Failed to fetch history:', err) }
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
fetchPending()
|
||||
fetchHistory()
|
||||
fetchStats(); fetchPending(); fetchHistory()
|
||||
}, [])
|
||||
|
||||
// Poll for live progress when running
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
|
||||
const fetchProgress = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/progress`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLiveProgress(data)
|
||||
// Refresh stats when complete
|
||||
if (!data.running && data.phase === 'complete') {
|
||||
onRefresh()
|
||||
// Also refresh indexed stats
|
||||
const statsRes = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`)
|
||||
if (statsRes.ok) {
|
||||
setIndexedStats(await statsRes.json())
|
||||
}
|
||||
if (statsRes.ok) setIndexedStats(await statsRes.json())
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch progress:', err)
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch progress:', err) }
|
||||
}
|
||||
|
||||
// Start polling immediately and every 1.5 seconds
|
||||
fetchProgress()
|
||||
interval = setInterval(fetchProgress, 1500)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
return () => { if (interval) clearInterval(interval) }
|
||||
}, [onRefresh])
|
||||
|
||||
const startIngestion = async () => {
|
||||
setStarting(true)
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/admin/nibis/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ewh_only: true, incremental: true }),
|
||||
})
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to start ingestion:', err)
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
} catch (err) { console.error('Failed to start ingestion:', err) }
|
||||
finally { setStarting(false) }
|
||||
}
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
idle: 'Bereit',
|
||||
extracting: 'Entpacke ZIP-Dateien...',
|
||||
discovering: 'Suche Dokumente...',
|
||||
indexing: 'Indexiere Dokumente...',
|
||||
complete: 'Abgeschlossen',
|
||||
idle: 'Bereit', extracting: 'Entpacke ZIP-Dateien...', discovering: 'Suche Dokumente...',
|
||||
indexing: 'Indexiere Dokumente...', complete: 'Abgeschlossen',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -136,131 +84,58 @@ function IngestionTab({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Ingestion Status</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Übersicht über laufende und vergangene Indexierungsvorgänge
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Uebersicht ueber laufende und vergangene Indexierungsvorgaenge</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={startIngestion}
|
||||
disabled={status?.running || starting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button onClick={onRefresh} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Aktualisieren</button>
|
||||
<button onClick={startIngestion} disabled={status?.running || starting} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{starting ? 'Startet...' : 'Ingestion starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Progress (when running) */}
|
||||
{/* Live Progress */}
|
||||
{liveProgress?.running && (
|
||||
<div className="bg-primary-50 rounded-lg border border-primary-200 p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-3 h-3 bg-primary-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-lg font-medium text-primary-900">
|
||||
{liveProgress.phase ? (phaseLabels[liveProgress.phase] || liveProgress.phase) : 'Läuft...'}
|
||||
</span>
|
||||
<span className="ml-auto text-sm text-primary-700 font-mono">
|
||||
{(liveProgress.percent ?? 0).toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-lg font-medium text-primary-900">{liveProgress.phase ? (phaseLabels[liveProgress.phase] || liveProgress.phase) : 'Laeuft...'}</span>
|
||||
<span className="ml-auto text-sm text-primary-700 font-mono">{(liveProgress.percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-3 bg-primary-200 rounded-full overflow-hidden mb-4">
|
||||
<div
|
||||
className="h-full bg-primary-600 transition-all duration-300"
|
||||
style={{ width: `${liveProgress.percent ?? 0}%` }}
|
||||
/>
|
||||
<div className="h-full bg-primary-600 transition-all duration-300" style={{ width: `${liveProgress.percent ?? 0}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Current File */}
|
||||
{liveProgress.current_filename && (
|
||||
<p className="text-sm text-primary-700 mb-4 truncate">
|
||||
<span className="font-medium">[{liveProgress.current_doc}/{liveProgress.total_docs}]</span>{' '}
|
||||
{liveProgress.current_filename}
|
||||
<span className="font-medium">[{liveProgress.current_doc}/{liveProgress.total_docs}]</span> {liveProgress.current_filename}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Live Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-white/50 rounded p-2">
|
||||
<p className="text-primary-600 text-xs uppercase">Indexiert</p>
|
||||
<p className="text-lg font-semibold text-primary-900">{liveProgress.documents_indexed}</p>
|
||||
</div>
|
||||
<div className="bg-white/50 rounded p-2">
|
||||
<p className="text-primary-600 text-xs uppercase">Übersprungen</p>
|
||||
<p className="text-lg font-semibold text-primary-900">{liveProgress.documents_skipped}</p>
|
||||
</div>
|
||||
<div className="bg-white/50 rounded p-2">
|
||||
<p className="text-primary-600 text-xs uppercase">Chunks</p>
|
||||
<p className="text-lg font-semibold text-primary-900">{liveProgress.chunks_created}</p>
|
||||
</div>
|
||||
<div className="bg-white/50 rounded p-2">
|
||||
<p className="text-primary-600 text-xs uppercase">Fehler</p>
|
||||
<p className={`text-lg font-semibold ${(liveProgress.errors_count ?? 0) > 0 ? 'text-red-600' : 'text-primary-900'}`}>
|
||||
{liveProgress.errors_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Indexiert</p><p className="text-lg font-semibold text-primary-900">{liveProgress.documents_indexed}</p></div>
|
||||
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Uebersprungen</p><p className="text-lg font-semibold text-primary-900">{liveProgress.documents_skipped}</p></div>
|
||||
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Chunks</p><p className="text-lg font-semibold text-primary-900">{liveProgress.chunks_created}</p></div>
|
||||
<div className="bg-white/50 rounded p-2"><p className="text-primary-600 text-xs uppercase">Fehler</p><p className={`text-lg font-semibold ${(liveProgress.errors_count ?? 0) > 0 ? 'text-red-600' : 'text-primary-900'}`}>{liveProgress.errors_count ?? 0}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indexed Data Overview - Always visible */}
|
||||
{/* Indexed Data Overview */}
|
||||
{indexedStats?.indexed && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-green-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
Indexierte Daten (Gesamt)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-4">
|
||||
<div className="bg-white/60 rounded-lg p-3">
|
||||
<p className="text-xs text-green-700 uppercase tracking-wider">Chunks gesamt</p>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
{(indexedStats.total_chunks ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3">
|
||||
<p className="text-xs text-green-700 uppercase tracking-wider">Jahre</p>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
{indexedStats.years?.length ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
{(indexedStats.years?.length ?? 0) > 0
|
||||
? `${Math.min(...indexedStats.years!)} - ${Math.max(...indexedStats.years!)}`
|
||||
: '-'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3">
|
||||
<p className="text-xs text-green-700 uppercase tracking-wider">Fächer</p>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
{indexedStats.subjects?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3">
|
||||
<p className="text-xs text-green-700 uppercase tracking-wider">Status</p>
|
||||
<p className="text-lg font-bold text-green-600">Bereit</p>
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Chunks gesamt</p><p className="text-2xl font-bold text-green-900">{(indexedStats.total_chunks ?? 0).toLocaleString()}</p></div>
|
||||
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Jahre</p><p className="text-2xl font-bold text-green-900">{indexedStats.years?.length ?? 0}</p><p className="text-xs text-green-600 mt-1">{(indexedStats.years?.length ?? 0) > 0 ? `${Math.min(...indexedStats.years!)} - ${Math.max(...indexedStats.years!)}` : '-'}</p></div>
|
||||
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Faecher</p><p className="text-2xl font-bold text-green-900">{indexedStats.subjects?.length || 0}</p></div>
|
||||
<div className="bg-white/60 rounded-lg p-3"><p className="text-xs text-green-700 uppercase tracking-wider">Status</p><p className="text-lg font-bold text-green-600">Bereit</p></div>
|
||||
</div>
|
||||
|
||||
{/* Years breakdown */}
|
||||
{indexedStats.years && indexedStats.years.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{indexedStats.years.sort((a, b) => a - b).map((year) => (
|
||||
<span
|
||||
key={year}
|
||||
className="px-3 py-1 bg-white/80 text-green-800 text-sm rounded-full font-medium"
|
||||
>
|
||||
{year}
|
||||
</span>
|
||||
<span key={year} className="px-3 py-1 bg-white/80 text-green-800 text-sm rounded-full font-medium">{year}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -271,63 +146,27 @@ function IngestionTab({
|
||||
{!liveProgress?.running && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Letzter Indexierungslauf</h3>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="text-lg font-medium text-slate-900">
|
||||
{liveProgress?.phase === 'complete' ? 'Abgeschlossen' : 'Bereit'}
|
||||
</span>
|
||||
<span className="text-lg font-medium text-slate-900">{liveProgress?.phase === 'complete' ? 'Abgeschlossen' : 'Bereit'}</span>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Zeitpunkt</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{status.lastRun
|
||||
? new Date(status.lastRun).toLocaleString('de-DE')
|
||||
: 'Noch nie'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Neu indexiert</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{status.documentsIndexed ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Neue Chunks</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{status.chunksCreated ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p>
|
||||
<p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{status.errors.length}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Zeitpunkt</p><p className="text-lg font-semibold text-slate-900">{status.lastRun ? new Date(status.lastRun).toLocaleString('de-DE') : 'Noch nie'}</p></div>
|
||||
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Neu indexiert</p><p className="text-lg font-semibold text-slate-900">{status.documentsIndexed ?? '-'}</p></div>
|
||||
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Neue Chunks</p><p className="text-lg font-semibold text-slate-900">{status.chunksCreated ?? '-'}</p></div>
|
||||
<div><p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p><p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>{status.errors.length}</p></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show skipped info from live progress */}
|
||||
{liveProgress && (liveProgress.documents_skipped ?? 0) > 0 && (
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
{liveProgress.documents_skipped} Dokumente übersprungen (bereits indexiert)
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-slate-500">{liveProgress.documents_skipped} Dokumente uebersprungen (bereits indexiert)</p>
|
||||
)}
|
||||
|
||||
{status?.errors && status.errors.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{status.errors.slice(0, 5).map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
{status.errors.length > 5 && (
|
||||
<li className="text-red-500">... und {status.errors.length - 5} weitere</li>
|
||||
)}
|
||||
{status.errors.slice(0, 5).map((error, i) => <li key={i}>{error}</li>)}
|
||||
{status.errors.length > 5 && <li className="text-red-500">... und {status.errors.length - 5} weitere</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,174 +178,40 @@ function IngestionTab({
|
||||
<div className="bg-amber-50 rounded-lg border border-amber-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-amber-900">
|
||||
{pendingFiles.pending_count} Dateien warten auf Indexierung
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700">
|
||||
{pendingFiles.indexed_count} von {pendingFiles.total_files} Dateien sind indexiert
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-amber-900">{pendingFiles.pending_count} Dateien warten auf Indexierung</h3>
|
||||
<p className="text-sm text-amber-700">{pendingFiles.indexed_count} von {pendingFiles.total_files} Dateien sind indexiert</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPending(!showPending)}
|
||||
className="text-sm text-amber-700 hover:text-amber-900 font-medium"
|
||||
>
|
||||
{showPending ? 'Ausblenden' : 'Details anzeigen'}
|
||||
</button>
|
||||
<button onClick={() => setShowPending(!showPending)} className="text-sm text-amber-700 hover:text-amber-900 font-medium">{showPending ? 'Ausblenden' : 'Details anzeigen'}</button>
|
||||
</div>
|
||||
|
||||
{/* Pending by year summary */}
|
||||
{pendingFiles.by_year && Object.keys(pendingFiles.by_year).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{Object.entries(pendingFiles.by_year)
|
||||
.sort(([a], [b]) => Number(b) - Number(a))
|
||||
.map(([year, count]) => (
|
||||
<span
|
||||
key={year}
|
||||
className="px-3 py-1 bg-amber-100 text-amber-800 text-sm rounded-full font-medium"
|
||||
>
|
||||
{year}: {count} Dateien
|
||||
</span>
|
||||
))}
|
||||
{Object.entries(pendingFiles.by_year).sort(([a], [b]) => Number(b) - Number(a)).map(([year, count]) => (
|
||||
<span key={year} className="px-3 py-1 bg-amber-100 text-amber-800 text-sm rounded-full font-medium">{year}: {count} Dateien</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending files list */}
|
||||
{showPending && pendingFiles.pending_files && (
|
||||
<div className="mt-4 max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-amber-700 border-b border-amber-200">
|
||||
<tr>
|
||||
<th className="pb-2">Dateiname</th>
|
||||
<th className="pb-2">Jahr</th>
|
||||
<th className="pb-2">Fach</th>
|
||||
<th className="pb-2">Niveau</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead className="text-left text-amber-700 border-b border-amber-200"><tr><th className="pb-2">Dateiname</th><th className="pb-2">Jahr</th><th className="pb-2">Fach</th><th className="pb-2">Niveau</th></tr></thead>
|
||||
<tbody className="divide-y divide-amber-100">
|
||||
{pendingFiles.pending_files.map((file) => (
|
||||
<tr key={file.id} className="text-amber-900">
|
||||
<td className="py-2 font-mono text-xs">{file.filename}</td>
|
||||
<td className="py-2">{file.year}</td>
|
||||
<td className="py-2">{file.subject}</td>
|
||||
<td className="py-2">{file.niveau}</td>
|
||||
</tr>
|
||||
<tr key={file.id} className="text-amber-900"><td className="py-2 font-mono text-xs">{file.filename}</td><td className="py-2">{file.year}</td><td className="py-2">{file.subject}</td><td className="py-2">{file.niveau}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(pendingFiles.pending_count ?? 0) > 100 && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
Zeige 100 von {pendingFiles.pending_count} Dateien
|
||||
</p>
|
||||
)}
|
||||
{(pendingFiles.pending_count ?? 0) > 100 && <p className="mt-2 text-xs text-amber-600">Zeige 100 von {pendingFiles.pending_count} Dateien</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingestion History Section */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Indexierungs-Historie</h3>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 font-medium"
|
||||
>
|
||||
{showHistory ? 'Ausblenden' : `Alle anzeigen (${ingestionHistory.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ingestionHistory.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Noch keine Indexierungsläufe durchgeführt.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(showHistory ? ingestionHistory : ingestionHistory.slice(0, 3)).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`rounded-lg p-4 ${
|
||||
entry.status === 'success'
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.status === 'success' ? (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium text-slate-900">
|
||||
{new Date(entry.started_at || entry.startedAt).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{entry.collection}</span>
|
||||
</div>
|
||||
|
||||
{entry.stats && (
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Gefunden:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.documents_found}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Indexiert:</span>{' '}
|
||||
<span className="font-medium text-green-700">{entry.stats.documents_indexed}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Übersprungen:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.documents_skipped}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Chunks:</span>{' '}
|
||||
<span className="font-medium">{entry.stats.chunks_created}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.filters && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{entry.filters.year && (
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
|
||||
Jahr: {entry.filters.year}
|
||||
</span>
|
||||
)}
|
||||
{entry.filters.subject && (
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded">
|
||||
Fach: {entry.filters.subject}
|
||||
</span>
|
||||
)}
|
||||
{entry.filters.incremental && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
|
||||
Inkrementell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<p className="mt-2 text-sm text-red-700">{entry.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IngestionHistory history={ingestionHistory} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Documents Tab
|
||||
// ============================================================================
|
||||
|
||||
|
||||
export { IngestionTab }
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* RAG Admin Page Components
|
||||
*/
|
||||
|
||||
export { CollectionsTab, CollectionCard } from './CollectionsTab'
|
||||
export { CollectionsTab } from './CollectionsTab'
|
||||
export { CollectionCard } from './CollectionCard'
|
||||
export { UploadTab } from './UploadTab'
|
||||
export { IngestionTab } from './IngestionTab'
|
||||
export { DocumentsTab } from './DocumentsTab'
|
||||
|
||||
92
website/app/admin/sbom/_components/SBOMTable.tsx
Normal file
92
website/app/admin/sbom/_components/SBOMTable.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Component } from './sbom-data'
|
||||
import { getCategoryColor, getLicenseColor } from './sbom-data'
|
||||
|
||||
interface SBOMTableProps {
|
||||
components: Component[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function SBOMTable({ components, loading }: SBOMTableProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<span className="ml-3 text-gray-600">Lade SBOM...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{components.map((component, idx) => {
|
||||
const licenseId = component.license || component.licenses?.[0]?.license?.id
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{component.name}</div>
|
||||
{component.description && (
|
||||
<div className="text-xs text-gray-500 max-w-xs truncate">{component.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-gray-900">{component.version}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
|
||||
{component.category || component.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.port ? (
|
||||
<span className="text-sm font-mono text-gray-600">{component.port}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{licenseId ? (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
|
||||
{licenseId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.sourceUrl && component.sourceUrl !== '-' ? (
|
||||
<a href={component.sourceUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-800 text-sm flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{components.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">Keine Komponenten gefunden.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
website/app/admin/sbom/_components/sbom-data.ts
Normal file
164
website/app/admin/sbom/_components/sbom-data.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
export interface Component {
|
||||
type: string
|
||||
name: string
|
||||
version: string
|
||||
purl?: string
|
||||
licenses?: { license: { id: string } }[]
|
||||
category?: string
|
||||
port?: string
|
||||
description?: string
|
||||
license?: string
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
export interface SBOMData {
|
||||
bomFormat?: string
|
||||
specVersion?: string
|
||||
version?: number
|
||||
metadata?: {
|
||||
timestamp?: string
|
||||
tools?: { vendor: string; name: string; version: string }[]
|
||||
component?: { type: string; name: string; version: string }
|
||||
}
|
||||
components?: Component[]
|
||||
}
|
||||
|
||||
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs'
|
||||
|
||||
// Infrastructure components from docker-compose.yml and project analysis
|
||||
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== DATABASES =====
|
||||
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
||||
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
||||
// ===== CACHE & QUEUE =====
|
||||
{ type: 'service', name: 'Redis', version: 'alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
{ type: 'service', name: 'ERPNext Redis Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
{ type: 'service', name: 'ERPNext Redis Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
// ===== SEARCH ENGINES =====
|
||||
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
||||
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
||||
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
||||
// ===== OBJECT STORAGE =====
|
||||
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
||||
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
||||
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
||||
// ===== SECURITY =====
|
||||
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||
// ===== COMMUNICATION =====
|
||||
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
||||
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
||||
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
||||
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
||||
// ===== APPLICATION SERVICES (Python) =====
|
||||
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API & Studio', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
||||
// ===== APPLICATION SERVICES (Go) =====
|
||||
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
||||
// ===== APPLICATION SERVICES (Node.js) =====
|
||||
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
||||
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
// ===== APPLICATION SERVICES (Vue) =====
|
||||
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
// ===== AI/LLM SERVICES =====
|
||||
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
||||
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
||||
// ===== ERP =====
|
||||
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
||||
// ===== DEVELOPMENT =====
|
||||
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||
]
|
||||
|
||||
export const SECURITY_TOOLS: Component[] = [
|
||||
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
||||
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
||||
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
||||
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
||||
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
||||
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
||||
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
||||
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
||||
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
||||
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
||||
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
||||
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
||||
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
||||
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
||||
]
|
||||
|
||||
export const PYTHON_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
||||
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
||||
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
||||
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
||||
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
||||
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
||||
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
||||
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
||||
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
||||
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
||||
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
||||
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
||||
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
||||
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
||||
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
||||
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
||||
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
||||
]
|
||||
|
||||
export const GO_MODULES: Component[] = [
|
||||
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
||||
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
||||
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
||||
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
||||
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
||||
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
||||
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
||||
]
|
||||
|
||||
export const NODE_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
||||
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
||||
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
||||
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
||||
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
||||
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
||||
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
||||
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
||||
]
|
||||
|
||||
export const getCategoryColor = (category?: string) => {
|
||||
switch (category) {
|
||||
case 'database': return 'bg-blue-100 text-blue-800'
|
||||
case 'security': return 'bg-purple-100 text-purple-800'
|
||||
case 'security-tool': return 'bg-red-100 text-red-800'
|
||||
case 'application': return 'bg-green-100 text-green-800'
|
||||
case 'communication': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'storage': return 'bg-orange-100 text-orange-800'
|
||||
case 'search': return 'bg-pink-100 text-pink-800'
|
||||
case 'erp': return 'bg-indigo-100 text-indigo-800'
|
||||
case 'cache': return 'bg-cyan-100 text-cyan-800'
|
||||
case 'ai': return 'bg-violet-100 text-violet-800'
|
||||
case 'development': return 'bg-gray-100 text-gray-800'
|
||||
case 'python': return 'bg-emerald-100 text-emerald-800'
|
||||
case 'go': return 'bg-sky-100 text-sky-800'
|
||||
case 'nodejs': return 'bg-lime-100 text-lime-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
}
|
||||
|
||||
export const getLicenseColor = (license?: string) => {
|
||||
if (!license) return 'bg-gray-100 text-gray-600'
|
||||
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
|
||||
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
|
||||
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
|
||||
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
@@ -2,250 +2,56 @@
|
||||
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) Admin Page
|
||||
*
|
||||
* Displays:
|
||||
* - All infrastructure components (Docker services)
|
||||
* - Python/Go dependencies
|
||||
* - Node.js packages
|
||||
* - License information
|
||||
* - Version tracking
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
|
||||
interface Component {
|
||||
type: string
|
||||
name: string
|
||||
version: string
|
||||
purl?: string
|
||||
licenses?: { license: { id: string } }[]
|
||||
category?: string
|
||||
port?: string
|
||||
description?: string
|
||||
license?: string
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
interface SBOMData {
|
||||
bomFormat?: string
|
||||
specVersion?: string
|
||||
version?: number
|
||||
metadata?: {
|
||||
timestamp?: string
|
||||
tools?: { vendor: string; name: string; version: string }[]
|
||||
component?: { type: string; name: string; version: string }
|
||||
}
|
||||
components?: Component[]
|
||||
}
|
||||
|
||||
type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs'
|
||||
|
||||
// Infrastructure components from docker-compose.yml and project analysis
|
||||
const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== DATABASES =====
|
||||
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
||||
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
||||
|
||||
// ===== CACHE & QUEUE =====
|
||||
{ type: 'service', name: 'Redis', version: 'alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
{ type: 'service', name: 'ERPNext Redis Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
{ type: 'service', name: 'ERPNext Redis Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/redis/redis' },
|
||||
|
||||
// ===== SEARCH ENGINES =====
|
||||
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
||||
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
||||
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
||||
|
||||
// ===== OBJECT STORAGE =====
|
||||
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
||||
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
||||
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== SECURITY =====
|
||||
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||
|
||||
// ===== COMMUNICATION =====
|
||||
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
||||
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
||||
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
||||
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Python) =====
|
||||
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API & Studio', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Go) =====
|
||||
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Node.js) =====
|
||||
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
||||
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Vue) =====
|
||||
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== AI/LLM SERVICES =====
|
||||
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
||||
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
||||
|
||||
// ===== ERP =====
|
||||
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
||||
|
||||
// ===== DEVELOPMENT =====
|
||||
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||
]
|
||||
|
||||
// Security Tools discovered in project
|
||||
const SECURITY_TOOLS: Component[] = [
|
||||
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
||||
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
||||
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
||||
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
||||
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
||||
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
||||
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
||||
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
||||
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
||||
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
||||
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
||||
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
||||
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
||||
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
||||
]
|
||||
|
||||
// Key Python packages (from requirements.txt)
|
||||
const PYTHON_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
||||
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
||||
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
||||
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
||||
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
||||
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
||||
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
||||
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
||||
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
||||
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
||||
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
||||
// Mail Module Dependencies
|
||||
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
||||
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
||||
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
||||
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
||||
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
||||
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
||||
]
|
||||
|
||||
// Key Go modules (from go.mod files)
|
||||
const GO_MODULES: Component[] = [
|
||||
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
||||
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
||||
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
||||
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
||||
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
||||
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
||||
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
||||
]
|
||||
|
||||
// Key Node.js packages (from package.json files)
|
||||
const NODE_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
||||
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
||||
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
||||
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
||||
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
||||
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
||||
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
||||
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
||||
]
|
||||
import {
|
||||
Component,
|
||||
SBOMData,
|
||||
CategoryType,
|
||||
INFRASTRUCTURE_COMPONENTS,
|
||||
SECURITY_TOOLS,
|
||||
PYTHON_PACKAGES,
|
||||
GO_MODULES,
|
||||
NODE_PACKAGES,
|
||||
} from './_components/sbom-data'
|
||||
import { SBOMTable } from './_components/SBOMTable'
|
||||
|
||||
export default function SBOMPage() {
|
||||
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryType>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
useEffect(() => {
|
||||
const loadSBOM = async () => {
|
||||
setLoading(true)
|
||||
try { const res = await fetch(`${BACKEND_URL}/api/v1/security/sbom`); if (res.ok) setSbomData(await res.json()) }
|
||||
catch (error) { console.error('Failed to load SBOM:', error) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
loadSBOM()
|
||||
}, [])
|
||||
|
||||
const loadSBOM = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/security/sbom`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSbomData(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load SBOM:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllComponents = (): Component[] => {
|
||||
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'infrastructure'
|
||||
}))
|
||||
|
||||
const securityToolsComponents = SECURITY_TOOLS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'security-tool'
|
||||
}))
|
||||
|
||||
const pythonComponents = PYTHON_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
const goComponents = GO_MODULES.map(c => ({
|
||||
...c,
|
||||
category: 'go'
|
||||
}))
|
||||
|
||||
const nodeComponents = NODE_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'nodejs'
|
||||
}))
|
||||
|
||||
// Add dynamic SBOM data from backend if available
|
||||
const dynamicPython = (sbomData?.components || []).map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...dynamicPython]
|
||||
}
|
||||
|
||||
const getFilteredComponents = () => {
|
||||
let components = getAllComponents()
|
||||
|
||||
if (activeCategory !== 'all') {
|
||||
if (activeCategory === 'infrastructure') {
|
||||
components = INFRASTRUCTURE_COMPONENTS
|
||||
} else if (activeCategory === 'security-tools') {
|
||||
components = SECURITY_TOOLS
|
||||
} else if (activeCategory === 'python') {
|
||||
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
|
||||
} else if (activeCategory === 'go') {
|
||||
components = GO_MODULES
|
||||
} else if (activeCategory === 'nodejs') {
|
||||
components = NODE_PACKAGES
|
||||
}
|
||||
let components: Component[]
|
||||
if (activeCategory === 'infrastructure') components = INFRASTRUCTURE_COMPONENTS
|
||||
else if (activeCategory === 'security-tools') components = SECURITY_TOOLS
|
||||
else if (activeCategory === 'python') components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
|
||||
else if (activeCategory === 'go') components = GO_MODULES
|
||||
else if (activeCategory === 'nodejs') components = NODE_PACKAGES
|
||||
else {
|
||||
components = [
|
||||
...INFRASTRUCTURE_COMPONENTS.map(c => ({ ...c, category: c.category || 'infrastructure' })),
|
||||
...SECURITY_TOOLS.map(c => ({ ...c, category: c.category || 'security-tool' })),
|
||||
...PYTHON_PACKAGES.map(c => ({ ...c, category: 'python' })),
|
||||
...GO_MODULES.map(c => ({ ...c, category: 'go' })),
|
||||
...NODE_PACKAGES.map(c => ({ ...c, category: 'nodejs' })),
|
||||
...(sbomData?.components || []).map(c => ({ ...c, category: 'python' })),
|
||||
]
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
components = components.filter(c =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -253,39 +59,9 @@ export default function SBOMPage() {
|
||||
(c.description?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
const getCategoryColor = (category?: string) => {
|
||||
switch (category) {
|
||||
case 'database': return 'bg-blue-100 text-blue-800'
|
||||
case 'security': return 'bg-purple-100 text-purple-800'
|
||||
case 'security-tool': return 'bg-red-100 text-red-800'
|
||||
case 'application': return 'bg-green-100 text-green-800'
|
||||
case 'communication': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'storage': return 'bg-orange-100 text-orange-800'
|
||||
case 'search': return 'bg-pink-100 text-pink-800'
|
||||
case 'erp': return 'bg-indigo-100 text-indigo-800'
|
||||
case 'cache': return 'bg-cyan-100 text-cyan-800'
|
||||
case 'ai': return 'bg-violet-100 text-violet-800'
|
||||
case 'development': return 'bg-gray-100 text-gray-800'
|
||||
case 'python': return 'bg-emerald-100 text-emerald-800'
|
||||
case 'go': return 'bg-sky-100 text-sky-800'
|
||||
case 'nodejs': return 'bg-lime-100 text-lime-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getLicenseColor = (license?: string) => {
|
||||
if (!license) return 'bg-gray-100 text-gray-600'
|
||||
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
|
||||
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
|
||||
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
|
||||
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
|
||||
totalSecurityTools: SECURITY_TOOLS.length,
|
||||
@@ -295,7 +71,6 @@ export default function SBOMPage() {
|
||||
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + (sbomData?.components?.length || 0),
|
||||
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
|
||||
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
|
||||
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
|
||||
}
|
||||
|
||||
const categories = [
|
||||
@@ -307,75 +82,41 @@ export default function SBOMPage() {
|
||||
{ id: 'nodejs', name: 'Node.js', count: stats.totalNode },
|
||||
]
|
||||
|
||||
const filteredComponents = getFilteredComponents()
|
||||
|
||||
return (
|
||||
<AdminLayout title="SBOM" description="Software Bill of Materials - Alle Komponenten & Abhängigkeiten">
|
||||
<AdminLayout title="SBOM" description="Software Bill of Materials - Alle Komponenten & Abhaengigkeiten">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-slate-800">{stats.totalAll}</div>
|
||||
<div className="text-sm text-slate-500">Komponenten Total</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.totalInfra}</div>
|
||||
<div className="text-sm text-slate-500">Docker Services</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.totalSecurityTools}</div>
|
||||
<div className="text-sm text-slate-500">Security Tools</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-emerald-600">{stats.totalPython}</div>
|
||||
<div className="text-sm text-slate-500">Python</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-sky-600">{stats.totalGo}</div>
|
||||
<div className="text-sm text-slate-500">Go</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-lime-600">{stats.totalNode}</div>
|
||||
<div className="text-sm text-slate-500">Node.js</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.databases}</div>
|
||||
<div className="text-sm text-slate-500">Datenbanken</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-green-600">{stats.services}</div>
|
||||
<div className="text-sm text-slate-500">App Services</div>
|
||||
</div>
|
||||
{[
|
||||
{ v: stats.totalAll, l: 'Komponenten Total', c: 'text-slate-800' },
|
||||
{ v: stats.totalInfra, l: 'Docker Services', c: 'text-purple-600' },
|
||||
{ v: stats.totalSecurityTools, l: 'Security Tools', c: 'text-red-600' },
|
||||
{ v: stats.totalPython, l: 'Python', c: 'text-emerald-600' },
|
||||
{ v: stats.totalGo, l: 'Go', c: 'text-sky-600' },
|
||||
{ v: stats.totalNode, l: 'Node.js', c: 'text-lime-600' },
|
||||
{ v: stats.databases, l: 'Datenbanken', c: 'text-blue-600' },
|
||||
{ v: stats.services, l: 'App Services', c: 'text-green-600' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow p-4">
|
||||
<div className={`text-3xl font-bold ${s.c}`}>{s.v}</div>
|
||||
<div className="text-sm text-slate-500">{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{/* Category Tabs */}
|
||||
<div className="flex gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id as CategoryType)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<button key={cat.id} onClick={() => setActiveCategory(cat.id as CategoryType)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeCategory === cat.id ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
||||
{cat.name} ({cat.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<input type="text" placeholder="Suchen..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" />
|
||||
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -387,135 +128,24 @@ export default function SBOMPage() {
|
||||
{sbomData?.metadata && (
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-slate-500">Format:</span>
|
||||
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Generiert:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Anwendung:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
|
||||
</span>
|
||||
</div>
|
||||
<div><span className="text-slate-500">Format:</span><span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span></div>
|
||||
<div><span className="text-slate-500">Generiert:</span><span className="ml-2 font-medium">{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}</span></div>
|
||||
<div><span className="text-slate-500">Anwendung:</span><span className="ml-2 font-medium">{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Components Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<span className="ml-3 text-gray-600">Lade SBOM...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredComponents.map((component, idx) => {
|
||||
// Get license from either the new license field or the old licenses array
|
||||
const licenseId = component.license || component.licenses?.[0]?.license?.id
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{component.name}</div>
|
||||
{component.description && (
|
||||
<div className="text-xs text-gray-500 max-w-xs truncate">{component.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-gray-900">{component.version}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
|
||||
{component.category || component.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.port ? (
|
||||
<span className="text-sm font-mono text-gray-600">{component.port}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{licenseId ? (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
|
||||
{licenseId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.sourceUrl && component.sourceUrl !== '-' ? (
|
||||
<a
|
||||
href={component.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-800 text-sm flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Keine Komponenten gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SBOMTable components={getFilteredComponents()} loading={loading} />
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify({
|
||||
...sbomData,
|
||||
infrastructure: INFRASTRUCTURE_COMPONENTS
|
||||
}, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
}}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<button onClick={() => {
|
||||
const data = JSON.stringify({ ...sbomData, infrastructure: INFRASTRUCTURE_COMPONENTS }, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = url; a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`; a.click()
|
||||
}} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
SBOM exportieren (JSON)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
100
website/app/admin/sbom/wizard/_components/CategoryDemo.tsx
Normal file
100
website/app/admin/sbom/wizard/_components/CategoryDemo.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
export function CategoryDemo({ stepId }: { stepId: string }) {
|
||||
if (stepId === 'categories') {
|
||||
const categories = [
|
||||
{ name: 'infrastructure', color: 'blue', count: 45 },
|
||||
{ name: 'security-tools', color: 'red', count: 12 },
|
||||
{ name: 'python', color: 'yellow', count: 35 },
|
||||
{ name: 'go', color: 'cyan', count: 18 },
|
||||
{ name: 'nodejs', color: 'green', count: 55 },
|
||||
{ name: 'unity', color: 'amber', count: 7, isNew: true },
|
||||
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
|
||||
{ name: 'game', color: 'rose', count: 1, isNew: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
|
||||
>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
|
||||
{cat.isNew && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'unity-game') {
|
||||
const unityComponents = [
|
||||
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
|
||||
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
|
||||
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
|
||||
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
|
||||
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
|
||||
<div className="space-y-2">
|
||||
{unityComponents.map((comp) => (
|
||||
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
|
||||
<span className="font-medium">{comp.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span>{comp.version}</span>
|
||||
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'licenses') {
|
||||
const licenses = [
|
||||
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
|
||||
<div className="space-y-2">
|
||||
{licenses.map((lic) => (
|
||||
<div key={lic.name} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{lic.name}</span>
|
||||
<span className="text-sm text-slate-500">({lic.count})</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
|
||||
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
Risiko: {lic.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { WizardStep, EDUCATION_CONTENT } from './types'
|
||||
|
||||
export function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: 'text-slate-400 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
|
||||
<span className="mr-2">📖</span>
|
||||
{content.title}
|
||||
</h3>
|
||||
<div className="space-y-2 text-primary-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: line
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/→/g, '<span class="text-primary-600">→</span>')
|
||||
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{content.tips && content.tips.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-primary-200">
|
||||
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
|
||||
{content.tips.map((tip, index) => (
|
||||
<p key={index} className="text-sm text-primary-700 ml-4">• {tip}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
website/app/admin/sbom/wizard/_components/types.ts
Normal file
124
website/app/admin/sbom/wizard/_components/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export type StepStatus = 'pending' | 'active' | 'completed'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
export const INITIAL_STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
|
||||
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
|
||||
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
|
||||
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
|
||||
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum SBOM-Wizard!',
|
||||
content: [
|
||||
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
|
||||
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
|
||||
'• Open-Source-Bibliotheken',
|
||||
'• Frameworks und Engines',
|
||||
'• Infrastruktur-Dienste',
|
||||
'• Entwicklungs-Tools',
|
||||
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
|
||||
],
|
||||
tips: ['SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht', 'Die EU plant aehnliche Vorschriften im Cyber Resilience Act'],
|
||||
},
|
||||
'what-is-sbom': {
|
||||
title: 'Was ist eine SBOM?',
|
||||
content: [
|
||||
'**SBOM = Software Bill of Materials**',
|
||||
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
|
||||
'**Enthaltene Informationen:**',
|
||||
'• Name der Komponente', '• Version', '• Lizenz (MIT, Apache, GPL, etc.)', '• Herkunft (Source URL)', '• Typ (Library, Service, Tool)',
|
||||
'**Formate:**',
|
||||
'• SPDX (Linux Foundation Standard)', '• CycloneDX (OWASP Standard)', '• SWID Tags (ISO Standard)',
|
||||
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
|
||||
],
|
||||
tips: ['Eine SBOM ist wie ein Beipackzettel fuer Medikamente', 'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken'],
|
||||
},
|
||||
'why-important': {
|
||||
title: 'Warum sind SBOMs wichtig?',
|
||||
content: [
|
||||
'**1. Sicherheit (Security)**',
|
||||
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
|
||||
'**2. Compliance (Lizenz-Einhaltung)**',
|
||||
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
|
||||
'• MIT: Fast keine Einschraenkungen', '• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein', '• Proprietary: Kommerzielle Nutzung eingeschraenkt',
|
||||
'**3. Supply Chain Security**',
|
||||
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
|
||||
'**4. Regulatorische Anforderungen**',
|
||||
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
|
||||
],
|
||||
tips: ['Log4Shell (2021) betraf Millionen von Systemen', 'Mit SBOM: Betroffenheit in Minuten geprueft'],
|
||||
},
|
||||
'categories': {
|
||||
title: 'SBOM-Kategorien in BreakPilot',
|
||||
content: [
|
||||
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
|
||||
'**infrastructure** (Blau)', '→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
|
||||
'**security-tools** (Rot)', '→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
|
||||
'**python** (Gelb)', '→ Python-Backend: FastAPI, Pydantic, httpx',
|
||||
'**go** (Cyan)', '→ Go-Services: Gin, GORM, JWT',
|
||||
'**nodejs** (Gruen)', '→ Frontend: Next.js, React, Tailwind',
|
||||
'**unity** (Amber) ← NEU!', '→ Game Engine: Unity 6, URP, TextMeshPro',
|
||||
'**csharp** (Fuchsia) ← NEU!', '→ C#/.NET: .NET Standard, UnityWebRequest',
|
||||
'**game** (Rose) ← NEU!', '→ Breakpilot Drive Service',
|
||||
],
|
||||
tips: ['Klicke auf eine Kategorie um zu filtern', 'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt'],
|
||||
},
|
||||
'infrastructure': {
|
||||
title: 'Infrastruktur-Komponenten',
|
||||
content: [
|
||||
'BreakPilot basiert auf robuster Infrastruktur:',
|
||||
'**Datenbanken:**', '• PostgreSQL 16 - Relationale Datenbank', '• Valkey 8 - In-Memory Cache (Redis-Fork)', '• ChromaDB - Vector Store fuer RAG',
|
||||
'**Auth & Security:**', '• Keycloak 23 - Identity & Access Management', '• HashiCorp Vault - Secrets Management',
|
||||
'**Container & Orchestrierung:**', '• Docker - Container Runtime', '• Traefik - Reverse Proxy',
|
||||
'**Kommunikation:**', '• Matrix Synapse - Chat/Messaging', '• Jitsi Meet - Video-Konferenzen',
|
||||
],
|
||||
tips: ['Alle Services laufen in Docker-Containern', 'Ports sind in docker-compose.yml definiert'],
|
||||
},
|
||||
'unity-game': {
|
||||
title: 'Unity & Breakpilot Drive',
|
||||
content: [
|
||||
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
|
||||
'**Unity Engine (6000.0)**', '→ Die Game Engine fuer das Lernspiel', '→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
|
||||
'**Universal Render Pipeline (17.x)**', '→ Optimierte Grafik-Pipeline fuer WebGL', '→ Lizenz: Unity Companion License',
|
||||
'**TextMeshPro (3.2)**', '→ Fortgeschrittenes Text-Rendering',
|
||||
'**Unity Mathematics (1.3)**', '→ SIMD-optimierte Mathe-Bibliothek',
|
||||
'**Newtonsoft.Json (3.2)**', '→ JSON-Serialisierung fuer API-Kommunikation',
|
||||
'**C# Abhängigkeiten:**', '• .NET Standard 2.1', '• UnityWebRequest (HTTP Client)', '• System.Text.Json',
|
||||
],
|
||||
tips: ['Unity 6 ist die neueste LTS-Version', 'WebGL-Builds sind ~30-50 MB gross'],
|
||||
},
|
||||
'licenses': {
|
||||
title: 'Lizenz-Compliance',
|
||||
content: [
|
||||
'**Lizenz-Typen in BreakPilot:**',
|
||||
'**Permissive (Unkompliziert):**', '• MIT - Die meisten JS/Python Libs', '• Apache 2.0 - FastAPI, Keycloak', '• BSD - PostgreSQL',
|
||||
'**Copyleft (Vorsicht bei Aenderungen):**', '• GPL - Wenige Komponenten', '• AGPL - Jitsi (Server-Side OK)',
|
||||
'**Proprietary:**', '• Unity EULA - Kostenlos bis 100k Revenue', '• Unity Companion - Packages an Engine gebunden',
|
||||
'**Wichtig:**', 'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
|
||||
],
|
||||
tips: ['MIT und Apache 2.0 sind am unproblematischsten', 'AGPL erfordert Source-Code-Freigabe bei Modifikation'],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung',
|
||||
content: [
|
||||
'Du hast die SBOM von BreakPilot kennengelernt:',
|
||||
'✅ Was eine SBOM ist und warum sie wichtig ist', '✅ Die verschiedenen Kategorien (8 Stueck)',
|
||||
'✅ Infrastruktur-Komponenten', '✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive', '✅ Lizenz-Typen und Compliance',
|
||||
'**Im SBOM-Dashboard kannst du:**', '• Nach Kategorie filtern', '• Nach Namen suchen', '• Lizenzen pruefen', '• Komponenten-Details ansehen',
|
||||
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
|
||||
],
|
||||
tips: ['Pruefe regelmaessig auf veraltete Komponenten', 'Bei neuen Abhaengigkeiten: SBOM aktualisieren'],
|
||||
},
|
||||
}
|
||||
@@ -3,410 +3,18 @@
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) - Lern-Wizard
|
||||
*
|
||||
* Interaktiver Wizard zum Verstehen der SBOM:
|
||||
* - Was ist eine SBOM?
|
||||
* - Warum ist sie wichtig?
|
||||
* - Kategorien erklaert
|
||||
* - Breakpilot Drive (Unity/C#/Game) Komponenten
|
||||
* - Lizenzen und Compliance
|
||||
* Interaktiver Wizard zum Verstehen der SBOM
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
type StepStatus = 'pending' | 'active' | 'completed'
|
||||
|
||||
interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
|
||||
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
|
||||
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
|
||||
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
|
||||
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum SBOM-Wizard!',
|
||||
content: [
|
||||
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
|
||||
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
|
||||
'• Open-Source-Bibliotheken',
|
||||
'• Frameworks und Engines',
|
||||
'• Infrastruktur-Dienste',
|
||||
'• Entwicklungs-Tools',
|
||||
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
|
||||
],
|
||||
tips: [
|
||||
'SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht',
|
||||
'Die EU plant aehnliche Vorschriften im Cyber Resilience Act',
|
||||
],
|
||||
},
|
||||
'what-is-sbom': {
|
||||
title: 'Was ist eine SBOM?',
|
||||
content: [
|
||||
'**SBOM = Software Bill of Materials**',
|
||||
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
|
||||
'**Enthaltene Informationen:**',
|
||||
'• Name der Komponente',
|
||||
'• Version',
|
||||
'• Lizenz (MIT, Apache, GPL, etc.)',
|
||||
'• Herkunft (Source URL)',
|
||||
'• Typ (Library, Service, Tool)',
|
||||
'**Formate:**',
|
||||
'• SPDX (Linux Foundation Standard)',
|
||||
'• CycloneDX (OWASP Standard)',
|
||||
'• SWID Tags (ISO Standard)',
|
||||
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
|
||||
],
|
||||
tips: [
|
||||
'Eine SBOM ist wie ein Beipackzettel fuer Medikamente',
|
||||
'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken',
|
||||
],
|
||||
},
|
||||
'why-important': {
|
||||
title: 'Warum sind SBOMs wichtig?',
|
||||
content: [
|
||||
'**1. Sicherheit (Security)**',
|
||||
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
|
||||
'**2. Compliance (Lizenz-Einhaltung)**',
|
||||
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
|
||||
'• MIT: Fast keine Einschraenkungen',
|
||||
'• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein',
|
||||
'• Proprietary: Kommerzielle Nutzung eingeschraenkt',
|
||||
'**3. Supply Chain Security**',
|
||||
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
|
||||
'**4. Regulatorische Anforderungen**',
|
||||
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
|
||||
],
|
||||
tips: [
|
||||
'Log4Shell (2021) betraf Millionen von Systemen',
|
||||
'Mit SBOM: Betroffenheit in Minuten geprueft',
|
||||
],
|
||||
},
|
||||
'categories': {
|
||||
title: 'SBOM-Kategorien in BreakPilot',
|
||||
content: [
|
||||
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
|
||||
'**infrastructure** (Blau)',
|
||||
'→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
|
||||
'**security-tools** (Rot)',
|
||||
'→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
|
||||
'**python** (Gelb)',
|
||||
'→ Python-Backend: FastAPI, Pydantic, httpx',
|
||||
'**go** (Cyan)',
|
||||
'→ Go-Services: Gin, GORM, JWT',
|
||||
'**nodejs** (Gruen)',
|
||||
'→ Frontend: Next.js, React, Tailwind',
|
||||
'**unity** (Amber) ← NEU!',
|
||||
'→ Game Engine: Unity 6, URP, TextMeshPro',
|
||||
'**csharp** (Fuchsia) ← NEU!',
|
||||
'→ C#/.NET: .NET Standard, UnityWebRequest',
|
||||
'**game** (Rose) ← NEU!',
|
||||
'→ Breakpilot Drive Service',
|
||||
],
|
||||
tips: [
|
||||
'Klicke auf eine Kategorie um zu filtern',
|
||||
'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt',
|
||||
],
|
||||
},
|
||||
'infrastructure': {
|
||||
title: 'Infrastruktur-Komponenten',
|
||||
content: [
|
||||
'BreakPilot basiert auf robuster Infrastruktur:',
|
||||
'**Datenbanken:**',
|
||||
'• PostgreSQL 16 - Relationale Datenbank',
|
||||
'• Valkey 8 - In-Memory Cache (Redis-Fork)',
|
||||
'• ChromaDB - Vector Store fuer RAG',
|
||||
'**Auth & Security:**',
|
||||
'• Keycloak 23 - Identity & Access Management',
|
||||
'• HashiCorp Vault - Secrets Management',
|
||||
'**Container & Orchestrierung:**',
|
||||
'• Docker - Container Runtime',
|
||||
'• Traefik - Reverse Proxy',
|
||||
'**Kommunikation:**',
|
||||
'• Matrix Synapse - Chat/Messaging',
|
||||
'• Jitsi Meet - Video-Konferenzen',
|
||||
],
|
||||
tips: [
|
||||
'Alle Services laufen in Docker-Containern',
|
||||
'Ports sind in docker-compose.yml definiert',
|
||||
],
|
||||
},
|
||||
'unity-game': {
|
||||
title: 'Unity & Breakpilot Drive',
|
||||
content: [
|
||||
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
|
||||
'**Unity Engine (6000.0)**',
|
||||
'→ Die Game Engine fuer das Lernspiel',
|
||||
'→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
|
||||
'**Universal Render Pipeline (17.x)**',
|
||||
'→ Optimierte Grafik-Pipeline fuer WebGL',
|
||||
'→ Lizenz: Unity Companion License',
|
||||
'**TextMeshPro (3.2)**',
|
||||
'→ Fortgeschrittenes Text-Rendering',
|
||||
'**Unity Mathematics (1.3)**',
|
||||
'→ SIMD-optimierte Mathe-Bibliothek',
|
||||
'**Newtonsoft.Json (3.2)**',
|
||||
'→ JSON-Serialisierung fuer API-Kommunikation',
|
||||
'**C# Abhängigkeiten:**',
|
||||
'• .NET Standard 2.1',
|
||||
'• UnityWebRequest (HTTP Client)',
|
||||
'• System.Text.Json',
|
||||
],
|
||||
tips: [
|
||||
'Unity 6 ist die neueste LTS-Version',
|
||||
'WebGL-Builds sind ~30-50 MB gross',
|
||||
],
|
||||
},
|
||||
'licenses': {
|
||||
title: 'Lizenz-Compliance',
|
||||
content: [
|
||||
'**Lizenz-Typen in BreakPilot:**',
|
||||
'**Permissive (Unkompliziert):**',
|
||||
'• MIT - Die meisten JS/Python Libs',
|
||||
'• Apache 2.0 - FastAPI, Keycloak',
|
||||
'• BSD - PostgreSQL',
|
||||
'**Copyleft (Vorsicht bei Aenderungen):**',
|
||||
'• GPL - Wenige Komponenten',
|
||||
'• AGPL - Jitsi (Server-Side OK)',
|
||||
'**Proprietary:**',
|
||||
'• Unity EULA - Kostenlos bis 100k Revenue',
|
||||
'• Unity Companion - Packages an Engine gebunden',
|
||||
'**Wichtig:**',
|
||||
'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
|
||||
],
|
||||
tips: [
|
||||
'MIT und Apache 2.0 sind am unproblematischsten',
|
||||
'AGPL erfordert Source-Code-Freigabe bei Modifikation',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung',
|
||||
content: [
|
||||
'Du hast die SBOM von BreakPilot kennengelernt:',
|
||||
'✅ Was eine SBOM ist und warum sie wichtig ist',
|
||||
'✅ Die verschiedenen Kategorien (8 Stueck)',
|
||||
'✅ Infrastruktur-Komponenten',
|
||||
'✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive',
|
||||
'✅ Lizenz-Typen und Compliance',
|
||||
'**Im SBOM-Dashboard kannst du:**',
|
||||
'• Nach Kategorie filtern',
|
||||
'• Nach Namen suchen',
|
||||
'• Lizenzen pruefen',
|
||||
'• Komponenten-Details ansehen',
|
||||
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
|
||||
],
|
||||
tips: [
|
||||
'Pruefe regelmaessig auf veraltete Komponenten',
|
||||
'Bei neuen Abhaengigkeiten: SBOM aktualisieren',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Components
|
||||
// ==============================================
|
||||
|
||||
function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: 'text-slate-400 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
|
||||
<span className="mr-2">📖</span>
|
||||
{content.title}
|
||||
</h3>
|
||||
<div className="space-y-2 text-primary-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: line
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/→/g, '<span class="text-primary-600">→</span>')
|
||||
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{content.tips && content.tips.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-primary-200">
|
||||
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
|
||||
{content.tips.map((tip, index) => (
|
||||
<p key={index} className="text-sm text-primary-700 ml-4">• {tip}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryDemo({ stepId }: { stepId: string }) {
|
||||
if (stepId === 'categories') {
|
||||
const categories = [
|
||||
{ name: 'infrastructure', color: 'blue', count: 45 },
|
||||
{ name: 'security-tools', color: 'red', count: 12 },
|
||||
{ name: 'python', color: 'yellow', count: 35 },
|
||||
{ name: 'go', color: 'cyan', count: 18 },
|
||||
{ name: 'nodejs', color: 'green', count: 55 },
|
||||
{ name: 'unity', color: 'amber', count: 7, isNew: true },
|
||||
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
|
||||
{ name: 'game', color: 'rose', count: 1, isNew: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
|
||||
>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
|
||||
{cat.isNew && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'unity-game') {
|
||||
const unityComponents = [
|
||||
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
|
||||
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
|
||||
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
|
||||
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
|
||||
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
|
||||
<div className="space-y-2">
|
||||
{unityComponents.map((comp) => (
|
||||
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
|
||||
<span className="font-medium">{comp.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span>{comp.version}</span>
|
||||
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'licenses') {
|
||||
const licenses = [
|
||||
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
|
||||
<div className="space-y-2">
|
||||
{licenses.map((lic) => (
|
||||
<div key={lic.name} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{lic.name}</span>
|
||||
<span className="text-sm text-slate-500">({lic.count})</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
|
||||
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
Risiko: {lic.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
import { INITIAL_STEPS, WizardStep } from './_components/types'
|
||||
import { WizardStepper, EducationCard } from './_components/WizardComponents'
|
||||
import { CategoryDemo } from './_components/CategoryDemo'
|
||||
|
||||
export default function SBOMWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(INITIAL_STEPS)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
@@ -509,7 +117,7 @@ export default function SBOMWizardPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentStep(0)
|
||||
setSteps(STEPS.map(s => ({ ...s, status: 'pending' })))
|
||||
setSteps(INITIAL_STEPS.map(s => ({ ...s, status: 'pending' })))
|
||||
}}
|
||||
className="px-6 py-3 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 transition-colors"
|
||||
>
|
||||
|
||||
134
website/app/admin/staff-search/_components/StaffDetailPanel.tsx
Normal file
134
website/app/admin/staff-search/_components/StaffDetailPanel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { StaffMember, Publication } from './types'
|
||||
|
||||
export function StaffDetailPanel({
|
||||
selectedStaff,
|
||||
publications,
|
||||
}: {
|
||||
selectedStaff: StaffMember | null
|
||||
publications: Publication[]
|
||||
}) {
|
||||
if (!selectedStaff) {
|
||||
return (
|
||||
<div className="p-8 text-center text-slate-400">
|
||||
Wahlen Sie eine Person aus der Liste
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-start gap-4">
|
||||
{selectedStaff.photo_url ? (
|
||||
<img
|
||||
src={selectedStaff.photo_url}
|
||||
alt={selectedStaff.full_name || selectedStaff.last_name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 text-xl font-medium">
|
||||
{(selectedStaff.first_name?.[0] || '') + (selectedStaff.last_name?.[0] || '')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{selectedStaff.title && `${selectedStaff.title} `}
|
||||
{selectedStaff.full_name || `${selectedStaff.first_name || ''} ${selectedStaff.last_name}`}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{selectedStaff.position}</p>
|
||||
<p className="text-sm text-slate-400">{selectedStaff.university_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="p-4 border-b space-y-2">
|
||||
{selectedStaff.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a href={`mailto:${selectedStaff.email}`} className="text-primary-600 hover:underline">
|
||||
{selectedStaff.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedStaff.profile_url && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<a href={selectedStaff.profile_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline truncate">
|
||||
Profil
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedStaff.orcid && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="w-4 h-4 text-green-600 font-bold text-xs">ID</span>
|
||||
<a href={`https://orcid.org/${selectedStaff.orcid}`} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
||||
ORCID: {selectedStaff.orcid}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Research Interests */}
|
||||
{selectedStaff.research_interests && selectedStaff.research_interests.length > 0 && (
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Forschungsgebiete</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedStaff.research_interests.map((interest, i) => (
|
||||
<span key={i} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publications */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
Publikationen ({publications.length})
|
||||
</h4>
|
||||
{publications.length > 0 ? (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{publications.map((pub) => (
|
||||
<div key={pub.id} className="text-sm">
|
||||
<p className="font-medium text-slate-800 line-clamp-2">{pub.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
|
||||
{pub.year && <span>{pub.year}</span>}
|
||||
{pub.venue && <span>| {pub.venue}</span>}
|
||||
{pub.citation_count > 0 && (
|
||||
<span className="text-green-600">{pub.citation_count} Zitierungen</span>
|
||||
)}
|
||||
</div>
|
||||
{pub.doi && (
|
||||
<a
|
||||
href={`https://doi.org/${pub.doi}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
DOI
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Keine Publikationen gefunden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 border-t bg-slate-50">
|
||||
<button className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm">
|
||||
Als Kunde markieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/staff-search/_components/StaffListItem.tsx
Normal file
97
website/app/admin/staff-search/_components/StaffListItem.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { StaffMember } from './types'
|
||||
import { getPositionBadgeColor } from './helpers'
|
||||
|
||||
export function StaffListItem({
|
||||
member,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
member: StaffMember
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
isSelected ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{member.photo_url ? (
|
||||
<img
|
||||
src={member.photo_url}
|
||||
alt={member.full_name || member.last_name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-medium">
|
||||
{(member.first_name?.[0] || '') + (member.last_name?.[0] || '')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">
|
||||
{member.title && `${member.title} `}
|
||||
{member.full_name || `${member.first_name || ''} ${member.last_name}`}
|
||||
</span>
|
||||
{member.is_professor && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
|
||||
Prof
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
{member.position || member.position_type}
|
||||
{member.department_name && ` - ${member.department_name}`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-400">
|
||||
{member.university_short || member.university_name}
|
||||
</span>
|
||||
{member.publication_count > 0 && (
|
||||
<span className="text-xs text-green-600">
|
||||
{member.publication_count} Publikationen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{member.position_type && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${getPositionBadgeColor(member.position_type)}`}>
|
||||
{member.position_type}
|
||||
</span>
|
||||
)}
|
||||
{member.email && (
|
||||
<a
|
||||
href={`mailto:${member.email}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
E-Mail
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{member.research_interests && member.research_interests.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{member.research_interests.slice(0, 5).map((interest, i) => (
|
||||
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 rounded-full">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
{member.research_interests.length > 5 && (
|
||||
<span className="px-2 py-0.5 text-xs text-slate-400">
|
||||
+{member.research_interests.length - 5} mehr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
website/app/admin/staff-search/_components/helpers.ts
Normal file
26
website/app/admin/staff-search/_components/helpers.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function getPositionBadgeColor(posType?: string) {
|
||||
switch (posType) {
|
||||
case 'professor':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'postdoc':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'researcher':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'phd_student':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateBadgeColor(state?: string) {
|
||||
const colors: Record<string, string> = {
|
||||
BW: 'bg-yellow-100 text-yellow-800',
|
||||
BY: 'bg-blue-100 text-blue-800',
|
||||
BE: 'bg-red-100 text-red-800',
|
||||
NW: 'bg-green-100 text-green-800',
|
||||
HE: 'bg-orange-100 text-orange-800',
|
||||
SN: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
return colors[state || ''] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
52
website/app/admin/staff-search/_components/types.ts
Normal file
52
website/app/admin/staff-search/_components/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface StaffMember {
|
||||
id: string
|
||||
first_name?: string
|
||||
last_name: string
|
||||
full_name?: string
|
||||
title?: string
|
||||
position?: string
|
||||
position_type?: string
|
||||
is_professor: boolean
|
||||
email?: string
|
||||
profile_url?: string
|
||||
photo_url?: string
|
||||
orcid?: string
|
||||
research_interests?: string[]
|
||||
university_name?: string
|
||||
university_short?: string
|
||||
department_name?: string
|
||||
publication_count: number
|
||||
}
|
||||
|
||||
export interface Publication {
|
||||
id: string
|
||||
title: string
|
||||
abstract?: string
|
||||
year?: number
|
||||
pub_type?: string
|
||||
venue?: string
|
||||
doi?: string
|
||||
url?: string
|
||||
citation_count: number
|
||||
}
|
||||
|
||||
export interface StaffStats {
|
||||
total_staff: number
|
||||
total_professors: number
|
||||
total_publications: number
|
||||
total_universities: number
|
||||
by_state?: Record<string, number>
|
||||
by_uni_type?: Record<string, number>
|
||||
by_position_type?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface University {
|
||||
id: string
|
||||
name: string
|
||||
short_name?: string
|
||||
url: string
|
||||
state?: string
|
||||
uni_type?: string
|
||||
}
|
||||
|
||||
export const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'
|
||||
120
website/app/admin/staff-search/_components/useStaffSearch.ts
Normal file
120
website/app/admin/staff-search/_components/useStaffSearch.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { StaffMember, Publication, StaffStats, University, EDU_SEARCH_API } from './types'
|
||||
|
||||
export function useStaffSearch() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [staff, setStaff] = useState<StaffMember[]>([])
|
||||
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null)
|
||||
const [publications, setPublications] = useState<Publication[]>([])
|
||||
const [stats, setStats] = useState<StaffStats | null>(null)
|
||||
const [universities, setUniversities] = useState<University[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState('')
|
||||
const [filterUniType, setFilterUniType] = useState('')
|
||||
const [filterPositionType, setFilterPositionType] = useState('')
|
||||
const [filterProfessorsOnly, setFilterProfessorsOnly] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
fetchUniversities()
|
||||
}, [])
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/stats`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// Stats not critical
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUniversities = async () => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/universities`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUniversities(data.universities || [])
|
||||
}
|
||||
} catch {
|
||||
// Universities not critical
|
||||
}
|
||||
}
|
||||
|
||||
const searchStaff = useCallback(async (newOffset = 0) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (searchQuery) params.append('q', searchQuery)
|
||||
if (filterState) params.append('state', filterState)
|
||||
if (filterUniType) params.append('uni_type', filterUniType)
|
||||
if (filterPositionType) params.append('position_type', filterPositionType)
|
||||
if (filterProfessorsOnly) params.append('is_professor', 'true')
|
||||
params.append('limit', limit.toString())
|
||||
params.append('offset', newOffset.toString())
|
||||
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/search?${params}`)
|
||||
if (!res.ok) throw new Error('Search failed')
|
||||
|
||||
const data = await res.json()
|
||||
setStaff(data.staff || [])
|
||||
setTotal(data.total || 0)
|
||||
setOffset(newOffset)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Search failed')
|
||||
setStaff([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchQuery, filterState, filterUniType, filterPositionType, filterProfessorsOnly])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchStaff(0)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchStaff])
|
||||
|
||||
const fetchPublications = async (staffId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/${staffId}/publications`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPublications(data.publications || [])
|
||||
}
|
||||
} catch {
|
||||
setPublications([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectStaff = (member: StaffMember) => {
|
||||
setSelectedStaff(member)
|
||||
fetchPublications(member.id)
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery, setSearchQuery,
|
||||
staff, selectedStaff, publications, stats,
|
||||
loading, error,
|
||||
filterState, setFilterState,
|
||||
filterUniType, setFilterUniType,
|
||||
filterPositionType, setFilterPositionType,
|
||||
filterProfessorsOnly, setFilterProfessorsOnly,
|
||||
total, offset, limit,
|
||||
searchStaff, handleSelectStaff,
|
||||
}
|
||||
}
|
||||
@@ -7,193 +7,23 @@
|
||||
* Potential customers for BreakPilot services.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
|
||||
interface StaffMember {
|
||||
id: string
|
||||
first_name?: string
|
||||
last_name: string
|
||||
full_name?: string
|
||||
title?: string
|
||||
position?: string
|
||||
position_type?: string
|
||||
is_professor: boolean
|
||||
email?: string
|
||||
profile_url?: string
|
||||
photo_url?: string
|
||||
orcid?: string
|
||||
research_interests?: string[]
|
||||
university_name?: string
|
||||
university_short?: string
|
||||
department_name?: string
|
||||
publication_count: number
|
||||
}
|
||||
|
||||
interface Publication {
|
||||
id: string
|
||||
title: string
|
||||
abstract?: string
|
||||
year?: number
|
||||
pub_type?: string
|
||||
venue?: string
|
||||
doi?: string
|
||||
url?: string
|
||||
citation_count: number
|
||||
}
|
||||
|
||||
interface StaffStats {
|
||||
total_staff: number
|
||||
total_professors: number
|
||||
total_publications: number
|
||||
total_universities: number
|
||||
by_state?: Record<string, number>
|
||||
by_uni_type?: Record<string, number>
|
||||
by_position_type?: Record<string, number>
|
||||
}
|
||||
|
||||
interface University {
|
||||
id: string
|
||||
name: string
|
||||
short_name?: string
|
||||
url: string
|
||||
state?: string
|
||||
uni_type?: string
|
||||
}
|
||||
|
||||
const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'
|
||||
import { useStaffSearch } from './_components/useStaffSearch'
|
||||
import { StaffListItem } from './_components/StaffListItem'
|
||||
import { StaffDetailPanel } from './_components/StaffDetailPanel'
|
||||
|
||||
export default function StaffSearchPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [staff, setStaff] = useState<StaffMember[]>([])
|
||||
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null)
|
||||
const [publications, setPublications] = useState<Publication[]>([])
|
||||
const [stats, setStats] = useState<StaffStats | null>(null)
|
||||
const [universities, setUniversities] = useState<University[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState('')
|
||||
const [filterUniType, setFilterUniType] = useState('')
|
||||
const [filterPositionType, setFilterPositionType] = useState('')
|
||||
const [filterProfessorsOnly, setFilterProfessorsOnly] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
// Fetch stats on mount
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
fetchUniversities()
|
||||
}, [])
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/stats`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// Stats not critical
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUniversities = async () => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/universities`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUniversities(data.universities || [])
|
||||
}
|
||||
} catch {
|
||||
// Universities not critical
|
||||
}
|
||||
}
|
||||
|
||||
const searchStaff = useCallback(async (newOffset = 0) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (searchQuery) params.append('q', searchQuery)
|
||||
if (filterState) params.append('state', filterState)
|
||||
if (filterUniType) params.append('uni_type', filterUniType)
|
||||
if (filterPositionType) params.append('position_type', filterPositionType)
|
||||
if (filterProfessorsOnly) params.append('is_professor', 'true')
|
||||
params.append('limit', limit.toString())
|
||||
params.append('offset', newOffset.toString())
|
||||
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/search?${params}`)
|
||||
if (!res.ok) throw new Error('Search failed')
|
||||
|
||||
const data = await res.json()
|
||||
setStaff(data.staff || [])
|
||||
setTotal(data.total || 0)
|
||||
setOffset(newOffset)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Search failed')
|
||||
setStaff([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchQuery, filterState, filterUniType, filterPositionType, filterProfessorsOnly])
|
||||
|
||||
// Search on filter change
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchStaff(0)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchStaff])
|
||||
|
||||
const fetchPublications = async (staffId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/${staffId}/publications`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPublications(data.publications || [])
|
||||
}
|
||||
} catch {
|
||||
setPublications([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectStaff = (member: StaffMember) => {
|
||||
setSelectedStaff(member)
|
||||
fetchPublications(member.id)
|
||||
}
|
||||
|
||||
const getPositionBadgeColor = (posType?: string) => {
|
||||
switch (posType) {
|
||||
case 'professor':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'postdoc':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'researcher':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'phd_student':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStateBadgeColor = (state?: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
BW: 'bg-yellow-100 text-yellow-800',
|
||||
BY: 'bg-blue-100 text-blue-800',
|
||||
BE: 'bg-red-100 text-red-800',
|
||||
NW: 'bg-green-100 text-green-800',
|
||||
HE: 'bg-orange-100 text-orange-800',
|
||||
SN: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
return colors[state || ''] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
const {
|
||||
searchQuery, setSearchQuery,
|
||||
staff, selectedStaff, publications, stats,
|
||||
loading, error,
|
||||
filterState, setFilterState,
|
||||
filterUniType, setFilterUniType,
|
||||
filterPositionType, setFilterPositionType,
|
||||
filterProfessorsOnly, setFilterProfessorsOnly,
|
||||
total, offset, limit,
|
||||
searchStaff, handleSelectStaff,
|
||||
} = useStaffSearch()
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
@@ -248,11 +78,7 @@ export default function StaffSearchPage() {
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<select
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm"
|
||||
>
|
||||
<select value={filterState} onChange={(e) => setFilterState(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
|
||||
<option value="">Alle Bundeslander</option>
|
||||
<option value="BW">Baden-Wurttemberg</option>
|
||||
<option value="BY">Bayern</option>
|
||||
@@ -271,12 +97,7 @@ export default function StaffSearchPage() {
|
||||
<option value="SH">Schleswig-Holstein</option>
|
||||
<option value="TH">Thuringen</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterUniType}
|
||||
onChange={(e) => setFilterUniType(e.target.value)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm"
|
||||
>
|
||||
<select value={filterUniType} onChange={(e) => setFilterUniType(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
|
||||
<option value="">Alle Hochschultypen</option>
|
||||
<option value="UNI">Universitaten</option>
|
||||
<option value="FH">Fachhochschulen</option>
|
||||
@@ -284,12 +105,7 @@ export default function StaffSearchPage() {
|
||||
<option value="KUNST">Kunsthochschulen</option>
|
||||
<option value="PRIVATE">Private</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterPositionType}
|
||||
onChange={(e) => setFilterPositionType(e.target.value)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm"
|
||||
>
|
||||
<select value={filterPositionType} onChange={(e) => setFilterPositionType(e.target.value)} className="px-3 py-1.5 border rounded-lg text-sm">
|
||||
<option value="">Alle Positionen</option>
|
||||
<option value="professor">Professoren</option>
|
||||
<option value="postdoc">Postdocs</option>
|
||||
@@ -297,14 +113,8 @@ export default function StaffSearchPage() {
|
||||
<option value="phd_student">Doktoranden</option>
|
||||
<option value="staff">Sonstige</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterProfessorsOnly}
|
||||
onChange={(e) => setFilterProfessorsOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<input type="checkbox" checked={filterProfessorsOnly} onChange={(e) => setFilterProfessorsOnly(e.target.checked)} className="rounded border-gray-300" />
|
||||
Nur Professoren
|
||||
</label>
|
||||
</div>
|
||||
@@ -312,9 +122,7 @@ export default function StaffSearchPage() {
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">
|
||||
{error}
|
||||
</div>
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
@@ -325,114 +133,21 @@ export default function StaffSearchPage() {
|
||||
</span>
|
||||
{total > limit && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => searchStaff(Math.max(0, offset - limit))}
|
||||
disabled={offset === 0 || loading}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Zuruck
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-500">
|
||||
{Math.floor(offset / limit) + 1} / {Math.ceil(total / limit)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => searchStaff(offset + limit)}
|
||||
disabled={offset + limit >= total || loading}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
<button onClick={() => searchStaff(Math.max(0, offset - limit))} disabled={offset === 0 || loading} className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50">Zuruck</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-500">{Math.floor(offset / limit) + 1} / {Math.ceil(total / limit)}</span>
|
||||
<button onClick={() => searchStaff(offset + limit)} disabled={offset + limit >= total || loading} className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50">Weiter</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{staff.map((member) => (
|
||||
<div
|
||||
<StaffListItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
isSelected={selectedStaff?.id === member.id}
|
||||
onClick={() => handleSelectStaff(member)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
selectedStaff?.id === member.id ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{member.photo_url ? (
|
||||
<img
|
||||
src={member.photo_url}
|
||||
alt={member.full_name || member.last_name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-medium">
|
||||
{(member.first_name?.[0] || '') + (member.last_name?.[0] || '')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">
|
||||
{member.title && `${member.title} `}
|
||||
{member.full_name || `${member.first_name || ''} ${member.last_name}`}
|
||||
</span>
|
||||
{member.is_professor && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
|
||||
Prof
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
{member.position || member.position_type}
|
||||
{member.department_name && ` - ${member.department_name}`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-400">
|
||||
{member.university_short || member.university_name}
|
||||
</span>
|
||||
{member.publication_count > 0 && (
|
||||
<span className="text-xs text-green-600">
|
||||
{member.publication_count} Publikationen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{member.position_type && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${getPositionBadgeColor(member.position_type)}`}>
|
||||
{member.position_type}
|
||||
</span>
|
||||
)}
|
||||
{member.email && (
|
||||
<a
|
||||
href={`mailto:${member.email}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
E-Mail
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{member.research_interests && member.research_interests.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{member.research_interests.slice(0, 5).map((interest, i) => (
|
||||
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 rounded-full">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
{member.research_interests.length > 5 && (
|
||||
<span className="px-2 py-0.5 text-xs text-slate-400">
|
||||
+{member.research_interests.length - 5} mehr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
|
||||
{staff.length === 0 && !loading && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
{searchQuery || filterState || filterUniType || filterPositionType
|
||||
@@ -440,11 +155,8 @@ export default function StaffSearchPage() {
|
||||
: 'Geben Sie einen Suchbegriff ein oder wahlen Sie Filter'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
Suche lauft...
|
||||
</div>
|
||||
<div className="p-8 text-center text-slate-500">Suche lauft...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,126 +165,7 @@ export default function StaffSearchPage() {
|
||||
{/* Right Panel: Detail View */}
|
||||
<div className="w-96 shrink-0">
|
||||
<div className="bg-white rounded-lg shadow-sm border sticky top-20">
|
||||
{selectedStaff ? (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-start gap-4">
|
||||
{selectedStaff.photo_url ? (
|
||||
<img
|
||||
src={selectedStaff.photo_url}
|
||||
alt={selectedStaff.full_name || selectedStaff.last_name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 text-xl font-medium">
|
||||
{(selectedStaff.first_name?.[0] || '') + (selectedStaff.last_name?.[0] || '')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{selectedStaff.title && `${selectedStaff.title} `}
|
||||
{selectedStaff.full_name || `${selectedStaff.first_name || ''} ${selectedStaff.last_name}`}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{selectedStaff.position}</p>
|
||||
<p className="text-sm text-slate-400">{selectedStaff.university_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="p-4 border-b space-y-2">
|
||||
{selectedStaff.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a href={`mailto:${selectedStaff.email}`} className="text-primary-600 hover:underline">
|
||||
{selectedStaff.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedStaff.profile_url && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<a href={selectedStaff.profile_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline truncate">
|
||||
Profil
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedStaff.orcid && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="w-4 h-4 text-green-600 font-bold text-xs">ID</span>
|
||||
<a href={`https://orcid.org/${selectedStaff.orcid}`} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
||||
ORCID: {selectedStaff.orcid}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Research Interests */}
|
||||
{selectedStaff.research_interests && selectedStaff.research_interests.length > 0 && (
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Forschungsgebiete</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedStaff.research_interests.map((interest, i) => (
|
||||
<span key={i} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publications */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
Publikationen ({publications.length})
|
||||
</h4>
|
||||
{publications.length > 0 ? (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{publications.map((pub) => (
|
||||
<div key={pub.id} className="text-sm">
|
||||
<p className="font-medium text-slate-800 line-clamp-2">{pub.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
|
||||
{pub.year && <span>{pub.year}</span>}
|
||||
{pub.venue && <span>| {pub.venue}</span>}
|
||||
{pub.citation_count > 0 && (
|
||||
<span className="text-green-600">{pub.citation_count} Zitierungen</span>
|
||||
)}
|
||||
</div>
|
||||
{pub.doi && (
|
||||
<a
|
||||
href={`https://doi.org/${pub.doi}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
DOI
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Keine Publikationen gefunden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 border-t bg-slate-50">
|
||||
<button className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm">
|
||||
Als Kunde markieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-slate-400">
|
||||
Wahlen Sie eine Person aus der Liste
|
||||
</div>
|
||||
)}
|
||||
<StaffDetailPanel selectedStaff={selectedStaff} publications={publications} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
89
website/app/admin/uni-crawler/_components/QueueList.tsx
Normal file
89
website/app/admin/uni-crawler/_components/QueueList.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { CrawlQueueItem } from './types'
|
||||
import { phaseConfig } from './types'
|
||||
|
||||
interface QueueListProps {
|
||||
queue: CrawlQueueItem[]
|
||||
onPause: (universityId: string) => void
|
||||
onResume: (universityId: string) => void
|
||||
onRemove: (universityId: string) => void
|
||||
}
|
||||
|
||||
export function QueueList({ queue, onPause, onResume, onRemove }: QueueListProps) {
|
||||
return (
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl Queue ({queue.length})</h2>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
Queue ist leer. Fuege Universitaeten hinzu, um das Crawling zu starten.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{queue.map((item) => (
|
||||
<div key={item.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-400">#{item.queue_position || '-'}</span>
|
||||
<h3 className="font-medium text-gray-900">{item.university_name}</h3>
|
||||
<span className="text-sm text-gray-500">({item.university_short})</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Badge */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[item.current_phase].color}`}>
|
||||
{phaseConfig[item.current_phase].label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">Prioritaet: {item.priority}</span>
|
||||
{item.retry_count > 0 && (
|
||||
<span className="text-xs text-orange-600">Versuch {item.retry_count}/{item.max_retries}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3">
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 transition-all duration-500" style={{ width: `${item.progress_percent}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{item.progress_percent}%</span>
|
||||
<span>{item.discovery_count} Disc / {item.professors_count} Prof / {item.staff_count} Staff / {item.publications_count} Pub</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Checkmarks */}
|
||||
<div className="flex gap-4 mt-3 text-xs">
|
||||
<span className={item.discovery_completed ? 'text-green-600' : 'text-gray-400'}>Discovery</span>
|
||||
<span className={item.professors_completed ? 'text-green-600' : 'text-gray-400'}>Professoren</span>
|
||||
<span className={item.all_staff_completed ? 'text-green-600' : 'text-gray-400'}>Mitarbeiter</span>
|
||||
<span className={item.publications_completed ? 'text-green-600' : 'text-gray-400'}>Publikationen</span>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{item.last_error && (
|
||||
<p className="mt-2 text-xs text-red-600 bg-red-50 px-2 py-1 rounded">{item.last_error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.current_phase === 'paused' ? (
|
||||
<button onClick={() => onResume(item.university_id)} className="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors">Fortsetzen</button>
|
||||
) : item.current_phase !== 'completed' && item.current_phase !== 'failed' ? (
|
||||
<button onClick={() => onPause(item.university_id)} className="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors">Pausieren</button>
|
||||
) : null}
|
||||
<button onClick={() => onRemove(item.university_id)} className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors">Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
website/app/admin/uni-crawler/_components/types.ts
Normal file
63
website/app/admin/uni-crawler/_components/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface CrawlQueueItem {
|
||||
id: string
|
||||
university_id: string
|
||||
university_name: string
|
||||
university_short: string
|
||||
queue_position: number | null
|
||||
priority: number
|
||||
current_phase: CrawlPhase
|
||||
discovery_completed: boolean
|
||||
discovery_completed_at?: string
|
||||
professors_completed: boolean
|
||||
professors_completed_at?: string
|
||||
all_staff_completed: boolean
|
||||
all_staff_completed_at?: string
|
||||
publications_completed: boolean
|
||||
publications_completed_at?: string
|
||||
discovery_count: number
|
||||
professors_count: number
|
||||
staff_count: number
|
||||
publications_count: number
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
last_error?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
progress_percent: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CrawlPhase = 'pending' | 'discovery' | 'professors' | 'all_staff' | 'publications' | 'completed' | 'failed' | 'paused'
|
||||
|
||||
export interface OrchestratorStatus {
|
||||
is_running: boolean
|
||||
current_university?: CrawlQueueItem
|
||||
current_phase: CrawlPhase
|
||||
queue_length: number
|
||||
completed_today: number
|
||||
total_processed: number
|
||||
last_activity?: string
|
||||
}
|
||||
|
||||
export interface University {
|
||||
id: string
|
||||
name: string
|
||||
short_name?: string
|
||||
url: string
|
||||
state?: string
|
||||
uni_type?: string
|
||||
}
|
||||
|
||||
export const API_BASE = '/api/admin/uni-crawler'
|
||||
|
||||
export const phaseConfig: Record<CrawlPhase, { label: string; color: string; icon: string }> = {
|
||||
pending: { label: 'Wartend', color: 'bg-gray-100 text-gray-700', icon: 'clock' },
|
||||
discovery: { label: 'Discovery', color: 'bg-blue-100 text-blue-700', icon: 'search' },
|
||||
professors: { label: 'Professoren', color: 'bg-indigo-100 text-indigo-700', icon: 'user' },
|
||||
all_staff: { label: 'Alle Mitarbeiter', color: 'bg-purple-100 text-purple-700', icon: 'users' },
|
||||
publications: { label: 'Publikationen', color: 'bg-orange-100 text-orange-700', icon: 'book' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700', icon: 'check' },
|
||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700', icon: 'x' },
|
||||
paused: { label: 'Pausiert', color: 'bg-yellow-100 text-yellow-700', icon: 'pause' }
|
||||
}
|
||||
@@ -4,79 +4,18 @@
|
||||
* University Crawler Control Panel
|
||||
*
|
||||
* Admin interface for managing the multi-phase university crawling system.
|
||||
* Allows starting/stopping the orchestrator, adding universities to the queue,
|
||||
* and monitoring crawl progress.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
|
||||
// Types matching the Go backend
|
||||
interface CrawlQueueItem {
|
||||
id: string
|
||||
university_id: string
|
||||
university_name: string
|
||||
university_short: string
|
||||
queue_position: number | null
|
||||
priority: number
|
||||
current_phase: CrawlPhase
|
||||
discovery_completed: boolean
|
||||
discovery_completed_at?: string
|
||||
professors_completed: boolean
|
||||
professors_completed_at?: string
|
||||
all_staff_completed: boolean
|
||||
all_staff_completed_at?: string
|
||||
publications_completed: boolean
|
||||
publications_completed_at?: string
|
||||
discovery_count: number
|
||||
professors_count: number
|
||||
staff_count: number
|
||||
publications_count: number
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
last_error?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
progress_percent: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type CrawlPhase = 'pending' | 'discovery' | 'professors' | 'all_staff' | 'publications' | 'completed' | 'failed' | 'paused'
|
||||
|
||||
interface OrchestratorStatus {
|
||||
is_running: boolean
|
||||
current_university?: CrawlQueueItem
|
||||
current_phase: CrawlPhase
|
||||
queue_length: number
|
||||
completed_today: number
|
||||
total_processed: number
|
||||
last_activity?: string
|
||||
}
|
||||
|
||||
interface University {
|
||||
id: string
|
||||
name: string
|
||||
short_name?: string
|
||||
url: string
|
||||
state?: string
|
||||
uni_type?: string
|
||||
}
|
||||
|
||||
// Use local API proxy to avoid CORS issues and keep API keys server-side
|
||||
const API_BASE = '/api/admin/uni-crawler'
|
||||
|
||||
// Phase display configuration
|
||||
const phaseConfig: Record<CrawlPhase, { label: string; color: string; icon: string }> = {
|
||||
pending: { label: 'Wartend', color: 'bg-gray-100 text-gray-700', icon: 'clock' },
|
||||
discovery: { label: 'Discovery', color: 'bg-blue-100 text-blue-700', icon: 'search' },
|
||||
professors: { label: 'Professoren', color: 'bg-indigo-100 text-indigo-700', icon: 'user' },
|
||||
all_staff: { label: 'Alle Mitarbeiter', color: 'bg-purple-100 text-purple-700', icon: 'users' },
|
||||
publications: { label: 'Publikationen', color: 'bg-orange-100 text-orange-700', icon: 'book' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700', icon: 'check' },
|
||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700', icon: 'x' },
|
||||
paused: { label: 'Pausiert', color: 'bg-yellow-100 text-yellow-700', icon: 'pause' }
|
||||
}
|
||||
import {
|
||||
CrawlQueueItem,
|
||||
OrchestratorStatus,
|
||||
University,
|
||||
API_BASE,
|
||||
phaseConfig,
|
||||
} from './_components/types'
|
||||
import { QueueList } from './_components/QueueList'
|
||||
|
||||
export default function UniCrawlerPage() {
|
||||
const [status, setStatus] = useState<OrchestratorStatus | null>(null)
|
||||
@@ -85,288 +24,130 @@ export default function UniCrawlerPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
// Add to queue form
|
||||
const [selectedUniversity, setSelectedUniversity] = useState('')
|
||||
const [priority, setPriority] = useState(5)
|
||||
|
||||
// Fetch status
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStatus(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch status:', err)
|
||||
}
|
||||
if (res.ok) setStatus(await res.json())
|
||||
} catch (err) { console.error('Failed to fetch status:', err) }
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=queue`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data.queue || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
if (res.ok) { const data = await res.json(); setQueue(data.queue || []) }
|
||||
} catch (err) { console.error('Failed to fetch queue:', err) }
|
||||
}, [])
|
||||
|
||||
// Fetch universities (for dropdown)
|
||||
const fetchUniversities = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=universities`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Handle null/undefined universities array from API
|
||||
const unis = data.universities ?? data ?? []
|
||||
setUniversities(Array.isArray(unis) ? unis : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch universities:', err)
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch universities:', err) }
|
||||
}, [])
|
||||
|
||||
// Initial load and polling
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchQueue()
|
||||
fetchUniversities()
|
||||
|
||||
// Poll every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus()
|
||||
fetchQueue()
|
||||
}, 5000)
|
||||
|
||||
fetchStatus(); fetchQueue(); fetchUniversities()
|
||||
const interval = setInterval(() => { fetchStatus(); fetchQueue() }, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus, fetchQueue, fetchUniversities])
|
||||
|
||||
// Start orchestrator
|
||||
const handleStart = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=start`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
setSuccess('Orchestrator gestartet')
|
||||
fetchStatus()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Start fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Verbindungsfehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const res = await fetch(`${API_BASE}?action=start`, { method: 'POST' })
|
||||
if (res.ok) { setSuccess('Orchestrator gestartet'); fetchStatus() }
|
||||
else { const data = await res.json(); setError(data.error || 'Start fehlgeschlagen') }
|
||||
} catch { setError('Verbindungsfehler') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
// Stop orchestrator
|
||||
const handleStop = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=stop`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
setSuccess('Orchestrator gestoppt')
|
||||
fetchStatus()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Stop fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Verbindungsfehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const res = await fetch(`${API_BASE}?action=stop`, { method: 'POST' })
|
||||
if (res.ok) { setSuccess('Orchestrator gestoppt'); fetchStatus() }
|
||||
else { const data = await res.json(); setError(data.error || 'Stop fehlgeschlagen') }
|
||||
} catch { setError('Verbindungsfehler') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
// Add university to queue
|
||||
const handleAddToQueue = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedUniversity) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=queue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
university_id: selectedUniversity,
|
||||
priority: priority,
|
||||
initiated_by: 'admin-ui'
|
||||
})
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ university_id: selectedUniversity, priority, initiated_by: 'admin-ui' })
|
||||
})
|
||||
if (res.ok) {
|
||||
setSuccess('Universitaet zur Queue hinzugefuegt')
|
||||
setSelectedUniversity('')
|
||||
setPriority(5)
|
||||
fetchQueue()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Hinzufuegen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Verbindungsfehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
if (res.ok) { setSuccess('Universitaet zur Queue hinzugefuegt'); setSelectedUniversity(''); setPriority(5); fetchQueue() }
|
||||
else { const data = await res.json(); setError(data.error || 'Hinzufuegen fehlgeschlagen') }
|
||||
} catch { setError('Verbindungsfehler') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
const handleRemove = async (universityId: string) => {
|
||||
if (!confirm('Wirklich aus der Queue entfernen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?university_id=${universityId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchQueue()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Remove failed:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}?university_id=${universityId}`, { method: 'DELETE' }); if (res.ok) fetchQueue() }
|
||||
catch (err) { console.error('Remove failed:', err) }
|
||||
}
|
||||
|
||||
// Pause/Resume
|
||||
const handlePause = async (universityId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=pause&university_id=${universityId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchQueue()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Pause failed:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}?action=pause&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
|
||||
catch (err) { console.error('Pause failed:', err) }
|
||||
}
|
||||
|
||||
const handleResume = async (universityId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=resume&university_id=${universityId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchQueue()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Resume failed:', err)
|
||||
}
|
||||
try { const res = await fetch(`${API_BASE}?action=resume&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
|
||||
catch (err) { console.error('Resume failed:', err) }
|
||||
}
|
||||
|
||||
// Clear messages after 5 seconds
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const timer = setTimeout(() => setSuccess(null), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [success])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [error])
|
||||
useEffect(() => { if (success) { const t = setTimeout(() => setSuccess(null), 5000); return () => clearTimeout(t) } }, [success])
|
||||
useEffect(() => { if (error) { const t = setTimeout(() => setError(null), 5000); return () => clearTimeout(t) } }, [error])
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="University Crawler"
|
||||
description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten"
|
||||
>
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
<AdminLayout title="University Crawler" description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten">
|
||||
{error && <div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{error}</div>}
|
||||
{success && <div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">{success}</div>}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Status Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Orchestrator Status</h2>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className={`w-4 h-4 rounded-full ${status?.is_running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
|
||||
<span className={`font-medium ${status?.is_running ? 'text-green-700' : 'text-gray-600'}`}>
|
||||
{status?.is_running ? 'Laeuft' : 'Gestoppt'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={loading || status?.is_running}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={loading || !status?.is_running}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button onClick={handleStart} disabled={loading || status?.is_running} className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Start</button>
|
||||
<button onClick={handleStop} disabled={loading || !status?.is_running} className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Stop</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">In Queue:</span>
|
||||
<span className="font-medium">{status?.queue_length || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Heute abgeschlossen:</span>
|
||||
<span className="font-medium text-green-600">{status?.completed_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Gesamt verarbeitet:</span>
|
||||
<span className="font-medium">{status?.total_processed || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm"><span className="text-gray-500">In Queue:</span><span className="font-medium">{status?.queue_length || 0}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-gray-500">Heute abgeschlossen:</span><span className="font-medium text-green-600">{status?.completed_today || 0}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-gray-500">Gesamt verarbeitet:</span><span className="font-medium">{status?.total_processed || 0}</span></div>
|
||||
{status?.last_activity && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Letzte Aktivitaet:</span>
|
||||
<span className="font-medium text-xs">
|
||||
{new Date(status.last_activity).toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm"><span className="text-gray-500">Letzte Aktivitaet:</span><span className="font-medium text-xs">{new Date(status.last_activity).toLocaleTimeString('de-DE')}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current University */}
|
||||
{status?.current_university && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Aktuelle Verarbeitung</h3>
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<p className="font-medium text-blue-900">{status.current_university.university_name}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[status.current_phase].color}`}>
|
||||
{phaseConfig[status.current_phase].label}
|
||||
</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
{status.current_university.progress_percent}%
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[status.current_phase].color}`}>{phaseConfig[status.current_phase].label}</span>
|
||||
<span className="text-xs text-blue-600">{status.current_university.progress_percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,157 +159,23 @@ export default function UniCrawlerPage() {
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Zur Queue hinzufuegen</h2>
|
||||
<form onSubmit={handleAddToQueue} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Universitaet
|
||||
</label>
|
||||
<select
|
||||
value={selectedUniversity}
|
||||
onChange={(e) => setSelectedUniversity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Universitaet</label>
|
||||
<select value={selectedUniversity} onChange={(e) => setSelectedUniversity(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Waehlen...</option>
|
||||
{universities.map((uni) => (
|
||||
<option key={uni.id} value={uni.id}>
|
||||
{uni.name} {uni.short_name && `(${uni.short_name})`}
|
||||
</option>
|
||||
))}
|
||||
{universities.map((uni) => (<option key={uni.id} value={uni.id}>{uni.name} {uni.short_name && `(${uni.short_name})`}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prioritaet (1-10)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet (1-10)</label>
|
||||
<input type="number" min={1} max={10} value={priority} onChange={(e) => setPriority(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
<p className="text-xs text-gray-500 mt-1">Hoeher = dringender</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedUniversity}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !selectedUniversity} className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Hinzufuegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl Queue ({queue.length})</h2>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
Queue ist leer. Fuege Universitaeten hinzu, um das Crawling zu starten.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{queue.map((item) => (
|
||||
<div key={item.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-400">#{item.queue_position || '-'}</span>
|
||||
<h3 className="font-medium text-gray-900">{item.university_name}</h3>
|
||||
<span className="text-sm text-gray-500">({item.university_short})</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Badge */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[item.current_phase].color}`}>
|
||||
{phaseConfig[item.current_phase].label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Prioritaet: {item.priority}
|
||||
</span>
|
||||
{item.retry_count > 0 && (
|
||||
<span className="text-xs text-orange-600">
|
||||
Versuch {item.retry_count}/{item.max_retries}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3">
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-500"
|
||||
style={{ width: `${item.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{item.progress_percent}%</span>
|
||||
<span>
|
||||
{item.discovery_count} Disc / {item.professors_count} Prof / {item.staff_count} Staff / {item.publications_count} Pub
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Checkmarks */}
|
||||
<div className="flex gap-4 mt-3 text-xs">
|
||||
<span className={item.discovery_completed ? 'text-green-600' : 'text-gray-400'}>
|
||||
{item.discovery_completed ? 'Discovery' : 'Discovery'}
|
||||
</span>
|
||||
<span className={item.professors_completed ? 'text-green-600' : 'text-gray-400'}>
|
||||
{item.professors_completed ? 'Professoren' : 'Professoren'}
|
||||
</span>
|
||||
<span className={item.all_staff_completed ? 'text-green-600' : 'text-gray-400'}>
|
||||
{item.all_staff_completed ? 'Mitarbeiter' : 'Mitarbeiter'}
|
||||
</span>
|
||||
<span className={item.publications_completed ? 'text-green-600' : 'text-gray-400'}>
|
||||
{item.publications_completed ? 'Publikationen' : 'Publikationen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{item.last_error && (
|
||||
<p className="mt-2 text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
||||
{item.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.current_phase === 'paused' ? (
|
||||
<button
|
||||
onClick={() => handleResume(item.university_id)}
|
||||
className="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
) : item.current_phase !== 'completed' && item.current_phase !== 'failed' ? (
|
||||
<button
|
||||
onClick={() => handlePause(item.university_id)}
|
||||
className="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors"
|
||||
>
|
||||
Pausieren
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => handleRemove(item.university_id)}
|
||||
className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QueueList queue={queue} onPause={handlePause} onResume={handleResume} onRemove={handleRemove} />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
|
||||
220
website/app/admin/voice/_components/TabContent.tsx
Normal file
220
website/app/admin/voice/_components/TabContent.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { TASK_STATES, INTENT_GROUPS, DSGVO_CATEGORIES, API_ENDPOINTS } from './constants'
|
||||
|
||||
export function TabDemo() {
|
||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
||||
<a href="http://localhost:3001/voice-test" target="_blank" rel="noopener noreferrer" className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1">
|
||||
In neuem Tab oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
||||
</div>
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
||||
{!demoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button onClick={() => setDemoLoaded(true)} className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Voice Demo laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{demoLoaded && (
|
||||
<iframe src="http://localhost:3001/voice-test?embed=true" className="w-full h-full border-0" title="Voice Demo" allow="microphone" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabTasks() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
DRAFT → QUEUED → RUNNING → READY
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
APPROVED REJECTED
|
||||
│ │
|
||||
COMPLETED DRAFT (revision)
|
||||
|
||||
Any State → EXPIRED (TTL)
|
||||
Any State → PAUSED (User Interrupt)
|
||||
`}</pre>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{TASK_STATES.map((state) => (
|
||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
||||
<div className="font-semibold text-lg">{state.state}</div>
|
||||
<p className="text-sm mt-1">{state.description}</p>
|
||||
{state.next.length > 0 && (
|
||||
<div className="mt-2 text-xs"><span className="opacity-75">Naechste:</span> {state.next.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabIntents() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
||||
{INTENT_GROUPS.map((group) => (
|
||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
||||
<div className="space-y-2">
|
||||
{group.intents.map((intent) => (
|
||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">{intent.type}</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 italic">Beispiel: "{intent.example}"</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabDsgvo() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{DSGVO_CATEGORIES.map((cat) => (
|
||||
<tr key={cat.category}>
|
||||
<td className="px-4 py-3"><span className="mr-2">{cat.icon}</span><span className="font-medium">{cat.category}</span></td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${cat.risk === 'low' ? 'bg-green-100 text-green-700' : cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{cat.risk.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1"><li>ref_id (truncated)</li><li>content_type</li><li>size_bytes</li><li>ttl_hours</li></ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium">Verboten:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1"><li>user_name</li><li>content / transcript</li><li>email</li><li>student_name</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabApi() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{API_ENDPOINTS.map((ep, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${ep.method === 'GET' ? 'bg-green-100 text-green-700' : ep.method === 'POST' ? 'bg-blue-100 text-blue-700' : ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' : ep.method === 'DELETE' ? 'bg-red-100 text-red-700' : 'bg-purple-100 text-purple-700'}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST http://localhost:8091/api/v1/sessions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
||||
"device_type": "pwa"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
website/app/admin/voice/_components/TabOverview.tsx
Normal file
83
website/app/admin/voice/_components/TabOverview.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
export function TabOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LEHRERGERAET (PWA / App) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ WebSocket (wss://)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ VOICE SERVICE (Port 8091) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Files */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
website/app/admin/voice/_components/constants.ts
Normal file
108
website/app/admin/voice/_components/constants.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
||||
|
||||
// Task State Machine data
|
||||
export const TASK_STATES = [
|
||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
||||
]
|
||||
|
||||
// Intent Types (22 types organized by group)
|
||||
export const INTENT_GROUPS = [
|
||||
{
|
||||
group: 'Notizen',
|
||||
color: 'bg-blue-50 border-blue-200',
|
||||
intents: [
|
||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Content-Generierung',
|
||||
color: 'bg-green-50 border-green-200',
|
||||
intents: [
|
||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation',
|
||||
color: 'bg-yellow-50 border-yellow-200',
|
||||
intents: [
|
||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Canvas-Editor',
|
||||
color: 'bg-purple-50 border-purple-200',
|
||||
intents: [
|
||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'RAG & Korrektur',
|
||||
color: 'bg-pink-50 border-pink-200',
|
||||
intents: [
|
||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Follow-up (TaskOrchestrator)',
|
||||
color: 'bg-teal-50 border-teal-200',
|
||||
intents: [
|
||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// DSGVO Data Categories
|
||||
export const DSGVO_CATEGORIES = [
|
||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' as const },
|
||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' as const },
|
||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' as const },
|
||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' as const },
|
||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' as const },
|
||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' as const },
|
||||
]
|
||||
|
||||
// API Endpoints
|
||||
export const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
||||
]
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
||||
{ id: 'api', name: 'API', icon: '🔌' },
|
||||
] as const
|
||||
@@ -14,149 +14,30 @@
|
||||
import { useState } from 'react'
|
||||
import AdminLayout from '@/components/admin/AdminLayout'
|
||||
import Link from 'next/link'
|
||||
|
||||
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
||||
|
||||
// Task State Machine data
|
||||
const TASK_STATES = [
|
||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
||||
]
|
||||
|
||||
// Intent Types (22 types organized by group)
|
||||
const INTENT_GROUPS = [
|
||||
{
|
||||
group: 'Notizen',
|
||||
color: 'bg-blue-50 border-blue-200',
|
||||
intents: [
|
||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Content-Generierung',
|
||||
color: 'bg-green-50 border-green-200',
|
||||
intents: [
|
||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation',
|
||||
color: 'bg-yellow-50 border-yellow-200',
|
||||
intents: [
|
||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Canvas-Editor',
|
||||
color: 'bg-purple-50 border-purple-200',
|
||||
intents: [
|
||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'RAG & Korrektur',
|
||||
color: 'bg-pink-50 border-pink-200',
|
||||
intents: [
|
||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Follow-up (TaskOrchestrator)',
|
||||
color: 'bg-teal-50 border-teal-200',
|
||||
intents: [
|
||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// DSGVO Data Categories
|
||||
const DSGVO_CATEGORIES = [
|
||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
|
||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
|
||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
|
||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
|
||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
|
||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
|
||||
]
|
||||
|
||||
// API Endpoints
|
||||
const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
||||
]
|
||||
import { TabType, TABS } from './_components/constants'
|
||||
import { TabOverview } from './_components/TabOverview'
|
||||
import { TabDemo, TabTasks, TabIntents, TabDsgvo, TabApi } from './_components/TabContent'
|
||||
|
||||
export default function VoicePage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
||||
{ id: 'api', name: 'API', icon: '🔌' },
|
||||
]
|
||||
|
||||
return (
|
||||
<AdminLayout title="Voice Service" description="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator">
|
||||
{/* Quick Links */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/voice-test"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<Link href="/voice-test" target="_blank" className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
Voice Test (Studio)
|
||||
</Link>
|
||||
<a
|
||||
href="http://localhost:8091/health"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
||||
>
|
||||
<a href="http://localhost:8091/health" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Health Check
|
||||
</a>
|
||||
<Link
|
||||
href="/admin/docs"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<Link href="/admin/docs" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||
</svg>
|
||||
@@ -166,37 +47,19 @@ export default function VoicePage() {
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-teal-600">8091</div>
|
||||
<div className="text-sm text-slate-500">Port</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">22</div>
|
||||
<div className="text-sm text-slate-500">Task Types</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">9</div>
|
||||
<div className="text-sm text-slate-500">Task States</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-green-600">24kHz</div>
|
||||
<div className="text-sm text-slate-500">Audio Rate</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">80ms</div>
|
||||
<div className="text-sm text-slate-500">Frame Size</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">0</div>
|
||||
<div className="text-sm text-slate-500">Audio Persist</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-teal-600">8091</div><div className="text-sm text-slate-500">Port</div></div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-blue-600">22</div><div className="text-sm text-slate-500">Task Types</div></div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-purple-600">9</div><div className="text-sm text-slate-500">Task States</div></div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-green-600">24kHz</div><div className="text-sm text-slate-500">Audio Rate</div></div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-orange-600">80ms</div><div className="text-sm text-slate-500">Frame Size</div></div>
|
||||
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-red-600">0</div><div className="text-sm text-slate-500">Audio Persist</div></div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="border-b border-slate-200 px-4">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
@@ -214,360 +77,12 @@ export default function VoicePage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LEHRERGERAET (PWA / App) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ WebSocket (wss://)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ VOICE SERVICE (Port 8091) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Files */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Tab */}
|
||||
{activeTab === 'demo' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
||||
<a
|
||||
href="http://localhost:3001/voice-test"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
|
||||
>
|
||||
In neuem Tab oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
||||
</div>
|
||||
|
||||
{/* Embedded Demo */}
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
||||
{!demoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setDemoLoaded(true)}
|
||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Voice Demo laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{demoLoaded && (
|
||||
<iframe
|
||||
src="http://localhost:3001/voice-test?embed=true"
|
||||
className="w-full h-full border-0"
|
||||
title="Voice Demo"
|
||||
allow="microphone"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task States Tab */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
||||
|
||||
{/* State Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
DRAFT → QUEUED → RUNNING → READY
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
APPROVED REJECTED
|
||||
│ │
|
||||
COMPLETED DRAFT (revision)
|
||||
|
||||
Any State → EXPIRED (TTL)
|
||||
Any State → PAUSED (User Interrupt)
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* States Table */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{TASK_STATES.map((state) => (
|
||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
||||
<div className="font-semibold text-lg">{state.state}</div>
|
||||
<p className="text-sm mt-1">{state.description}</p>
|
||||
{state.next.length > 0 && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="opacity-75">Naechste:</span>{' '}
|
||||
{state.next.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intents Tab */}
|
||||
{activeTab === 'intents' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
||||
|
||||
{INTENT_GROUPS.map((group) => (
|
||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
||||
<div className="space-y-2">
|
||||
{group.intents.map((intent) => (
|
||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
|
||||
{intent.type}
|
||||
</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 italic">
|
||||
Beispiel: "{intent.example}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DSGVO Tab */}
|
||||
{activeTab === 'dsgvo' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
||||
|
||||
{/* Key Principles */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Data Categories Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{DSGVO_CATEGORIES.map((cat) => (
|
||||
<tr key={cat.category}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="mr-2">{cat.icon}</span>
|
||||
<span className="font-medium">{cat.category}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cat.risk.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Info */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>ref_id (truncated)</li>
|
||||
<li>content_type</li>
|
||||
<li>size_bytes</li>
|
||||
<li>ttl_hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium">Verboten:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>user_name</li>
|
||||
<li>content / transcript</li>
|
||||
<li>email</li>
|
||||
<li>student_name</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
||||
|
||||
{/* REST Endpoints */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{API_ENDPOINTS.map((ep, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Protocol */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example curl commands */}
|
||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST http://localhost:8091/api/v1/sessions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
||||
"device_type": "pwa"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'overview' && <TabOverview />}
|
||||
{activeTab === 'demo' && <TabDemo />}
|
||||
{activeTab === 'tasks' && <TabTasks />}
|
||||
{activeTab === 'intents' && <TabIntents />}
|
||||
{activeTab === 'dsgvo' && <TabDsgvo />}
|
||||
{activeTab === 'api' && <TabApi />}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
interface AssistantSidebarProps {
|
||||
isRTL: boolean
|
||||
t: (key: string) => string
|
||||
assistantHistory: { role: string; content: string }[]
|
||||
assistantMessage: string
|
||||
setAssistantMessage: (msg: string) => void
|
||||
onAskAssistant: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AssistantSidebar({
|
||||
isRTL,
|
||||
t,
|
||||
assistantHistory,
|
||||
assistantMessage,
|
||||
setAssistantMessage,
|
||||
onAskAssistant,
|
||||
onClose,
|
||||
}: AssistantSidebarProps) {
|
||||
return (
|
||||
<div className={`fixed ${isRTL ? 'left-0' : 'right-0'} top-0 h-full w-96 bg-white border-${isRTL ? 'r' : 'l'} border-slate-200 shadow-xl z-30 flex flex-col`}>
|
||||
<div className={`p-4 border-b border-slate-200 flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
|
||||
<div className={`flex items-center gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{t('fa_assistant_title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{assistantHistory.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500 text-sm">
|
||||
{t('fa_assistant_placeholder')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{assistantHistory.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] p-3 rounded-xl text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-slate-200">
|
||||
<div className={`flex gap-2 ${isRTL ? 'flex-row-reverse' : ''}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={assistantMessage}
|
||||
onChange={(e) => setAssistantMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onAskAssistant()}
|
||||
placeholder={t('fa_assistant_placeholder')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={onAskAssistant}
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
|
||||
export const stepIcons: Record<string, React.ReactNode> = {
|
||||
'document-text': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||
</svg>
|
||||
),
|
||||
'academic-cap': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
|
||||
</svg>
|
||||
),
|
||||
'server': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
),
|
||||
'document-report': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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" />
|
||||
</svg>
|
||||
),
|
||||
'currency-euro': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
'calculator': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
'calendar': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
'document-download': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export function Step1({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-slate-600">{t('fa_step1_desc')}</p>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-700">{t('fa_wizard_next')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step2({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_title')} *</label>
|
||||
<input type="text" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
|
||||
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step2_subtitle')}</label>
|
||||
<input type="number" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step3({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step3_desc')}</label>
|
||||
<select className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option>16 Mbit/s</option>
|
||||
<option>16-50 Mbit/s</option>
|
||||
<option>50-100 Mbit/s</option>
|
||||
<option>100-250 Mbit/s</option>
|
||||
<option>250+ Mbit/s</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step4({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_desc')} *</label>
|
||||
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step4_subtitle')} *</label>
|
||||
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step5({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-slate-600">{t('fa_step5_desc')}</p>
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-700">{t('fa_step5_subtitle')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-slate-200">
|
||||
<td className="px-4 py-2">
|
||||
<button className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
+
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step6({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step6_desc')}</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input type="range" min="50" max="100" defaultValue="90" className="flex-1" />
|
||||
<span className="text-lg font-semibold text-slate-900">90%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg">
|
||||
<div className="text-sm text-slate-500">{t('fa_step6_subtitle')}</div>
|
||||
<div className="text-xl font-bold text-slate-900">0,00 EUR</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm text-blue-600">{t('fa_step6_title')}</div>
|
||||
<div className="text-xl font-bold text-blue-700">0,00 EUR</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step7({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
|
||||
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step7_subtitle')} *</label>
|
||||
<input type="date" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step8({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h3 className="font-semibold text-green-800">{t('fa_step8_title')}</h3>
|
||||
<p className="text-sm text-green-700 mt-1">{t('fa_step8_desc')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('fa_step8_subtitle')} *</label>
|
||||
<textarea className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
|
||||
<span className="text-sm text-slate-700">{t('fa_info_text')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface WizardStep {
|
||||
number: number
|
||||
id: string
|
||||
titleKey: string
|
||||
subtitleKey: string
|
||||
descKey: string
|
||||
icon: string
|
||||
is_required: boolean
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const DEFAULT_STEPS: WizardStep[] = [
|
||||
{ number: 1, id: 'foerderprogramm', titleKey: 'fa_step1_title', subtitleKey: 'fa_step1_subtitle', descKey: 'fa_step1_desc', icon: 'document-text', is_required: true, is_completed: false },
|
||||
{ number: 2, id: 'schulinformationen', titleKey: 'fa_step2_title', subtitleKey: 'fa_step2_subtitle', descKey: 'fa_step2_desc', icon: 'academic-cap', is_required: true, is_completed: false },
|
||||
{ number: 3, id: 'bestandsaufnahme', titleKey: 'fa_step3_title', subtitleKey: 'fa_step3_subtitle', descKey: 'fa_step3_desc', icon: 'server', is_required: true, is_completed: false },
|
||||
{ number: 4, id: 'projektbeschreibung', titleKey: 'fa_step4_title', subtitleKey: 'fa_step4_subtitle', descKey: 'fa_step4_desc', icon: 'document-report', is_required: true, is_completed: false },
|
||||
{ number: 5, id: 'investitionen', titleKey: 'fa_step5_title', subtitleKey: 'fa_step5_subtitle', descKey: 'fa_step5_desc', icon: 'currency-euro', is_required: true, is_completed: false },
|
||||
{ number: 6, id: 'finanzierungsplan', titleKey: 'fa_step6_title', subtitleKey: 'fa_step6_subtitle', descKey: 'fa_step6_desc', icon: 'calculator', is_required: true, is_completed: false },
|
||||
{ number: 7, id: 'zeitplan', titleKey: 'fa_step7_title', subtitleKey: 'fa_step7_subtitle', descKey: 'fa_step7_desc', icon: 'calendar', is_required: true, is_completed: false },
|
||||
{ number: 8, id: 'abschluss', titleKey: 'fa_step8_title', subtitleKey: 'fa_step8_subtitle', descKey: 'fa_step8_desc', icon: 'document-download', is_required: true, is_completed: false },
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user