[split-required] Split final 43 files (500-668 LOC) to complete refactoring

klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 09:41:42 +02:00
parent 451365a312
commit bd4b956e3c
113 changed files with 13790 additions and 14148 deletions
@@ -0,0 +1,244 @@
"""
AI Processor - HTML Templates for Print Versions
Contains HTML/CSS header templates for Q&A, Cloze, and Multiple Choice print output.
"""
def get_qa_html_header(title: str) -> str:
"""Get HTML header for Q&A print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Fragen</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
}}
body {{
font-family: Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}}
h1 {{ font-size: 24px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 24px; }}
.question-block {{
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px dashed #ccc;
}}
.question-number {{ font-weight: bold; color: #333; }}
.question-text {{ font-size: 16px; margin: 8px 0; }}
.answer-lines {{ margin-top: 12px; }}
.answer-line {{ border-bottom: 1px solid #999; height: 28px; }}
.answer {{
margin-top: 8px;
padding: 8px;
background: #e8f5e9;
border-left: 3px solid #4caf50;
}}
.key-terms {{ font-size: 12px; color: #666; margin-top: 8px; }}
.key-terms span {{
background: #fff3e0;
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
}}
</style>
</head>
<body>
"""
def get_cloze_html_header(title: str) -> str:
"""Get HTML header for cloze print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Lueckentext</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
}}
body {{
font-family: Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.8;
}}
h1 {{ font-size: 24px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 24px; }}
.cloze-item {{
margin-bottom: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}}
.cloze-number {{ font-weight: bold; color: #333; margin-bottom: 8px; }}
.cloze-sentence {{ font-size: 16px; line-height: 2; }}
.gap {{
display: inline-block;
min-width: 80px;
border-bottom: 2px solid #333;
margin: 0 4px;
text-align: center;
}}
.gap-filled {{
display: inline-block;
padding: 2px 8px;
background: #e8f5e9;
border: 1px solid #4caf50;
border-radius: 4px;
font-weight: bold;
}}
.translation {{
margin-top: 12px;
padding: 8px;
background: #e3f2fd;
border-left: 3px solid #2196f3;
font-size: 14px;
color: #555;
}}
.translation-label {{ font-size: 12px; color: #777; margin-bottom: 4px; }}
.word-bank {{
margin-top: 32px;
padding: 16px;
background: #fff3e0;
border-radius: 8px;
}}
.word-bank-title {{ font-weight: bold; margin-bottom: 12px; }}
.word {{
display: inline-block;
padding: 4px 12px;
margin: 4px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
}}
</style>
</head>
<body>
"""
def get_mc_html_header(title: str) -> str:
"""Get HTML header for MC print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Multiple Choice</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
body {{ font-size: 14pt; }}
}}
body {{
font-family: Arial, Helvetica, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
color: #000;
}}
h1 {{
font-size: 28px;
margin-bottom: 8px;
border-bottom: 2px solid #000;
padding-bottom: 8px;
}}
.meta {{ color: #333; margin-bottom: 32px; font-size: 14px; }}
.instructions {{
background: #f5f5f5;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 24px;
font-size: 14px;
}}
.question-block {{
margin-bottom: 28px;
padding-bottom: 16px;
border-bottom: 1px solid #ddd;
}}
.question-number {{ font-weight: bold; font-size: 18px; color: #000; margin-bottom: 8px; }}
.question-text {{ font-size: 16px; margin: 8px 0 16px 0; line-height: 1.5; }}
.options {{ margin-left: 20px; }}
.option {{
display: flex;
align-items: flex-start;
margin-bottom: 12px;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
}}
.option-correct {{
background: #e8f5e9;
border-color: #4caf50;
border-width: 2px;
}}
.option-checkbox {{
width: 20px;
height: 20px;
border: 2px solid #333;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}}
.option-checkbox.checked::after {{
content: "\\2713";
font-weight: bold;
color: #4caf50;
}}
.option-label {{ font-weight: bold; margin-right: 8px; min-width: 24px; }}
.option-text {{ flex: 1; }}
.explanation {{
margin-top: 8px;
padding: 8px 12px;
background: #e3f2fd;
border-left: 3px solid #2196f3;
font-size: 13px;
color: #333;
}}
.answer-key {{
margin-top: 40px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
}}
.answer-key-title {{
font-weight: bold;
font-size: 18px;
margin-bottom: 12px;
border-bottom: 1px solid #999;
padding-bottom: 8px;
}}
.answer-key-grid {{
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}}
.answer-key-item {{
padding: 8px;
text-align: center;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
}}
.answer-key-q {{ font-weight: bold; }}
.answer-key-a {{ color: #4caf50; font-weight: bold; }}
</style>
</head>
<body>
"""
@@ -10,6 +10,7 @@ import logging
import random
from ..config import BEREINIGT_DIR
from .print_templates import get_qa_html_header, get_cloze_html_header, get_mc_html_header
logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@ def generate_print_version_qa(qa_path: Path, include_answers: bool = False) -> P
grade = metadata.get("grade_level", "")
html_parts = []
html_parts.append(_get_qa_html_header(title))
html_parts.append(get_qa_html_header(title))
# Header
version_text = "Loesungsblatt" if include_answers else "Fragenblatt"
@@ -106,7 +107,7 @@ def generate_print_version_cloze(cloze_path: Path, include_answers: bool = False
total_gaps = metadata.get("total_gaps", 0)
html_parts = []
html_parts.append(_get_cloze_html_header(title))
html_parts.append(get_cloze_html_header(title))
# Header
version_text = "Loesungsblatt" if include_answers else "Lueckentext"
@@ -200,7 +201,7 @@ def generate_print_version_mc(mc_path: Path, include_answers: bool = False) -> s
grade = metadata.get("grade_level", "")
html_parts = []
html_parts.append(_get_mc_html_header(title))
html_parts.append(get_mc_html_header(title))
# Header
version_text = "Loesungsblatt" if include_answers else "Multiple Choice Test"
@@ -267,242 +268,3 @@ def generate_print_version_mc(mc_path: Path, include_answers: bool = False) -> s
html_parts.append("</body></html>")
return "\n".join(html_parts)
def _get_qa_html_header(title: str) -> str:
"""Get HTML header for Q&A print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Fragen</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
}}
body {{
font-family: Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}}
h1 {{ font-size: 24px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 24px; }}
.question-block {{
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px dashed #ccc;
}}
.question-number {{ font-weight: bold; color: #333; }}
.question-text {{ font-size: 16px; margin: 8px 0; }}
.answer-lines {{ margin-top: 12px; }}
.answer-line {{ border-bottom: 1px solid #999; height: 28px; }}
.answer {{
margin-top: 8px;
padding: 8px;
background: #e8f5e9;
border-left: 3px solid #4caf50;
}}
.key-terms {{ font-size: 12px; color: #666; margin-top: 8px; }}
.key-terms span {{
background: #fff3e0;
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
}}
</style>
</head>
<body>
"""
def _get_cloze_html_header(title: str) -> str:
"""Get HTML header for cloze print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Lueckentext</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
}}
body {{
font-family: Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.8;
}}
h1 {{ font-size: 24px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 24px; }}
.cloze-item {{
margin-bottom: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}}
.cloze-number {{ font-weight: bold; color: #333; margin-bottom: 8px; }}
.cloze-sentence {{ font-size: 16px; line-height: 2; }}
.gap {{
display: inline-block;
min-width: 80px;
border-bottom: 2px solid #333;
margin: 0 4px;
text-align: center;
}}
.gap-filled {{
display: inline-block;
padding: 2px 8px;
background: #e8f5e9;
border: 1px solid #4caf50;
border-radius: 4px;
font-weight: bold;
}}
.translation {{
margin-top: 12px;
padding: 8px;
background: #e3f2fd;
border-left: 3px solid #2196f3;
font-size: 14px;
color: #555;
}}
.translation-label {{ font-size: 12px; color: #777; margin-bottom: 4px; }}
.word-bank {{
margin-top: 32px;
padding: 16px;
background: #fff3e0;
border-radius: 8px;
}}
.word-bank-title {{ font-weight: bold; margin-bottom: 12px; }}
.word {{
display: inline-block;
padding: 4px 12px;
margin: 4px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
}}
</style>
</head>
<body>
"""
def _get_mc_html_header(title: str) -> str:
"""Get HTML header for MC print version."""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{title} - Multiple Choice</title>
<style>
@media print {{
.no-print {{ display: none; }}
.page-break {{ page-break-before: always; }}
body {{ font-size: 14pt; }}
}}
body {{
font-family: Arial, Helvetica, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
color: #000;
}}
h1 {{
font-size: 28px;
margin-bottom: 8px;
border-bottom: 2px solid #000;
padding-bottom: 8px;
}}
.meta {{ color: #333; margin-bottom: 32px; font-size: 14px; }}
.instructions {{
background: #f5f5f5;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 24px;
font-size: 14px;
}}
.question-block {{
margin-bottom: 28px;
padding-bottom: 16px;
border-bottom: 1px solid #ddd;
}}
.question-number {{ font-weight: bold; font-size: 18px; color: #000; margin-bottom: 8px; }}
.question-text {{ font-size: 16px; margin: 8px 0 16px 0; line-height: 1.5; }}
.options {{ margin-left: 20px; }}
.option {{
display: flex;
align-items: flex-start;
margin-bottom: 12px;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
}}
.option-correct {{
background: #e8f5e9;
border-color: #4caf50;
border-width: 2px;
}}
.option-checkbox {{
width: 20px;
height: 20px;
border: 2px solid #333;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}}
.option-checkbox.checked::after {{
content: "";
font-weight: bold;
color: #4caf50;
}}
.option-label {{ font-weight: bold; margin-right: 8px; min-width: 24px; }}
.option-text {{ flex: 1; }}
.explanation {{
margin-top: 8px;
padding: 8px 12px;
background: #e3f2fd;
border-left: 3px solid #2196f3;
font-size: 13px;
color: #333;
}}
.answer-key {{
margin-top: 40px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
}}
.answer-key-title {{
font-weight: bold;
font-size: 18px;
margin-bottom: 12px;
border-bottom: 1px solid #999;
padding-bottom: 8px;
}}
.answer-key-grid {{
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}}
.answer-key-item {{
padding: 8px;
text-align: center;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
}}
.answer-key-q {{ font-weight: bold; }}
.answer-key-a {{ color: #4caf50; font-weight: bold; }}
</style>
</head>
<body>
"""
+33 -338
View File
@@ -9,13 +9,10 @@ Endpoints:
- POST /digests/{id}/send-email - Digest per E-Mail versenden
"""
import uuid
import io
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
@@ -23,126 +20,27 @@ from ..db.models import (
AlertDigestDB, UserAlertSubscriptionDB, DigestStatusEnum
)
from ..processing.digest_generator import DigestGenerator
from .digests_models import (
DigestDetail,
DigestListResponse,
GenerateDigestRequest,
GenerateDigestResponse,
SendEmailRequest,
SendEmailResponse,
digest_to_list_item,
digest_to_detail,
)
from .digests_email import generate_pdf_from_html, send_digest_by_email
router = APIRouter(prefix="/digests", tags=["digests"])
# ============================================================================
# Request/Response Models
# ============================================================================
class DigestListItem(BaseModel):
"""Kurze Digest-Info fuer Liste."""
id: str
period_start: datetime
period_end: datetime
total_alerts: int
critical_count: int
urgent_count: int
status: str
created_at: datetime
class DigestDetail(BaseModel):
"""Vollstaendige Digest-Details."""
id: str
subscription_id: Optional[str]
user_id: str
period_start: datetime
period_end: datetime
summary_html: str
summary_pdf_url: Optional[str]
total_alerts: int
critical_count: int
urgent_count: int
important_count: int
review_count: int
info_count: int
status: str
sent_at: Optional[datetime]
created_at: datetime
class DigestListResponse(BaseModel):
"""Response fuer Digest-Liste."""
digests: List[DigestListItem]
total: int
class GenerateDigestRequest(BaseModel):
"""Request fuer manuelle Digest-Generierung."""
weeks_back: int = Field(default=1, ge=1, le=4, description="Wochen zurueck")
force_regenerate: bool = Field(default=False, description="Vorhandenen Digest ueberschreiben")
class GenerateDigestResponse(BaseModel):
"""Response fuer Digest-Generierung."""
status: str
digest_id: Optional[str]
message: str
class SendEmailRequest(BaseModel):
"""Request fuer E-Mail-Versand."""
email: Optional[str] = Field(default=None, description="E-Mail-Adresse (optional, sonst aus Subscription)")
class SendEmailResponse(BaseModel):
"""Response fuer E-Mail-Versand."""
status: str
sent_to: str
message: str
# ============================================================================
# Helper Functions
# ============================================================================
def get_user_id_from_request() -> str:
"""
Extrahiert User-ID aus Request.
TODO: JWT-Token auswerten, aktuell Dummy.
"""
"""Extrahiert User-ID aus Request. TODO: JWT-Token auswerten."""
return "demo-user"
def _digest_to_list_item(digest: AlertDigestDB) -> DigestListItem:
"""Konvertiere DB-Model zu List-Item."""
return DigestListItem(
id=digest.id,
period_start=digest.period_start,
period_end=digest.period_end,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
status=digest.status.value if digest.status else "pending",
created_at=digest.created_at
)
def _digest_to_detail(digest: AlertDigestDB) -> DigestDetail:
"""Konvertiere DB-Model zu Detail."""
return DigestDetail(
id=digest.id,
subscription_id=digest.subscription_id,
user_id=digest.user_id,
period_start=digest.period_start,
period_end=digest.period_end,
summary_html=digest.summary_html or "",
summary_pdf_url=digest.summary_pdf_url,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
important_count=digest.important_count or 0,
review_count=digest.review_count or 0,
info_count=digest.info_count or 0,
status=digest.status.value if digest.status else "pending",
sent_at=digest.sent_at,
created_at=digest.created_at
)
# ============================================================================
# Endpoints
# ============================================================================
@@ -153,11 +51,7 @@ async def list_digests(
offset: int = Query(0, ge=0),
db: DBSession = Depends(get_db)
):
"""
Liste alle Digests des aktuellen Users.
Sortiert nach Erstellungsdatum (neueste zuerst).
"""
"""Liste alle Digests des aktuellen Users."""
user_id = get_user_id_from_request()
query = db.query(AlertDigestDB).filter(
@@ -168,18 +62,14 @@ async def list_digests(
digests = query.offset(offset).limit(limit).all()
return DigestListResponse(
digests=[_digest_to_list_item(d) for d in digests],
digests=[digest_to_list_item(d) for d in digests],
total=total
)
@router.get("/latest", response_model=DigestDetail)
async def get_latest_digest(
db: DBSession = Depends(get_db)
):
"""
Hole den neuesten Digest des Users.
"""
async def get_latest_digest(db: DBSession = Depends(get_db)):
"""Hole den neuesten Digest des Users."""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
@@ -189,17 +79,12 @@ async def get_latest_digest(
if not digest:
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
return _digest_to_detail(digest)
return digest_to_detail(digest)
@router.get("/{digest_id}", response_model=DigestDetail)
async def get_digest(
digest_id: str,
db: DBSession = Depends(get_db)
):
"""
Hole Details eines spezifischen Digests.
"""
async def get_digest(digest_id: str, db: DBSession = Depends(get_db)):
"""Hole Details eines spezifischen Digests."""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
@@ -210,17 +95,12 @@ async def get_digest(
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
return _digest_to_detail(digest)
return digest_to_detail(digest)
@router.get("/{digest_id}/pdf")
async def get_digest_pdf(
digest_id: str,
db: DBSession = Depends(get_db)
):
"""
Generiere und lade PDF-Version des Digests herunter.
"""
async def get_digest_pdf(digest_id: str, db: DBSession = Depends(get_db)):
"""Generiere und lade PDF-Version des Digests herunter."""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
@@ -230,35 +110,26 @@ async def get_digest_pdf(
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
if not digest.summary_html:
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
# PDF generieren
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {str(e)}")
# Dateiname
filename = f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}_{digest.period_end.strftime('%Y%m%d')}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/latest/pdf")
async def get_latest_digest_pdf(
db: DBSession = Depends(get_db)
):
"""
PDF des neuesten Digests herunterladen.
"""
async def get_latest_digest_pdf(db: DBSession = Depends(get_db)):
"""PDF des neuesten Digests herunterladen."""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
@@ -267,11 +138,9 @@ async def get_latest_digest_pdf(
if not digest:
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
if not digest.summary_html:
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
# PDF generieren
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
except Exception as e:
@@ -282,9 +151,7 @@ async def get_latest_digest_pdf(
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@@ -293,16 +160,10 @@ async def generate_digest(
request: GenerateDigestRequest = None,
db: DBSession = Depends(get_db)
):
"""
Generiere einen neuen Digest manuell.
Normalerweise werden Digests automatisch woechentlich generiert.
Diese Route erlaubt manuelle Generierung fuer Tests oder On-Demand.
"""
"""Generiere einen neuen Digest manuell."""
user_id = get_user_id_from_request()
weeks_back = request.weeks_back if request else 1
# Pruefe ob bereits ein Digest fuer diesen Zeitraum existiert
now = datetime.utcnow()
period_end = now - timedelta(days=now.weekday())
period_start = period_end - timedelta(weeks=weeks_back)
@@ -315,12 +176,10 @@ async def generate_digest(
if existing and not (request and request.force_regenerate):
return GenerateDigestResponse(
status="exists",
digest_id=existing.id,
status="exists", digest_id=existing.id,
message="Digest fuer diesen Zeitraum existiert bereits"
)
# Generiere neuen Digest
generator = DigestGenerator(db)
try:
@@ -328,14 +187,12 @@ async def generate_digest(
if digest:
return GenerateDigestResponse(
status="success",
digest_id=digest.id,
status="success", digest_id=digest.id,
message="Digest erfolgreich generiert"
)
else:
return GenerateDigestResponse(
status="empty",
digest_id=None,
status="empty", digest_id=None,
message="Keine Alerts fuer diesen Zeitraum vorhanden"
)
except Exception as e:
@@ -348,9 +205,7 @@ async def send_digest_email(
request: SendEmailRequest = None,
db: DBSession = Depends(get_db)
):
"""
Versende Digest per E-Mail.
"""
"""Versende Digest per E-Mail."""
user_id = get_user_id_from_request()
digest = db.query(AlertDigestDB).filter(
@@ -361,12 +216,10 @@ async def send_digest_email(
if not digest:
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
# E-Mail-Adresse ermitteln
email = None
if request and request.email:
email = request.email
else:
# Aus Subscription holen
subscription = db.query(UserAlertSubscriptionDB).filter(
UserAlertSubscriptionDB.id == digest.subscription_id
).first()
@@ -376,176 +229,18 @@ async def send_digest_email(
if not email:
raise HTTPException(status_code=400, detail="Keine E-Mail-Adresse angegeben")
# E-Mail versenden
try:
await send_digest_by_email(digest, email)
# Status aktualisieren
digest.status = DigestStatusEnum.SENT
digest.sent_at = datetime.utcnow()
db.commit()
return SendEmailResponse(
status="success",
sent_to=email,
status="success", sent_to=email,
message="E-Mail erfolgreich versendet"
)
except Exception as e:
digest.status = DigestStatusEnum.FAILED
db.commit()
raise HTTPException(status_code=500, detail=f"E-Mail-Versand fehlgeschlagen: {str(e)}")
# ============================================================================
# PDF Generation
# ============================================================================
async def generate_pdf_from_html(html_content: str) -> bytes:
"""
Generiere PDF aus HTML.
Verwendet WeasyPrint oder wkhtmltopdf als Fallback.
"""
try:
# Versuche WeasyPrint (bevorzugt)
from weasyprint import HTML
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
except ImportError:
pass
try:
# Fallback: wkhtmltopdf via pdfkit
import pdfkit
pdf_bytes = pdfkit.from_string(html_content, False)
return pdf_bytes
except ImportError:
pass
try:
# Fallback: xhtml2pdf
from xhtml2pdf import pisa
result = io.BytesIO()
pisa.CreatePDF(io.StringIO(html_content), dest=result)
return result.getvalue()
except ImportError:
pass
# Letzter Fallback: Einfache Text-Konvertierung
raise ImportError(
"Keine PDF-Bibliothek verfuegbar. "
"Installieren Sie: pip install weasyprint oder pip install pdfkit oder pip install xhtml2pdf"
)
# ============================================================================
# Email Sending
# ============================================================================
async def send_digest_by_email(digest: AlertDigestDB, recipient_email: str):
"""
Versende Digest per E-Mail.
Verwendet:
- Lokalen SMTP-Server (Postfix/Sendmail)
- SMTP-Relay (z.B. SES, Mailgun)
- SendGrid API
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
# E-Mail zusammenstellen
msg = MIMEMultipart('alternative')
msg['Subject'] = f"Wochenbericht: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}"
msg['From'] = os.getenv('SMTP_FROM', 'alerts@breakpilot.app')
msg['To'] = recipient_email
# Text-Version
text_content = f"""
BreakPilot Alerts - Wochenbericht
Zeitraum: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}
Gesamt: {digest.total_alerts} Meldungen
Kritisch: {digest.critical_count}
Dringend: {digest.urgent_count}
Oeffnen Sie die HTML-Version fuer die vollstaendige Uebersicht.
---
Diese E-Mail wurde automatisch von BreakPilot Alerts generiert.
"""
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
# HTML-Version
if digest.summary_html:
msg.attach(MIMEText(digest.summary_html, 'html', 'utf-8'))
# PDF-Anhang (optional)
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
pdf_attachment = MIMEApplication(pdf_bytes, _subtype='pdf')
pdf_attachment.add_header(
'Content-Disposition', 'attachment',
filename=f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}.pdf"
)
msg.attach(pdf_attachment)
except Exception:
pass # PDF-Anhang ist optional
# Senden
smtp_host = os.getenv('SMTP_HOST', 'localhost')
smtp_port = int(os.getenv('SMTP_PORT', '25'))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
try:
if smtp_port == 465:
# SSL
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
else:
server = smtplib.SMTP(smtp_host, smtp_port)
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
except Exception as e:
# Fallback: SendGrid API
sendgrid_key = os.getenv('SENDGRID_API_KEY')
if sendgrid_key:
await send_via_sendgrid(msg, sendgrid_key)
else:
raise e
async def send_via_sendgrid(msg, api_key: str):
"""Fallback: SendGrid API."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"personalizations": [{"to": [{"email": msg['To']}]}],
"from": {"email": msg['From']},
"subject": msg['Subject'],
"content": [
{"type": "text/plain", "value": msg.get_payload(0).get_payload()},
{"type": "text/html", "value": msg.get_payload(1).get_payload() if len(msg.get_payload()) > 1 else ""}
]
}
)
if response.status_code >= 400:
raise Exception(f"SendGrid error: {response.status_code}")
@@ -0,0 +1,146 @@
"""
Alert Digests - PDF-Generierung und E-Mail-Versand.
"""
import io
import logging
from ..db.models import AlertDigestDB
logger = logging.getLogger(__name__)
async def generate_pdf_from_html(html_content: str) -> bytes:
"""
Generiere PDF aus HTML.
Verwendet WeasyPrint oder wkhtmltopdf als Fallback.
"""
try:
from weasyprint import HTML
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
except ImportError:
pass
try:
import pdfkit
pdf_bytes = pdfkit.from_string(html_content, False)
return pdf_bytes
except ImportError:
pass
try:
from xhtml2pdf import pisa
result = io.BytesIO()
pisa.CreatePDF(io.StringIO(html_content), dest=result)
return result.getvalue()
except ImportError:
pass
raise ImportError(
"Keine PDF-Bibliothek verfuegbar. "
"Installieren Sie: pip install weasyprint oder pip install pdfkit oder pip install xhtml2pdf"
)
async def send_digest_by_email(digest: AlertDigestDB, recipient_email: str):
"""
Versende Digest per E-Mail.
Verwendet:
- Lokalen SMTP-Server (Postfix/Sendmail)
- SMTP-Relay (z.B. SES, Mailgun)
- SendGrid API
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
msg = MIMEMultipart('alternative')
msg['Subject'] = f"Wochenbericht: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}"
msg['From'] = os.getenv('SMTP_FROM', 'alerts@breakpilot.app')
msg['To'] = recipient_email
text_content = f"""
BreakPilot Alerts - Wochenbericht
Zeitraum: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}
Gesamt: {digest.total_alerts} Meldungen
Kritisch: {digest.critical_count}
Dringend: {digest.urgent_count}
Oeffnen Sie die HTML-Version fuer die vollstaendige Uebersicht.
---
Diese E-Mail wurde automatisch von BreakPilot Alerts generiert.
"""
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
if digest.summary_html:
msg.attach(MIMEText(digest.summary_html, 'html', 'utf-8'))
try:
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
pdf_attachment = MIMEApplication(pdf_bytes, _subtype='pdf')
pdf_attachment.add_header(
'Content-Disposition', 'attachment',
filename=f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}.pdf"
)
msg.attach(pdf_attachment)
except Exception:
pass # PDF-Anhang ist optional
smtp_host = os.getenv('SMTP_HOST', 'localhost')
smtp_port = int(os.getenv('SMTP_PORT', '25'))
smtp_user = os.getenv('SMTP_USER', '')
smtp_pass = os.getenv('SMTP_PASS', '')
try:
if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
else:
server = smtplib.SMTP(smtp_host, smtp_port)
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
except Exception as e:
sendgrid_key = os.getenv('SENDGRID_API_KEY')
if sendgrid_key:
await send_via_sendgrid(msg, sendgrid_key)
else:
raise e
async def send_via_sendgrid(msg, api_key: str):
"""Fallback: SendGrid API."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"personalizations": [{"to": [{"email": msg['To']}]}],
"from": {"email": msg['From']},
"subject": msg['Subject'],
"content": [
{"type": "text/plain", "value": msg.get_payload(0).get_payload()},
{"type": "text/html", "value": msg.get_payload(1).get_payload() if len(msg.get_payload()) > 1 else ""}
]
}
)
if response.status_code >= 400:
raise Exception(f"SendGrid error: {response.status_code}")
@@ -0,0 +1,116 @@
"""
Alert Digests - Request/Response Models und Konverter.
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field
from ..db.models import AlertDigestDB
# ============================================================================
# Request/Response Models
# ============================================================================
class DigestListItem(BaseModel):
"""Kurze Digest-Info fuer Liste."""
id: str
period_start: datetime
period_end: datetime
total_alerts: int
critical_count: int
urgent_count: int
status: str
created_at: datetime
class DigestDetail(BaseModel):
"""Vollstaendige Digest-Details."""
id: str
subscription_id: Optional[str]
user_id: str
period_start: datetime
period_end: datetime
summary_html: str
summary_pdf_url: Optional[str]
total_alerts: int
critical_count: int
urgent_count: int
important_count: int
review_count: int
info_count: int
status: str
sent_at: Optional[datetime]
created_at: datetime
class DigestListResponse(BaseModel):
"""Response fuer Digest-Liste."""
digests: List[DigestListItem]
total: int
class GenerateDigestRequest(BaseModel):
"""Request fuer manuelle Digest-Generierung."""
weeks_back: int = Field(default=1, ge=1, le=4, description="Wochen zurueck")
force_regenerate: bool = Field(default=False, description="Vorhandenen Digest ueberschreiben")
class GenerateDigestResponse(BaseModel):
"""Response fuer Digest-Generierung."""
status: str
digest_id: Optional[str]
message: str
class SendEmailRequest(BaseModel):
"""Request fuer E-Mail-Versand."""
email: Optional[str] = Field(default=None, description="E-Mail-Adresse (optional)")
class SendEmailResponse(BaseModel):
"""Response fuer E-Mail-Versand."""
status: str
sent_to: str
message: str
# ============================================================================
# Converter Functions
# ============================================================================
def digest_to_list_item(digest: AlertDigestDB) -> DigestListItem:
"""Konvertiere DB-Model zu List-Item."""
return DigestListItem(
id=digest.id,
period_start=digest.period_start,
period_end=digest.period_end,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
status=digest.status.value if digest.status else "pending",
created_at=digest.created_at
)
def digest_to_detail(digest: AlertDigestDB) -> DigestDetail:
"""Konvertiere DB-Model zu Detail."""
return DigestDetail(
id=digest.id,
subscription_id=digest.subscription_id,
user_id=digest.user_id,
period_start=digest.period_start,
period_end=digest.period_end,
summary_html=digest.summary_html or "",
summary_pdf_url=digest.summary_pdf_url,
total_alerts=digest.total_alerts or 0,
critical_count=digest.critical_count or 0,
urgent_count=digest.urgent_count or 0,
important_count=digest.important_count or 0,
review_count=digest.review_count or 0,
info_count=digest.info_count or 0,
status=digest.status.value if digest.status else "pending",
sent_at=digest.sent_at,
created_at=digest.created_at
)
+25 -133
View File
@@ -1,5 +1,5 @@
"""
API Routes für Alerts Agent.
API Routes fuer Alerts Agent.
Endpoints:
- POST /alerts/ingest - Manuell Alerts importieren
@@ -13,12 +13,18 @@ Endpoints:
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, Query
from ..models.alert_item import AlertItem, AlertStatus
from ..models.relevance_profile import RelevanceProfile, PriorityItem
from ..processing.relevance_scorer import RelevanceDecision, RelevanceScorer
from .schemas import (
AlertIngestRequest, AlertIngestResponse,
AlertRunRequest, AlertRunResponse,
InboxItem, InboxResponse,
FeedbackRequest, FeedbackResponse,
ProfilePriorityRequest, ProfileUpdateRequest, ProfileResponse,
)
router = APIRouter(prefix="/alerts", tags=["alerts"])
@@ -30,113 +36,13 @@ ALERTS_USE_LLM = os.getenv("ALERTS_USE_LLM", "false").lower() == "true"
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# In-Memory Storage (spaeter durch DB ersetzen)
# ============================================================================
_alerts_store: dict[str, AlertItem] = {}
_profile_store: dict[str, RelevanceProfile] = {}
# ============================================================================
# Request/Response Models
# ============================================================================
class AlertIngestRequest(BaseModel):
"""Request für manuelles Alert-Import."""
title: str = Field(..., min_length=1, max_length=500)
url: str = Field(..., min_length=1)
snippet: Optional[str] = Field(default=None, max_length=2000)
topic_label: str = Field(default="Manual Import")
published_at: Optional[datetime] = None
class AlertIngestResponse(BaseModel):
"""Response für Alert-Import."""
id: str
status: str
message: str
class AlertRunRequest(BaseModel):
"""Request für Scoring-Pipeline."""
limit: int = Field(default=50, ge=1, le=200)
skip_scored: bool = Field(default=True)
class AlertRunResponse(BaseModel):
"""Response für Scoring-Pipeline."""
processed: int
keep: int
drop: int
review: int
errors: int
duration_ms: int
class InboxItem(BaseModel):
"""Ein Item in der Inbox."""
id: str
title: str
url: str
snippet: Optional[str]
topic_label: str
published_at: Optional[datetime]
relevance_score: Optional[float]
relevance_decision: Optional[str]
relevance_summary: Optional[str]
status: str
class InboxResponse(BaseModel):
"""Response für Inbox-Abfrage."""
items: list[InboxItem]
total: int
page: int
page_size: int
class FeedbackRequest(BaseModel):
"""Request für Relevanz-Feedback."""
alert_id: str
is_relevant: bool
reason: Optional[str] = None
tags: list[str] = Field(default_factory=list)
class FeedbackResponse(BaseModel):
"""Response für Feedback."""
success: bool
message: str
profile_updated: bool
class ProfilePriorityRequest(BaseModel):
"""Priority für Profile-Update."""
label: str
weight: float = Field(default=0.5, ge=0.0, le=1.0)
keywords: list[str] = Field(default_factory=list)
description: Optional[str] = None
class ProfileUpdateRequest(BaseModel):
"""Request für Profile-Update."""
priorities: Optional[list[ProfilePriorityRequest]] = None
exclusions: Optional[list[str]] = None
policies: Optional[dict] = None
class ProfileResponse(BaseModel):
"""Response für Profile."""
id: str
priorities: list[dict]
exclusions: list[str]
policies: dict
total_scored: int
total_kept: int
total_dropped: int
accuracy_estimate: Optional[float]
# ============================================================================
# Endpoints
# ============================================================================
@@ -146,7 +52,7 @@ async def ingest_alert(request: AlertIngestRequest):
"""
Manuell einen Alert importieren.
Nützlich für Tests oder manuelles Hinzufügen von Artikeln.
Nuetzlich fuer Tests oder manuelles Hinzufuegen von Artikeln.
"""
alert = AlertItem(
title=request.title,
@@ -168,13 +74,13 @@ async def ingest_alert(request: AlertIngestRequest):
@router.post("/run", response_model=AlertRunResponse)
async def run_scoring_pipeline(request: AlertRunRequest):
"""
Scoring-Pipeline für neue Alerts starten.
Scoring-Pipeline fuer neue Alerts starten.
Bewertet alle unbewerteten Alerts und klassifiziert sie
in KEEP, DROP oder REVIEW.
Wenn ALERTS_USE_LLM=true, wird das LLM Gateway für Scoring verwendet.
Sonst wird ein schnelles Keyword-basiertes Scoring durchgeführt.
Wenn ALERTS_USE_LLM=true, wird das LLM Gateway fuer Scoring verwendet.
Sonst wird ein schnelles Keyword-basiertes Scoring durchgefuehrt.
"""
import time
start = time.time()
@@ -193,7 +99,7 @@ async def run_scoring_pipeline(request: AlertRunRequest):
keep = drop = review = errors = 0
# Profil für Scoring laden
# Profil fuer Scoring laden
profile = _profile_store.get("default")
if not profile:
profile = RelevanceProfile.create_default_education_profile()
@@ -201,7 +107,7 @@ async def run_scoring_pipeline(request: AlertRunRequest):
_profile_store["default"] = profile
if ALERTS_USE_LLM and LLM_API_KEY:
# LLM-basiertes Scoring über Gateway
# LLM-basiertes Scoring ueber Gateway
scorer = RelevanceScorer(
gateway_url=LLM_GATEWAY_URL,
api_key=LLM_API_KEY,
@@ -227,12 +133,12 @@ async def run_scoring_pipeline(request: AlertRunRequest):
snippet_lower = (alert.snippet or "").lower()
combined = title_lower + " " + snippet_lower
# Ausschlüsse aus Profil prüfen
# Ausschluesse aus Profil pruefen
if any(excl.lower() in combined for excl in profile.exclusions):
alert.relevance_score = 0.15
alert.relevance_decision = RelevanceDecision.DROP.value
drop += 1
# Prioritäten aus Profil prüfen
# Prioritaeten aus Profil pruefen
elif any(
p.label.lower() in combined or
any(kw.lower() in combined for kw in (p.keywords if hasattr(p, 'keywords') else []))
@@ -285,9 +191,9 @@ async def get_inbox(
# Pagination
total = len(alerts)
start = (page - 1) * page_size
end = start + page_size
page_alerts = alerts[start:end]
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
page_alerts = alerts[start_idx:end_idx]
items = [
InboxItem(
@@ -327,7 +233,7 @@ async def submit_feedback(request: FeedbackRequest):
# Alert Status aktualisieren
alert.status = AlertStatus.REVIEWED
# Profile aktualisieren (Default-Profile für Demo)
# Profile aktualisieren (Default-Profile fuer Demo)
profile = _profile_store.get("default")
if not profile:
profile = RelevanceProfile.create_default_education_profile()
@@ -353,7 +259,7 @@ async def get_profile(user_id: Optional[str] = Query(default=None)):
"""
Relevanz-Profil abrufen.
Ohne user_id wird das Default-Profil zurückgegeben.
Ohne user_id wird das Default-Profil zurueckgegeben.
"""
profile_id = user_id or "default"
profile = _profile_store.get(profile_id)
@@ -385,7 +291,7 @@ async def update_profile(
"""
Relevanz-Profil aktualisieren.
Erlaubt Anpassung von Prioritäten, Ausschlüssen und Policies.
Erlaubt Anpassung von Prioritaeten, Ausschluessen und Policies.
"""
profile_id = user_id or "default"
profile = _profile_store.get(profile_id)
@@ -431,34 +337,24 @@ async def update_profile(
@router.get("/stats")
async def get_stats():
"""
Statistiken über Alerts und Scoring.
Gibt Statistiken im Format zurück, das das Frontend erwartet:
- total_alerts, new_alerts, kept_alerts, review_alerts, dropped_alerts
- total_topics, active_topics, total_rules
Statistiken ueber Alerts und Scoring.
"""
alerts = list(_alerts_store.values())
total = len(alerts)
# Zähle nach Status und Decision
new_alerts = sum(1 for a in alerts if a.status == AlertStatus.NEW)
kept_alerts = sum(1 for a in alerts if a.relevance_decision == "KEEP")
review_alerts = sum(1 for a in alerts if a.relevance_decision == "REVIEW")
dropped_alerts = sum(1 for a in alerts if a.relevance_decision == "DROP")
# Topics und Rules (In-Memory hat diese nicht, aber wir geben 0 zurück)
# Bei DB-Implementierung würden wir hier die Repositories nutzen
total_topics = 0
active_topics = 0
total_rules = 0
# Versuche DB-Statistiken zu laden wenn verfügbar
try:
from alerts_agent.db import get_db
from alerts_agent.db.repository import TopicRepository, RuleRepository
from contextlib import contextmanager
# Versuche eine DB-Session zu bekommen
db_gen = get_db()
db = next(db_gen, None)
if db:
@@ -478,15 +374,12 @@ async def get_stats():
except StopIteration:
pass
except Exception:
# DB nicht verfügbar, nutze In-Memory Defaults
pass
# Berechne Durchschnittsscore
scored_alerts = [a for a in alerts if a.relevance_score is not None]
avg_score = sum(a.relevance_score for a in scored_alerts) / len(scored_alerts) if scored_alerts else 0.0
return {
# Frontend-kompatibles Format
"total_alerts": total,
"new_alerts": new_alerts,
"kept_alerts": kept_alerts,
@@ -496,7 +389,6 @@ async def get_stats():
"active_topics": active_topics,
"total_rules": total_rules,
"avg_score": avg_score,
# Zusätzliche Details (Abwärtskompatibilität)
"by_status": {
"new": new_alerts,
"scored": sum(1 for a in alerts if a.status == AlertStatus.SCORED),
+111
View File
@@ -0,0 +1,111 @@
"""
Request/Response Schemas fuer Alerts Agent API.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ============================================================================
# Request Models
# ============================================================================
class AlertIngestRequest(BaseModel):
"""Request fuer manuelles Alert-Import."""
title: str = Field(..., min_length=1, max_length=500)
url: str = Field(..., min_length=1)
snippet: Optional[str] = Field(default=None, max_length=2000)
topic_label: str = Field(default="Manual Import")
published_at: Optional[datetime] = None
class AlertRunRequest(BaseModel):
"""Request fuer Scoring-Pipeline."""
limit: int = Field(default=50, ge=1, le=200)
skip_scored: bool = Field(default=True)
class FeedbackRequest(BaseModel):
"""Request fuer Relevanz-Feedback."""
alert_id: str
is_relevant: bool
reason: Optional[str] = None
tags: list[str] = Field(default_factory=list)
class ProfilePriorityRequest(BaseModel):
"""Priority fuer Profile-Update."""
label: str
weight: float = Field(default=0.5, ge=0.0, le=1.0)
keywords: list[str] = Field(default_factory=list)
description: Optional[str] = None
class ProfileUpdateRequest(BaseModel):
"""Request fuer Profile-Update."""
priorities: Optional[list[ProfilePriorityRequest]] = None
exclusions: Optional[list[str]] = None
policies: Optional[dict] = None
# ============================================================================
# Response Models
# ============================================================================
class AlertIngestResponse(BaseModel):
"""Response fuer Alert-Import."""
id: str
status: str
message: str
class AlertRunResponse(BaseModel):
"""Response fuer Scoring-Pipeline."""
processed: int
keep: int
drop: int
review: int
errors: int
duration_ms: int
class InboxItem(BaseModel):
"""Ein Item in der Inbox."""
id: str
title: str
url: str
snippet: Optional[str]
topic_label: str
published_at: Optional[datetime]
relevance_score: Optional[float]
relevance_decision: Optional[str]
relevance_summary: Optional[str]
status: str
class InboxResponse(BaseModel):
"""Response fuer Inbox-Abfrage."""
items: list[InboxItem]
total: int
page: int
page_size: int
class FeedbackResponse(BaseModel):
"""Response fuer Feedback."""
success: bool
message: str
profile_updated: bool
class ProfileResponse(BaseModel):
"""Response fuer Profile."""
id: str
priorities: list[dict]
exclusions: list[str]
policies: dict
total_scored: int
total_kept: int
total_dropped: int
accuracy_estimate: Optional[float]
+52 -237
View File
@@ -7,21 +7,12 @@ Verwaltet den 3-Schritt Setup-Wizard:
3. Bestätigung und Aktivierung
Zusätzlich: Migration-Wizard für bestehende Google Alerts.
Endpoints:
- GET /wizard/state - Aktuellen Wizard-Status abrufen
- PUT /wizard/step/{step} - Schritt speichern
- POST /wizard/complete - Wizard abschließen
- POST /wizard/reset - Wizard zurücksetzen
- POST /wizard/migrate/email - E-Mail-Migration starten
- POST /wizard/migrate/rss - RSS-Import
"""
import uuid
from typing import Optional, List, Dict, Any
from typing import List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
@@ -29,77 +20,22 @@ from ..db.models import (
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
)
from .wizard_models import (
WizardState,
Step1Data,
Step2Data,
Step3Data,
StepResponse,
MigrateEmailRequest,
MigrateEmailResponse,
MigrateRssRequest,
MigrateRssResponse,
)
router = APIRouter(prefix="/wizard", tags=["wizard"])
# ============================================================================
# Request/Response Models
# ============================================================================
class WizardState(BaseModel):
"""Aktueller Wizard-Status."""
subscription_id: Optional[str] = None
current_step: int = 0 # 0=nicht gestartet, 1-3=Schritte, 4=abgeschlossen
is_completed: bool = False
step_data: Dict[str, Any] = {}
recommended_templates: List[Dict[str, Any]] = []
class Step1Data(BaseModel):
"""Daten für Schritt 1: Rollenwahl."""
role: str = Field(..., description="lehrkraft, schulleitung, it_beauftragte")
class Step2Data(BaseModel):
"""Daten für Schritt 2: Template-Auswahl."""
template_ids: List[str] = Field(..., min_length=1, max_length=3)
class Step3Data(BaseModel):
"""Daten für Schritt 3: Bestätigung."""
notification_email: Optional[str] = None
digest_enabled: bool = True
digest_frequency: str = "weekly"
class StepResponse(BaseModel):
"""Response für Schritt-Update."""
status: str
current_step: int
next_step: int
message: str
recommended_templates: List[Dict[str, Any]] = []
class MigrateEmailRequest(BaseModel):
"""Request für E-Mail-Migration."""
original_label: Optional[str] = Field(default=None, description="Beschreibung des Alerts")
class MigrateEmailResponse(BaseModel):
"""Response für E-Mail-Migration."""
status: str
inbound_address: str
instructions: List[str]
source_id: str
class MigrateRssRequest(BaseModel):
"""Request für RSS-Import."""
rss_urls: List[str] = Field(..., min_length=1, max_length=20)
labels: Optional[List[str]] = None
class MigrateRssResponse(BaseModel):
"""Response für RSS-Import."""
status: str
sources_created: int
topics_created: int
message: str
# ============================================================================
# Helper Functions
# ============================================================================
@@ -144,13 +80,9 @@ def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]
for t in templates:
if role in (t.target_roles or []):
result.append({
"id": t.id,
"slug": t.slug,
"name": t.name,
"description": t.description,
"icon": t.icon,
"category": t.category,
"recommended": True,
"id": t.id, "slug": t.slug, "name": t.name,
"description": t.description, "icon": t.icon,
"category": t.category, "recommended": True,
})
return result
@@ -167,14 +99,8 @@ def _generate_inbound_address(user_id: str, source_id: str) -> str:
# ============================================================================
@router.get("/state", response_model=WizardState)
async def get_wizard_state(
db: DBSession = Depends(get_db)
):
"""
Hole aktuellen Wizard-Status.
Gibt Schritt, gespeicherte Daten und empfohlene Templates zurück.
"""
async def get_wizard_state(db: DBSession = Depends(get_db)):
"""Hole aktuellen Wizard-Status."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -182,15 +108,8 @@ async def get_wizard_state(
).order_by(UserAlertSubscriptionDB.created_at.desc()).first()
if not subscription:
return WizardState(
subscription_id=None,
current_step=0,
is_completed=False,
step_data={},
recommended_templates=[],
)
return WizardState()
# Empfohlene Templates basierend auf Rolle
role = subscription.user_role.value if subscription.user_role else None
recommended = _get_recommended_templates(db, role) if role else []
@@ -204,61 +123,37 @@ async def get_wizard_state(
@router.put("/step/1", response_model=StepResponse)
async def save_step_1(
data: Step1Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 1: Rolle speichern.
Wählt die Rolle des Nutzers und gibt passende Template-Empfehlungen.
"""
async def save_step_1(data: Step1Data, db: DBSession = Depends(get_db)):
"""Schritt 1: Rolle speichern."""
user_id = get_user_id_from_request()
# Validiere Rolle
try:
role = UserRoleEnum(data.role)
except ValueError:
raise HTTPException(
status_code=400,
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
)
raise HTTPException(status_code=400, detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'")
subscription = _get_or_create_subscription(db, user_id)
# Update
subscription.user_role = role
subscription.wizard_step = 1
wizard_state = subscription.wizard_state or {}
wizard_state["step1"] = {"role": data.role}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
db.refresh(subscription)
# Empfohlene Templates
recommended = _get_recommended_templates(db, data.role)
return StepResponse(
status="success",
current_step=1,
next_step=2,
status="success", current_step=1, next_step=2,
message=f"Rolle '{data.role}' gespeichert. Bitte wählen Sie jetzt Ihre Themen.",
recommended_templates=recommended,
)
@router.put("/step/2", response_model=StepResponse)
async def save_step_2(
data: Step2Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 2: Templates auswählen.
Speichert die ausgewählten Templates (1-3).
"""
async def save_step_2(data: Step2Data, db: DBSession = Depends(get_db)):
"""Schritt 2: Templates auswählen."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -269,46 +164,28 @@ async def save_step_2(
if not subscription:
raise HTTPException(status_code=400, detail="Bitte zuerst Schritt 1 abschließen")
# Validiere Template-IDs
templates = db.query(AlertTemplateDB).filter(
AlertTemplateDB.id.in_(data.template_ids)
).all()
templates = db.query(AlertTemplateDB).filter(AlertTemplateDB.id.in_(data.template_ids)).all()
if len(templates) != len(data.template_ids):
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
# Update
subscription.selected_template_ids = data.template_ids
subscription.wizard_step = 2
wizard_state = subscription.wizard_state or {}
wizard_state["step2"] = {
"template_ids": data.template_ids,
"template_names": [t.name for t in templates],
}
wizard_state["step2"] = {"template_ids": data.template_ids, "template_names": [t.name for t in templates]}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
return StepResponse(
status="success",
current_step=2,
next_step=3,
status="success", current_step=2, next_step=3,
message=f"{len(templates)} Themen ausgewählt. Bitte bestätigen Sie Ihre Auswahl.",
recommended_templates=[],
)
@router.put("/step/3", response_model=StepResponse)
async def save_step_3(
data: Step3Data,
db: DBSession = Depends(get_db)
):
"""
Schritt 3: Digest-Einstellungen und Bestätigung.
Speichert E-Mail und Digest-Präferenzen.
"""
async def save_step_3(data: Step3Data, db: DBSession = Depends(get_db)):
"""Schritt 3: Digest-Einstellungen und Bestätigung."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -318,16 +195,13 @@ async def save_step_3(
if not subscription:
raise HTTPException(status_code=400, detail="Bitte zuerst Schritte 1 und 2 abschließen")
if not subscription.selected_template_ids:
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen (Schritt 2)")
# Update
subscription.notification_email = data.notification_email
subscription.digest_enabled = data.digest_enabled
subscription.digest_frequency = data.digest_frequency
subscription.wizard_step = 3
wizard_state = subscription.wizard_state or {}
wizard_state["step3"] = {
"notification_email": data.notification_email,
@@ -336,27 +210,17 @@ async def save_step_3(
}
subscription.wizard_state = wizard_state
subscription.updated_at = datetime.utcnow()
db.commit()
return StepResponse(
status="success",
current_step=3,
next_step=4,
status="success", current_step=3, next_step=4,
message="Einstellungen gespeichert. Klicken Sie auf 'Jetzt starten' um den Wizard abzuschließen.",
recommended_templates=[],
)
@router.post("/complete")
async def complete_wizard(
db: DBSession = Depends(get_db)
):
"""
Wizard abschließen und Templates aktivieren.
Erstellt Topics, Rules und Profile basierend auf den gewählten Templates.
"""
async def complete_wizard(db: DBSession = Depends(get_db)):
"""Wizard abschließen und Templates aktivieren."""
user_id = get_user_id_from_request()
subscription = db.query(UserAlertSubscriptionDB).filter(
@@ -366,18 +230,14 @@ async def complete_wizard(
if not subscription:
raise HTTPException(status_code=400, detail="Kein aktiver Wizard gefunden")
if not subscription.selected_template_ids:
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen")
# Aktiviere Templates (über Subscription-Endpoint)
from .subscriptions import activate_template, ActivateTemplateRequest
# Markiere als abgeschlossen
subscription.wizard_completed = True
subscription.wizard_step = 4
subscription.updated_at = datetime.utcnow()
db.commit()
return {
@@ -390,9 +250,7 @@ async def complete_wizard(
@router.post("/reset")
async def reset_wizard(
db: DBSession = Depends(get_db)
):
async def reset_wizard(db: DBSession = Depends(get_db)):
"""Wizard zurücksetzen (für Neustart)."""
user_id = get_user_id_from_request()
@@ -405,10 +263,7 @@ async def reset_wizard(
db.delete(subscription)
db.commit()
return {
"status": "success",
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
}
return {"status": "success", "message": "Wizard zurückgesetzt. Sie können neu beginnen."}
# ============================================================================
@@ -416,29 +271,16 @@ async def reset_wizard(
# ============================================================================
@router.post("/migrate/email", response_model=MigrateEmailResponse)
async def start_email_migration(
request: MigrateEmailRequest = None,
db: DBSession = Depends(get_db)
):
"""
Starte E-Mail-Migration für bestehende Google Alerts.
Generiert eine eindeutige Inbound-E-Mail-Adresse, an die der Nutzer
seine Google Alerts weiterleiten kann.
"""
async def start_email_migration(request: MigrateEmailRequest = None, db: DBSession = Depends(get_db)):
"""Starte E-Mail-Migration für bestehende Google Alerts."""
user_id = get_user_id_from_request()
# Erstelle AlertSource
source = AlertSourceDB(
id=str(uuid.uuid4()),
user_id=user_id,
id=str(uuid.uuid4()), user_id=user_id,
source_type=FeedTypeEnum.EMAIL,
original_label=request.original_label if request else "Google Alert Migration",
migration_mode=MigrationModeEnum.FORWARD,
is_active=True,
migration_mode=MigrationModeEnum.FORWARD, is_active=True,
)
# Generiere Inbound-Adresse
source.inbound_address = _generate_inbound_address(user_id, source.id)
db.add(source)
@@ -446,9 +288,7 @@ async def start_email_migration(
db.refresh(source)
return MigrateEmailResponse(
status="success",
inbound_address=source.inbound_address,
source_id=source.id,
status="success", inbound_address=source.inbound_address, source_id=source.id,
instructions=[
"1. Öffnen Sie Google Alerts (google.com/alerts)",
"2. Klicken Sie auf das Bearbeiten-Symbol bei Ihrem Alert",
@@ -460,74 +300,49 @@ async def start_email_migration(
@router.post("/migrate/rss", response_model=MigrateRssResponse)
async def import_rss_feeds(
request: MigrateRssRequest,
db: DBSession = Depends(get_db)
):
"""
Importiere bestehende Google Alert RSS-Feeds.
Erstellt für jede RSS-URL einen AlertSource und Topic.
"""
async def import_rss_feeds(request: MigrateRssRequest, db: DBSession = Depends(get_db)):
"""Importiere bestehende Google Alert RSS-Feeds."""
user_id = get_user_id_from_request()
from ..db.models import AlertTopicDB
sources_created = 0
topics_created = 0
sources_created, topics_created = 0, 0
for i, url in enumerate(request.rss_urls):
# Label aus Request oder generieren
label = None
if request.labels and i < len(request.labels):
label = request.labels[i]
if not label:
label = f"RSS Feed {i + 1}"
# Erstelle AlertSource
source = AlertSourceDB(
id=str(uuid.uuid4()),
user_id=user_id,
source_type=FeedTypeEnum.RSS,
original_label=label,
rss_url=url,
migration_mode=MigrationModeEnum.IMPORT,
is_active=True,
id=str(uuid.uuid4()), user_id=user_id,
source_type=FeedTypeEnum.RSS, original_label=label,
rss_url=url, migration_mode=MigrationModeEnum.IMPORT, is_active=True,
)
db.add(source)
sources_created += 1
# Erstelle Topic
topic = AlertTopicDB(
id=str(uuid.uuid4()),
user_id=user_id,
name=label,
description=f"Importiert aus RSS: {url[:50]}...",
feed_url=url,
feed_type=FeedTypeEnum.RSS,
is_active=True,
fetch_interval_minutes=60,
id=str(uuid.uuid4()), user_id=user_id,
name=label, description=f"Importiert aus RSS: {url[:50]}...",
feed_url=url, feed_type=FeedTypeEnum.RSS,
is_active=True, fetch_interval_minutes=60,
)
db.add(topic)
# Verknüpfe Source mit Topic
source.topic_id = topic.id
topics_created += 1
db.commit()
return MigrateRssResponse(
status="success",
sources_created=sources_created,
topics_created=topics_created,
status="success", sources_created=sources_created, topics_created=topics_created,
message=f"{sources_created} RSS-Feeds importiert. Die Alerts werden automatisch abgerufen.",
)
@router.get("/migrate/sources")
async def list_migration_sources(
db: DBSession = Depends(get_db)
):
async def list_migration_sources(db: DBSession = Depends(get_db)):
"""Liste alle Migration-Quellen des Users."""
user_id = get_user_id_from_request()
@@ -0,0 +1,68 @@
"""
Wizard API - Request/Response Models.
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class WizardState(BaseModel):
"""Aktueller Wizard-Status."""
subscription_id: Optional[str] = None
current_step: int = 0 # 0=nicht gestartet, 1-3=Schritte, 4=abgeschlossen
is_completed: bool = False
step_data: Dict[str, Any] = {}
recommended_templates: List[Dict[str, Any]] = []
class Step1Data(BaseModel):
"""Daten für Schritt 1: Rollenwahl."""
role: str = Field(..., description="lehrkraft, schulleitung, it_beauftragte")
class Step2Data(BaseModel):
"""Daten für Schritt 2: Template-Auswahl."""
template_ids: List[str] = Field(..., min_length=1, max_length=3)
class Step3Data(BaseModel):
"""Daten für Schritt 3: Bestätigung."""
notification_email: Optional[str] = None
digest_enabled: bool = True
digest_frequency: str = "weekly"
class StepResponse(BaseModel):
"""Response für Schritt-Update."""
status: str
current_step: int
next_step: int
message: str
recommended_templates: List[Dict[str, Any]] = []
class MigrateEmailRequest(BaseModel):
"""Request für E-Mail-Migration."""
original_label: Optional[str] = Field(default=None, description="Beschreibung des Alerts")
class MigrateEmailResponse(BaseModel):
"""Response für E-Mail-Migration."""
status: str
inbound_address: str
instructions: List[str]
source_id: str
class MigrateRssRequest(BaseModel):
"""Request für RSS-Import."""
rss_urls: List[str] = Field(..., min_length=1, max_length=20)
labels: Optional[List[str]] = None
class MigrateRssResponse(BaseModel):
"""Response für RSS-Import."""
status: str
sources_created: int
topics_created: int
message: str
@@ -2,277 +2,49 @@
Rule Engine für Alerts Agent.
Evaluiert Regeln gegen Alert-Items und führt Aktionen aus.
Regel-Struktur:
- Bedingungen: [{field, operator, value}, ...] (AND-verknüpft)
- Aktion: keep, drop, tag, email, webhook, slack
- Priorität: Höhere Priorität wird zuerst evaluiert
Batch-Verarbeitung und Action-Anwendung.
"""
import re
import logging
from dataclasses import dataclass
from typing import List, Dict, Any, Optional, Callable
from enum import Enum
from typing import List, Dict, Any, Optional
from alerts_agent.db.models import AlertItemDB, AlertRuleDB, RuleActionEnum
from .rule_models import (
ConditionOperator,
RuleCondition,
RuleMatch,
get_field_value,
evaluate_condition,
evaluate_rule,
evaluate_rules_for_alert,
create_keyword_rule,
create_exclusion_rule,
create_score_threshold_rule,
)
logger = logging.getLogger(__name__)
class ConditionOperator(str, Enum):
"""Operatoren für Regel-Bedingungen."""
CONTAINS = "contains"
NOT_CONTAINS = "not_contains"
EQUALS = "equals"
NOT_EQUALS = "not_equals"
STARTS_WITH = "starts_with"
ENDS_WITH = "ends_with"
REGEX = "regex"
GREATER_THAN = "gt"
LESS_THAN = "lt"
GREATER_EQUAL = "gte"
LESS_EQUAL = "lte"
IN_LIST = "in"
NOT_IN_LIST = "not_in"
@dataclass
class RuleCondition:
"""Eine einzelne Regel-Bedingung."""
field: str # "title", "snippet", "url", "source", "relevance_score"
operator: ConditionOperator
value: Any # str, float, list
@classmethod
def from_dict(cls, data: Dict) -> "RuleCondition":
"""Erstellt eine Bedingung aus einem Dict."""
return cls(
field=data.get("field", ""),
operator=ConditionOperator(data.get("operator", data.get("op", "contains"))),
value=data.get("value", ""),
)
@dataclass
class RuleMatch:
"""Ergebnis einer Regel-Evaluierung."""
rule_id: str
rule_name: str
matched: bool
action: RuleActionEnum
action_config: Dict[str, Any]
conditions_met: List[str] # Welche Bedingungen haben gematched
def get_field_value(alert: AlertItemDB, field: str) -> Any:
"""
Extrahiert einen Feldwert aus einem Alert.
Args:
alert: Alert-Item
field: Feldname
Returns:
Feldwert oder None
"""
field_map = {
"title": alert.title,
"snippet": alert.snippet,
"url": alert.url,
"source": alert.source.value if alert.source else "",
"status": alert.status.value if alert.status else "",
"relevance_score": alert.relevance_score,
"relevance_decision": alert.relevance_decision.value if alert.relevance_decision else "",
"lang": alert.lang,
"topic_id": alert.topic_id,
}
return field_map.get(field)
def evaluate_condition(
alert: AlertItemDB,
condition: RuleCondition,
) -> bool:
"""
Evaluiert eine einzelne Bedingung gegen einen Alert.
Args:
alert: Alert-Item
condition: Zu evaluierende Bedingung
Returns:
True wenn Bedingung erfüllt
"""
field_value = get_field_value(alert, condition.field)
if field_value is None:
return False
op = condition.operator
target = condition.value
try:
# String-Operationen (case-insensitive)
if isinstance(field_value, str):
field_lower = field_value.lower()
target_lower = str(target).lower() if isinstance(target, str) else target
if op == ConditionOperator.CONTAINS:
return target_lower in field_lower
elif op == ConditionOperator.NOT_CONTAINS:
return target_lower not in field_lower
elif op == ConditionOperator.EQUALS:
return field_lower == target_lower
elif op == ConditionOperator.NOT_EQUALS:
return field_lower != target_lower
elif op == ConditionOperator.STARTS_WITH:
return field_lower.startswith(target_lower)
elif op == ConditionOperator.ENDS_WITH:
return field_lower.endswith(target_lower)
elif op == ConditionOperator.REGEX:
try:
return bool(re.search(str(target), field_value, re.IGNORECASE))
except re.error:
logger.warning(f"Invalid regex pattern: {target}")
return False
elif op == ConditionOperator.IN_LIST:
if isinstance(target, list):
return any(t.lower() in field_lower for t in target if isinstance(t, str))
return False
elif op == ConditionOperator.NOT_IN_LIST:
if isinstance(target, list):
return not any(t.lower() in field_lower for t in target if isinstance(t, str))
return True
# Numerische Operationen
elif isinstance(field_value, (int, float)):
target_num = float(target) if target else 0
if op == ConditionOperator.EQUALS:
return field_value == target_num
elif op == ConditionOperator.NOT_EQUALS:
return field_value != target_num
elif op == ConditionOperator.GREATER_THAN:
return field_value > target_num
elif op == ConditionOperator.LESS_THAN:
return field_value < target_num
elif op == ConditionOperator.GREATER_EQUAL:
return field_value >= target_num
elif op == ConditionOperator.LESS_EQUAL:
return field_value <= target_num
except Exception as e:
logger.error(f"Error evaluating condition: {e}")
return False
return False
def evaluate_rule(
alert: AlertItemDB,
rule: AlertRuleDB,
) -> RuleMatch:
"""
Evaluiert eine Regel gegen einen Alert.
Alle Bedingungen müssen erfüllt sein (AND-Verknüpfung).
Args:
alert: Alert-Item
rule: Zu evaluierende Regel
Returns:
RuleMatch-Ergebnis
"""
conditions = rule.conditions or []
conditions_met = []
all_matched = True
for cond_dict in conditions:
condition = RuleCondition.from_dict(cond_dict)
if evaluate_condition(alert, condition):
conditions_met.append(f"{condition.field} {condition.operator.value} {condition.value}")
else:
all_matched = False
# Wenn keine Bedingungen definiert sind, matcht die Regel immer
if not conditions:
all_matched = True
return RuleMatch(
rule_id=rule.id,
rule_name=rule.name,
matched=all_matched,
action=rule.action_type,
action_config=rule.action_config or {},
conditions_met=conditions_met,
)
def evaluate_rules_for_alert(
alert: AlertItemDB,
rules: List[AlertRuleDB],
) -> Optional[RuleMatch]:
"""
Evaluiert alle Regeln gegen einen Alert und gibt den ersten Match zurück.
Regeln werden nach Priorität (absteigend) evaluiert.
Args:
alert: Alert-Item
rules: Liste von Regeln (sollte bereits nach Priorität sortiert sein)
Returns:
Erster RuleMatch oder None
"""
for rule in rules:
if not rule.is_active:
continue
# Topic-Filter: Regel gilt nur für bestimmtes Topic
if rule.topic_id and rule.topic_id != alert.topic_id:
continue
match = evaluate_rule(alert, rule)
if match.matched:
logger.debug(
f"Rule '{rule.name}' matched alert '{alert.id[:8]}': "
f"{match.conditions_met}"
)
return match
return None
# Re-export for backward compatibility
__all__ = [
"ConditionOperator",
"RuleCondition",
"RuleMatch",
"get_field_value",
"evaluate_condition",
"evaluate_rule",
"evaluate_rules_for_alert",
"RuleEngine",
"create_keyword_rule",
"create_exclusion_rule",
"create_score_threshold_rule",
]
class RuleEngine:
"""
Rule Engine für Batch-Verarbeitung von Alerts.
Verwendet für das Scoring von mehreren Alerts gleichzeitig.
"""
"""Rule Engine für Batch-Verarbeitung von Alerts."""
def __init__(self, db_session):
"""
Initialisiert die Rule Engine.
Args:
db_session: SQLAlchemy Session
"""
self.db = db_session
self._rules_cache: Optional[List[AlertRuleDB]] = None
@@ -282,42 +54,19 @@ class RuleEngine:
from alerts_agent.db.repository import RuleRepository
repo = RuleRepository(self.db)
self._rules_cache = repo.get_active()
return self._rules_cache
def clear_cache(self) -> None:
"""Leert den Regel-Cache."""
self._rules_cache = None
def process_alert(
self,
alert: AlertItemDB,
) -> Optional[RuleMatch]:
"""
Verarbeitet einen Alert mit allen aktiven Regeln.
Args:
alert: Alert-Item
Returns:
RuleMatch wenn eine Regel matcht, sonst None
"""
def process_alert(self, alert: AlertItemDB) -> Optional[RuleMatch]:
"""Verarbeitet einen Alert mit allen aktiven Regeln."""
rules = self._get_active_rules()
return evaluate_rules_for_alert(alert, rules)
def process_alerts(
self,
alerts: List[AlertItemDB],
) -> Dict[str, RuleMatch]:
"""
Verarbeitet mehrere Alerts mit allen aktiven Regeln.
Args:
alerts: Liste von Alert-Items
Returns:
Dict von alert_id -> RuleMatch (nur für gematschte Alerts)
"""
def process_alerts(self, alerts: List[AlertItemDB]) -> Dict[str, RuleMatch]:
"""Verarbeitet mehrere Alerts mit allen aktiven Regeln."""
rules = self._get_active_rules()
results = {}
@@ -328,21 +77,8 @@ class RuleEngine:
return results
def apply_rule_actions(
self,
alert: AlertItemDB,
match: RuleMatch,
) -> Dict[str, Any]:
"""
Wendet die Regel-Aktion auf einen Alert an.
Args:
alert: Alert-Item
match: RuleMatch mit Aktionsinformationen
Returns:
Dict mit Ergebnis der Aktion
"""
def apply_rule_actions(self, alert: AlertItemDB, match: RuleMatch) -> Dict[str, Any]:
"""Wendet die Regel-Aktion auf einen Alert an."""
from alerts_agent.db.repository import AlertItemRepository, RuleRepository
alert_repo = AlertItemRepository(self.db)
@@ -350,36 +86,26 @@ class RuleEngine:
action = match.action
config = match.action_config
result = {"action": action.value, "success": False}
try:
if action == RuleActionEnum.KEEP:
# Alert als KEEP markieren
alert_repo.update_scoring(
alert_id=alert.id,
score=1.0,
decision="KEEP",
reasons=["rule_match"],
summary=f"Matched rule: {match.rule_name}",
alert_id=alert.id, score=1.0, decision="KEEP",
reasons=["rule_match"], summary=f"Matched rule: {match.rule_name}",
model="rule_engine",
)
result["success"] = True
elif action == RuleActionEnum.DROP:
# Alert als DROP markieren
alert_repo.update_scoring(
alert_id=alert.id,
score=0.0,
decision="DROP",
reasons=["rule_match"],
summary=f"Dropped by rule: {match.rule_name}",
alert_id=alert.id, score=0.0, decision="DROP",
reasons=["rule_match"], summary=f"Dropped by rule: {match.rule_name}",
model="rule_engine",
)
result["success"] = True
elif action == RuleActionEnum.TAG:
# Tags hinzufügen
tags = config.get("tags", [])
if tags:
existing_tags = alert.user_tags or []
@@ -389,27 +115,20 @@ class RuleEngine:
result["success"] = True
elif action == RuleActionEnum.EMAIL:
# E-Mail-Benachrichtigung senden
# Wird von Actions-Modul behandelt
result["email_config"] = config
result["success"] = True
result["deferred"] = True # Wird später gesendet
result["deferred"] = True
elif action == RuleActionEnum.WEBHOOK:
# Webhook aufrufen
# Wird von Actions-Modul behandelt
result["webhook_config"] = config
result["success"] = True
result["deferred"] = True
elif action == RuleActionEnum.SLACK:
# Slack-Nachricht senden
# Wird von Actions-Modul behandelt
result["slack_config"] = config
result["success"] = True
result["deferred"] = True
# Match-Count erhöhen
rule_repo.increment_match_count(match.rule_id)
except Exception as e:
@@ -417,96 +136,3 @@ class RuleEngine:
result["error"] = str(e)
return result
# Convenience-Funktionen für einfache Nutzung
def create_keyword_rule(
name: str,
keywords: List[str],
action: str = "keep",
field: str = "title",
) -> Dict:
"""
Erstellt eine Keyword-basierte Regel.
Args:
name: Regelname
keywords: Liste von Keywords (OR-verknüpft über IN_LIST)
action: Aktion (keep, drop, tag)
field: Feld zum Prüfen (title, snippet, url)
Returns:
Regel-Definition als Dict
"""
return {
"name": name,
"conditions": [
{
"field": field,
"operator": "in",
"value": keywords,
}
],
"action_type": action,
"action_config": {},
}
def create_exclusion_rule(
name: str,
excluded_terms: List[str],
field: str = "title",
) -> Dict:
"""
Erstellt eine Ausschluss-Regel.
Args:
name: Regelname
excluded_terms: Liste von auszuschließenden Begriffen
field: Feld zum Prüfen
Returns:
Regel-Definition als Dict
"""
return {
"name": name,
"conditions": [
{
"field": field,
"operator": "in",
"value": excluded_terms,
}
],
"action_type": "drop",
"action_config": {},
}
def create_score_threshold_rule(
name: str,
min_score: float,
action: str = "keep",
) -> Dict:
"""
Erstellt eine Score-basierte Regel.
Args:
name: Regelname
min_score: Mindest-Score
action: Aktion bei Erreichen des Scores
Returns:
Regel-Definition als Dict
"""
return {
"name": name,
"conditions": [
{
"field": "relevance_score",
"operator": "gte",
"value": min_score,
}
],
"action_type": action,
"action_config": {},
}
@@ -0,0 +1,206 @@
"""
Rule Engine - Models, Condition Evaluation, and Convenience Functions.
Datenmodelle und Evaluierungs-Logik fuer Alert-Regeln.
"""
import re
import logging
from dataclasses import dataclass
from typing import List, Dict, Any, Optional
from enum import Enum
from alerts_agent.db.models import AlertItemDB, AlertRuleDB, RuleActionEnum
logger = logging.getLogger(__name__)
class ConditionOperator(str, Enum):
"""Operatoren für Regel-Bedingungen."""
CONTAINS = "contains"
NOT_CONTAINS = "not_contains"
EQUALS = "equals"
NOT_EQUALS = "not_equals"
STARTS_WITH = "starts_with"
ENDS_WITH = "ends_with"
REGEX = "regex"
GREATER_THAN = "gt"
LESS_THAN = "lt"
GREATER_EQUAL = "gte"
LESS_EQUAL = "lte"
IN_LIST = "in"
NOT_IN_LIST = "not_in"
@dataclass
class RuleCondition:
"""Eine einzelne Regel-Bedingung."""
field: str
operator: ConditionOperator
value: Any
@classmethod
def from_dict(cls, data: Dict) -> "RuleCondition":
return cls(
field=data.get("field", ""),
operator=ConditionOperator(data.get("operator", data.get("op", "contains"))),
value=data.get("value", ""),
)
@dataclass
class RuleMatch:
"""Ergebnis einer Regel-Evaluierung."""
rule_id: str
rule_name: str
matched: bool
action: RuleActionEnum
action_config: Dict[str, Any]
conditions_met: List[str]
def get_field_value(alert: AlertItemDB, field: str) -> Any:
"""Extrahiert einen Feldwert aus einem Alert."""
field_map = {
"title": alert.title,
"snippet": alert.snippet,
"url": alert.url,
"source": alert.source.value if alert.source else "",
"status": alert.status.value if alert.status else "",
"relevance_score": alert.relevance_score,
"relevance_decision": alert.relevance_decision.value if alert.relevance_decision else "",
"lang": alert.lang,
"topic_id": alert.topic_id,
}
return field_map.get(field)
def evaluate_condition(alert: AlertItemDB, condition: RuleCondition) -> bool:
"""Evaluiert eine einzelne Bedingung gegen einen Alert."""
field_value = get_field_value(alert, condition.field)
if field_value is None:
return False
op = condition.operator
target = condition.value
try:
if isinstance(field_value, str):
field_lower = field_value.lower()
target_lower = str(target).lower() if isinstance(target, str) else target
if op == ConditionOperator.CONTAINS:
return target_lower in field_lower
elif op == ConditionOperator.NOT_CONTAINS:
return target_lower not in field_lower
elif op == ConditionOperator.EQUALS:
return field_lower == target_lower
elif op == ConditionOperator.NOT_EQUALS:
return field_lower != target_lower
elif op == ConditionOperator.STARTS_WITH:
return field_lower.startswith(target_lower)
elif op == ConditionOperator.ENDS_WITH:
return field_lower.endswith(target_lower)
elif op == ConditionOperator.REGEX:
try:
return bool(re.search(str(target), field_value, re.IGNORECASE))
except re.error:
logger.warning(f"Invalid regex pattern: {target}")
return False
elif op == ConditionOperator.IN_LIST:
if isinstance(target, list):
return any(t.lower() in field_lower for t in target if isinstance(t, str))
return False
elif op == ConditionOperator.NOT_IN_LIST:
if isinstance(target, list):
return not any(t.lower() in field_lower for t in target if isinstance(t, str))
return True
elif isinstance(field_value, (int, float)):
target_num = float(target) if target else 0
if op == ConditionOperator.EQUALS:
return field_value == target_num
elif op == ConditionOperator.NOT_EQUALS:
return field_value != target_num
elif op == ConditionOperator.GREATER_THAN:
return field_value > target_num
elif op == ConditionOperator.LESS_THAN:
return field_value < target_num
elif op == ConditionOperator.GREATER_EQUAL:
return field_value >= target_num
elif op == ConditionOperator.LESS_EQUAL:
return field_value <= target_num
except Exception as e:
logger.error(f"Error evaluating condition: {e}")
return False
return False
def evaluate_rule(alert: AlertItemDB, rule: AlertRuleDB) -> RuleMatch:
"""Evaluiert eine Regel gegen einen Alert (AND-Verknüpfung)."""
conditions = rule.conditions or []
conditions_met = []
all_matched = True
for cond_dict in conditions:
condition = RuleCondition.from_dict(cond_dict)
if evaluate_condition(alert, condition):
conditions_met.append(f"{condition.field} {condition.operator.value} {condition.value}")
else:
all_matched = False
if not conditions:
all_matched = True
return RuleMatch(
rule_id=rule.id, rule_name=rule.name, matched=all_matched,
action=rule.action_type, action_config=rule.action_config or {},
conditions_met=conditions_met,
)
def evaluate_rules_for_alert(alert: AlertItemDB, rules: List[AlertRuleDB]) -> Optional[RuleMatch]:
"""Evaluiert alle Regeln gegen einen Alert und gibt den ersten Match zurück."""
for rule in rules:
if not rule.is_active:
continue
if rule.topic_id and rule.topic_id != alert.topic_id:
continue
match = evaluate_rule(alert, rule)
if match.matched:
logger.debug(f"Rule '{rule.name}' matched alert '{alert.id[:8]}': {match.conditions_met}")
return match
return None
# Convenience-Funktionen
def create_keyword_rule(name: str, keywords: List[str], action: str = "keep", field: str = "title") -> Dict:
"""Erstellt eine Keyword-basierte Regel."""
return {
"name": name,
"conditions": [{"field": field, "operator": "in", "value": keywords}],
"action_type": action, "action_config": {},
}
def create_exclusion_rule(name: str, excluded_terms: List[str], field: str = "title") -> Dict:
"""Erstellt eine Ausschluss-Regel."""
return {
"name": name,
"conditions": [{"field": field, "operator": "in", "value": excluded_terms}],
"action_type": "drop", "action_config": {},
}
def create_score_threshold_rule(name: str, min_score: float, action: str = "keep") -> Dict:
"""Erstellt eine Score-basierte Regel."""
return {
"name": name,
"conditions": [{"field": "relevance_score", "operator": "gte", "value": min_score}],
"action_type": action, "action_config": {},
}
+9 -5
View File
@@ -4,15 +4,11 @@ BreakPilot Authentication Module
Hybrid authentication supporting both Keycloak and local JWT tokens.
"""
from .keycloak_auth import (
from .keycloak_models import (
# Config
KeycloakConfig,
KeycloakUser,
# Authenticators
KeycloakAuthenticator,
HybridAuthenticator,
# Exceptions
KeycloakAuthError,
TokenExpiredError,
@@ -21,6 +17,14 @@ from .keycloak_auth import (
# Factory functions
get_keycloak_config_from_env,
)
from .keycloak_auth import (
# Authenticators
KeycloakAuthenticator,
HybridAuthenticator,
# Factory functions
get_authenticator,
get_auth,
+41 -294
View File
@@ -14,110 +14,24 @@ import os
import httpx
import jwt
from jwt import PyJWKClient
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from functools import lru_cache
import logging
from typing import Optional, Dict, Any
from .keycloak_models import (
KeycloakConfig,
KeycloakUser,
KeycloakAuthError,
TokenExpiredError,
TokenInvalidError,
KeycloakConfigError,
get_keycloak_config_from_env,
)
logger = logging.getLogger(__name__)
@dataclass
class KeycloakConfig:
"""Keycloak connection configuration."""
server_url: str
realm: str
client_id: str
client_secret: Optional[str] = None
verify_ssl: bool = True
@property
def issuer_url(self) -> str:
return f"{self.server_url}/realms/{self.realm}"
@property
def jwks_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/certs"
@property
def token_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/token"
@property
def userinfo_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/userinfo"
@dataclass
class KeycloakUser:
"""User information extracted from Keycloak token."""
user_id: str # Keycloak subject (sub)
email: str
email_verified: bool
name: Optional[str]
given_name: Optional[str]
family_name: Optional[str]
realm_roles: List[str] # Keycloak realm roles
client_roles: Dict[str, List[str]] # Client-specific roles
groups: List[str] # Keycloak groups
tenant_id: Optional[str] # Custom claim for school/tenant
raw_claims: Dict[str, Any] # All claims for debugging
def has_realm_role(self, role: str) -> bool:
"""Check if user has a specific realm role."""
return role in self.realm_roles
def has_client_role(self, client_id: str, role: str) -> bool:
"""Check if user has a specific client role."""
client_roles = self.client_roles.get(client_id, [])
return role in client_roles
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.has_realm_role("admin") or self.has_realm_role("schul_admin")
def is_teacher(self) -> bool:
"""Check if user is a teacher."""
return self.has_realm_role("teacher") or self.has_realm_role("lehrer")
class KeycloakAuthError(Exception):
"""Base exception for Keycloak authentication errors."""
pass
class TokenExpiredError(KeycloakAuthError):
"""Token has expired."""
pass
class TokenInvalidError(KeycloakAuthError):
"""Token is invalid."""
pass
class KeycloakConfigError(KeycloakAuthError):
"""Keycloak configuration error."""
pass
class KeycloakAuthenticator:
"""
Validates JWT tokens against Keycloak.
Usage:
config = KeycloakConfig(
server_url="https://keycloak.example.com",
realm="breakpilot",
client_id="breakpilot-backend"
)
auth = KeycloakAuthenticator(config)
user = await auth.validate_token(token)
if user.is_teacher():
# Grant access
"""
"""Validates JWT tokens against Keycloak."""
def __init__(self, config: KeycloakConfig):
self.config = config
@@ -126,64 +40,29 @@ class KeycloakAuthenticator:
@property
def jwks_client(self) -> PyJWKClient:
"""Lazy-load JWKS client."""
if self._jwks_client is None:
self._jwks_client = PyJWKClient(
self.config.jwks_url,
cache_keys=True,
lifespan=3600 # Cache keys for 1 hour
)
self._jwks_client = PyJWKClient(self.config.jwks_url, cache_keys=True, lifespan=3600)
return self._jwks_client
async def get_http_client(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
verify=self.config.verify_ssl,
timeout=30.0
)
self._http_client = httpx.AsyncClient(verify=self.config.verify_ssl, timeout=30.0)
return self._http_client
async def close(self):
"""Close HTTP client."""
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
def validate_token_sync(self, token: str) -> KeycloakUser:
"""
Synchronously validate a JWT token against Keycloak JWKS.
Args:
token: The JWT access token
Returns:
KeycloakUser with extracted claims
Raises:
TokenExpiredError: If token has expired
TokenInvalidError: If token signature is invalid
"""
"""Synchronously validate a JWT token against Keycloak JWKS."""
try:
# Get signing key from JWKS
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
# Decode and validate token
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=self.config.client_id,
issuer=self.config.issuer_url,
options={
"verify_exp": True,
"verify_iat": True,
"verify_aud": True,
"verify_iss": True
}
token, signing_key.key, algorithms=["RS256"],
audience=self.config.client_id, issuer=self.config.issuer_url,
options={"verify_exp": True, "verify_iat": True, "verify_aud": True, "verify_iss": True}
)
return self._extract_user(payload)
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except jwt.InvalidAudienceError:
@@ -197,27 +76,14 @@ class KeycloakAuthenticator:
raise TokenInvalidError(f"Token validation failed: {e}")
async def validate_token(self, token: str) -> KeycloakUser:
"""
Asynchronously validate a JWT token.
Note: JWKS fetching is synchronous due to PyJWKClient limitations,
but this wrapper allows async context usage.
"""
"""Asynchronously validate a JWT token."""
return self.validate_token_sync(token)
async def get_userinfo(self, token: str) -> Dict[str, Any]:
"""
Fetch user info from Keycloak userinfo endpoint.
This provides additional user claims not in the access token.
"""
"""Fetch user info from Keycloak userinfo endpoint."""
client = await self.get_http_client()
try:
response = await client.get(
self.config.userinfo_url,
headers={"Authorization": f"Bearer {token}"}
)
response = await client.get(self.config.userinfo_url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
@@ -227,94 +93,51 @@ class KeycloakAuthenticator:
def _extract_user(self, payload: Dict[str, Any]) -> KeycloakUser:
"""Extract KeycloakUser from JWT payload."""
# Extract realm roles
realm_access = payload.get("realm_access", {})
realm_roles = realm_access.get("roles", [])
# Extract client roles
resource_access = payload.get("resource_access", {})
client_roles = {}
for client_id, access in resource_access.items():
client_roles[client_id] = access.get("roles", [])
# Extract groups
groups = payload.get("groups", [])
# Extract custom tenant claim (if configured in Keycloak)
tenant_id = payload.get("tenant_id") or payload.get("school_id")
return KeycloakUser(
user_id=payload.get("sub", ""),
email=payload.get("email", ""),
user_id=payload.get("sub", ""), email=payload.get("email", ""),
email_verified=payload.get("email_verified", False),
name=payload.get("name"),
given_name=payload.get("given_name"),
name=payload.get("name"), given_name=payload.get("given_name"),
family_name=payload.get("family_name"),
realm_roles=realm_roles,
client_roles=client_roles,
groups=groups,
tenant_id=tenant_id,
raw_claims=payload
realm_roles=realm_roles, client_roles=client_roles,
groups=groups, tenant_id=tenant_id, raw_claims=payload
)
# =============================================
# HYBRID AUTH: Keycloak + Local JWT
# =============================================
class HybridAuthenticator:
"""
Hybrid authenticator supporting both Keycloak and local JWT tokens.
"""Hybrid authenticator supporting both Keycloak and local JWT tokens."""
This allows gradual migration from local JWT to Keycloak:
1. Development: Use local JWT (fast, no external dependencies)
2. Production: Use Keycloak for full IAM capabilities
Token type detection:
- Keycloak tokens: Have 'iss' claim matching Keycloak URL
- Local tokens: Have 'iss' claim as 'breakpilot' or no 'iss'
"""
def __init__(
self,
keycloak_config: Optional[KeycloakConfig] = None,
local_jwt_secret: Optional[str] = None,
environment: str = "development"
):
def __init__(self, keycloak_config=None, local_jwt_secret=None, environment="development"):
self.environment = environment
self.keycloak_enabled = keycloak_config is not None
self.local_jwt_secret = local_jwt_secret
if keycloak_config:
self.keycloak_auth = KeycloakAuthenticator(keycloak_config)
else:
self.keycloak_auth = None
self.keycloak_auth = KeycloakAuthenticator(keycloak_config) if keycloak_config else None
async def validate_token(self, token: str) -> Dict[str, Any]:
"""
Validate token using appropriate method.
Returns a unified user dict compatible with existing code.
"""
"""Validate token using appropriate method."""
if not token:
raise TokenInvalidError("No token provided")
# Try to peek at the token to determine type
try:
# Decode without verification to check issuer
unverified = jwt.decode(token, options={"verify_signature": False})
issuer = unverified.get("iss", "")
except jwt.InvalidTokenError:
raise TokenInvalidError("Cannot decode token")
# Check if it's a Keycloak token
if self.keycloak_auth and self.keycloak_auth.config.issuer_url in issuer:
# Validate with Keycloak
kc_user = await self.keycloak_auth.validate_token(token)
return self._keycloak_user_to_dict(kc_user)
# Fall back to local JWT validation
if self.local_jwt_secret:
return self._validate_local_token(token)
@@ -326,13 +149,7 @@ class HybridAuthenticator:
raise KeycloakConfigError("Local JWT secret not configured")
try:
payload = jwt.decode(
token,
self.local_jwt_secret,
algorithms=["HS256"]
)
# Map local token claims to unified format
payload = jwt.decode(token, self.local_jwt_secret, algorithms=["HS256"])
return {
"user_id": payload.get("user_id", payload.get("sub", "")),
"email": payload.get("email", ""),
@@ -349,7 +166,6 @@ class HybridAuthenticator:
def _keycloak_user_to_dict(self, user: KeycloakUser) -> Dict[str, Any]:
"""Convert KeycloakUser to dict compatible with existing code."""
# Map Keycloak roles to our role system
role = "user"
if user.is_admin():
role = "admin"
@@ -357,20 +173,15 @@ class HybridAuthenticator:
role = "teacher"
return {
"user_id": user.user_id,
"email": user.email,
"user_id": user.user_id, "email": user.email,
"name": user.name or f"{user.given_name or ''} {user.family_name or ''}".strip(),
"role": role,
"realm_roles": user.realm_roles,
"client_roles": user.client_roles,
"groups": user.groups,
"tenant_id": user.tenant_id,
"email_verified": user.email_verified,
"role": role, "realm_roles": user.realm_roles,
"client_roles": user.client_roles, "groups": user.groups,
"tenant_id": user.tenant_id, "email_verified": user.email_verified,
"auth_method": "keycloak"
}
async def close(self):
"""Cleanup resources."""
if self.keycloak_auth:
await self.keycloak_auth.close()
@@ -379,57 +190,17 @@ class HybridAuthenticator:
# FACTORY FUNCTIONS
# =============================================
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
"""
Create KeycloakConfig from environment variables.
Required env vars:
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
- KEYCLOAK_REALM: e.g., breakpilot
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
Optional:
- KEYCLOAK_CLIENT_SECRET: For confidential clients
- KEYCLOAK_VERIFY_SSL: Default true
"""
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
realm = os.environ.get("KEYCLOAK_REALM")
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
if not all([server_url, realm, client_id]):
logger.info("Keycloak not configured, using local JWT only")
return None
return KeycloakConfig(
server_url=server_url,
realm=realm,
client_id=client_id,
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
)
def get_authenticator() -> HybridAuthenticator:
"""
Get configured authenticator instance.
Uses environment variables to determine configuration.
"""
"""Get configured authenticator instance."""
keycloak_config = get_keycloak_config_from_env()
# JWT_SECRET is required - no default fallback in production
jwt_secret = os.environ.get("JWT_SECRET")
environment = os.environ.get("ENVIRONMENT", "development")
if not jwt_secret and environment == "production":
raise KeycloakConfigError(
"JWT_SECRET environment variable is required in production"
)
raise KeycloakConfigError("JWT_SECRET environment variable is required in production")
return HybridAuthenticator(
keycloak_config=keycloak_config,
local_jwt_secret=jwt_secret,
environment=environment
keycloak_config=keycloak_config, local_jwt_secret=jwt_secret, environment=environment
)
@@ -439,7 +210,6 @@ def get_authenticator() -> HybridAuthenticator:
from fastapi import Request, HTTPException, Depends
# Global authenticator instance (lazy-initialized)
_authenticator: Optional[HybridAuthenticator] = None
@@ -452,26 +222,16 @@ def get_auth() -> HybridAuthenticator:
async def get_current_user(request: Request) -> Dict[str, Any]:
"""
FastAPI dependency to get current authenticated user.
Usage:
@app.get("/api/protected")
async def protected_endpoint(user: dict = Depends(get_current_user)):
return {"user_id": user["user_id"]}
"""
"""FastAPI dependency to get current authenticated user."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
# Check for development mode
environment = os.environ.get("ENVIRONMENT", "development")
if environment == "development":
# Return demo user in development without token
return {
"user_id": "10000000-0000-0000-0000-000000000024",
"email": "demo@breakpilot.app",
"role": "admin",
"realm_roles": ["admin"],
"role": "admin", "realm_roles": ["admin"],
"tenant_id": "a0000000-0000-0000-0000-000000000001",
"auth_method": "development_bypass"
}
@@ -492,24 +252,11 @@ async def get_current_user(request: Request) -> Dict[str, Any]:
async def require_role(required_role: str):
"""
FastAPI dependency factory for role-based access.
Usage:
@app.get("/api/admin-only")
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
return {"message": "Admin access granted"}
"""
"""FastAPI dependency factory for role-based access."""
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
user_role = user.get("role", "user")
realm_roles = user.get("realm_roles", [])
if user_role == required_role or required_role in realm_roles:
return user
raise HTTPException(
status_code=403,
detail=f"Role '{required_role}' required"
)
raise HTTPException(status_code=403, detail=f"Role '{required_role}' required")
return role_checker
+104
View File
@@ -0,0 +1,104 @@
"""
Keycloak Authentication - Models, Config, and Exceptions.
"""
import os
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class KeycloakConfig:
"""Keycloak connection configuration."""
server_url: str
realm: str
client_id: str
client_secret: Optional[str] = None
verify_ssl: bool = True
@property
def issuer_url(self) -> str:
return f"{self.server_url}/realms/{self.realm}"
@property
def jwks_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/certs"
@property
def token_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/token"
@property
def userinfo_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/userinfo"
@dataclass
class KeycloakUser:
"""User information extracted from Keycloak token."""
user_id: str
email: str
email_verified: bool
name: Optional[str]
given_name: Optional[str]
family_name: Optional[str]
realm_roles: List[str]
client_roles: Dict[str, List[str]]
groups: List[str]
tenant_id: Optional[str]
raw_claims: Dict[str, Any]
def has_realm_role(self, role: str) -> bool:
return role in self.realm_roles
def has_client_role(self, client_id: str, role: str) -> bool:
client_roles = self.client_roles.get(client_id, [])
return role in client_roles
def is_admin(self) -> bool:
return self.has_realm_role("admin") or self.has_realm_role("schul_admin")
def is_teacher(self) -> bool:
return self.has_realm_role("teacher") or self.has_realm_role("lehrer")
class KeycloakAuthError(Exception):
"""Base exception for Keycloak authentication errors."""
pass
class TokenExpiredError(KeycloakAuthError):
"""Token has expired."""
pass
class TokenInvalidError(KeycloakAuthError):
"""Token is invalid."""
pass
class KeycloakConfigError(KeycloakAuthError):
"""Keycloak configuration error."""
pass
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
"""Create KeycloakConfig from environment variables."""
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
realm = os.environ.get("KEYCLOAK_REALM")
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
if not all([server_url, realm, client_id]):
logger.info("Keycloak not configured, using local JWT only")
return None
return KeycloakConfig(
server_url=server_url,
realm=realm,
client_id=client_id,
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
)
+63 -562
View File
@@ -2,567 +2,68 @@
Classroom API - Pydantic Models
Alle Request- und Response-Models fuer die Classroom API.
Barrel re-export aus aufgeteilten Modulen.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === Session Models ===
class CreateSessionRequest(BaseModel):
"""Request zum Erstellen einer neuen Session."""
teacher_id: str = Field(..., description="ID des Lehrers")
class_id: str = Field(..., description="ID der Klasse")
subject: str = Field(..., description="Unterrichtsfach")
topic: Optional[str] = Field(None, description="Thema der Stunde")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Optionale individuelle Phasendauern in Minuten"
)
class NotesRequest(BaseModel):
"""Request zum Aktualisieren von Notizen."""
notes: str = Field("", description="Stundennotizen")
homework: str = Field("", description="Hausaufgaben")
class ExtendTimeRequest(BaseModel):
"""Request zum Verlaengern der aktuellen Phase (Feature f28)."""
minutes: int = Field(5, ge=1, le=30, description="Zusaetzliche Minuten (1-30)")
class PhaseInfo(BaseModel):
"""Informationen zu einer Phase."""
phase: str
display_name: str
icon: str
duration_minutes: int
is_completed: bool
is_current: bool
is_future: bool
class TimerStatus(BaseModel):
"""Timer-Status einer Phase."""
remaining_seconds: int
remaining_formatted: str
total_seconds: int
total_formatted: str
elapsed_seconds: int
elapsed_formatted: str
percentage_remaining: int
percentage_elapsed: int
percentage: int = Field(description="Alias fuer percentage_remaining (Visual Timer)")
warning: bool
overtime: bool
overtime_seconds: int
overtime_formatted: Optional[str]
is_paused: bool = Field(False, description="Ist der Timer pausiert?")
class SuggestionItem(BaseModel):
"""Ein Aktivitaets-Vorschlag."""
id: str
title: str
description: str
activity_type: str
estimated_minutes: int
icon: str
content_url: Optional[str]
class SessionResponse(BaseModel):
"""Vollstaendige Session-Response."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
current_phase: str
phase_display_name: str
phase_started_at: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
timer: TimerStatus
phases: List[PhaseInfo]
phase_history: List[Dict[str, Any]]
notes: str
homework: str
is_active: bool
is_ended: bool
is_paused: bool = Field(False, description="Ist die Stunde pausiert?")
class SuggestionsResponse(BaseModel):
"""Response fuer Vorschlaege."""
suggestions: List[SuggestionItem]
current_phase: str
phase_display_name: str
total_available: int
class PhasesListResponse(BaseModel):
"""Liste aller verfuegbaren Phasen."""
phases: List[Dict[str, Any]]
class ActiveSessionsResponse(BaseModel):
"""Liste aktiver Sessions."""
sessions: List[Dict[str, Any]]
count: int
# === Session History Models ===
class SessionHistoryItem(BaseModel):
"""Einzelner Eintrag in der Session-History."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
total_duration_minutes: Optional[int]
phases_completed: int
notes: str
homework: str
class SessionHistoryResponse(BaseModel):
"""Response fuer Session-History."""
sessions: List[SessionHistoryItem]
total_count: int
limit: int
offset: int
# === Template Models ===
class TemplateCreate(BaseModel):
"""Request zum Erstellen einer Vorlage."""
name: str = Field(..., min_length=1, max_length=200, description="Name der Vorlage")
description: str = Field("", max_length=1000, description="Beschreibung")
subject: str = Field("", max_length=100, description="Fach")
grade_level: str = Field("", max_length=50, description="Klassenstufe (z.B. '7', '10')")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Phasendauern in Minuten"
)
default_topic: str = Field("", max_length=500, description="Vorausgefuelltes Thema")
default_notes: str = Field("", description="Vorausgefuellte Notizen")
is_public: bool = Field(False, description="Vorlage fuer alle sichtbar?")
class TemplateUpdate(BaseModel):
"""Request zum Aktualisieren einer Vorlage."""
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
subject: Optional[str] = Field(None, max_length=100)
grade_level: Optional[str] = Field(None, max_length=50)
phase_durations: Optional[Dict[str, int]] = None
default_topic: Optional[str] = Field(None, max_length=500)
default_notes: Optional[str] = None
is_public: Optional[bool] = None
class TemplateResponse(BaseModel):
"""Response fuer eine einzelne Vorlage."""
template_id: str
teacher_id: str
name: str
description: str
subject: str
grade_level: str
phase_durations: Dict[str, int]
default_topic: str
default_notes: str
is_public: bool
usage_count: int
total_duration_minutes: int
created_at: Optional[str]
updated_at: Optional[str]
is_system_template: bool = False
class TemplateListResponse(BaseModel):
"""Response fuer Template-Liste."""
templates: List[TemplateResponse]
total_count: int
# === Homework Models ===
class CreateHomeworkRequest(BaseModel):
"""Request zum Erstellen einer Hausaufgabe."""
teacher_id: str
class_id: str
subject: str
title: str = Field(..., max_length=300)
description: str = ""
session_id: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
class UpdateHomeworkRequest(BaseModel):
"""Request zum Aktualisieren einer Hausaufgabe."""
title: Optional[str] = Field(None, max_length=300)
description: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
status: Optional[str] = Field(None, description="assigned, in_progress, completed")
class HomeworkResponse(BaseModel):
"""Response fuer eine Hausaufgabe."""
homework_id: str
teacher_id: str
class_id: str
subject: str
title: str
description: str
session_id: Optional[str]
due_date: Optional[str]
status: str
is_overdue: bool
created_at: Optional[str]
updated_at: Optional[str]
class HomeworkListResponse(BaseModel):
"""Response fuer Liste von Hausaufgaben."""
homework: List[HomeworkResponse]
total: int
# === Material Models ===
class CreateMaterialRequest(BaseModel):
"""Request zum Erstellen eines Materials."""
teacher_id: str
title: str = Field(..., max_length=300)
material_type: str = Field("document", description="document, link, video, image, worksheet, presentation, other")
url: Optional[str] = Field(None, max_length=2000)
description: str = ""
phase: Optional[str] = Field(None, description="einstieg, erarbeitung, sicherung, transfer, reflexion")
subject: str = ""
grade_level: str = ""
tags: List[str] = []
is_public: bool = False
session_id: Optional[str] = None
class UpdateMaterialRequest(BaseModel):
"""Request zum Aktualisieren eines Materials."""
title: Optional[str] = Field(None, max_length=300)
material_type: Optional[str] = None
url: Optional[str] = Field(None, max_length=2000)
description: Optional[str] = None
phase: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class MaterialResponse(BaseModel):
"""Response fuer ein Material."""
material_id: str
teacher_id: str
title: str
material_type: str
url: Optional[str]
description: str
phase: Optional[str]
subject: str
grade_level: str
tags: List[str]
is_public: bool
usage_count: int
session_id: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class MaterialListResponse(BaseModel):
"""Response fuer Liste von Materialien."""
materials: List[MaterialResponse]
total: int
# === Analytics Models ===
class SessionSummaryResponse(BaseModel):
"""Response fuer Session-Summary."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: Optional[str]
date_formatted: str
total_duration_seconds: int
total_duration_formatted: str
planned_duration_seconds: int
planned_duration_formatted: str
phases_completed: int
total_phases: int
completion_percentage: int
phase_statistics: List[Dict[str, Any]]
total_overtime_seconds: int
total_overtime_formatted: str
phases_with_overtime: int
total_pause_count: int
total_pause_seconds: int
reflection_notes: str = ""
reflection_rating: Optional[int] = None
key_learnings: List[str] = []
class TeacherAnalyticsResponse(BaseModel):
"""Response fuer Lehrer-Analytics."""
teacher_id: str
period_start: Optional[str]
period_end: Optional[str]
total_sessions: int
completed_sessions: int
total_teaching_minutes: int
total_teaching_hours: float
avg_phase_durations: Dict[str, int]
sessions_with_overtime: int
overtime_percentage: int
avg_overtime_seconds: int
avg_overtime_formatted: str
most_overtime_phase: Optional[str]
avg_pause_count: float
avg_pause_duration_seconds: int
subjects_taught: Dict[str, int]
classes_taught: Dict[str, int]
class ReflectionCreate(BaseModel):
"""Request-Body fuer Reflection-Erstellung."""
session_id: str
teacher_id: str
notes: str = ""
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: List[str] = []
improvements: List[str] = []
notes_for_next_lesson: str = ""
class ReflectionUpdate(BaseModel):
"""Request-Body fuer Reflection-Update."""
notes: Optional[str] = None
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: Optional[List[str]] = None
improvements: Optional[List[str]] = None
notes_for_next_lesson: Optional[str] = None
class ReflectionResponse(BaseModel):
"""Response fuer eine einzelne Reflection."""
reflection_id: str
session_id: str
teacher_id: str
notes: str
overall_rating: Optional[int]
what_worked: List[str]
improvements: List[str]
notes_for_next_lesson: str
created_at: Optional[str]
updated_at: Optional[str]
# === Feedback Models ===
class FeedbackCreate(BaseModel):
"""Request zum Erstellen von Feedback."""
title: str = Field(..., min_length=3, max_length=500, description="Kurzer Titel")
description: str = Field(..., min_length=10, description="Beschreibung")
feedback_type: str = Field("improvement", description="bug, feature_request, improvement, praise, question")
priority: str = Field("medium", description="critical, high, medium, low")
teacher_name: str = Field("", description="Name des Lehrers")
teacher_email: str = Field("", description="E-Mail fuer Rueckfragen")
context_url: str = Field("", description="URL wo Feedback gegeben wurde")
context_phase: str = Field("", description="Aktuelle Phase")
context_session_id: Optional[str] = Field(None, description="Session-ID falls aktiv")
related_feature: Optional[str] = Field(None, description="Verwandtes Feature")
class FeedbackResponse(BaseModel):
"""Response fuer Feedback."""
id: str
teacher_id: str
teacher_name: str
title: str
description: str
feedback_type: str
priority: str
status: str
created_at: str
response: Optional[str] = None
class FeedbackListResponse(BaseModel):
"""Liste von Feedbacks."""
feedbacks: List[Dict[str, Any]]
total: int
class FeedbackStatsResponse(BaseModel):
"""Feedback-Statistiken."""
total: int
by_status: Dict[str, int]
by_type: Dict[str, int]
by_priority: Dict[str, int]
# === Settings Models ===
class TeacherSettingsResponse(BaseModel):
"""Response fuer Lehrer-Einstellungen."""
teacher_id: str
default_phase_durations: Dict[str, int]
audio_enabled: bool = True
high_contrast: bool = False
show_statistics: bool = True
class UpdatePhaseDurationsRequest(BaseModel):
"""Request zum Aktualisieren der Phasen-Dauern."""
durations: Dict[str, int] = Field(
...,
description="Phasen-Dauern in Minuten, z.B. {'einstieg': 10, 'erarbeitung': 25}",
examples=[{"einstieg": 10, "erarbeitung": 25, "sicherung": 10, "transfer": 8, "reflexion": 5}]
)
class UpdatePreferencesRequest(BaseModel):
"""Request zum Aktualisieren der UI-Praeferenzen."""
audio_enabled: Optional[bool] = None
high_contrast: Optional[bool] = None
show_statistics: Optional[bool] = None
# === Context Models ===
class SchoolInfo(BaseModel):
"""Schul-Informationen."""
federal_state: str
federal_state_name: str = ""
school_type: str
school_type_name: str = ""
class SchoolYearInfo(BaseModel):
"""Schuljahr-Informationen."""
id: str
start: Optional[str] = None
current_week: int = 1
class MacroPhaseInfo(BaseModel):
"""Makro-Phase Informationen."""
id: str
label: str
confidence: float = 1.0
class CoreCounts(BaseModel):
"""Kern-Zaehler fuer den Kontext."""
classes: int = 0
exams_scheduled: int = 0
corrections_pending: int = 0
class ContextFlags(BaseModel):
"""Status-Flags des Kontexts."""
onboarding_completed: bool = False
has_classes: bool = False
has_schedule: bool = False
is_exam_period: bool = False
is_before_holidays: bool = False
class TeacherContextResponse(BaseModel):
"""Response fuer GET /v1/context."""
schema_version: str = "1.0"
teacher_id: str
school: SchoolInfo
school_year: SchoolYearInfo
macro_phase: MacroPhaseInfo
core_counts: CoreCounts
flags: ContextFlags
class UpdateContextRequest(BaseModel):
"""Request zum Aktualisieren des Kontexts."""
federal_state: Optional[str] = None
school_type: Optional[str] = None
schoolyear: Optional[str] = None
schoolyear_start: Optional[str] = None
macro_phase: Optional[str] = None
current_week: Optional[int] = None
# === Event Models ===
class CreateEventRequest(BaseModel):
"""Request zum Erstellen eines Events."""
title: str
event_type: str = "other"
start_date: str
end_date: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
description: str = ""
needs_preparation: bool = True
reminder_days_before: int = 7
class EventResponse(BaseModel):
"""Response fuer ein Event."""
id: str
teacher_id: str
event_type: str
title: str
description: str
start_date: str
end_date: Optional[str]
class_id: Optional[str]
subject: Optional[str]
status: str
needs_preparation: bool
preparation_done: bool
reminder_days_before: int
# === Routine Models ===
class CreateRoutineRequest(BaseModel):
"""Request zum Erstellen einer Routine."""
title: str
routine_type: str = "other"
recurrence_pattern: str = "weekly"
day_of_week: Optional[int] = None
day_of_month: Optional[int] = None
time_of_day: Optional[str] = None
duration_minutes: int = 60
description: str = ""
class RoutineResponse(BaseModel):
"""Response fuer eine Routine."""
id: str
teacher_id: str
routine_type: str
title: str
description: str
recurrence_pattern: str
day_of_week: Optional[int]
day_of_month: Optional[int]
time_of_day: Optional[str]
duration_minutes: int
is_active: bool
# Session & Phase Models
from .models_session import (
CreateSessionRequest,
NotesRequest,
ExtendTimeRequest,
PhaseInfo,
TimerStatus,
SuggestionItem,
SessionResponse,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
# Template, Homework, Material Models
from .models_templates import (
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TemplateListResponse,
CreateHomeworkRequest,
UpdateHomeworkRequest,
HomeworkResponse,
HomeworkListResponse,
CreateMaterialRequest,
UpdateMaterialRequest,
MaterialResponse,
MaterialListResponse,
)
# Analytics, Reflection, Feedback, Settings Models
from .models_analytics import (
SessionSummaryResponse,
TeacherAnalyticsResponse,
ReflectionCreate,
ReflectionUpdate,
ReflectionResponse,
FeedbackCreate,
FeedbackResponse,
FeedbackListResponse,
FeedbackStatsResponse,
TeacherSettingsResponse,
UpdatePhaseDurationsRequest,
UpdatePreferencesRequest,
)
# Context, Event, Routine Models
from .models_context import (
SchoolInfo,
SchoolYearInfo,
MacroPhaseInfo,
CoreCounts,
ContextFlags,
TeacherContextResponse,
UpdateContextRequest,
CreateEventRequest,
EventResponse,
CreateRoutineRequest,
RoutineResponse,
)
@@ -0,0 +1,161 @@
"""
Classroom API - Analytics, Reflection, Feedback, Settings Pydantic Models.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === Analytics Models ===
class SessionSummaryResponse(BaseModel):
"""Response fuer Session-Summary."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: Optional[str]
date_formatted: str
total_duration_seconds: int
total_duration_formatted: str
planned_duration_seconds: int
planned_duration_formatted: str
phases_completed: int
total_phases: int
completion_percentage: int
phase_statistics: List[Dict[str, Any]]
total_overtime_seconds: int
total_overtime_formatted: str
phases_with_overtime: int
total_pause_count: int
total_pause_seconds: int
reflection_notes: str = ""
reflection_rating: Optional[int] = None
key_learnings: List[str] = []
class TeacherAnalyticsResponse(BaseModel):
"""Response fuer Lehrer-Analytics."""
teacher_id: str
period_start: Optional[str]
period_end: Optional[str]
total_sessions: int
completed_sessions: int
total_teaching_minutes: int
total_teaching_hours: float
avg_phase_durations: Dict[str, int]
sessions_with_overtime: int
overtime_percentage: int
avg_overtime_seconds: int
avg_overtime_formatted: str
most_overtime_phase: Optional[str]
avg_pause_count: float
avg_pause_duration_seconds: int
subjects_taught: Dict[str, int]
classes_taught: Dict[str, int]
class ReflectionCreate(BaseModel):
"""Request-Body fuer Reflection-Erstellung."""
session_id: str
teacher_id: str
notes: str = ""
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: List[str] = []
improvements: List[str] = []
notes_for_next_lesson: str = ""
class ReflectionUpdate(BaseModel):
"""Request-Body fuer Reflection-Update."""
notes: Optional[str] = None
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: Optional[List[str]] = None
improvements: Optional[List[str]] = None
notes_for_next_lesson: Optional[str] = None
class ReflectionResponse(BaseModel):
"""Response fuer eine einzelne Reflection."""
reflection_id: str
session_id: str
teacher_id: str
notes: str
overall_rating: Optional[int]
what_worked: List[str]
improvements: List[str]
notes_for_next_lesson: str
created_at: Optional[str]
updated_at: Optional[str]
# === Feedback Models ===
class FeedbackCreate(BaseModel):
"""Request zum Erstellen von Feedback."""
title: str = Field(..., min_length=3, max_length=500, description="Kurzer Titel")
description: str = Field(..., min_length=10, description="Beschreibung")
feedback_type: str = Field("improvement", description="bug, feature_request, improvement, praise, question")
priority: str = Field("medium", description="critical, high, medium, low")
teacher_name: str = Field("", description="Name des Lehrers")
teacher_email: str = Field("", description="E-Mail fuer Rueckfragen")
context_url: str = Field("", description="URL wo Feedback gegeben wurde")
context_phase: str = Field("", description="Aktuelle Phase")
context_session_id: Optional[str] = Field(None, description="Session-ID falls aktiv")
related_feature: Optional[str] = Field(None, description="Verwandtes Feature")
class FeedbackResponse(BaseModel):
"""Response fuer Feedback."""
id: str
teacher_id: str
teacher_name: str
title: str
description: str
feedback_type: str
priority: str
status: str
created_at: str
response: Optional[str] = None
class FeedbackListResponse(BaseModel):
"""Liste von Feedbacks."""
feedbacks: List[Dict[str, Any]]
total: int
class FeedbackStatsResponse(BaseModel):
"""Feedback-Statistiken."""
total: int
by_status: Dict[str, int]
by_type: Dict[str, int]
by_priority: Dict[str, int]
# === Settings Models ===
class TeacherSettingsResponse(BaseModel):
"""Response fuer Lehrer-Einstellungen."""
teacher_id: str
default_phase_durations: Dict[str, int]
audio_enabled: bool = True
high_contrast: bool = False
show_statistics: bool = True
class UpdatePhaseDurationsRequest(BaseModel):
"""Request zum Aktualisieren der Phasen-Dauern."""
durations: Dict[str, int] = Field(
...,
description="Phasen-Dauern in Minuten, z.B. {'einstieg': 10, 'erarbeitung': 25}",
examples=[{"einstieg": 10, "erarbeitung": 25, "sicherung": 10, "transfer": 8, "reflexion": 5}]
)
class UpdatePreferencesRequest(BaseModel):
"""Request zum Aktualisieren der UI-Praeferenzen."""
audio_enabled: Optional[bool] = None
high_contrast: Optional[bool] = None
show_statistics: Optional[bool] = None
+128
View File
@@ -0,0 +1,128 @@
"""
Classroom API - Context, Event, Routine Pydantic Models.
"""
from typing import Optional
from pydantic import BaseModel, Field
# === Context Models ===
class SchoolInfo(BaseModel):
"""Schul-Informationen."""
federal_state: str
federal_state_name: str = ""
school_type: str
school_type_name: str = ""
class SchoolYearInfo(BaseModel):
"""Schuljahr-Informationen."""
id: str
start: Optional[str] = None
current_week: int = 1
class MacroPhaseInfo(BaseModel):
"""Makro-Phase Informationen."""
id: str
label: str
confidence: float = 1.0
class CoreCounts(BaseModel):
"""Kern-Zaehler fuer den Kontext."""
classes: int = 0
exams_scheduled: int = 0
corrections_pending: int = 0
class ContextFlags(BaseModel):
"""Status-Flags des Kontexts."""
onboarding_completed: bool = False
has_classes: bool = False
has_schedule: bool = False
is_exam_period: bool = False
is_before_holidays: bool = False
class TeacherContextResponse(BaseModel):
"""Response fuer GET /v1/context."""
schema_version: str = "1.0"
teacher_id: str
school: SchoolInfo
school_year: SchoolYearInfo
macro_phase: MacroPhaseInfo
core_counts: CoreCounts
flags: ContextFlags
class UpdateContextRequest(BaseModel):
"""Request zum Aktualisieren des Kontexts."""
federal_state: Optional[str] = None
school_type: Optional[str] = None
schoolyear: Optional[str] = None
schoolyear_start: Optional[str] = None
macro_phase: Optional[str] = None
current_week: Optional[int] = None
# === Event Models ===
class CreateEventRequest(BaseModel):
"""Request zum Erstellen eines Events."""
title: str
event_type: str = "other"
start_date: str
end_date: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
description: str = ""
needs_preparation: bool = True
reminder_days_before: int = 7
class EventResponse(BaseModel):
"""Response fuer ein Event."""
id: str
teacher_id: str
event_type: str
title: str
description: str
start_date: str
end_date: Optional[str]
class_id: Optional[str]
subject: Optional[str]
status: str
needs_preparation: bool
preparation_done: bool
reminder_days_before: int
# === Routine Models ===
class CreateRoutineRequest(BaseModel):
"""Request zum Erstellen einer Routine."""
title: str
routine_type: str = "other"
recurrence_pattern: str = "weekly"
day_of_week: Optional[int] = None
day_of_month: Optional[int] = None
time_of_day: Optional[str] = None
duration_minutes: int = 60
description: str = ""
class RoutineResponse(BaseModel):
"""Response fuer eine Routine."""
id: str
teacher_id: str
routine_type: str
title: str
description: str
recurrence_pattern: str
day_of_week: Optional[int]
day_of_month: Optional[int]
time_of_day: Optional[str]
duration_minutes: int
is_active: bool
+137
View File
@@ -0,0 +1,137 @@
"""
Classroom API - Session & Phase Pydantic Models.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === Session Models ===
class CreateSessionRequest(BaseModel):
"""Request zum Erstellen einer neuen Session."""
teacher_id: str = Field(..., description="ID des Lehrers")
class_id: str = Field(..., description="ID der Klasse")
subject: str = Field(..., description="Unterrichtsfach")
topic: Optional[str] = Field(None, description="Thema der Stunde")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Optionale individuelle Phasendauern in Minuten"
)
class NotesRequest(BaseModel):
"""Request zum Aktualisieren von Notizen."""
notes: str = Field("", description="Stundennotizen")
homework: str = Field("", description="Hausaufgaben")
class ExtendTimeRequest(BaseModel):
"""Request zum Verlaengern der aktuellen Phase (Feature f28)."""
minutes: int = Field(5, ge=1, le=30, description="Zusaetzliche Minuten (1-30)")
class PhaseInfo(BaseModel):
"""Informationen zu einer Phase."""
phase: str
display_name: str
icon: str
duration_minutes: int
is_completed: bool
is_current: bool
is_future: bool
class TimerStatus(BaseModel):
"""Timer-Status einer Phase."""
remaining_seconds: int
remaining_formatted: str
total_seconds: int
total_formatted: str
elapsed_seconds: int
elapsed_formatted: str
percentage_remaining: int
percentage_elapsed: int
percentage: int = Field(description="Alias fuer percentage_remaining (Visual Timer)")
warning: bool
overtime: bool
overtime_seconds: int
overtime_formatted: Optional[str]
is_paused: bool = Field(False, description="Ist der Timer pausiert?")
class SuggestionItem(BaseModel):
"""Ein Aktivitaets-Vorschlag."""
id: str
title: str
description: str
activity_type: str
estimated_minutes: int
icon: str
content_url: Optional[str]
class SessionResponse(BaseModel):
"""Vollstaendige Session-Response."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
current_phase: str
phase_display_name: str
phase_started_at: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
timer: TimerStatus
phases: List[PhaseInfo]
phase_history: List[Dict[str, Any]]
notes: str
homework: str
is_active: bool
is_ended: bool
is_paused: bool = Field(False, description="Ist die Stunde pausiert?")
class SuggestionsResponse(BaseModel):
"""Response fuer Vorschlaege."""
suggestions: List[SuggestionItem]
current_phase: str
phase_display_name: str
total_available: int
class PhasesListResponse(BaseModel):
"""Liste aller verfuegbaren Phasen."""
phases: List[Dict[str, Any]]
class ActiveSessionsResponse(BaseModel):
"""Liste aktiver Sessions."""
sessions: List[Dict[str, Any]]
count: int
# === Session History Models ===
class SessionHistoryItem(BaseModel):
"""Einzelner Eintrag in der Session-History."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
total_duration_minutes: Optional[int]
phases_completed: int
notes: str
homework: str
class SessionHistoryResponse(BaseModel):
"""Response fuer Session-History."""
sessions: List[SessionHistoryItem]
total_count: int
limit: int
offset: int
@@ -0,0 +1,158 @@
"""
Classroom API - Template, Homework, Material Pydantic Models.
"""
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
# === Template Models ===
class TemplateCreate(BaseModel):
"""Request zum Erstellen einer Vorlage."""
name: str = Field(..., min_length=1, max_length=200, description="Name der Vorlage")
description: str = Field("", max_length=1000, description="Beschreibung")
subject: str = Field("", max_length=100, description="Fach")
grade_level: str = Field("", max_length=50, description="Klassenstufe (z.B. '7', '10')")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Phasendauern in Minuten"
)
default_topic: str = Field("", max_length=500, description="Vorausgefuelltes Thema")
default_notes: str = Field("", description="Vorausgefuellte Notizen")
is_public: bool = Field(False, description="Vorlage fuer alle sichtbar?")
class TemplateUpdate(BaseModel):
"""Request zum Aktualisieren einer Vorlage."""
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
subject: Optional[str] = Field(None, max_length=100)
grade_level: Optional[str] = Field(None, max_length=50)
phase_durations: Optional[Dict[str, int]] = None
default_topic: Optional[str] = Field(None, max_length=500)
default_notes: Optional[str] = None
is_public: Optional[bool] = None
class TemplateResponse(BaseModel):
"""Response fuer eine einzelne Vorlage."""
template_id: str
teacher_id: str
name: str
description: str
subject: str
grade_level: str
phase_durations: Dict[str, int]
default_topic: str
default_notes: str
is_public: bool
usage_count: int
total_duration_minutes: int
created_at: Optional[str]
updated_at: Optional[str]
is_system_template: bool = False
class TemplateListResponse(BaseModel):
"""Response fuer Template-Liste."""
templates: List[TemplateResponse]
total_count: int
# === Homework Models ===
class CreateHomeworkRequest(BaseModel):
"""Request zum Erstellen einer Hausaufgabe."""
teacher_id: str
class_id: str
subject: str
title: str = Field(..., max_length=300)
description: str = ""
session_id: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
class UpdateHomeworkRequest(BaseModel):
"""Request zum Aktualisieren einer Hausaufgabe."""
title: Optional[str] = Field(None, max_length=300)
description: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
status: Optional[str] = Field(None, description="assigned, in_progress, completed")
class HomeworkResponse(BaseModel):
"""Response fuer eine Hausaufgabe."""
homework_id: str
teacher_id: str
class_id: str
subject: str
title: str
description: str
session_id: Optional[str]
due_date: Optional[str]
status: str
is_overdue: bool
created_at: Optional[str]
updated_at: Optional[str]
class HomeworkListResponse(BaseModel):
"""Response fuer Liste von Hausaufgaben."""
homework: List[HomeworkResponse]
total: int
# === Material Models ===
class CreateMaterialRequest(BaseModel):
"""Request zum Erstellen eines Materials."""
teacher_id: str
title: str = Field(..., max_length=300)
material_type: str = Field("document", description="document, link, video, image, worksheet, presentation, other")
url: Optional[str] = Field(None, max_length=2000)
description: str = ""
phase: Optional[str] = Field(None, description="einstieg, erarbeitung, sicherung, transfer, reflexion")
subject: str = ""
grade_level: str = ""
tags: List[str] = []
is_public: bool = False
session_id: Optional[str] = None
class UpdateMaterialRequest(BaseModel):
"""Request zum Aktualisieren eines Materials."""
title: Optional[str] = Field(None, max_length=300)
material_type: Optional[str] = None
url: Optional[str] = Field(None, max_length=2000)
description: Optional[str] = None
phase: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class MaterialResponse(BaseModel):
"""Response fuer ein Material."""
material_id: str
teacher_id: str
title: str
material_type: str
url: Optional[str]
description: str
phase: Optional[str]
subject: str
grade_level: str
tags: List[str]
is_public: bool
usage_count: int
session_id: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class MaterialListResponse(BaseModel):
"""Response fuer Liste von Materialien."""
materials: List[MaterialResponse]
total: int
+10 -518
View File
@@ -1,525 +1,17 @@
"""
Classroom API - Session Routes
Classroom API - Session Routes (barrel re-export)
Session management endpoints: create, get, start, next-phase, end, etc.
Combines core session routes and action routes into a single router.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Query
from .sessions_core import router as core_router, build_session_response
from .sessions_actions import router as actions_router
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
router = APIRouter()
router.include_router(core_router)
router.include_router(actions_router)
from ..models import (
CreateSessionRequest,
NotesRequest,
ExtendTimeRequest,
PhaseInfo,
TimerStatus,
SuggestionItem,
SessionResponse,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import notify_phase_change, notify_session_ended
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""
Erstellt eine neue Unterrichtsstunde (Session).
Die Stunde ist nach Erstellung im Status NOT_STARTED.
Zum Starten muss /sessions/{id}/start aufgerufen werden.
"""
init_db_if_needed()
# Default-Dauern mit uebergebenen Werten mergen
phase_durations = {
"einstieg": 8,
"erarbeitung": 20,
"sicherung": 10,
"transfer": 7,
"reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""
Ruft den aktuellen Status einer Session ab.
Enthaelt alle Informationen inkl. Timer-Status und Phasen-Timeline.
"""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""
Startet die Unterrichtsstunde.
Wechselt von NOT_STARTED zur ersten Phase (EINSTIEG).
"""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""
Wechselt zur naechsten Phase.
Wirft 400 wenn keine naechste Phase verfuegbar (z.B. bei ENDED).
"""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
# WebSocket-Benachrichtigung
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""
Beendet die Unterrichtsstunde sofort.
Kann von jeder aktiven Phase aus aufgerufen werden.
"""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
# Direkt zur Endphase springen (ueberspringt evtl. Phasen)
fsm = LessonStateMachine()
# Phasen bis zum Ende durchlaufen
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
# WebSocket-Benachrichtigung
await notify_session_ended(session_id)
return build_session_response(session)
# === Quick Actions (Feature f26/f27/f28) ===
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
async def toggle_pause(session_id: str) -> SessionResponse:
"""
Pausiert oder setzt die laufende Stunde fort (Feature f27).
Toggle-Funktion: Wenn pausiert -> fortsetzen, wenn laufend -> pausieren.
Die Pause-Zeit wird nicht auf die Phasendauer angerechnet.
"""
session = get_session_or_404(session_id)
# Nur aktive Phasen koennen pausiert werden
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(
status_code=400,
detail="Stunde ist nicht aktiv"
)
if session.is_paused:
# Fortsetzen: Pause-Zeit zur Gesamt-Pause addieren
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
# Pausieren
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""
Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28).
Nuetzlich wenn mehr Zeit benoetigt wird, z.B. fuer vertiefte Diskussionen.
"""
session = get_session_or_404(session_id)
# Nur aktive Phasen koennen verlaengert werden
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(
status_code=400,
detail="Stunde ist nicht aktiv"
)
# Aktuelle Phasendauer erhoehen
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""
Ruft den Timer-Status der aktuellen Phase ab.
Enthaelt verbleibende Zeit, Warnung und Overtime-Status.
Sollte alle 5 Sekunden gepollt werden.
"""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10, description="Anzahl Vorschlaege")
) -> SuggestionsResponse:
"""
Ruft phasenspezifische Aktivitaets-Vorschlaege ab.
Die Vorschlaege aendern sich je nach aktueller Phase.
"""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""
Aktualisiert Notizen und Hausaufgaben der Stunde.
"""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""
Loescht eine Session.
"""
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
# Auch aus DB loeschen
if DB_ENABLED:
try:
from ..services.persistence import delete_session_from_db
delete_session_from_db(session_id)
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100, description="Max. Anzahl Eintraege"),
offset: int = Query(0, ge=0, description="Offset fuer Pagination")
) -> SessionHistoryResponse:
"""
Ruft die Session-History eines Lehrers ab (Feature f17).
Zeigt abgeschlossene Unterrichtsstunden mit Statistiken.
Nur verfuegbar wenn DB aktiviert ist.
"""
init_db_if_needed()
if not DB_ENABLED:
# Fallback: In-Memory Sessions filtern
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(
key=lambda x: x.lesson_ended_at or datetime.min,
reverse=True
)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id,
teacher_id=s.teacher_id,
class_id=s.class_id,
subject=s.subject,
topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes,
homework=s.homework,
))
return SessionHistoryResponse(
sessions=items,
total_count=len(ended_sessions),
limit=limit,
offset=offset,
)
# DB-basierte History
try:
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
# Beendete Sessions abrufen
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
# Gesamtanzahl ermitteln
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id,
teacher_id=db_session.teacher_id,
class_id=db_session.class_id,
subject=db_session.subject,
topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "",
homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items,
total_count=total_count,
limit=limit,
offset=offset,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")
# === Utility Endpoints ===
@router.get("/phases", response_model=PhasesListResponse)
async def list_phases() -> PhasesListResponse:
"""
Listet alle verfuegbaren Unterrichtsphasen mit Metadaten.
"""
phases = []
for phase_id, config in LESSON_PHASES.items():
phases.append({
"phase": phase_id,
"display_name": config["display_name"],
"default_duration_minutes": config["default_duration_minutes"],
"activities": config["activities"],
"icon": config["icon"],
"description": config.get("description", ""),
})
return PhasesListResponse(phases=phases)
@router.get("/sessions", response_model=ActiveSessionsResponse)
async def list_active_sessions(
teacher_id: Optional[str] = Query(None, description="Filter nach Lehrer")
) -> ActiveSessionsResponse:
"""
Listet alle (optionally gefilterten) Sessions.
"""
sessions_list = []
for session in sessions.values():
if teacher_id and session.teacher_id != teacher_id:
continue
fsm = LessonStateMachine()
sessions_list.append({
"session_id": session.session_id,
"teacher_id": session.teacher_id,
"class_id": session.class_id,
"subject": session.subject,
"current_phase": session.current_phase.value,
"is_active": fsm.is_lesson_active(session),
"lesson_started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
})
return ActiveSessionsResponse(
sessions=sessions_list,
count=len(sessions_list)
)
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""
Health-Check fuer den Classroom Service.
"""
from sqlalchemy import text
db_status = "disabled"
if DB_ENABLED:
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"timestamp": datetime.utcnow().isoformat(),
}
# Re-export for backward compatibility
__all__ = ["router", "build_session_response"]
@@ -0,0 +1,173 @@
"""
Classroom API - Session Actions Routes
Quick actions (pause, extend, timer), suggestions, utility endpoints.
"""
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from classroom_engine import (
LessonPhase,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
from ..models import (
ExtendTimeRequest,
TimerStatus,
SuggestionItem,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
)
from ..services.persistence import (
sessions,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from .sessions_core import build_session_response, SessionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
# === Quick Actions (Feature f26/f27/f28) ===
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
async def toggle_pause(session_id: str) -> SessionResponse:
"""Pausiert oder setzt die laufende Stunde fort (Feature f27)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
if session.is_paused:
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""Ruft den Timer-Status der aktuellen Phase ab."""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10)
) -> SuggestionsResponse:
"""Ruft phasenspezifische Aktivitaets-Vorschlaege ab."""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
# === Utility Endpoints ===
@router.get("/phases", response_model=PhasesListResponse)
async def list_phases() -> PhasesListResponse:
"""Listet alle verfuegbaren Unterrichtsphasen mit Metadaten."""
phases = []
for phase_id, config in LESSON_PHASES.items():
phases.append({
"phase": phase_id,
"display_name": config["display_name"],
"default_duration_minutes": config["default_duration_minutes"],
"activities": config["activities"],
"icon": config["icon"],
"description": config.get("description", ""),
})
return PhasesListResponse(phases=phases)
@router.get("/sessions", response_model=ActiveSessionsResponse)
async def list_active_sessions(
teacher_id: Optional[str] = Query(None)
) -> ActiveSessionsResponse:
"""Listet alle (optionally gefilterten) Sessions."""
sessions_list = []
for session in sessions.values():
if teacher_id and session.teacher_id != teacher_id:
continue
fsm = LessonStateMachine()
sessions_list.append({
"session_id": session.session_id,
"teacher_id": session.teacher_id,
"class_id": session.class_id,
"subject": session.subject,
"current_phase": session.current_phase.value,
"is_active": fsm.is_lesson_active(session),
"lesson_started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
})
return ActiveSessionsResponse(sessions=sessions_list, count=len(sessions_list))
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""Health-Check fuer den Classroom Service."""
db_status = "disabled"
if DB_ENABLED:
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"timestamp": datetime.utcnow().isoformat(),
}
@@ -0,0 +1,283 @@
"""
Classroom API - Session Core Routes
Session CRUD, lifecycle, and history endpoints.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
LESSON_PHASES,
)
from ..models import (
CreateSessionRequest,
NotesRequest,
PhaseInfo,
TimerStatus,
SessionResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import notify_phase_change, notify_session_ended
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""Erstellt eine neue Unterrichtsstunde (Session)."""
init_db_if_needed()
phase_durations = {
"einstieg": 8, "erarbeitung": 20, "sicherung": 10,
"transfer": 7, "reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""Ruft den aktuellen Status einer Session ab."""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""Startet die Unterrichtsstunde."""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""Wechselt zur naechsten Phase."""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""Beendet die Unterrichtsstunde sofort."""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
fsm = LessonStateMachine()
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
await notify_session_ended(session_id)
return build_session_response(session)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""Aktualisiert Notizen und Hausaufgaben der Stunde."""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""Loescht eine Session."""
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
if DB_ENABLED:
try:
from ..services.persistence import delete_session_from_db
delete_session_from_db(session_id)
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
) -> SessionHistoryResponse:
"""Ruft die Session-History eines Lehrers ab (Feature f17)."""
init_db_if_needed()
if not DB_ENABLED:
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(key=lambda x: x.lesson_ended_at or datetime.min, reverse=True)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id, teacher_id=s.teacher_id,
class_id=s.class_id, subject=s.subject, topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes, homework=s.homework,
))
return SessionHistoryResponse(
sessions=items, total_count=len(ended_sessions), limit=limit, offset=offset,
)
try:
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id, teacher_id=db_session.teacher_id,
class_id=db_session.class_id, subject=db_session.subject, topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "", homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items, total_count=total_count, limit=limit, offset=offset,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")
+2 -1
View File
@@ -32,7 +32,8 @@ from .models import (
)
from .fsm import LessonStateMachine
from .timer import PhaseTimer
from .suggestions import SuggestionEngine, PHASE_SUGGESTIONS, SUBJECT_SUGGESTIONS
from .suggestions import SuggestionEngine
from .suggestion_data import PHASE_SUGGESTIONS, SUBJECT_SUGGESTIONS
from .context_models import (
MacroPhaseEnum,
EventTypeEnum,
+30 -326
View File
@@ -11,256 +11,28 @@ WICHTIG: Keine wertenden Metriken (z.B. "Sie haben 70% geredet").
Fokus auf neutrale, hilfreiche Statistiken.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from .analytics_models import (
PhaseStatistics,
SessionSummary,
TeacherAnalytics,
LessonReflection,
)
# ==================== Analytics Models ====================
# Re-export models for backward compatibility
__all__ = [
"PhaseStatistics",
"SessionSummary",
"TeacherAnalytics",
"LessonReflection",
"AnalyticsCalculator",
]
@dataclass
class PhaseStatistics:
"""Statistik fuer eine einzelne Phase."""
phase: str
display_name: str
# Dauer-Metriken
planned_duration_seconds: int
actual_duration_seconds: int
difference_seconds: int # positiv = laenger als geplant
# Overtime
had_overtime: bool
overtime_seconds: int = 0
# Erweiterungen
was_extended: bool = False
extension_minutes: int = 0
# Pausen
pause_count: int = 0
total_pause_seconds: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"phase": self.phase,
"display_name": self.display_name,
"planned_duration_seconds": self.planned_duration_seconds,
"actual_duration_seconds": self.actual_duration_seconds,
"difference_seconds": self.difference_seconds,
"difference_formatted": self._format_difference(),
"had_overtime": self.had_overtime,
"overtime_seconds": self.overtime_seconds,
"overtime_formatted": self._format_seconds(self.overtime_seconds),
"was_extended": self.was_extended,
"extension_minutes": self.extension_minutes,
"pause_count": self.pause_count,
"total_pause_seconds": self.total_pause_seconds,
}
def _format_difference(self) -> str:
"""Formatiert die Differenz als +/-MM:SS."""
prefix = "+" if self.difference_seconds >= 0 else ""
return f"{prefix}{self._format_seconds(abs(self.difference_seconds))}"
def _format_seconds(self, seconds: int) -> str:
"""Formatiert Sekunden als MM:SS."""
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
@dataclass
class SessionSummary:
"""
Zusammenfassung einer Unterrichtsstunde.
Wird nach Stundenende generiert und fuer das Lehrer-Dashboard verwendet.
"""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: datetime
# Dauer
total_duration_seconds: int
planned_duration_seconds: int
# Phasen-Statistiken
phases_completed: int
total_phases: int = 5
phase_statistics: List[PhaseStatistics] = field(default_factory=list)
# Overtime-Zusammenfassung
total_overtime_seconds: int = 0
phases_with_overtime: int = 0
# Pausen-Zusammenfassung
total_pause_count: int = 0
total_pause_seconds: int = 0
# Post-Lesson Reflection
reflection_notes: str = ""
reflection_rating: Optional[int] = None # 1-5 Sterne (optional)
key_learnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"class_id": self.class_id,
"subject": self.subject,
"topic": self.topic,
"date": self.date.isoformat() if self.date else None,
"date_formatted": self._format_date(),
"total_duration_seconds": self.total_duration_seconds,
"total_duration_formatted": self._format_seconds(self.total_duration_seconds),
"planned_duration_seconds": self.planned_duration_seconds,
"planned_duration_formatted": self._format_seconds(self.planned_duration_seconds),
"phases_completed": self.phases_completed,
"total_phases": self.total_phases,
"completion_percentage": round(self.phases_completed / self.total_phases * 100),
"phase_statistics": [p.to_dict() for p in self.phase_statistics],
"total_overtime_seconds": self.total_overtime_seconds,
"total_overtime_formatted": self._format_seconds(self.total_overtime_seconds),
"phases_with_overtime": self.phases_with_overtime,
"total_pause_count": self.total_pause_count,
"total_pause_seconds": self.total_pause_seconds,
"reflection_notes": self.reflection_notes,
"reflection_rating": self.reflection_rating,
"key_learnings": self.key_learnings,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
def _format_date(self) -> str:
if not self.date:
return ""
return self.date.strftime("%d.%m.%Y %H:%M")
@dataclass
class TeacherAnalytics:
"""
Aggregierte Statistiken fuer einen Lehrer.
Zeigt Trends und Muster ueber mehrere Stunden.
"""
teacher_id: str
period_start: datetime
period_end: datetime
# Stunden-Uebersicht
total_sessions: int = 0
completed_sessions: int = 0
total_teaching_minutes: int = 0
# Durchschnittliche Phasendauern
avg_phase_durations: Dict[str, float] = field(default_factory=dict)
# Overtime-Trends
sessions_with_overtime: int = 0
avg_overtime_seconds: float = 0
most_overtime_phase: Optional[str] = None
# Pausen-Statistik
avg_pause_count: float = 0
avg_pause_duration_seconds: float = 0
# Faecher-Verteilung
subjects_taught: Dict[str, int] = field(default_factory=dict)
# Klassen-Verteilung
classes_taught: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"teacher_id": self.teacher_id,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
"total_sessions": self.total_sessions,
"completed_sessions": self.completed_sessions,
"total_teaching_minutes": self.total_teaching_minutes,
"total_teaching_hours": round(self.total_teaching_minutes / 60, 1),
"avg_phase_durations": self.avg_phase_durations,
"sessions_with_overtime": self.sessions_with_overtime,
"overtime_percentage": round(self.sessions_with_overtime / max(self.total_sessions, 1) * 100),
"avg_overtime_seconds": round(self.avg_overtime_seconds),
"avg_overtime_formatted": self._format_seconds(int(self.avg_overtime_seconds)),
"most_overtime_phase": self.most_overtime_phase,
"avg_pause_count": round(self.avg_pause_count, 1),
"avg_pause_duration_seconds": round(self.avg_pause_duration_seconds),
"subjects_taught": self.subjects_taught,
"classes_taught": self.classes_taught,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
# ==================== Reflection Model ====================
@dataclass
class LessonReflection:
"""
Post-Lesson Reflection (Feature).
Ermoeglicht Lehrern, nach der Stunde Notizen zu machen.
Keine Bewertung, nur Reflexion.
"""
reflection_id: str
session_id: str
teacher_id: str
# Reflexionsnotizen
notes: str = ""
# Optional: Sterne-Bewertung (selbst-eingeschaetzt)
overall_rating: Optional[int] = None # 1-5
# Was hat gut funktioniert?
what_worked: List[str] = field(default_factory=list)
# Was wuerde ich naechstes Mal anders machen?
improvements: List[str] = field(default_factory=list)
# Notizen fuer naechste Stunde
notes_for_next_lesson: str = ""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
return {
"reflection_id": self.reflection_id,
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"notes": self.notes,
"overall_rating": self.overall_rating,
"what_worked": self.what_worked,
"improvements": self.improvements,
"notes_for_next_lesson": self.notes_for_next_lesson,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
# ==================== Analytics Calculator ====================
class AnalyticsCalculator:
"""
Berechnet Analytics aus Session-Daten.
Verwendet In-Memory-Daten oder DB-Daten.
"""
"""Berechnet Analytics aus Session-Daten."""
PHASE_DISPLAY_NAMES = {
"einstieg": "Einstieg",
@@ -276,24 +48,13 @@ class AnalyticsCalculator:
session_data: Dict[str, Any],
phase_history: List[Dict[str, Any]]
) -> SessionSummary:
"""
Berechnet die Zusammenfassung einer Session.
Args:
session_data: Session-Dictionary (aus LessonSession.to_dict())
phase_history: Liste der Phasen-History-Eintraege
Returns:
SessionSummary mit allen berechneten Statistiken
"""
# Basis-Daten
"""Berechnet die Zusammenfassung einer Session."""
session_id = session_data.get("session_id", "")
teacher_id = session_data.get("teacher_id", "")
class_id = session_data.get("class_id", "")
subject = session_data.get("subject", "")
topic = session_data.get("topic")
# Timestamps
lesson_started = session_data.get("lesson_started_at")
lesson_ended = session_data.get("lesson_ended_at")
@@ -302,16 +63,13 @@ class AnalyticsCalculator:
if isinstance(lesson_ended, str):
lesson_ended = datetime.fromisoformat(lesson_ended.replace("Z", "+00:00"))
# Dauer berechnen
total_duration = 0
if lesson_started and lesson_ended:
total_duration = int((lesson_ended - lesson_started).total_seconds())
# Geplante Dauer
phase_durations = session_data.get("phase_durations", {})
planned_duration = sum(phase_durations.values()) * 60 # Minuten zu Sekunden
planned_duration = sum(phase_durations.values()) * 60
# Phasen-Statistiken berechnen
phase_stats = []
total_overtime = 0
phases_with_overtime = 0
@@ -324,18 +82,10 @@ class AnalyticsCalculator:
if phase in ["not_started", "ended"]:
continue
# Geplante Dauer fuer diese Phase
planned_seconds = phase_durations.get(phase, 0) * 60
# Tatsaechliche Dauer
actual_seconds = entry.get("duration_seconds", 0)
if actual_seconds is None:
actual_seconds = 0
# Differenz
actual_seconds = entry.get("duration_seconds", 0) or 0
difference = actual_seconds - planned_seconds
# Overtime (nur positive Differenz zaehlt)
had_overtime = difference > 0
overtime_seconds = max(0, difference)
@@ -343,13 +93,11 @@ class AnalyticsCalculator:
total_overtime += overtime_seconds
phases_with_overtime += 1
# Pausen
pause_count = entry.get("pause_count", 0) or 0
pause_seconds = entry.get("total_pause_seconds", 0) or 0
total_pause_count += pause_count
total_pause_seconds += pause_seconds
# Phase als abgeschlossen zaehlen
if entry.get("ended_at"):
phases_completed += 1
@@ -368,16 +116,12 @@ class AnalyticsCalculator:
))
return SessionSummary(
session_id=session_id,
teacher_id=teacher_id,
class_id=class_id,
subject=subject,
topic=topic,
session_id=session_id, teacher_id=teacher_id,
class_id=class_id, subject=subject, topic=topic,
date=lesson_started or datetime.now(),
total_duration_seconds=total_duration,
planned_duration_seconds=planned_duration,
phases_completed=phases_completed,
total_phases=5,
phases_completed=phases_completed, total_phases=5,
phase_statistics=phase_stats,
total_overtime_seconds=total_overtime,
phases_with_overtime=phases_with_overtime,
@@ -392,31 +136,15 @@ class AnalyticsCalculator:
period_start: datetime,
period_end: datetime
) -> TeacherAnalytics:
"""
Berechnet aggregierte Statistiken fuer einen Lehrer.
Args:
sessions: Liste von Session-Dictionaries
period_start: Beginn des Zeitraums
period_end: Ende des Zeitraums
Returns:
TeacherAnalytics mit aggregierten Statistiken
"""
"""Berechnet aggregierte Statistiken fuer einen Lehrer."""
if not sessions:
return TeacherAnalytics(
teacher_id="",
period_start=period_start,
period_end=period_end,
)
return TeacherAnalytics(teacher_id="", period_start=period_start, period_end=period_end)
teacher_id = sessions[0].get("teacher_id", "")
# Basis-Zaehler
total_sessions = len(sessions)
completed_sessions = sum(1 for s in sessions if s.get("lesson_ended_at"))
# Gesamtdauer berechnen
total_minutes = 0
for session in sessions:
started = session.get("lesson_started_at")
@@ -428,41 +156,29 @@ class AnalyticsCalculator:
ended = datetime.fromisoformat(ended.replace("Z", "+00:00"))
total_minutes += (ended - started).total_seconds() / 60
# Durchschnittliche Phasendauern
phase_durations_sum: Dict[str, List[int]] = {
"einstieg": [],
"erarbeitung": [],
"sicherung": [],
"transfer": [],
"reflexion": [],
"einstieg": [], "erarbeitung": [], "sicherung": [],
"transfer": [], "reflexion": [],
}
# Overtime-Tracking
overtime_count = 0
overtime_seconds_total = 0
phase_overtime: Dict[str, int] = {}
# Pausen-Tracking
pause_counts = []
pause_durations = []
# Faecher und Klassen
subjects: Dict[str, int] = {}
classes: Dict[str, int] = {}
for session in sessions:
# Fach und Klasse zaehlen
subject = session.get("subject", "")
class_id = session.get("class_id", "")
subjects[subject] = subjects.get(subject, 0) + 1
classes[class_id] = classes.get(class_id, 0) + 1
# Phase History analysieren
history = session.get("phase_history", [])
session_has_overtime = False
session_pause_count = 0
session_pause_duration = 0
phase_durations_dict = session.get("phase_durations", {})
for entry in history:
@@ -471,7 +187,6 @@ class AnalyticsCalculator:
duration = entry.get("duration_seconds", 0) or 0
phase_durations_sum[phase].append(duration)
# Overtime berechnen
planned = phase_durations_dict.get(phase, 0) * 60
if duration > planned:
overtime = duration - planned
@@ -479,35 +194,25 @@ class AnalyticsCalculator:
session_has_overtime = True
phase_overtime[phase] = phase_overtime.get(phase, 0) + overtime
# Pausen zaehlen
session_pause_count += entry.get("pause_count", 0) or 0
session_pause_duration += entry.get("total_pause_seconds", 0) or 0
if session_has_overtime:
overtime_count += 1
pause_counts.append(session_pause_count)
pause_durations.append(session_pause_duration)
# Durchschnitte berechnen
avg_durations = {}
for phase, durations in phase_durations_sum.items():
if durations:
avg_durations[phase] = round(sum(durations) / len(durations))
else:
avg_durations[phase] = 0
avg_durations[phase] = round(sum(durations) / len(durations)) if durations else 0
# Phase mit meistem Overtime finden
most_overtime_phase = None
if phase_overtime:
most_overtime_phase = max(phase_overtime, key=phase_overtime.get)
return TeacherAnalytics(
teacher_id=teacher_id,
period_start=period_start,
period_end=period_end,
total_sessions=total_sessions,
completed_sessions=completed_sessions,
teacher_id=teacher_id, period_start=period_start, period_end=period_end,
total_sessions=total_sessions, completed_sessions=completed_sessions,
total_teaching_minutes=int(total_minutes),
avg_phase_durations=avg_durations,
sessions_with_overtime=overtime_count,
@@ -515,6 +220,5 @@ class AnalyticsCalculator:
most_overtime_phase=most_overtime_phase,
avg_pause_count=sum(pause_counts) / max(len(pause_counts), 1),
avg_pause_duration_seconds=sum(pause_durations) / max(len(pause_durations), 1),
subjects_taught=subjects,
classes_taught=classes,
subjects_taught=subjects, classes_taught=classes,
)
@@ -0,0 +1,205 @@
"""
Analytics Models - Datenstrukturen fuer Classroom Analytics.
Enthaelt PhaseStatistics, SessionSummary, TeacherAnalytics, LessonReflection.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, List, Dict, Any
@dataclass
class PhaseStatistics:
"""Statistik fuer eine einzelne Phase."""
phase: str
display_name: str
# Dauer-Metriken
planned_duration_seconds: int
actual_duration_seconds: int
difference_seconds: int # positiv = laenger als geplant
# Overtime
had_overtime: bool
overtime_seconds: int = 0
# Erweiterungen
was_extended: bool = False
extension_minutes: int = 0
# Pausen
pause_count: int = 0
total_pause_seconds: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"phase": self.phase,
"display_name": self.display_name,
"planned_duration_seconds": self.planned_duration_seconds,
"actual_duration_seconds": self.actual_duration_seconds,
"difference_seconds": self.difference_seconds,
"difference_formatted": self._format_difference(),
"had_overtime": self.had_overtime,
"overtime_seconds": self.overtime_seconds,
"overtime_formatted": self._format_seconds(self.overtime_seconds),
"was_extended": self.was_extended,
"extension_minutes": self.extension_minutes,
"pause_count": self.pause_count,
"total_pause_seconds": self.total_pause_seconds,
}
def _format_difference(self) -> str:
prefix = "+" if self.difference_seconds >= 0 else ""
return f"{prefix}{self._format_seconds(abs(self.difference_seconds))}"
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
@dataclass
class SessionSummary:
"""Zusammenfassung einer Unterrichtsstunde."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: datetime
total_duration_seconds: int
planned_duration_seconds: int
phases_completed: int
total_phases: int = 5
phase_statistics: List[PhaseStatistics] = field(default_factory=list)
total_overtime_seconds: int = 0
phases_with_overtime: int = 0
total_pause_count: int = 0
total_pause_seconds: int = 0
reflection_notes: str = ""
reflection_rating: Optional[int] = None
key_learnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"class_id": self.class_id,
"subject": self.subject,
"topic": self.topic,
"date": self.date.isoformat() if self.date else None,
"date_formatted": self._format_date(),
"total_duration_seconds": self.total_duration_seconds,
"total_duration_formatted": self._format_seconds(self.total_duration_seconds),
"planned_duration_seconds": self.planned_duration_seconds,
"planned_duration_formatted": self._format_seconds(self.planned_duration_seconds),
"phases_completed": self.phases_completed,
"total_phases": self.total_phases,
"completion_percentage": round(self.phases_completed / self.total_phases * 100),
"phase_statistics": [p.to_dict() for p in self.phase_statistics],
"total_overtime_seconds": self.total_overtime_seconds,
"total_overtime_formatted": self._format_seconds(self.total_overtime_seconds),
"phases_with_overtime": self.phases_with_overtime,
"total_pause_count": self.total_pause_count,
"total_pause_seconds": self.total_pause_seconds,
"reflection_notes": self.reflection_notes,
"reflection_rating": self.reflection_rating,
"key_learnings": self.key_learnings,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
def _format_date(self) -> str:
if not self.date:
return ""
return self.date.strftime("%d.%m.%Y %H:%M")
@dataclass
class TeacherAnalytics:
"""Aggregierte Statistiken fuer einen Lehrer."""
teacher_id: str
period_start: datetime
period_end: datetime
total_sessions: int = 0
completed_sessions: int = 0
total_teaching_minutes: int = 0
avg_phase_durations: Dict[str, float] = field(default_factory=dict)
sessions_with_overtime: int = 0
avg_overtime_seconds: float = 0
most_overtime_phase: Optional[str] = None
avg_pause_count: float = 0
avg_pause_duration_seconds: float = 0
subjects_taught: Dict[str, int] = field(default_factory=dict)
classes_taught: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"teacher_id": self.teacher_id,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
"total_sessions": self.total_sessions,
"completed_sessions": self.completed_sessions,
"total_teaching_minutes": self.total_teaching_minutes,
"total_teaching_hours": round(self.total_teaching_minutes / 60, 1),
"avg_phase_durations": self.avg_phase_durations,
"sessions_with_overtime": self.sessions_with_overtime,
"overtime_percentage": round(self.sessions_with_overtime / max(self.total_sessions, 1) * 100),
"avg_overtime_seconds": round(self.avg_overtime_seconds),
"avg_overtime_formatted": self._format_seconds(int(self.avg_overtime_seconds)),
"most_overtime_phase": self.most_overtime_phase,
"avg_pause_count": round(self.avg_pause_count, 1),
"avg_pause_duration_seconds": round(self.avg_pause_duration_seconds),
"subjects_taught": self.subjects_taught,
"classes_taught": self.classes_taught,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
@dataclass
class LessonReflection:
"""Post-Lesson Reflection (Feature)."""
reflection_id: str
session_id: str
teacher_id: str
notes: str = ""
overall_rating: Optional[int] = None
what_worked: List[str] = field(default_factory=list)
improvements: List[str] = field(default_factory=list)
notes_for_next_lesson: str = ""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
return {
"reflection_id": self.reflection_id,
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"notes": self.notes,
"overall_rating": self.overall_rating,
"what_worked": self.what_worked,
"improvements": self.improvements,
"notes_for_next_lesson": self.notes_for_next_lesson,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -0,0 +1,494 @@
"""
Phasenspezifische und fachspezifische Vorschlags-Daten (Feature f18).
Enthaelt die vordefinierten Vorschlaege fuer allgemeine Phasen
und fachspezifische Aktivitaeten.
"""
from typing import List, Dict, Any
from .models import LessonPhase
# Unterstuetzte Faecher fuer fachspezifische Vorschlaege
SUPPORTED_SUBJECTS = [
"mathematik", "mathe", "math",
"deutsch",
"englisch", "english",
"biologie", "bio",
"physik",
"chemie",
"geschichte",
"geografie", "erdkunde",
"kunst",
"musik",
"sport",
"informatik",
]
# Fachspezifische Vorschlaege (Feature f18)
SUBJECT_SUGGESTIONS: Dict[str, Dict[LessonPhase, List[Dict[str, Any]]]] = {
"mathematik": {
LessonPhase.EINSTIEG: [
{
"id": "math_warm_up",
"title": "Kopfrechnen-Challenge",
"description": "5 schnelle Kopfrechenaufgaben zum Aufwaermen",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "calculate",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_puzzle",
"title": "Mathematisches Raetsel",
"description": "Ein kniffliges Zahlenraetsel als Einstieg",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "extension",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "math_geogebra",
"title": "GeoGebra-Exploration",
"description": "Interaktive Visualisierung mit GeoGebra",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "functions",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_peer_explain",
"title": "Rechenweg erklaeren",
"description": "Schueler erklaeren sich gegenseitig ihre Loesungswege",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "math_formula_card",
"title": "Formelkarte erstellen",
"description": "Wichtigste Formeln auf einer Karte festhalten",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "note_alt",
"subjects": ["mathematik", "mathe"],
},
],
},
"deutsch": {
LessonPhase.EINSTIEG: [
{
"id": "deutsch_wordle",
"title": "Wordle-Variante",
"description": "Wort des Tages erraten",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "abc",
"subjects": ["deutsch"],
},
{
"id": "deutsch_zitat",
"title": "Zitat-Interpretation",
"description": "Ein literarisches Zitat gemeinsam deuten",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "format_quote",
"subjects": ["deutsch"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "deutsch_textarbeit",
"title": "Textanalyse in Gruppen",
"description": "Gruppenarbeit zu verschiedenen Textabschnitten",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "menu_book",
"subjects": ["deutsch"],
},
{
"id": "deutsch_schreibworkshop",
"title": "Schreibwerkstatt",
"description": "Kreatives Schreiben mit Peer-Feedback",
"activity_type": "individual_work",
"estimated_minutes": 20,
"icon": "edit_note",
"subjects": ["deutsch"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "deutsch_zusammenfassung",
"title": "Text-Zusammenfassung",
"description": "Die wichtigsten Punkte in 3 Saetzen formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "summarize",
"subjects": ["deutsch"],
},
],
},
"englisch": {
LessonPhase.EINSTIEG: [
{
"id": "english_smalltalk",
"title": "Small Talk Warm-Up",
"description": "2-Minuten Gespraeche zu einem Alltagsthema",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "chat",
"subjects": ["englisch", "english"],
},
{
"id": "english_video",
"title": "Authentic Video Clip",
"description": "Kurzer Clip aus einer englischen Serie oder Nachricht",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "movie",
"subjects": ["englisch", "english"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "english_role_play",
"title": "Role Play Activity",
"description": "Dialoguebung in authentischen Situationen",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "theater_comedy",
"subjects": ["englisch", "english"],
},
{
"id": "english_reading_circle",
"title": "Reading Circle",
"description": "Gemeinsames Lesen mit verteilten Rollen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "auto_stories",
"subjects": ["englisch", "english"],
},
],
},
"biologie": {
LessonPhase.EINSTIEG: [
{
"id": "bio_nature_question",
"title": "Naturfrage",
"description": "Eine spannende Frage aus der Natur diskutieren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "eco",
"subjects": ["biologie", "bio"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "bio_experiment",
"title": "Mini-Experiment",
"description": "Einfaches Experiment zum Thema durchfuehren",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "science",
"subjects": ["biologie", "bio"],
},
{
"id": "bio_diagram",
"title": "Biologische Zeichnung",
"description": "Beschriftete Zeichnung eines Organismus",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "draw",
"subjects": ["biologie", "bio"],
},
],
},
"physik": {
LessonPhase.EINSTIEG: [
{
"id": "physik_demo",
"title": "Phaenomen-Demo",
"description": "Ein physikalisches Phaenomen vorfuehren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "bolt",
"subjects": ["physik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "physik_simulation",
"title": "PhET-Simulation",
"description": "Interaktive Simulation von phet.colorado.edu",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "smart_toy",
"subjects": ["physik"],
},
{
"id": "physik_rechnung",
"title": "Physikalische Rechnung",
"description": "Rechenaufgabe mit physikalischem Kontext",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "calculate",
"subjects": ["physik"],
},
],
},
"informatik": {
LessonPhase.EINSTIEG: [
{
"id": "info_code_puzzle",
"title": "Code-Puzzle",
"description": "Kurzen Code-Schnipsel analysieren - was macht er?",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "code",
"subjects": ["informatik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "info_live_coding",
"title": "Live Coding",
"description": "Gemeinsam Code entwickeln mit Erklaerungen",
"activity_type": "instruction",
"estimated_minutes": 15,
"icon": "terminal",
"subjects": ["informatik"],
},
{
"id": "info_pair_programming",
"title": "Pair Programming",
"description": "Zu zweit programmieren - Driver und Navigator",
"activity_type": "partner_work",
"estimated_minutes": 20,
"icon": "computer",
"subjects": ["informatik"],
},
],
},
}
# Vordefinierte allgemeine Vorschlaege pro Phase
PHASE_SUGGESTIONS: Dict[LessonPhase, List[Dict[str, Any]]] = {
LessonPhase.EINSTIEG: [
{
"id": "warmup_quiz",
"title": "Kurzes Quiz zum Einstieg",
"description": "Aktivieren Sie das Vorwissen der Schueler mit 3-5 Fragen zum Thema",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "quiz"
},
{
"id": "problem_story",
"title": "Problemgeschichte erzaehlen",
"description": "Stellen Sie ein alltagsnahes Problem vor, das zum Thema fuehrt",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "auto_stories"
},
{
"id": "video_intro",
"title": "Kurzes Erklaervideo",
"description": "Zeigen Sie ein 2-3 Minuten Video zur Einfuehrung ins Thema",
"activity_type": "motivation",
"estimated_minutes": 4,
"icon": "play_circle"
},
{
"id": "brainstorming",
"title": "Brainstorming",
"description": "Sammeln Sie Ideen und Vorkenntnisse der Schueler an der Tafel",
"activity_type": "warmup",
"estimated_minutes": 5,
"icon": "psychology"
},
{
"id": "daily_challenge",
"title": "Tagesaufgabe vorstellen",
"description": "Praesentieren Sie die zentrale Frage oder Aufgabe der Stunde",
"activity_type": "problem_introduction",
"estimated_minutes": 3,
"icon": "flag"
}
],
LessonPhase.ERARBEITUNG: [
{
"id": "think_pair_share",
"title": "Think-Pair-Share",
"description": "Schueler denken erst einzeln nach, tauschen sich dann zu zweit aus und praesentieren im Plenum",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups"
},
{
"id": "worksheet_digital",
"title": "Digitales Arbeitsblatt",
"description": "Schueler bearbeiten ein interaktives Arbeitsblatt am Tablet oder Computer",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "description"
},
{
"id": "station_learning",
"title": "Stationenlernen",
"description": "Verschiedene Stationen mit unterschiedlichen Aufgaben und Materialien",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "hub"
},
{
"id": "expert_puzzle",
"title": "Expertenrunde (Jigsaw)",
"description": "Schueler werden Experten fuer ein Teilthema und lehren es anderen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "extension"
},
{
"id": "guided_instruction",
"title": "Geleitete Instruktion",
"description": "Schrittweise Erklaerung mit Uebungsphasen zwischendurch",
"activity_type": "instruction",
"estimated_minutes": 12,
"icon": "school"
},
{
"id": "pair_programming",
"title": "Partnerarbeit",
"description": "Zwei Schueler loesen gemeinsam eine Aufgabe",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "people"
}
],
LessonPhase.SICHERUNG: [
{
"id": "mindmap_class",
"title": "Gemeinsame Mindmap",
"description": "Ergebnisse als Mindmap an der Tafel oder digital sammeln und strukturieren",
"activity_type": "visualization",
"estimated_minutes": 8,
"icon": "account_tree"
},
{
"id": "exit_ticket",
"title": "Exit Ticket",
"description": "Schueler notieren 3 Dinge die sie gelernt haben und 1 offene Frage",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "sticky_note_2"
},
{
"id": "gallery_walk",
"title": "Galerie-Rundgang",
"description": "Schueler praesentieren ihre Ergebnisse und geben sich Feedback",
"activity_type": "presentation",
"estimated_minutes": 10,
"icon": "photo_library"
},
{
"id": "key_points",
"title": "Kernpunkte zusammenfassen",
"description": "Gemeinsam die wichtigsten Erkenntnisse der Stunde formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "format_list_bulleted"
},
{
"id": "quick_check",
"title": "Schneller Wissenscheck",
"description": "5 kurze Fragen zur Ueberpruefung des Verstaendnisses",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "fact_check"
}
],
LessonPhase.TRANSFER: [
{
"id": "real_world_example",
"title": "Alltagsbeispiele finden",
"description": "Schueler suchen Beispiele aus ihrem Alltag, wo das Gelernte vorkommt",
"activity_type": "application",
"estimated_minutes": 5,
"icon": "public"
},
{
"id": "challenge_task",
"title": "Knobelaufgabe",
"description": "Eine anspruchsvollere Aufgabe fuer schnelle Schueler oder als Bonus",
"activity_type": "differentiation",
"estimated_minutes": 7,
"icon": "psychology"
},
{
"id": "creative_application",
"title": "Kreative Anwendung",
"description": "Schueler wenden das Gelernte in einem kreativen Projekt an",
"activity_type": "application",
"estimated_minutes": 10,
"icon": "palette"
},
{
"id": "peer_teaching",
"title": "Peer-Teaching",
"description": "Schueler erklaeren sich gegenseitig das Gelernte",
"activity_type": "real_world_connection",
"estimated_minutes": 5,
"icon": "supervisor_account"
}
],
LessonPhase.REFLEXION: [
{
"id": "thumbs_feedback",
"title": "Daumen-Feedback",
"description": "Schnelle Stimmungsabfrage: Daumen hoch/mitte/runter",
"activity_type": "feedback",
"estimated_minutes": 2,
"icon": "thumb_up"
},
{
"id": "homework_assign",
"title": "Hausaufgabe vergeben",
"description": "Passende Hausaufgabe zur Vertiefung des Gelernten",
"activity_type": "homework",
"estimated_minutes": 3,
"icon": "home_work"
},
{
"id": "one_word",
"title": "Ein-Wort-Reflexion",
"description": "Jeder Schueler nennt ein Wort, das die Stunde beschreibt",
"activity_type": "feedback",
"estimated_minutes": 3,
"icon": "chat"
},
{
"id": "preview_next",
"title": "Ausblick naechste Stunde",
"description": "Kurzer Ausblick auf das Thema der naechsten Stunde",
"activity_type": "preview",
"estimated_minutes": 2,
"icon": "event"
},
{
"id": "learning_log",
"title": "Lerntagebuch",
"description": "Schueler notieren ihre wichtigsten Erkenntnisse im Lerntagebuch",
"activity_type": "feedback",
"estimated_minutes": 4,
"icon": "menu_book"
}
]
}
+5 -484
View File
@@ -8,490 +8,11 @@ und optional dem Fach.
from typing import List, Dict, Any, Optional
from .models import LessonPhase, LessonSession, PhaseSuggestion
# Unterstuetzte Faecher fuer fachspezifische Vorschlaege
SUPPORTED_SUBJECTS = [
"mathematik", "mathe", "math",
"deutsch",
"englisch", "english",
"biologie", "bio",
"physik",
"chemie",
"geschichte",
"geografie", "erdkunde",
"kunst",
"musik",
"sport",
"informatik",
]
# Fachspezifische Vorschlaege (Feature f18)
SUBJECT_SUGGESTIONS: Dict[str, Dict[LessonPhase, List[Dict[str, Any]]]] = {
"mathematik": {
LessonPhase.EINSTIEG: [
{
"id": "math_warm_up",
"title": "Kopfrechnen-Challenge",
"description": "5 schnelle Kopfrechenaufgaben zum Aufwaermen",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "calculate",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_puzzle",
"title": "Mathematisches Raetsel",
"description": "Ein kniffliges Zahlenraetsel als Einstieg",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "extension",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "math_geogebra",
"title": "GeoGebra-Exploration",
"description": "Interaktive Visualisierung mit GeoGebra",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "functions",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_peer_explain",
"title": "Rechenweg erklaeren",
"description": "Schueler erklaeren sich gegenseitig ihre Loesungswege",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "math_formula_card",
"title": "Formelkarte erstellen",
"description": "Wichtigste Formeln auf einer Karte festhalten",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "note_alt",
"subjects": ["mathematik", "mathe"],
},
],
},
"deutsch": {
LessonPhase.EINSTIEG: [
{
"id": "deutsch_wordle",
"title": "Wordle-Variante",
"description": "Wort des Tages erraten",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "abc",
"subjects": ["deutsch"],
},
{
"id": "deutsch_zitat",
"title": "Zitat-Interpretation",
"description": "Ein literarisches Zitat gemeinsam deuten",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "format_quote",
"subjects": ["deutsch"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "deutsch_textarbeit",
"title": "Textanalyse in Gruppen",
"description": "Gruppenarbeit zu verschiedenen Textabschnitten",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "menu_book",
"subjects": ["deutsch"],
},
{
"id": "deutsch_schreibworkshop",
"title": "Schreibwerkstatt",
"description": "Kreatives Schreiben mit Peer-Feedback",
"activity_type": "individual_work",
"estimated_minutes": 20,
"icon": "edit_note",
"subjects": ["deutsch"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "deutsch_zusammenfassung",
"title": "Text-Zusammenfassung",
"description": "Die wichtigsten Punkte in 3 Saetzen formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "summarize",
"subjects": ["deutsch"],
},
],
},
"englisch": {
LessonPhase.EINSTIEG: [
{
"id": "english_smalltalk",
"title": "Small Talk Warm-Up",
"description": "2-Minuten Gespraeche zu einem Alltagsthema",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "chat",
"subjects": ["englisch", "english"],
},
{
"id": "english_video",
"title": "Authentic Video Clip",
"description": "Kurzer Clip aus einer englischen Serie oder Nachricht",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "movie",
"subjects": ["englisch", "english"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "english_role_play",
"title": "Role Play Activity",
"description": "Dialoguebung in authentischen Situationen",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "theater_comedy",
"subjects": ["englisch", "english"],
},
{
"id": "english_reading_circle",
"title": "Reading Circle",
"description": "Gemeinsames Lesen mit verteilten Rollen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "auto_stories",
"subjects": ["englisch", "english"],
},
],
},
"biologie": {
LessonPhase.EINSTIEG: [
{
"id": "bio_nature_question",
"title": "Naturfrage",
"description": "Eine spannende Frage aus der Natur diskutieren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "eco",
"subjects": ["biologie", "bio"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "bio_experiment",
"title": "Mini-Experiment",
"description": "Einfaches Experiment zum Thema durchfuehren",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "science",
"subjects": ["biologie", "bio"],
},
{
"id": "bio_diagram",
"title": "Biologische Zeichnung",
"description": "Beschriftete Zeichnung eines Organismus",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "draw",
"subjects": ["biologie", "bio"],
},
],
},
"physik": {
LessonPhase.EINSTIEG: [
{
"id": "physik_demo",
"title": "Phaenomen-Demo",
"description": "Ein physikalisches Phaenomen vorfuehren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "bolt",
"subjects": ["physik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "physik_simulation",
"title": "PhET-Simulation",
"description": "Interaktive Simulation von phet.colorado.edu",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "smart_toy",
"subjects": ["physik"],
},
{
"id": "physik_rechnung",
"title": "Physikalische Rechnung",
"description": "Rechenaufgabe mit physikalischem Kontext",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "calculate",
"subjects": ["physik"],
},
],
},
"informatik": {
LessonPhase.EINSTIEG: [
{
"id": "info_code_puzzle",
"title": "Code-Puzzle",
"description": "Kurzen Code-Schnipsel analysieren - was macht er?",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "code",
"subjects": ["informatik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "info_live_coding",
"title": "Live Coding",
"description": "Gemeinsam Code entwickeln mit Erklaerungen",
"activity_type": "instruction",
"estimated_minutes": 15,
"icon": "terminal",
"subjects": ["informatik"],
},
{
"id": "info_pair_programming",
"title": "Pair Programming",
"description": "Zu zweit programmieren - Driver und Navigator",
"activity_type": "partner_work",
"estimated_minutes": 20,
"icon": "computer",
"subjects": ["informatik"],
},
],
},
}
# Vordefinierte allgemeine Vorschlaege pro Phase
PHASE_SUGGESTIONS: Dict[LessonPhase, List[Dict[str, Any]]] = {
LessonPhase.EINSTIEG: [
{
"id": "warmup_quiz",
"title": "Kurzes Quiz zum Einstieg",
"description": "Aktivieren Sie das Vorwissen der Schueler mit 3-5 Fragen zum Thema",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "quiz"
},
{
"id": "problem_story",
"title": "Problemgeschichte erzaehlen",
"description": "Stellen Sie ein alltagsnahes Problem vor, das zum Thema fuehrt",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "auto_stories"
},
{
"id": "video_intro",
"title": "Kurzes Erklaervideo",
"description": "Zeigen Sie ein 2-3 Minuten Video zur Einfuehrung ins Thema",
"activity_type": "motivation",
"estimated_minutes": 4,
"icon": "play_circle"
},
{
"id": "brainstorming",
"title": "Brainstorming",
"description": "Sammeln Sie Ideen und Vorkenntnisse der Schueler an der Tafel",
"activity_type": "warmup",
"estimated_minutes": 5,
"icon": "psychology"
},
{
"id": "daily_challenge",
"title": "Tagesaufgabe vorstellen",
"description": "Praesentieren Sie die zentrale Frage oder Aufgabe der Stunde",
"activity_type": "problem_introduction",
"estimated_minutes": 3,
"icon": "flag"
}
],
LessonPhase.ERARBEITUNG: [
{
"id": "think_pair_share",
"title": "Think-Pair-Share",
"description": "Schueler denken erst einzeln nach, tauschen sich dann zu zweit aus und praesentieren im Plenum",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups"
},
{
"id": "worksheet_digital",
"title": "Digitales Arbeitsblatt",
"description": "Schueler bearbeiten ein interaktives Arbeitsblatt am Tablet oder Computer",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "description"
},
{
"id": "station_learning",
"title": "Stationenlernen",
"description": "Verschiedene Stationen mit unterschiedlichen Aufgaben und Materialien",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "hub"
},
{
"id": "expert_puzzle",
"title": "Expertenrunde (Jigsaw)",
"description": "Schueler werden Experten fuer ein Teilthema und lehren es anderen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "extension"
},
{
"id": "guided_instruction",
"title": "Geleitete Instruktion",
"description": "Schrittweise Erklaerung mit Uebungsphasen zwischendurch",
"activity_type": "instruction",
"estimated_minutes": 12,
"icon": "school"
},
{
"id": "pair_programming",
"title": "Partnerarbeit",
"description": "Zwei Schueler loesen gemeinsam eine Aufgabe",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "people"
}
],
LessonPhase.SICHERUNG: [
{
"id": "mindmap_class",
"title": "Gemeinsame Mindmap",
"description": "Ergebnisse als Mindmap an der Tafel oder digital sammeln und strukturieren",
"activity_type": "visualization",
"estimated_minutes": 8,
"icon": "account_tree"
},
{
"id": "exit_ticket",
"title": "Exit Ticket",
"description": "Schueler notieren 3 Dinge die sie gelernt haben und 1 offene Frage",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "sticky_note_2"
},
{
"id": "gallery_walk",
"title": "Galerie-Rundgang",
"description": "Schueler praesentieren ihre Ergebnisse und geben sich Feedback",
"activity_type": "presentation",
"estimated_minutes": 10,
"icon": "photo_library"
},
{
"id": "key_points",
"title": "Kernpunkte zusammenfassen",
"description": "Gemeinsam die wichtigsten Erkenntnisse der Stunde formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "format_list_bulleted"
},
{
"id": "quick_check",
"title": "Schneller Wissenscheck",
"description": "5 kurze Fragen zur Ueberpruefung des Verstaendnisses",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "fact_check"
}
],
LessonPhase.TRANSFER: [
{
"id": "real_world_example",
"title": "Alltagsbeispiele finden",
"description": "Schueler suchen Beispiele aus ihrem Alltag, wo das Gelernte vorkommt",
"activity_type": "application",
"estimated_minutes": 5,
"icon": "public"
},
{
"id": "challenge_task",
"title": "Knobelaufgabe",
"description": "Eine anspruchsvollere Aufgabe fuer schnelle Schueler oder als Bonus",
"activity_type": "differentiation",
"estimated_minutes": 7,
"icon": "psychology"
},
{
"id": "creative_application",
"title": "Kreative Anwendung",
"description": "Schueler wenden das Gelernte in einem kreativen Projekt an",
"activity_type": "application",
"estimated_minutes": 10,
"icon": "palette"
},
{
"id": "peer_teaching",
"title": "Peer-Teaching",
"description": "Schueler erklaeren sich gegenseitig das Gelernte",
"activity_type": "real_world_connection",
"estimated_minutes": 5,
"icon": "supervisor_account"
}
],
LessonPhase.REFLEXION: [
{
"id": "thumbs_feedback",
"title": "Daumen-Feedback",
"description": "Schnelle Stimmungsabfrage: Daumen hoch/mitte/runter",
"activity_type": "feedback",
"estimated_minutes": 2,
"icon": "thumb_up"
},
{
"id": "homework_assign",
"title": "Hausaufgabe vergeben",
"description": "Passende Hausaufgabe zur Vertiefung des Gelernten",
"activity_type": "homework",
"estimated_minutes": 3,
"icon": "home_work"
},
{
"id": "one_word",
"title": "Ein-Wort-Reflexion",
"description": "Jeder Schueler nennt ein Wort, das die Stunde beschreibt",
"activity_type": "feedback",
"estimated_minutes": 3,
"icon": "chat"
},
{
"id": "preview_next",
"title": "Ausblick naechste Stunde",
"description": "Kurzer Ausblick auf das Thema der naechsten Stunde",
"activity_type": "preview",
"estimated_minutes": 2,
"icon": "event"
},
{
"id": "learning_log",
"title": "Lerntagebuch",
"description": "Schueler notieren ihre wichtigsten Erkenntnisse im Lerntagebuch",
"activity_type": "feedback",
"estimated_minutes": 4,
"icon": "menu_book"
}
]
}
from .suggestion_data import (
SUPPORTED_SUBJECTS,
SUBJECT_SUGGESTIONS,
PHASE_SUGGESTIONS,
)
class SuggestionEngine:
@@ -11,10 +11,12 @@ from .h5p_generator import (
generate_h5p_manifest,
)
from .pdf_generator import (
PDFGenerator,
from .worksheet_models import (
Worksheet,
WorksheetSection,
)
from .pdf_generator import (
PDFGenerator,
generate_worksheet_html,
generate_worksheet_pdf,
)
@@ -12,252 +12,9 @@ Structure:
6. Reflection Questions
"""
import io
from dataclasses import dataclass
from typing import Any, Optional, Union
from typing import Optional, Union
# Note: In production, use reportlab or weasyprint for actual PDF generation
# This module generates an intermediate format that can be converted to PDF
@dataclass
class WorksheetSection:
"""A section of the worksheet"""
title: str
content_type: str # "text", "table", "exercises", "blanks"
content: Any
difficulty: int = 1 # 1-4
@dataclass
class Worksheet:
"""Complete worksheet structure"""
title: str
subtitle: str
unit_id: str
locale: str
sections: list[WorksheetSection]
footer: str = ""
def to_html(self) -> str:
"""Convert worksheet to HTML (for PDF conversion via weasyprint)"""
html_parts = [
"<!DOCTYPE html>",
"<html lang='de'>",
"<head>",
"<meta charset='UTF-8'>",
"<style>",
self._get_styles(),
"</style>",
"</head>",
"<body>",
f"<header><h1>{self.title}</h1>",
f"<p class='subtitle'>{self.subtitle}</p></header>",
]
for section in self.sections:
html_parts.append(self._render_section(section))
html_parts.extend([
f"<footer>{self.footer}</footer>",
"</body>",
"</html>"
])
return "\n".join(html_parts)
def _get_styles(self) -> str:
return """
@page {
size: A4;
margin: 2cm;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #333;
}
header {
text-align: center;
margin-bottom: 1.5em;
border-bottom: 2px solid #2c5282;
padding-bottom: 1em;
}
h1 {
color: #2c5282;
margin-bottom: 0.25em;
font-size: 20pt;
}
.subtitle {
color: #666;
font-style: italic;
}
h2 {
color: #2c5282;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.25em;
margin-top: 1.5em;
font-size: 14pt;
}
h3 {
color: #4a5568;
font-size: 12pt;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
th, td {
border: 1px solid #e2e8f0;
padding: 0.5em;
text-align: left;
}
th {
background-color: #edf2f7;
font-weight: bold;
}
.exercise {
margin: 1em 0;
padding: 1em;
background-color: #f7fafc;
border-left: 4px solid #4299e1;
}
.exercise-number {
font-weight: bold;
color: #2c5282;
}
.blank {
display: inline-block;
min-width: 100px;
border-bottom: 1px solid #333;
margin: 0 0.25em;
}
.difficulty {
font-size: 9pt;
color: #718096;
}
.difficulty-1 { color: #48bb78; }
.difficulty-2 { color: #4299e1; }
.difficulty-3 { color: #ed8936; }
.difficulty-4 { color: #f56565; }
.reflection {
margin-top: 2em;
padding: 1em;
background-color: #fffaf0;
border: 1px dashed #ed8936;
}
.write-area {
min-height: 80px;
border: 1px solid #e2e8f0;
margin: 0.5em 0;
background-color: #fff;
}
footer {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid #e2e8f0;
font-size: 9pt;
color: #718096;
text-align: center;
}
ul, ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.objectives {
background-color: #ebf8ff;
padding: 1em;
border-radius: 4px;
}
"""
def _render_section(self, section: WorksheetSection) -> str:
parts = [f"<section><h2>{section.title}</h2>"]
if section.content_type == "text":
parts.append(f"<p>{section.content}</p>")
elif section.content_type == "objectives":
parts.append("<div class='objectives'><ul>")
for obj in section.content:
parts.append(f"<li>{obj}</li>")
parts.append("</ul></div>")
elif section.content_type == "table":
parts.append("<table><thead><tr>")
for header in section.content.get("headers", []):
parts.append(f"<th>{header}</th>")
parts.append("</tr></thead><tbody>")
for row in section.content.get("rows", []):
parts.append("<tr>")
for cell in row:
parts.append(f"<td>{cell}</td>")
parts.append("</tr>")
parts.append("</tbody></table>")
elif section.content_type == "exercises":
for i, ex in enumerate(section.content, 1):
diff_class = f"difficulty-{ex.get('difficulty', 1)}"
diff_stars = "*" * ex.get("difficulty", 1)
parts.append(f"""
<div class='exercise'>
<span class='exercise-number'>Aufgabe {i}</span>
<span class='difficulty {diff_class}'>({diff_stars})</span>
<p>{ex.get('question', '')}</p>
{self._render_exercise_input(ex)}
</div>
""")
elif section.content_type == "blanks":
text = section.content
# Replace *word* with blank
import re
text = re.sub(r'\*([^*]+)\*', r"<span class='blank'></span>", text)
parts.append(f"<p>{text}</p>")
elif section.content_type == "reflection":
parts.append("<div class='reflection'>")
parts.append(f"<p><strong>{section.content.get('prompt', '')}</strong></p>")
parts.append("<div class='write-area'></div>")
parts.append("</div>")
parts.append("</section>")
return "\n".join(parts)
def _render_exercise_input(self, exercise: dict) -> str:
ex_type = exercise.get("type", "text")
if ex_type == "multiple_choice":
options = exercise.get("options", [])
parts = ["<ul style='list-style-type: none;'>"]
for opt in options:
parts.append(f"<li>&#9633; {opt}</li>")
parts.append("</ul>")
return "\n".join(parts)
elif ex_type == "matching":
left = exercise.get("left", [])
right = exercise.get("right", [])
parts = ["<table><tr><th>Begriff</th><th>Zuordnung</th></tr>"]
for i, item in enumerate(left):
right_item = right[i] if i < len(right) else ""
parts.append(f"<tr><td>{item}</td><td class='blank' style='width:200px'></td></tr>")
parts.append("</table>")
return "\n".join(parts)
elif ex_type == "sequence":
items = exercise.get("items", [])
parts = ["<p>Bringe in die richtige Reihenfolge:</p><ol>"]
for item in items:
parts.append(f"<li class='blank' style='min-width:200px'></li>")
parts.append("</ol>")
parts.append(f"<p style='font-size:9pt;color:#718096'>Begriffe: {', '.join(items)}</p>")
return "\n".join(parts)
else:
return "<div class='write-area'></div>"
from .worksheet_models import Worksheet, WorksheetSection
class PDFGenerator:
@@ -267,15 +24,7 @@ class PDFGenerator:
self.locale = locale
def generate_from_unit(self, unit: dict) -> Worksheet:
"""
Generate a worksheet from a unit definition.
Args:
unit: Unit definition dictionary
Returns:
Worksheet object
"""
"""Generate a worksheet from a unit definition."""
unit_id = unit.get("unit_id", "unknown")
title = self._get_localized(unit.get("title"), "Arbeitsblatt")
objectives = unit.get("learning_objectives", [])
@@ -283,51 +32,36 @@ class PDFGenerator:
sections = []
# Learning Objectives
if objectives:
sections.append(WorksheetSection(
title="Lernziele",
content_type="objectives",
content=objectives
title="Lernziele", content_type="objectives", content=objectives
))
# Vocabulary Table
vocab_section = self._create_vocabulary_section(stops)
if vocab_section:
sections.append(vocab_section)
# Key Concepts Summary
concepts_section = self._create_concepts_section(stops)
if concepts_section:
sections.append(concepts_section)
# Basic Exercises
basic_exercises = self._create_basic_exercises(stops)
if basic_exercises:
sections.append(WorksheetSection(
title="Ubungen - Basis",
content_type="exercises",
content=basic_exercises,
difficulty=1
title="Ubungen - Basis", content_type="exercises",
content=basic_exercises, difficulty=1
))
# Challenge Exercises
challenge_exercises = self._create_challenge_exercises(stops, unit)
if challenge_exercises:
sections.append(WorksheetSection(
title="Ubungen - Herausforderung",
content_type="exercises",
content=challenge_exercises,
difficulty=3
title="Ubungen - Herausforderung", content_type="exercises",
content=challenge_exercises, difficulty=3
))
# Reflection
sections.append(WorksheetSection(
title="Reflexion",
content_type="reflection",
content={
"prompt": "Erklaere in eigenen Worten, was du heute gelernt hast:"
}
title="Reflexion", content_type="reflection",
content={"prompt": "Erklaere in eigenen Worten, was du heute gelernt hast:"}
))
return Worksheet(
@@ -370,12 +104,8 @@ class PDFGenerator:
return None
return WorksheetSection(
title="Wichtige Begriffe",
content_type="table",
content={
"headers": ["Begriff", "Erklarung"],
"rows": rows
}
title="Wichtige Begriffe", content_type="table",
content={"headers": ["Begriff", "Erklarung"], "rows": rows}
)
def _create_concepts_section(self, stops: list) -> Optional[WorksheetSection]:
@@ -392,19 +122,14 @@ class PDFGenerator:
return None
return WorksheetSection(
title="Zusammenfassung",
content_type="table",
content={
"headers": ["Station", "Was hast du gelernt?"],
"rows": rows
}
title="Zusammenfassung", content_type="table",
content={"headers": ["Station", "Was hast du gelernt?"], "rows": rows}
)
def _create_basic_exercises(self, stops: list) -> list[dict]:
"""Create basic difficulty exercises"""
exercises = []
# Vocabulary matching
vocab_items = []
for stop in stops:
for v in stop.get("vocab", []):
@@ -422,7 +147,6 @@ class PDFGenerator:
"difficulty": 1
})
# True/False from concepts
for stop in stops[:3]:
concept = stop.get("concept", {})
why = self._get_localized(concept.get("why"))
@@ -435,7 +159,6 @@ class PDFGenerator:
})
break
# Sequence ordering (for FlightPath)
if len(stops) >= 4:
labels = [self._get_localized(s.get("label")) for s in stops[:6] if self._get_localized(s.get("label"))]
if len(labels) >= 4:
@@ -455,7 +178,6 @@ class PDFGenerator:
"""Create challenging exercises"""
exercises = []
# Misconception identification
for stop in stops:
concept = stop.get("concept", {})
misconception = self._get_localized(concept.get("common_misconception"))
@@ -472,14 +194,12 @@ class PDFGenerator:
if len(exercises) >= 2:
break
# Transfer/Application question
exercises.append({
"type": "text",
"question": "Erklaere einem Freund in 2-3 Satzen, was du gelernt hast:",
"difficulty": 3
})
# Critical thinking
exercises.append({
"type": "text",
"question": "Was moechtest du noch mehr uber dieses Thema erfahren?",
@@ -490,35 +210,14 @@ class PDFGenerator:
def generate_worksheet_html(unit_definition: dict, locale: str = "de-DE") -> str:
"""
Generate HTML worksheet from unit definition.
Args:
unit_definition: The unit JSON definition
locale: Target locale for content
Returns:
HTML string ready for PDF conversion
"""
"""Generate HTML worksheet from unit definition."""
generator = PDFGenerator(locale=locale)
worksheet = generator.generate_from_unit(unit_definition)
return worksheet.to_html()
def generate_worksheet_pdf(unit_definition: dict, locale: str = "de-DE") -> bytes:
"""
Generate PDF worksheet from unit definition.
Requires weasyprint to be installed:
pip install weasyprint
Args:
unit_definition: The unit JSON definition
locale: Target locale for content
Returns:
PDF bytes
"""
"""Generate PDF worksheet from unit definition."""
try:
from weasyprint import HTML
except ImportError:
@@ -0,0 +1,247 @@
"""
Worksheet Models - Datenstrukturen und HTML-Rendering fuer Arbeitsblaetter.
"""
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class WorksheetSection:
"""A section of the worksheet"""
title: str
content_type: str # "text", "table", "exercises", "blanks"
content: Any
difficulty: int = 1 # 1-4
@dataclass
class Worksheet:
"""Complete worksheet structure"""
title: str
subtitle: str
unit_id: str
locale: str
sections: list[WorksheetSection]
footer: str = ""
def to_html(self) -> str:
"""Convert worksheet to HTML (for PDF conversion via weasyprint)"""
html_parts = [
"<!DOCTYPE html>",
"<html lang='de'>",
"<head>",
"<meta charset='UTF-8'>",
"<style>",
_get_styles(),
"</style>",
"</head>",
"<body>",
f"<header><h1>{self.title}</h1>",
f"<p class='subtitle'>{self.subtitle}</p></header>",
]
for section in self.sections:
html_parts.append(_render_section(section))
html_parts.extend([
f"<footer>{self.footer}</footer>",
"</body>",
"</html>"
])
return "\n".join(html_parts)
def _get_styles() -> str:
return """
@page {
size: A4;
margin: 2cm;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #333;
}
header {
text-align: center;
margin-bottom: 1.5em;
border-bottom: 2px solid #2c5282;
padding-bottom: 1em;
}
h1 {
color: #2c5282;
margin-bottom: 0.25em;
font-size: 20pt;
}
.subtitle {
color: #666;
font-style: italic;
}
h2 {
color: #2c5282;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.25em;
margin-top: 1.5em;
font-size: 14pt;
}
h3 {
color: #4a5568;
font-size: 12pt;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
th, td {
border: 1px solid #e2e8f0;
padding: 0.5em;
text-align: left;
}
th {
background-color: #edf2f7;
font-weight: bold;
}
.exercise {
margin: 1em 0;
padding: 1em;
background-color: #f7fafc;
border-left: 4px solid #4299e1;
}
.exercise-number {
font-weight: bold;
color: #2c5282;
}
.blank {
display: inline-block;
min-width: 100px;
border-bottom: 1px solid #333;
margin: 0 0.25em;
}
.difficulty {
font-size: 9pt;
color: #718096;
}
.difficulty-1 { color: #48bb78; }
.difficulty-2 { color: #4299e1; }
.difficulty-3 { color: #ed8936; }
.difficulty-4 { color: #f56565; }
.reflection {
margin-top: 2em;
padding: 1em;
background-color: #fffaf0;
border: 1px dashed #ed8936;
}
.write-area {
min-height: 80px;
border: 1px solid #e2e8f0;
margin: 0.5em 0;
background-color: #fff;
}
footer {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid #e2e8f0;
font-size: 9pt;
color: #718096;
text-align: center;
}
ul, ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.objectives {
background-color: #ebf8ff;
padding: 1em;
border-radius: 4px;
}
"""
def _render_section(section: WorksheetSection) -> str:
parts = [f"<section><h2>{section.title}</h2>"]
if section.content_type == "text":
parts.append(f"<p>{section.content}</p>")
elif section.content_type == "objectives":
parts.append("<div class='objectives'><ul>")
for obj in section.content:
parts.append(f"<li>{obj}</li>")
parts.append("</ul></div>")
elif section.content_type == "table":
parts.append("<table><thead><tr>")
for header in section.content.get("headers", []):
parts.append(f"<th>{header}</th>")
parts.append("</tr></thead><tbody>")
for row in section.content.get("rows", []):
parts.append("<tr>")
for cell in row:
parts.append(f"<td>{cell}</td>")
parts.append("</tr>")
parts.append("</tbody></table>")
elif section.content_type == "exercises":
for i, ex in enumerate(section.content, 1):
diff_class = f"difficulty-{ex.get('difficulty', 1)}"
diff_stars = "*" * ex.get("difficulty", 1)
parts.append(f"""
<div class='exercise'>
<span class='exercise-number'>Aufgabe {i}</span>
<span class='difficulty {diff_class}'>({diff_stars})</span>
<p>{ex.get('question', '')}</p>
{_render_exercise_input(ex)}
</div>
""")
elif section.content_type == "blanks":
text = section.content
text = re.sub(r'\*([^*]+)\*', r"<span class='blank'></span>", text)
parts.append(f"<p>{text}</p>")
elif section.content_type == "reflection":
parts.append("<div class='reflection'>")
parts.append(f"<p><strong>{section.content.get('prompt', '')}</strong></p>")
parts.append("<div class='write-area'></div>")
parts.append("</div>")
parts.append("</section>")
return "\n".join(parts)
def _render_exercise_input(exercise: dict) -> str:
ex_type = exercise.get("type", "text")
if ex_type == "multiple_choice":
options = exercise.get("options", [])
parts = ["<ul style='list-style-type: none;'>"]
for opt in options:
parts.append(f"<li>&#9633; {opt}</li>")
parts.append("</ul>")
return "\n".join(parts)
elif ex_type == "matching":
left = exercise.get("left", [])
right = exercise.get("right", [])
parts = ["<table><tr><th>Begriff</th><th>Zuordnung</th></tr>"]
for i, item in enumerate(left):
parts.append(f"<tr><td>{item}</td><td class='blank' style='width:200px'></td></tr>")
parts.append("</table>")
return "\n".join(parts)
elif ex_type == "sequence":
items = exercise.get("items", [])
parts = ["<p>Bringe in die richtige Reihenfolge:</p><ol>"]
for item in items:
parts.append(f"<li class='blank' style='min-width:200px'></li>")
parts.append("</ol>")
parts.append(f"<p style='font-size:9pt;color:#718096'>Begriffe: {', '.join(items)}</p>")
return "\n".join(parts)
else:
return "<div class='write-area'></div>"
+23 -139
View File
@@ -10,66 +10,27 @@ Generiert:
import logging
import json
import re
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from typing import List, Dict, Any, Optional
from .quiz_models import (
QuizType,
TrueFalseQuestion,
MatchingPair,
SortingItem,
OpenQuestion,
Quiz,
)
from .quiz_helpers import (
extract_factual_sentences,
negate_sentence,
extract_definitions,
extract_sequence,
extract_keywords,
)
logger = logging.getLogger(__name__)
class QuizType(str, Enum):
"""Typen von Quiz-Aufgaben."""
TRUE_FALSE = "true_false"
MATCHING = "matching"
SORTING = "sorting"
OPEN_ENDED = "open_ended"
@dataclass
class TrueFalseQuestion:
"""Eine Wahr/Falsch-Frage."""
statement: str
is_true: bool
explanation: str
source_reference: Optional[str] = None
@dataclass
class MatchingPair:
"""Ein Zuordnungspaar."""
left: str
right: str
hint: Optional[str] = None
@dataclass
class SortingItem:
"""Ein Element zum Sortieren."""
text: str
correct_position: int
category: Optional[str] = None
@dataclass
class OpenQuestion:
"""Eine offene Frage."""
question: str
model_answer: str
keywords: List[str]
points: int = 1
@dataclass
class Quiz:
"""Ein komplettes Quiz."""
quiz_type: QuizType
title: str
questions: List[Any] # Je nach Typ unterschiedlich
topic: Optional[str] = None
difficulty: str = "medium"
class QuizGenerator:
"""
Generiert verschiedene Quiz-Typen aus Quelltexten.
@@ -146,13 +107,12 @@ class QuizGenerator:
return self._generate_true_false_llm(source_text, num_questions, difficulty)
# Automatische Generierung
sentences = self._extract_factual_sentences(source_text)
sentences = extract_factual_sentences(source_text)
questions = []
for i, sentence in enumerate(sentences[:num_questions]):
# Abwechselnd wahre und falsche Aussagen
if i % 2 == 0:
# Wahre Aussage
questions.append(TrueFalseQuestion(
statement=sentence,
is_true=True,
@@ -160,8 +120,7 @@ class QuizGenerator:
source_reference=sentence[:50]
))
else:
# Falsche Aussage (Negation)
false_statement = self._negate_sentence(sentence)
false_statement = negate_sentence(sentence)
questions.append(TrueFalseQuestion(
statement=false_statement,
is_true=False,
@@ -222,9 +181,8 @@ Antworte im JSON-Format:
if self.llm_client:
return self._generate_matching_llm(source_text, num_pairs, difficulty)
# Automatische Generierung: Begriff -> Definition
pairs = []
definitions = self._extract_definitions(source_text)
definitions = extract_definitions(source_text)
for term, definition in definitions[:num_pairs]:
pairs.append(MatchingPair(
@@ -286,9 +244,8 @@ Antworte im JSON-Format:
if self.llm_client:
return self._generate_sorting_llm(source_text, num_items, difficulty)
# Automatische Generierung: Chronologische Reihenfolge
items = []
steps = self._extract_sequence(source_text)
steps = extract_sequence(source_text)
for i, step in enumerate(steps[:num_items]):
items.append(SortingItem(
@@ -349,9 +306,8 @@ Antworte im JSON-Format:
if self.llm_client:
return self._generate_open_ended_llm(source_text, num_questions, difficulty)
# Automatische Generierung
questions = []
sentences = self._extract_factual_sentences(source_text)
sentences = extract_factual_sentences(source_text)
question_starters = [
"Was bedeutet",
@@ -362,8 +318,7 @@ Antworte im JSON-Format:
]
for i, sentence in enumerate(sentences[:num_questions]):
# Extrahiere Schlüsselwort
keywords = self._extract_keywords(sentence)
keywords = extract_keywords(sentence)
if keywords:
keyword = keywords[0]
starter = question_starters[i % len(question_starters)]
@@ -421,76 +376,6 @@ Antworte im JSON-Format:
logger.error(f"LLM error: {e}")
return self._generate_open_ended(source_text, num_questions, difficulty)
# Hilfsmethoden
def _extract_factual_sentences(self, text: str) -> List[str]:
"""Extrahiert Fakten-Sätze aus dem Text."""
sentences = re.split(r'[.!?]+', text)
factual = []
for sentence in sentences:
sentence = sentence.strip()
# Filtere zu kurze oder fragende Sätze
if len(sentence) > 20 and '?' not in sentence:
factual.append(sentence)
return factual
def _negate_sentence(self, sentence: str) -> str:
"""Negiert eine Aussage einfach."""
# Einfache Negation durch Einfügen von "nicht"
words = sentence.split()
if len(words) > 2:
# Nach erstem Verb "nicht" einfügen
for i, word in enumerate(words):
if word.endswith(('t', 'en', 'st')) and i > 0:
words.insert(i + 1, 'nicht')
break
return ' '.join(words)
def _extract_definitions(self, text: str) -> List[Tuple[str, str]]:
"""Extrahiert Begriff-Definition-Paare."""
definitions = []
# Suche nach Mustern wie "X ist Y" oder "X bezeichnet Y"
patterns = [
r'(\w+)\s+ist\s+(.+?)[.]',
r'(\w+)\s+bezeichnet\s+(.+?)[.]',
r'(\w+)\s+bedeutet\s+(.+?)[.]',
r'(\w+):\s+(.+?)[.]',
]
for pattern in patterns:
matches = re.findall(pattern, text)
for term, definition in matches:
if len(definition) > 10:
definitions.append((term, definition.strip()))
return definitions
def _extract_sequence(self, text: str) -> List[str]:
"""Extrahiert eine Sequenz von Schritten."""
steps = []
# Suche nach nummerierten Schritten
numbered = re.findall(r'\d+[.)]\s*([^.]+)', text)
steps.extend(numbered)
# Suche nach Signalwörtern
signal_words = ['zuerst', 'dann', 'danach', 'anschließend', 'schließlich']
for word in signal_words:
pattern = rf'{word}\s+([^.]+)'
matches = re.findall(pattern, text, re.IGNORECASE)
steps.extend(matches)
return steps
def _extract_keywords(self, text: str) -> List[str]:
"""Extrahiert Schlüsselwörter."""
# Längere Wörter mit Großbuchstaben (meist Substantive)
words = re.findall(r'\b[A-ZÄÖÜ][a-zäöüß]+\b', text)
return list(set(words))[:5]
def _empty_quiz(self, quiz_type: QuizType, title: str) -> Quiz:
"""Erstellt leeres Quiz bei Fehler."""
return Quiz(
@@ -549,7 +434,6 @@ Antworte im JSON-Format:
return self._true_false_to_h5p(quiz)
elif quiz.quiz_type == QuizType.MATCHING:
return self._matching_to_h5p(quiz)
# Weitere Typen...
return {}
def _true_false_to_h5p(self, quiz: Quiz) -> Dict[str, Any]:
+70
View File
@@ -0,0 +1,70 @@
"""
Quiz Helpers - Text-Verarbeitungs-Hilfsfunktionen fuer Quiz-Generierung.
"""
import re
from typing import List, Tuple
def extract_factual_sentences(text: str) -> List[str]:
"""Extrahiert Fakten-Sätze aus dem Text."""
sentences = re.split(r'[.!?]+', text)
factual = []
for sentence in sentences:
sentence = sentence.strip()
if len(sentence) > 20 and '?' not in sentence:
factual.append(sentence)
return factual
def negate_sentence(sentence: str) -> str:
"""Negiert eine Aussage einfach."""
words = sentence.split()
if len(words) > 2:
for i, word in enumerate(words):
if word.endswith(('t', 'en', 'st')) and i > 0:
words.insert(i + 1, 'nicht')
break
return ' '.join(words)
def extract_definitions(text: str) -> List[Tuple[str, str]]:
"""Extrahiert Begriff-Definition-Paare."""
definitions = []
patterns = [
r'(\w+)\s+ist\s+(.+?)[.]',
r'(\w+)\s+bezeichnet\s+(.+?)[.]',
r'(\w+)\s+bedeutet\s+(.+?)[.]',
r'(\w+):\s+(.+?)[.]',
]
for pattern in patterns:
matches = re.findall(pattern, text)
for term, definition in matches:
if len(definition) > 10:
definitions.append((term, definition.strip()))
return definitions
def extract_sequence(text: str) -> List[str]:
"""Extrahiert eine Sequenz von Schritten."""
steps = []
numbered = re.findall(r'\d+[.)]\s*([^.]+)', text)
steps.extend(numbered)
signal_words = ['zuerst', 'dann', 'danach', 'anschließend', 'schließlich']
for word in signal_words:
pattern = rf'{word}\s+([^.]+)'
matches = re.findall(pattern, text, re.IGNORECASE)
steps.extend(matches)
return steps
def extract_keywords(text: str) -> List[str]:
"""Extrahiert Schlüsselwörter."""
words = re.findall(r'\b[A-ZÄÖÜ][a-zäöüß]+\b', text)
return list(set(words))[:5]
+65
View File
@@ -0,0 +1,65 @@
"""
Quiz Models - Datenmodelle fuer Quiz-Generierung.
Enthaelt alle Dataclasses und Enums fuer Quiz-Typen:
- True/False Fragen
- Zuordnungsaufgaben (Matching)
- Sortieraufgaben
- Offene Fragen
"""
from typing import List, Any, Optional
from dataclasses import dataclass
from enum import Enum
class QuizType(str, Enum):
"""Typen von Quiz-Aufgaben."""
TRUE_FALSE = "true_false"
MATCHING = "matching"
SORTING = "sorting"
OPEN_ENDED = "open_ended"
@dataclass
class TrueFalseQuestion:
"""Eine Wahr/Falsch-Frage."""
statement: str
is_true: bool
explanation: str
source_reference: Optional[str] = None
@dataclass
class MatchingPair:
"""Ein Zuordnungspaar."""
left: str
right: str
hint: Optional[str] = None
@dataclass
class SortingItem:
"""Ein Element zum Sortieren."""
text: str
correct_position: int
category: Optional[str] = None
@dataclass
class OpenQuestion:
"""Eine offene Frage."""
question: str
model_answer: str
keywords: List[str]
points: int = 1
@dataclass
class Quiz:
"""Ein komplettes Quiz."""
quiz_type: QuizType
title: str
questions: List[Any] # Je nach Typ unterschiedlich
topic: Optional[str] = None
difficulty: str = "medium"
+21 -372
View File
@@ -9,378 +9,33 @@ Dieses Modul ermoeglicht:
import asyncio
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, Depends
from ..models.chat import ChatMessage
from ..middleware.auth import verify_api_key
from .comparison_models import (
ComparisonRequest,
LLMResponse,
ComparisonResponse,
SavedComparison,
_comparisons_store,
_system_prompts_store,
)
from .comparison_providers import (
call_openai,
call_claude,
search_tavily,
search_edusearch,
call_selfhosted_with_search,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/comparison", tags=["LLM Comparison"])
class ComparisonRequest(BaseModel):
"""Request fuer LLM-Vergleich."""
prompt: str = Field(..., description="User prompt (z.B. Lehrer-Frage)")
system_prompt: Optional[str] = Field(None, description="Optionaler System Prompt")
enable_openai: bool = Field(True, description="OpenAI/ChatGPT aktivieren")
enable_claude: bool = Field(True, description="Claude aktivieren")
enable_selfhosted_tavily: bool = Field(True, description="Self-hosted + Tavily aktivieren")
enable_selfhosted_edusearch: bool = Field(True, description="Self-hosted + EduSearch aktivieren")
# Parameter fuer Self-hosted Modelle
selfhosted_model: str = Field("llama3.2:3b", description="Self-hosted Modell")
temperature: float = Field(0.7, ge=0.0, le=2.0, description="Temperature")
top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p Sampling")
max_tokens: int = Field(2048, ge=1, le=8192, description="Max Tokens")
# Search Parameter
search_results_count: int = Field(5, ge=1, le=20, description="Anzahl Suchergebnisse")
edu_search_filters: Optional[dict] = Field(None, description="Filter fuer EduSearch")
class LLMResponse(BaseModel):
"""Antwort eines einzelnen LLM."""
provider: str
model: str
response: str
latency_ms: int
tokens_used: Optional[int] = None
search_results: Optional[list] = None
error: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
class ComparisonResponse(BaseModel):
"""Gesamt-Antwort des Vergleichs."""
comparison_id: str
prompt: str
system_prompt: Optional[str]
responses: list[LLMResponse]
created_at: datetime = Field(default_factory=datetime.utcnow)
class SavedComparison(BaseModel):
"""Gespeicherter Vergleich fuer QA."""
comparison_id: str
prompt: str
system_prompt: Optional[str]
responses: list[LLMResponse]
notes: Optional[str] = None
rating: Optional[dict] = None # {"openai": 4, "claude": 5, ...}
created_at: datetime
created_by: Optional[str] = None
# In-Memory Storage (in Production: Database)
_comparisons_store: dict[str, SavedComparison] = {}
_system_prompts_store: dict[str, dict] = {
"default": {
"id": "default",
"name": "Standard Lehrer-Assistent",
"prompt": """Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland.
Deine Aufgaben:
- Hilfe bei der Unterrichtsplanung
- Erklaerung von Fachinhalten
- Erstellung von Arbeitsblaettern und Pruefungen
- Beratung zu paedagogischen Methoden
Antworte immer auf Deutsch und beachte den deutschen Lehrplankontext.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
"curriculum": {
"id": "curriculum",
"name": "Lehrplan-Experte",
"prompt": """Du bist ein Experte fuer deutsche Lehrplaene und Bildungsstandards.
Du kennst:
- Lehrplaene aller 16 Bundeslaender
- KMK Bildungsstandards
- Kompetenzorientierung im deutschen Bildungssystem
Beziehe dich immer auf konkrete Lehrplanvorgaben wenn moeglich.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
"worksheet": {
"id": "worksheet",
"name": "Arbeitsblatt-Generator",
"prompt": """Du bist ein spezialisierter Assistent fuer die Erstellung von Arbeitsblaettern.
Erstelle didaktisch sinnvolle Aufgaben mit:
- Klaren Arbeitsanweisungen
- Differenzierungsmoeglichkeiten
- Loesungshinweisen
Format: Markdown mit klarer Struktur.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
}
async def _call_openai(prompt: str, system_prompt: Optional[str]) -> LLMResponse:
"""Ruft OpenAI ChatGPT auf."""
import os
import httpx
start_time = time.time()
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response="",
latency_ms=0,
error="OPENAI_API_KEY nicht konfiguriert"
)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "gpt-4o-mini",
"messages": messages,
"temperature": 0.7,
"max_tokens": 2048,
},
)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
content = data["choices"][0]["message"]["content"]
tokens = data.get("usage", {}).get("total_tokens")
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
)
except Exception as e:
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
)
async def _call_claude(prompt: str, system_prompt: Optional[str]) -> LLMResponse:
"""Ruft Anthropic Claude auf."""
import os
start_time = time.time()
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response="",
latency_ms=0,
error="ANTHROPIC_API_KEY nicht konfiguriert"
)
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=api_key)
response = await client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=2048,
system=system_prompt or "",
messages=[{"role": "user", "content": prompt}],
)
latency_ms = int((time.time() - start_time) * 1000)
content = response.content[0].text if response.content else ""
tokens = response.usage.input_tokens + response.usage.output_tokens
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
)
except Exception as e:
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
)
async def _search_tavily(query: str, count: int = 5) -> list[dict]:
"""Sucht mit Tavily API."""
import os
import httpx
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
return []
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://api.tavily.com/search",
json={
"api_key": api_key,
"query": query,
"max_results": count,
"include_domains": [
"kmk.org", "bildungsserver.de", "bpb.de",
"bayern.de", "nrw.de", "berlin.de",
],
},
)
response.raise_for_status()
data = response.json()
return data.get("results", [])
except Exception as e:
logger.error(f"Tavily search error: {e}")
return []
async def _search_edusearch(query: str, count: int = 5, filters: Optional[dict] = None) -> list[dict]:
"""Sucht mit EduSearch API."""
import os
import httpx
edu_search_url = os.getenv("EDU_SEARCH_URL", "http://edu-search-service:8084")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
payload = {
"q": query,
"limit": count,
"mode": "keyword",
}
if filters:
payload["filters"] = filters
response = await client.post(
f"{edu_search_url}/v1/search",
json=payload,
)
response.raise_for_status()
data = response.json()
# Formatiere Ergebnisse
results = []
for r in data.get("results", []):
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"content": r.get("snippet", ""),
"score": r.get("scores", {}).get("final", 0),
})
return results
except Exception as e:
logger.error(f"EduSearch error: {e}")
return []
async def _call_selfhosted_with_search(
prompt: str,
system_prompt: Optional[str],
search_provider: str,
search_results: list[dict],
model: str,
temperature: float,
top_p: float,
max_tokens: int,
) -> LLMResponse:
"""Ruft Self-hosted LLM mit Suchergebnissen auf."""
import os
import httpx
start_time = time.time()
ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434")
# Baue Kontext aus Suchergebnissen
context_parts = []
for i, result in enumerate(search_results, 1):
context_parts.append(f"[{i}] {result.get('title', 'Untitled')}")
context_parts.append(f" URL: {result.get('url', '')}")
context_parts.append(f" {result.get('content', '')[:500]}")
context_parts.append("")
search_context = "\n".join(context_parts)
# Erweitere System Prompt mit Suchergebnissen
augmented_system = f"""{system_prompt or ''}
Du hast Zugriff auf folgende Suchergebnisse aus {"Tavily" if search_provider == "tavily" else "EduSearch (deutsche Bildungsquellen)"}:
{search_context}
Nutze diese Quellen um deine Antwort zu unterstuetzen. Zitiere relevante Quellen mit [Nummer]."""
messages = [
{"role": "system", "content": augmented_system},
{"role": "user", "content": prompt},
]
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{ollama_url}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"top_p": top_p,
"num_predict": max_tokens,
},
},
)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
content = data.get("message", {}).get("content", "")
tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
return LLMResponse(
provider=f"selfhosted_{search_provider}",
model=model,
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
search_results=search_results,
)
except Exception as e:
return LLMResponse(
provider=f"selfhosted_{search_provider}",
model=model,
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
search_results=search_results,
)
@router.post("/run", response_model=ComparisonResponse)
async def run_comparison(
request: ComparisonRequest,
@@ -395,23 +50,19 @@ async def run_comparison(
comparison_id = f"cmp-{uuid.uuid4().hex[:12]}"
tasks = []
# System Prompt vorbereiten
system_prompt = request.system_prompt
# OpenAI
if request.enable_openai:
tasks.append(("openai", _call_openai(request.prompt, system_prompt)))
tasks.append(("openai", call_openai(request.prompt, system_prompt)))
# Claude
if request.enable_claude:
tasks.append(("claude", _call_claude(request.prompt, system_prompt)))
tasks.append(("claude", call_claude(request.prompt, system_prompt)))
# Self-hosted + Tavily
if request.enable_selfhosted_tavily:
tavily_results = await _search_tavily(request.prompt, request.search_results_count)
tavily_results = await search_tavily(request.prompt, request.search_results_count)
tasks.append((
"selfhosted_tavily",
_call_selfhosted_with_search(
call_selfhosted_with_search(
request.prompt,
system_prompt,
"tavily",
@@ -423,16 +74,15 @@ async def run_comparison(
)
))
# Self-hosted + EduSearch
if request.enable_selfhosted_edusearch:
edu_results = await _search_edusearch(
edu_results = await search_edusearch(
request.prompt,
request.search_results_count,
request.edu_search_filters,
)
tasks.append((
"selfhosted_edusearch",
_call_selfhosted_with_search(
call_selfhosted_with_search(
request.prompt,
system_prompt,
"edusearch",
@@ -444,7 +94,6 @@ async def run_comparison(
)
))
# Parallele Ausfuehrung
responses = []
if tasks:
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
@@ -0,0 +1,103 @@
"""
LLM Comparison - Pydantic Models und In-Memory Storage.
"""
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field
class ComparisonRequest(BaseModel):
"""Request fuer LLM-Vergleich."""
prompt: str = Field(..., description="User prompt (z.B. Lehrer-Frage)")
system_prompt: Optional[str] = Field(None, description="Optionaler System Prompt")
enable_openai: bool = Field(True, description="OpenAI/ChatGPT aktivieren")
enable_claude: bool = Field(True, description="Claude aktivieren")
enable_selfhosted_tavily: bool = Field(True, description="Self-hosted + Tavily aktivieren")
enable_selfhosted_edusearch: bool = Field(True, description="Self-hosted + EduSearch aktivieren")
# Parameter fuer Self-hosted Modelle
selfhosted_model: str = Field("llama3.2:3b", description="Self-hosted Modell")
temperature: float = Field(0.7, ge=0.0, le=2.0, description="Temperature")
top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p Sampling")
max_tokens: int = Field(2048, ge=1, le=8192, description="Max Tokens")
# Search Parameter
search_results_count: int = Field(5, ge=1, le=20, description="Anzahl Suchergebnisse")
edu_search_filters: Optional[dict] = Field(None, description="Filter fuer EduSearch")
class LLMResponse(BaseModel):
"""Antwort eines einzelnen LLM."""
provider: str
model: str
response: str
latency_ms: int
tokens_used: Optional[int] = None
search_results: Optional[list] = None
error: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
class ComparisonResponse(BaseModel):
"""Gesamt-Antwort des Vergleichs."""
comparison_id: str
prompt: str
system_prompt: Optional[str]
responses: list[LLMResponse]
created_at: datetime = Field(default_factory=datetime.utcnow)
class SavedComparison(BaseModel):
"""Gespeicherter Vergleich fuer QA."""
comparison_id: str
prompt: str
system_prompt: Optional[str]
responses: list[LLMResponse]
notes: Optional[str] = None
rating: Optional[dict] = None # {"openai": 4, "claude": 5, ...}
created_at: datetime
created_by: Optional[str] = None
# In-Memory Storage (in Production: Database)
_comparisons_store: dict[str, SavedComparison] = {}
_system_prompts_store: dict[str, dict] = {
"default": {
"id": "default",
"name": "Standard Lehrer-Assistent",
"prompt": """Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland.
Deine Aufgaben:
- Hilfe bei der Unterrichtsplanung
- Erklaerung von Fachinhalten
- Erstellung von Arbeitsblaettern und Pruefungen
- Beratung zu paedagogischen Methoden
Antworte immer auf Deutsch und beachte den deutschen Lehrplankontext.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
"curriculum": {
"id": "curriculum",
"name": "Lehrplan-Experte",
"prompt": """Du bist ein Experte fuer deutsche Lehrplaene und Bildungsstandards.
Du kennst:
- Lehrplaene aller 16 Bundeslaender
- KMK Bildungsstandards
- Kompetenzorientierung im deutschen Bildungssystem
Beziehe dich immer auf konkrete Lehrplanvorgaben wenn moeglich.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
"worksheet": {
"id": "worksheet",
"name": "Arbeitsblatt-Generator",
"prompt": """Du bist ein spezialisierter Assistent fuer die Erstellung von Arbeitsblaettern.
Erstelle didaktisch sinnvolle Aufgaben mit:
- Klaren Arbeitsanweisungen
- Differenzierungsmoeglichkeiten
- Loesungshinweisen
Format: Markdown mit klarer Struktur.""",
"created_at": datetime.now(timezone.utc).isoformat(),
},
}
@@ -0,0 +1,270 @@
"""
LLM Comparison - Provider-Aufrufe (OpenAI, Claude, Self-hosted, Search).
"""
import logging
import time
from typing import Optional
from .comparison_models import LLMResponse
logger = logging.getLogger(__name__)
async def call_openai(prompt: str, system_prompt: Optional[str]) -> LLMResponse:
"""Ruft OpenAI ChatGPT auf."""
import os
import httpx
start_time = time.time()
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response="",
latency_ms=0,
error="OPENAI_API_KEY nicht konfiguriert"
)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "gpt-4o-mini",
"messages": messages,
"temperature": 0.7,
"max_tokens": 2048,
},
)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
content = data["choices"][0]["message"]["content"]
tokens = data.get("usage", {}).get("total_tokens")
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
)
except Exception as e:
return LLMResponse(
provider="openai",
model="gpt-4o-mini",
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
)
async def call_claude(prompt: str, system_prompt: Optional[str]) -> LLMResponse:
"""Ruft Anthropic Claude auf."""
import os
start_time = time.time()
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response="",
latency_ms=0,
error="ANTHROPIC_API_KEY nicht konfiguriert"
)
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=api_key)
response = await client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=2048,
system=system_prompt or "",
messages=[{"role": "user", "content": prompt}],
)
latency_ms = int((time.time() - start_time) * 1000)
content = response.content[0].text if response.content else ""
tokens = response.usage.input_tokens + response.usage.output_tokens
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
)
except Exception as e:
return LLMResponse(
provider="claude",
model="claude-3-5-sonnet-20241022",
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
)
async def search_tavily(query: str, count: int = 5) -> list[dict]:
"""Sucht mit Tavily API."""
import os
import httpx
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
return []
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://api.tavily.com/search",
json={
"api_key": api_key,
"query": query,
"max_results": count,
"include_domains": [
"kmk.org", "bildungsserver.de", "bpb.de",
"bayern.de", "nrw.de", "berlin.de",
],
},
)
response.raise_for_status()
data = response.json()
return data.get("results", [])
except Exception as e:
logger.error(f"Tavily search error: {e}")
return []
async def search_edusearch(query: str, count: int = 5, filters: Optional[dict] = None) -> list[dict]:
"""Sucht mit EduSearch API."""
import os
import httpx
edu_search_url = os.getenv("EDU_SEARCH_URL", "http://edu-search-service:8084")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
payload = {
"q": query,
"limit": count,
"mode": "keyword",
}
if filters:
payload["filters"] = filters
response = await client.post(
f"{edu_search_url}/v1/search",
json=payload,
)
response.raise_for_status()
data = response.json()
results = []
for r in data.get("results", []):
results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"content": r.get("snippet", ""),
"score": r.get("scores", {}).get("final", 0),
})
return results
except Exception as e:
logger.error(f"EduSearch error: {e}")
return []
async def call_selfhosted_with_search(
prompt: str,
system_prompt: Optional[str],
search_provider: str,
search_results: list[dict],
model: str,
temperature: float,
top_p: float,
max_tokens: int,
) -> LLMResponse:
"""Ruft Self-hosted LLM mit Suchergebnissen auf."""
import os
import httpx
start_time = time.time()
ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434")
# Baue Kontext aus Suchergebnissen
context_parts = []
for i, result in enumerate(search_results, 1):
context_parts.append(f"[{i}] {result.get('title', 'Untitled')}")
context_parts.append(f" URL: {result.get('url', '')}")
context_parts.append(f" {result.get('content', '')[:500]}")
context_parts.append("")
search_context = "\n".join(context_parts)
augmented_system = f"""{system_prompt or ''}
Du hast Zugriff auf folgende Suchergebnisse aus {"Tavily" if search_provider == "tavily" else "EduSearch (deutsche Bildungsquellen)"}:
{search_context}
Nutze diese Quellen um deine Antwort zu unterstuetzen. Zitiere relevante Quellen mit [Nummer]."""
messages = [
{"role": "system", "content": augmented_system},
{"role": "user", "content": prompt},
]
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{ollama_url}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"top_p": top_p,
"num_predict": max_tokens,
},
},
)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
content = data.get("message", {}).get("content", "")
tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
return LLMResponse(
provider=f"selfhosted_{search_provider}",
model=model,
response=content,
latency_ms=latency_ms,
tokens_used=tokens,
search_results=search_results,
)
except Exception as e:
return LLMResponse(
provider=f"selfhosted_{search_provider}",
model=model,
response="",
latency_ms=int((time.time() - start_time) * 1000),
error=str(e),
search_results=search_results,
)
+27 -387
View File
@@ -8,10 +8,8 @@ Unterstützt:
"""
import httpx
import json
import logging
from typing import AsyncIterator, Optional
from dataclasses import dataclass
from ..config import get_config, LLMBackendConfig
from ..models.chat import (
@@ -20,26 +18,23 @@ from ..models.chat import (
ChatCompletionChunk,
ChatMessage,
ChatChoice,
StreamChoice,
ChatChoiceDelta,
Usage,
ModelInfo,
ModelListResponse,
)
from .inference_backends import (
InferenceResult,
call_ollama,
stream_ollama,
call_openai_compatible,
stream_openai_compatible,
call_anthropic,
stream_anthropic,
)
logger = logging.getLogger(__name__)
@dataclass
class InferenceResult:
"""Ergebnis einer Inference-Anfrage."""
content: str
model: str
backend: str
usage: Optional[Usage] = None
finish_reason: str = "stop"
class InferenceService:
"""Service für LLM Inference über verschiedene Backends."""
@@ -68,26 +63,17 @@ class InferenceService:
return None
def _map_model_to_backend(self, model: str) -> tuple[str, LLMBackendConfig]:
"""
Mapped ein Modell-Name zum entsprechenden Backend.
Beispiele:
- "breakpilot-teacher-8b" Ollama/vLLM mit llama3.1:8b
- "claude-3-5-sonnet" Anthropic
"""
"""Mapped ein Modell-Name zum entsprechenden Backend."""
model_lower = model.lower()
# Explizite Claude-Modelle → Anthropic
if "claude" in model_lower:
if self.config.anthropic and self.config.anthropic.enabled:
return self.config.anthropic.default_model, self.config.anthropic
raise ValueError("Anthropic backend not configured")
# BreakPilot Modelle → primäres Backend
if "breakpilot" in model_lower or "teacher" in model_lower:
backend = self._get_available_backend()
if backend:
# Map zu tatsächlichem Modell-Namen
if "70b" in model_lower:
actual_model = "llama3.1:70b" if backend.name == "ollama" else "meta-llama/Meta-Llama-3.1-70B-Instruct"
else:
@@ -95,7 +81,6 @@ class InferenceService:
return actual_model, backend
raise ValueError("No LLM backend available")
# Mistral Modelle
if "mistral" in model_lower:
backend = self._get_available_backend()
if backend:
@@ -103,409 +88,64 @@ class InferenceService:
return actual_model, backend
raise ValueError("No LLM backend available")
# Fallback: verwende Modell-Name direkt
backend = self._get_available_backend()
if backend:
return model, backend
raise ValueError("No LLM backend available")
async def _call_ollama(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
) -> InferenceResult:
"""Ruft Ollama API auf (nicht OpenAI-kompatibel)."""
client = await self.get_client()
# Ollama verwendet eigenes Format
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": request.temperature,
"top_p": request.top_p,
},
}
if request.max_tokens:
payload["options"]["num_predict"] = request.max_tokens
response = await client.post(
f"{backend.base_url}/api/chat",
json=payload,
timeout=backend.timeout,
)
response.raise_for_status()
data = response.json()
return InferenceResult(
content=data.get("message", {}).get("content", ""),
model=model,
backend="ollama",
usage=Usage(
prompt_tokens=data.get("prompt_eval_count", 0),
completion_tokens=data.get("eval_count", 0),
total_tokens=data.get("prompt_eval_count", 0) + data.get("eval_count", 0),
),
finish_reason="stop" if data.get("done") else "length",
)
async def _stream_ollama(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
response_id: str,
) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von Ollama."""
client = await self.get_client()
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
payload = {
"model": model,
"messages": messages,
"stream": True,
"options": {
"temperature": request.temperature,
"top_p": request.top_p,
},
}
if request.max_tokens:
payload["options"]["num_predict"] = request.max_tokens
async with client.stream(
"POST",
f"{backend.base_url}/api/chat",
json=payload,
timeout=backend.timeout,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
try:
data = json.loads(line)
content = data.get("message", {}).get("content", "")
done = data.get("done", False)
yield ChatCompletionChunk(
id=response_id,
model=model,
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(content=content),
finish_reason="stop" if done else None,
)
],
)
except json.JSONDecodeError:
continue
async def _call_openai_compatible(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
) -> InferenceResult:
"""Ruft OpenAI-kompatible API auf (vLLM, etc.)."""
client = await self.get_client()
headers = {"Content-Type": "application/json"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
payload = {
"model": model,
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
"stream": False,
"temperature": request.temperature,
"top_p": request.top_p,
}
if request.max_tokens:
payload["max_tokens"] = request.max_tokens
if request.stop:
payload["stop"] = request.stop
response = await client.post(
f"{backend.base_url}/v1/chat/completions",
json=payload,
headers=headers,
timeout=backend.timeout,
)
response.raise_for_status()
data = response.json()
choice = data.get("choices", [{}])[0]
usage_data = data.get("usage", {})
return InferenceResult(
content=choice.get("message", {}).get("content", ""),
model=model,
backend=backend.name,
usage=Usage(
prompt_tokens=usage_data.get("prompt_tokens", 0),
completion_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
),
finish_reason=choice.get("finish_reason", "stop"),
)
async def _stream_openai_compatible(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
response_id: str,
) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von OpenAI-kompatibler API."""
client = await self.get_client()
headers = {"Content-Type": "application/json"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
payload = {
"model": model,
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
"stream": True,
"temperature": request.temperature,
"top_p": request.top_p,
}
if request.max_tokens:
payload["max_tokens"] = request.max_tokens
async with client.stream(
"POST",
f"{backend.base_url}/v1/chat/completions",
json=payload,
headers=headers,
timeout=backend.timeout,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data: "):
continue
data_str = line[6:] # Remove "data: " prefix
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
choice = data.get("choices", [{}])[0]
delta = choice.get("delta", {})
yield ChatCompletionChunk(
id=response_id,
model=model,
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(
role=delta.get("role"),
content=delta.get("content"),
),
finish_reason=choice.get("finish_reason"),
)
],
)
except json.JSONDecodeError:
continue
async def _call_anthropic(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
) -> InferenceResult:
"""Ruft Anthropic Claude API auf."""
# Anthropic SDK verwenden (bereits installiert)
try:
import anthropic
except ImportError:
raise ImportError("anthropic package required for Claude API")
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
# System message extrahieren
system_content = ""
messages = []
for msg in request.messages:
if msg.role == "system":
system_content += (msg.content or "") + "\n"
else:
messages.append({"role": msg.role, "content": msg.content or ""})
response = await client.messages.create(
model=model,
max_tokens=request.max_tokens or 4096,
system=system_content.strip() if system_content else None,
messages=messages,
temperature=request.temperature,
top_p=request.top_p,
)
content = ""
if response.content:
content = response.content[0].text if response.content[0].type == "text" else ""
return InferenceResult(
content=content,
model=model,
backend="anthropic",
usage=Usage(
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
),
finish_reason="stop" if response.stop_reason == "end_turn" else response.stop_reason or "stop",
)
async def _stream_anthropic(
self,
backend: LLMBackendConfig,
model: str,
request: ChatCompletionRequest,
response_id: str,
) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von Anthropic Claude API."""
try:
import anthropic
except ImportError:
raise ImportError("anthropic package required for Claude API")
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
# System message extrahieren
system_content = ""
messages = []
for msg in request.messages:
if msg.role == "system":
system_content += (msg.content or "") + "\n"
else:
messages.append({"role": msg.role, "content": msg.content or ""})
async with client.messages.stream(
model=model,
max_tokens=request.max_tokens or 4096,
system=system_content.strip() if system_content else None,
messages=messages,
temperature=request.temperature,
top_p=request.top_p,
) as stream:
async for text in stream.text_stream:
yield ChatCompletionChunk(
id=response_id,
model=model,
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(content=text),
finish_reason=None,
)
],
)
# Final chunk with finish_reason
yield ChatCompletionChunk(
id=response_id,
model=model,
choices=[
StreamChoice(
index=0,
delta=ChatChoiceDelta(),
finish_reason="stop",
)
],
)
async def complete(self, request: ChatCompletionRequest) -> ChatCompletionResponse:
"""
Führt Chat Completion durch (non-streaming).
"""
"""Führt Chat Completion durch (non-streaming)."""
actual_model, backend = self._map_model_to_backend(request.model)
logger.info(f"Inference request: model={request.model} -> {actual_model} via {backend.name}")
logger.info(f"Inference request: model={request.model}{actual_model} via {backend.name}")
client = await self.get_client()
if backend.name == "ollama":
result = await self._call_ollama(backend, actual_model, request)
result = await call_ollama(client, backend, actual_model, request)
elif backend.name == "anthropic":
result = await self._call_anthropic(backend, actual_model, request)
result = await call_anthropic(backend, actual_model, request)
else:
result = await self._call_openai_compatible(backend, actual_model, request)
result = await call_openai_compatible(client, backend, actual_model, request)
return ChatCompletionResponse(
model=request.model, # Original requested model name
choices=[
ChatChoice(
index=0,
message=ChatMessage(role="assistant", content=result.content),
finish_reason=result.finish_reason,
)
],
model=request.model,
choices=[ChatChoice(index=0, message=ChatMessage(role="assistant", content=result.content), finish_reason=result.finish_reason)],
usage=result.usage,
)
async def stream(self, request: ChatCompletionRequest) -> AsyncIterator[ChatCompletionChunk]:
"""
Führt Chat Completion mit Streaming durch.
"""
"""Führt Chat Completion mit Streaming durch."""
import uuid
response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
actual_model, backend = self._map_model_to_backend(request.model)
logger.info(f"Streaming request: model={request.model} -> {actual_model} via {backend.name}")
logger.info(f"Streaming request: model={request.model}{actual_model} via {backend.name}")
client = await self.get_client()
if backend.name == "ollama":
async for chunk in self._stream_ollama(backend, actual_model, request, response_id):
async for chunk in stream_ollama(client, backend, actual_model, request, response_id):
yield chunk
elif backend.name == "anthropic":
async for chunk in self._stream_anthropic(backend, actual_model, request, response_id):
async for chunk in stream_anthropic(backend, actual_model, request, response_id):
yield chunk
else:
async for chunk in self._stream_openai_compatible(backend, actual_model, request, response_id):
async for chunk in stream_openai_compatible(client, backend, actual_model, request, response_id):
yield chunk
async def list_models(self) -> ModelListResponse:
"""Listet verfügbare Modelle."""
models = []
# BreakPilot Modelle (mapped zu verfügbaren Backends)
backend = self._get_available_backend()
if backend:
models.extend([
ModelInfo(
id="breakpilot-teacher-8b",
owned_by="breakpilot",
description="Llama 3.1 8B optimiert für Schulkontext",
context_length=8192,
),
ModelInfo(
id="breakpilot-teacher-70b",
owned_by="breakpilot",
description="Llama 3.1 70B für komplexe Aufgaben",
context_length=8192,
),
ModelInfo(id="breakpilot-teacher-8b", owned_by="breakpilot", description="Llama 3.1 8B optimiert für Schulkontext", context_length=8192),
ModelInfo(id="breakpilot-teacher-70b", owned_by="breakpilot", description="Llama 3.1 70B für komplexe Aufgaben", context_length=8192),
])
# Claude Modelle (wenn Anthropic konfiguriert)
if self.config.anthropic and self.config.anthropic.enabled:
models.append(
ModelInfo(
id="claude-3-5-sonnet",
owned_by="anthropic",
description="Claude 3.5 Sonnet - Fallback für höchste Qualität",
context_length=200000,
)
)
models.append(ModelInfo(id="claude-3-5-sonnet", owned_by="anthropic", description="Claude 3.5 Sonnet - Fallback für höchste Qualität", context_length=200000))
return ModelListResponse(data=models)
@@ -0,0 +1,230 @@
"""
Inference Backends - Kommunikation mit einzelnen LLM-Providern.
Unterstützt Ollama, OpenAI-kompatible APIs und Anthropic Claude.
"""
import json
import logging
from typing import AsyncIterator, Optional
from dataclasses import dataclass
from ..config import LLMBackendConfig
from ..models.chat import (
ChatCompletionRequest,
ChatCompletionChunk,
ChatMessage,
StreamChoice,
ChatChoiceDelta,
Usage,
)
logger = logging.getLogger(__name__)
@dataclass
class InferenceResult:
"""Ergebnis einer Inference-Anfrage."""
content: str
model: str
backend: str
usage: Optional[Usage] = None
finish_reason: str = "stop"
async def call_ollama(client, backend: LLMBackendConfig, model: str, request: ChatCompletionRequest) -> InferenceResult:
"""Ruft Ollama API auf (nicht OpenAI-kompatibel)."""
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": request.temperature, "top_p": request.top_p},
}
if request.max_tokens:
payload["options"]["num_predict"] = request.max_tokens
response = await client.post(f"{backend.base_url}/api/chat", json=payload, timeout=backend.timeout)
response.raise_for_status()
data = response.json()
return InferenceResult(
content=data.get("message", {}).get("content", ""),
model=model, backend="ollama",
usage=Usage(
prompt_tokens=data.get("prompt_eval_count", 0),
completion_tokens=data.get("eval_count", 0),
total_tokens=data.get("prompt_eval_count", 0) + data.get("eval_count", 0),
),
finish_reason="stop" if data.get("done") else "length",
)
async def stream_ollama(client, backend, model, request, response_id) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von Ollama."""
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
payload = {
"model": model, "messages": messages, "stream": True,
"options": {"temperature": request.temperature, "top_p": request.top_p},
}
if request.max_tokens:
payload["options"]["num_predict"] = request.max_tokens
async with client.stream("POST", f"{backend.base_url}/api/chat", json=payload, timeout=backend.timeout) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
try:
data = json.loads(line)
content = data.get("message", {}).get("content", "")
done = data.get("done", False)
yield ChatCompletionChunk(
id=response_id, model=model,
choices=[StreamChoice(index=0, delta=ChatChoiceDelta(content=content), finish_reason="stop" if done else None)],
)
except json.JSONDecodeError:
continue
async def call_openai_compatible(client, backend, model, request) -> InferenceResult:
"""Ruft OpenAI-kompatible API auf (vLLM, etc.)."""
headers = {"Content-Type": "application/json"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
payload = {
"model": model,
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
"stream": False, "temperature": request.temperature, "top_p": request.top_p,
}
if request.max_tokens:
payload["max_tokens"] = request.max_tokens
if request.stop:
payload["stop"] = request.stop
response = await client.post(f"{backend.base_url}/v1/chat/completions", json=payload, headers=headers, timeout=backend.timeout)
response.raise_for_status()
data = response.json()
choice = data.get("choices", [{}])[0]
usage_data = data.get("usage", {})
return InferenceResult(
content=choice.get("message", {}).get("content", ""),
model=model, backend=backend.name,
usage=Usage(
prompt_tokens=usage_data.get("prompt_tokens", 0),
completion_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
),
finish_reason=choice.get("finish_reason", "stop"),
)
async def stream_openai_compatible(client, backend, model, request, response_id) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von OpenAI-kompatibler API."""
headers = {"Content-Type": "application/json"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
payload = {
"model": model,
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
"stream": True, "temperature": request.temperature, "top_p": request.top_p,
}
if request.max_tokens:
payload["max_tokens"] = request.max_tokens
async with client.stream("POST", f"{backend.base_url}/v1/chat/completions", json=payload, headers=headers, timeout=backend.timeout) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data: "):
continue
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
choice = data.get("choices", [{}])[0]
delta = choice.get("delta", {})
yield ChatCompletionChunk(
id=response_id, model=model,
choices=[StreamChoice(index=0, delta=ChatChoiceDelta(role=delta.get("role"), content=delta.get("content")), finish_reason=choice.get("finish_reason"))],
)
except json.JSONDecodeError:
continue
async def call_anthropic(backend, model, request) -> InferenceResult:
"""Ruft Anthropic Claude API auf."""
try:
import anthropic
except ImportError:
raise ImportError("anthropic package required for Claude API")
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
system_content = ""
messages = []
for msg in request.messages:
if msg.role == "system":
system_content += (msg.content or "") + "\n"
else:
messages.append({"role": msg.role, "content": msg.content or ""})
response = await client.messages.create(
model=model, max_tokens=request.max_tokens or 4096,
system=system_content.strip() if system_content else None,
messages=messages, temperature=request.temperature, top_p=request.top_p,
)
content = ""
if response.content:
content = response.content[0].text if response.content[0].type == "text" else ""
return InferenceResult(
content=content, model=model, backend="anthropic",
usage=Usage(
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
),
finish_reason="stop" if response.stop_reason == "end_turn" else response.stop_reason or "stop",
)
async def stream_anthropic(backend, model, request, response_id) -> AsyncIterator[ChatCompletionChunk]:
"""Streamt von Anthropic Claude API."""
try:
import anthropic
except ImportError:
raise ImportError("anthropic package required for Claude API")
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
system_content = ""
messages = []
for msg in request.messages:
if msg.role == "system":
system_content += (msg.content or "") + "\n"
else:
messages.append({"role": msg.role, "content": msg.content or ""})
async with client.messages.stream(
model=model, max_tokens=request.max_tokens or 4096,
system=system_content.strip() if system_content else None,
messages=messages, temperature=request.temperature, top_p=request.top_p,
) as stream:
async for text in stream.text_stream:
yield ChatCompletionChunk(
id=response_id, model=model,
choices=[StreamChoice(index=0, delta=ChatChoiceDelta(content=text), finish_reason=None)],
)
yield ChatCompletionChunk(
id=response_id, model=model,
choices=[StreamChoice(index=0, delta=ChatChoiceDelta(), finish_reason="stop")],
)
+55 -271
View File
@@ -15,60 +15,24 @@ Verwendet:
"""
import logging
import os
import io
import base64
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Union
from dataclasses import dataclass
from enum import Enum
from typing import Optional, List, Dict, Any
import cv2
import numpy as np
from PIL import Image
from .file_processor_models import (
FileType,
ProcessingMode,
ProcessedRegion,
ProcessingResult,
)
logger = logging.getLogger(__name__)
class FileType(str, Enum):
"""Unterstützte Dateitypen."""
PDF = "pdf"
IMAGE = "image"
DOCX = "docx"
DOC = "doc"
TXT = "txt"
UNKNOWN = "unknown"
class ProcessingMode(str, Enum):
"""Verarbeitungsmodi."""
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
OCR_PRINTED = "ocr_printed" # Gedruckter Text
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
MIXED = "mixed" # Kombiniert OCR + Textextraktion
@dataclass
class ProcessedRegion:
"""Ein erkannter Textbereich."""
text: str
confidence: float
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
page: int = 1
@dataclass
class ProcessingResult:
"""Ergebnis der Dokumentenverarbeitung."""
text: str
confidence: float
regions: List[ProcessedRegion]
page_count: int
file_type: FileType
processing_mode: ProcessingMode
metadata: Dict[str, Any]
class FileProcessor:
"""
Zentrale Dokumentenverarbeitung für BreakPilot.
@@ -81,17 +45,9 @@ class FileProcessor:
"""
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
"""
Initialisiert den File Processor.
Args:
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
"""
self.ocr_lang = ocr_lang
self.use_gpu = use_gpu
self._ocr_engine = None
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
@property
@@ -107,7 +63,7 @@ class FileProcessor:
from paddleocr import PaddleOCR
return PaddleOCR(
use_angle_cls=True,
lang='german', # Deutsch
lang='german',
use_gpu=self.use_gpu,
show_log=False
)
@@ -116,16 +72,7 @@ class FileProcessor:
return None
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
"""
Erkennt den Dateityp.
Args:
file_path: Pfad zur Datei
file_bytes: Dateiinhalt als Bytes
Returns:
FileType enum
"""
"""Erkennt den Dateityp."""
if file_path:
ext = Path(file_path).suffix.lower()
if ext == ".pdf":
@@ -140,14 +87,13 @@ class FileProcessor:
return FileType.TXT
if file_bytes:
# Magic number detection
if file_bytes[:4] == b'%PDF':
return FileType.PDF
elif file_bytes[:8] == b'\x89PNG\r\n\x1a\n':
return FileType.IMAGE
elif file_bytes[:2] in [b'\xff\xd8', b'BM']: # JPEG, BMP
elif file_bytes[:2] in [b'\xff\xd8', b'BM']:
return FileType.IMAGE
elif file_bytes[:4] == b'PK\x03\x04': # ZIP (DOCX)
elif file_bytes[:4] == b'PK\x03\x04':
return FileType.DOCX
return FileType.UNKNOWN
@@ -158,17 +104,7 @@ class FileProcessor:
file_bytes: bytes = None,
mode: ProcessingMode = ProcessingMode.MIXED
) -> ProcessingResult:
"""
Verarbeitet ein Dokument.
Args:
file_path: Pfad zur Datei
file_bytes: Dateiinhalt als Bytes
mode: Verarbeitungsmodus
Returns:
ProcessingResult mit extrahiertem Text und Metadaten
"""
"""Verarbeitet ein Dokument."""
if not file_path and not file_bytes:
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
@@ -186,18 +122,12 @@ class FileProcessor:
else:
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
def _process_pdf(
self,
file_path: str = None,
file_bytes: bytes = None,
mode: ProcessingMode = ProcessingMode.MIXED
) -> ProcessingResult:
def _process_pdf(self, file_path=None, file_bytes=None, mode=ProcessingMode.MIXED):
"""Verarbeitet PDF-Dateien."""
try:
import fitz # PyMuPDF
import fitz
except ImportError:
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
# Fallback: PDF als Bild behandeln
return self._process_image(file_path, file_bytes, mode)
if file_bytes:
@@ -205,35 +135,27 @@ class FileProcessor:
else:
doc = fitz.open(file_path)
all_text = []
all_regions = []
total_confidence = 0.0
region_count = 0
all_text, all_regions = [], []
total_confidence, region_count = 0.0, 0
for page_num, page in enumerate(doc, start=1):
# Erst versuchen Text direkt zu extrahieren
page_text = page.get_text()
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
# PDF enthält Text (nicht nur Bilder)
all_text.append(page_text)
all_regions.append(ProcessedRegion(
text=page_text,
confidence=1.0,
text=page_text, confidence=1.0,
bbox=(0, 0, int(page.rect.width), int(page.rect.height)),
page=page_num
))
total_confidence += 1.0
region_count += 1
else:
# Seite als Bild rendern und OCR anwenden
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(img_bytes))
ocr_result = self._ocr_image(img)
all_text.append(ocr_result["text"])
for region in ocr_result["regions"]:
region.page = page_num
all_regions.append(region)
@@ -241,55 +163,34 @@ class FileProcessor:
region_count += 1
doc.close()
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
return ProcessingResult(
text="\n\n".join(all_text),
confidence=avg_confidence,
text="\n\n".join(all_text), confidence=avg_confidence,
regions=all_regions,
page_count=len(doc) if hasattr(doc, '__len__') else 1,
file_type=FileType.PDF,
processing_mode=mode,
file_type=FileType.PDF, processing_mode=mode,
metadata={"source": file_path or "bytes"}
)
def _process_image(
self,
file_path: str = None,
file_bytes: bytes = None,
mode: ProcessingMode = ProcessingMode.MIXED
) -> ProcessingResult:
def _process_image(self, file_path=None, file_bytes=None, mode=ProcessingMode.MIXED):
"""Verarbeitet Bilddateien."""
if file_bytes:
img = Image.open(io.BytesIO(file_bytes))
else:
img = Image.open(file_path)
# Bildvorverarbeitung
processed_img = self._preprocess_image(img)
# OCR
ocr_result = self._ocr_image(processed_img)
return ProcessingResult(
text=ocr_result["text"],
confidence=ocr_result["confidence"],
regions=ocr_result["regions"],
page_count=1,
file_type=FileType.IMAGE,
processing_mode=mode,
metadata={
"source": file_path or "bytes",
"image_size": img.size
}
text=ocr_result["text"], confidence=ocr_result["confidence"],
regions=ocr_result["regions"], page_count=1,
file_type=FileType.IMAGE, processing_mode=mode,
metadata={"source": file_path or "bytes", "image_size": img.size}
)
def _process_docx(
self,
file_path: str = None,
file_bytes: bytes = None
) -> ProcessingResult:
def _process_docx(self, file_path=None, file_bytes=None):
"""Verarbeitet DOCX-Dateien."""
try:
from docx import Document
@@ -306,7 +207,6 @@ class FileProcessor:
if para.text.strip():
paragraphs.append(para.text)
# Auch Tabellen extrahieren
for table in doc.tables:
for row in table.rows:
row_text = " | ".join(cell.text for cell in row.cells)
@@ -316,25 +216,14 @@ class FileProcessor:
text = "\n\n".join(paragraphs)
return ProcessingResult(
text=text,
confidence=1.0, # Direkte Textextraktion
regions=[ProcessedRegion(
text=text,
confidence=1.0,
bbox=(0, 0, 0, 0),
page=1
)],
page_count=1,
file_type=FileType.DOCX,
text=text, confidence=1.0,
regions=[ProcessedRegion(text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1)],
page_count=1, file_type=FileType.DOCX,
processing_mode=ProcessingMode.TEXT_EXTRACT,
metadata={"source": file_path or "bytes"}
)
def _process_txt(
self,
file_path: str = None,
file_bytes: bytes = None
) -> ProcessingResult:
def _process_txt(self, file_path=None, file_bytes=None):
"""Verarbeitet Textdateien."""
if file_bytes:
text = file_bytes.decode('utf-8', errors='ignore')
@@ -343,146 +232,65 @@ class FileProcessor:
text = f.read()
return ProcessingResult(
text=text,
confidence=1.0,
regions=[ProcessedRegion(
text=text,
confidence=1.0,
bbox=(0, 0, 0, 0),
page=1
)],
page_count=1,
file_type=FileType.TXT,
text=text, confidence=1.0,
regions=[ProcessedRegion(text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1)],
page_count=1, file_type=FileType.TXT,
processing_mode=ProcessingMode.TEXT_EXTRACT,
metadata={"source": file_path or "bytes"}
)
def _preprocess_image(self, img: Image.Image) -> Image.Image:
"""
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
- Konvertierung zu Graustufen
- Kontrastverstärkung
- Rauschunterdrückung
- Binarisierung
"""
# PIL zu OpenCV
"""Vorverarbeitung des Bildes für bessere OCR-Ergebnisse."""
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
# Zu Graustufen konvertieren
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Rauschunterdrückung
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
# Kontrastverstärkung (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)
# Adaptive Binarisierung
binary = cv2.adaptiveThreshold(
enhanced,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11,
2
enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
# Zurück zu PIL
return Image.fromarray(binary)
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
"""
Führt OCR auf einem Bild aus.
Returns:
Dict mit text, confidence und regions
"""
"""Führt OCR auf einem Bild aus."""
if self.ocr_engine is None:
# Fallback wenn kein OCR-Engine verfügbar
return {
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
"confidence": 0.0,
"regions": []
}
return {"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
"confidence": 0.0, "regions": []}
# PIL zu numpy array
img_array = np.array(img)
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
if len(img_array.shape) == 2:
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
# OCR ausführen
result = self.ocr_engine.ocr(img_array, cls=True)
if not result or not result[0]:
return {"text": "", "confidence": 0.0, "regions": []}
all_text = []
all_regions = []
all_text, all_regions = [], []
total_confidence = 0.0
for line in result[0]:
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
bbox_points = line[0]
text, confidence = line[1]
# Bounding Box zu x1, y1, x2, y2 konvertieren
x_coords = [p[0] for p in bbox_points]
y_coords = [p[1] for p in bbox_points]
bbox = (
int(min(x_coords)),
int(min(y_coords)),
int(max(x_coords)),
int(max(y_coords))
)
bbox = (int(min(x_coords)), int(min(y_coords)),
int(max(x_coords)), int(max(y_coords)))
all_text.append(text)
all_regions.append(ProcessedRegion(
text=text,
confidence=confidence,
bbox=bbox
))
all_regions.append(ProcessedRegion(text=text, confidence=confidence, bbox=bbox))
total_confidence += confidence
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
return {"text": "\n".join(all_text), "confidence": avg_confidence, "regions": all_regions}
return {
"text": "\n".join(all_text),
"confidence": avg_confidence,
"regions": all_regions
}
def extract_handwriting_regions(
self,
img: Image.Image,
min_area: int = 500
) -> List[Dict[str, Any]]:
"""
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
Args:
img: Eingabebild
min_area: Minimale Fläche für erkannte Regionen
Returns:
Liste von Regionen mit Koordinaten und erkanntem Text
"""
# Bildvorverarbeitung
def extract_handwriting_regions(self, img: Image.Image, min_area: int = 500) -> List[Dict[str, Any]]:
"""Erkennt und extrahiert handschriftliche Bereiche aus einem Bild."""
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
# Kanten erkennen
edges = cv2.Canny(gray, 50, 150)
# Morphologische Operationen zum Verbinden
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
dilated = cv2.dilate(edges, kernel, iterations=2)
# Konturen finden
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
regions = []
@@ -490,25 +298,15 @@ class FileProcessor:
area = cv2.contourArea(contour)
if area < min_area:
continue
x, y, w, h = cv2.boundingRect(contour)
# Region ausschneiden
region_img = img.crop((x, y, x + w, y + h))
# OCR auf Region anwenden
ocr_result = self._ocr_image(region_img)
regions.append({
"bbox": (x, y, x + w, y + h),
"area": area,
"text": ocr_result["text"],
"confidence": ocr_result["confidence"]
"bbox": (x, y, x + w, y + h), "area": area,
"text": ocr_result["text"], "confidence": ocr_result["confidence"]
})
# Nach Y-Position sortieren (oben nach unten)
regions.sort(key=lambda r: r["bbox"][1])
return regions
@@ -525,39 +323,25 @@ def get_file_processor() -> FileProcessor:
# Convenience functions
def process_file(
file_path: str = None,
file_bytes: bytes = None,
mode: ProcessingMode = ProcessingMode.MIXED
) -> ProcessingResult:
"""
Convenience function zum Verarbeiten einer Datei.
Args:
file_path: Pfad zur Datei
file_bytes: Dateiinhalt als Bytes
mode: Verarbeitungsmodus
Returns:
ProcessingResult
"""
def process_file(file_path=None, file_bytes=None, mode=ProcessingMode.MIXED) -> ProcessingResult:
"""Convenience function zum Verarbeiten einer Datei."""
processor = get_file_processor()
return processor.process(file_path, file_bytes, mode)
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
def extract_text_from_pdf(file_path=None, file_bytes=None) -> str:
"""Extrahiert Text aus einer PDF-Datei."""
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
return result.text
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
def ocr_image(file_path=None, file_bytes=None) -> str:
"""Führt OCR auf einem Bild aus."""
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
return result.text
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
def ocr_handwriting(file_path=None, file_bytes=None) -> str:
"""Führt Handschrift-OCR auf einem Bild aus."""
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
return result.text
@@ -0,0 +1,48 @@
"""
File Processor - Datenmodelle und Enums.
Typen fuer Dokumentenverarbeitung: Dateitypen, Modi, Ergebnisse.
"""
from typing import List, Dict, Any, Tuple
from dataclasses import dataclass
from enum import Enum
class FileType(str, Enum):
"""Unterstützte Dateitypen."""
PDF = "pdf"
IMAGE = "image"
DOCX = "docx"
DOC = "doc"
TXT = "txt"
UNKNOWN = "unknown"
class ProcessingMode(str, Enum):
"""Verarbeitungsmodi."""
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
OCR_PRINTED = "ocr_printed" # Gedruckter Text
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
MIXED = "mixed" # Kombiniert OCR + Textextraktion
@dataclass
class ProcessedRegion:
"""Ein erkannter Textbereich."""
text: str
confidence: float
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
page: int = 1
@dataclass
class ProcessingResult:
"""Ergebnis der Dokumentenverarbeitung."""
text: str
confidence: float
regions: List[ProcessedRegion]
page_count: int
file_type: FileType
processing_mode: ProcessingMode
metadata: Dict[str, Any]
+38 -211
View File
@@ -12,21 +12,29 @@ Endpoints:
import logging
import uuid
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from state_engine import (
AnticipationEngine,
PhaseService,
TeacherContext,
SchoolYearPhase,
ClassSummary,
Event,
TeacherStats,
get_phase_info,
PHASE_INFO
)
from state_engine_models import (
MilestoneRequest,
TransitionRequest,
ContextResponse,
SuggestionsResponse,
DashboardResponse,
_teacher_contexts,
_milestones,
get_or_create_context,
update_context_from_services,
get_phase_display_name,
)
logger = logging.getLogger(__name__)
@@ -41,157 +49,15 @@ _engine = AnticipationEngine()
_phase_service = PhaseService()
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# ============================================================================
# Simulierter Lehrer-Kontext (in Produktion aus DB)
_teacher_contexts: Dict[str, TeacherContext] = {}
_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones
# ============================================================================
# Pydantic Models
# ============================================================================
class MilestoneRequest(BaseModel):
"""Request zum Abschließen eines Meilensteins."""
milestone: str = Field(..., description="Name des Meilensteins")
class TransitionRequest(BaseModel):
"""Request für Phasen-Übergang."""
target_phase: str = Field(..., description="Zielphase")
class ContextResponse(BaseModel):
"""Response mit TeacherContext."""
context: Dict[str, Any]
phase_info: Dict[str, Any]
class SuggestionsResponse(BaseModel):
"""Response mit Vorschlägen."""
suggestions: List[Dict[str, Any]]
current_phase: str
phase_display_name: str
priority_counts: Dict[str, int]
class DashboardResponse(BaseModel):
"""Response mit Dashboard-Daten."""
context: Dict[str, Any]
suggestions: List[Dict[str, Any]]
stats: Dict[str, Any]
upcoming_events: List[Dict[str, Any]]
progress: Dict[str, Any]
phases: List[Dict[str, Any]]
# ============================================================================
# Helper Functions
# ============================================================================
def _get_or_create_context(teacher_id: str) -> TeacherContext:
"""
Holt oder erstellt TeacherContext.
In Produktion würde dies aus der Datenbank geladen.
"""
if teacher_id not in _teacher_contexts:
# Erstelle Demo-Kontext
now = datetime.now()
school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1)
weeks_since_start = (now - school_year_start).days // 7
# Bestimme Phase basierend auf Monat
month = now.month
if month in [8, 9]:
phase = SchoolYearPhase.SCHOOL_YEAR_START
elif month in [10, 11]:
phase = SchoolYearPhase.TEACHING_SETUP
elif month == 12:
phase = SchoolYearPhase.PERFORMANCE_1
elif month in [1, 2]:
phase = SchoolYearPhase.SEMESTER_END
elif month in [3, 4]:
phase = SchoolYearPhase.TEACHING_2
elif month in [5, 6]:
phase = SchoolYearPhase.PERFORMANCE_2
else:
phase = SchoolYearPhase.YEAR_END
_teacher_contexts[teacher_id] = TeacherContext(
teacher_id=teacher_id,
school_id=str(uuid.uuid4()),
school_year_id=str(uuid.uuid4()),
federal_state="niedersachsen",
school_type="gymnasium",
school_year_start=school_year_start,
current_phase=phase,
phase_entered_at=now - timedelta(days=7),
weeks_since_start=weeks_since_start,
days_in_phase=7,
classes=[],
total_students=0,
upcoming_events=[],
completed_milestones=_milestones.get(teacher_id, []),
pending_milestones=[],
stats=TeacherStats(),
)
return _teacher_contexts[teacher_id]
def _update_context_from_services(ctx: TeacherContext) -> TeacherContext:
"""
Aktualisiert Kontext mit Daten aus anderen Services.
In Produktion würde dies von school-service, gradebook etc. laden.
"""
# Simulierte Daten - in Produktion API-Calls
# Hier könnten wir den Kontext mit echten Daten anreichern
# Berechne days_in_phase
ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days
# Lade abgeschlossene Meilensteine
ctx.completed_milestones = _milestones.get(ctx.teacher_id, [])
# Berechne pending milestones
phase_info = get_phase_info(ctx.current_phase)
ctx.pending_milestones = [
m for m in phase_info.required_actions
if m not in ctx.completed_milestones
]
return ctx
def _get_phase_display_name(phase: str) -> str:
"""Gibt Display-Name für Phase zurück."""
try:
return get_phase_info(SchoolYearPhase(phase)).display_name
except (ValueError, KeyError):
return phase
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/context", response_model=ContextResponse)
async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
"""
Gibt den aggregierten TeacherContext zurück.
Enthält alle relevanten Informationen für:
- Phasen-Anzeige
- Antizipations-Engine
- Dashboard
"""
ctx = _get_or_create_context(teacher_id)
ctx = _update_context_from_services(ctx)
"""Gibt den aggregierten TeacherContext zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
phase_info = get_phase_info(ctx.current_phase)
@@ -210,10 +76,8 @@ async def get_teacher_context(teacher_id: str = Query("demo-teacher")):
@router.get("/phase")
async def get_current_phase(teacher_id: str = Query("demo-teacher")):
"""
Gibt die aktuelle Phase mit Details zurück.
"""
ctx = _get_or_create_context(teacher_id)
"""Gibt die aktuelle Phase mit Details zurück."""
ctx = get_or_create_context(teacher_id)
phase_info = get_phase_info(ctx.current_phase)
return {
@@ -230,11 +94,7 @@ async def get_current_phase(teacher_id: str = Query("demo-teacher")):
@router.get("/phases")
async def get_all_phases():
"""
Gibt alle Phasen mit Metadaten zurück.
Nützlich für die Phasen-Anzeige im Dashboard.
"""
"""Gibt alle Phasen mit Metadaten zurück."""
return {
"phases": _phase_service.get_all_phases()
}
@@ -242,13 +102,9 @@ async def get_all_phases():
@router.get("/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(teacher_id: str = Query("demo-teacher")):
"""
Gibt Vorschläge basierend auf dem aktuellen Kontext zurück.
Die Vorschläge sind priorisiert und auf max. 5 limitiert.
"""
ctx = _get_or_create_context(teacher_id)
ctx = _update_context_from_services(ctx)
"""Gibt Vorschläge basierend auf dem aktuellen Kontext zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestions = _engine.get_suggestions(ctx)
priority_counts = _engine.count_by_priority(ctx)
@@ -256,18 +112,16 @@ async def get_suggestions(teacher_id: str = Query("demo-teacher")):
return SuggestionsResponse(
suggestions=[s.to_dict() for s in suggestions],
current_phase=ctx.current_phase.value,
phase_display_name=_get_phase_display_name(ctx.current_phase.value),
phase_display_name=get_phase_display_name(ctx.current_phase.value),
priority_counts=priority_counts,
)
@router.get("/suggestions/top")
async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
"""
Gibt den wichtigsten einzelnen Vorschlag zurück.
"""
ctx = _get_or_create_context(teacher_id)
ctx = _update_context_from_services(ctx)
"""Gibt den wichtigsten einzelnen Vorschlag zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestion = _engine.get_top_suggestion(ctx)
@@ -284,28 +138,17 @@ async def get_top_suggestion(teacher_id: str = Query("demo-teacher")):
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard_data(teacher_id: str = Query("demo-teacher")):
"""
Gibt alle Daten für das Begleiter-Dashboard zurück.
Kombiniert:
- TeacherContext
- Vorschläge
- Statistiken
- Termine
- Fortschritt
"""
ctx = _get_or_create_context(teacher_id)
ctx = _update_context_from_services(ctx)
"""Gibt alle Daten für das Begleiter-Dashboard zurück."""
ctx = get_or_create_context(teacher_id)
ctx = update_context_from_services(ctx)
suggestions = _engine.get_suggestions(ctx)
phase_info = get_phase_info(ctx.current_phase)
# Berechne Fortschritt
required = set(phase_info.required_actions)
completed = set(ctx.completed_milestones)
completed_in_phase = len(required.intersection(completed))
# Alle Phasen für Anzeige
all_phases = []
phase_order = [
SchoolYearPhase.ONBOARDING,
@@ -376,14 +219,9 @@ async def complete_milestone(
request: MilestoneRequest,
teacher_id: str = Query("demo-teacher")
):
"""
Markiert einen Meilenstein als erledigt.
Prüft automatisch ob ein Phasen-Übergang möglich ist.
"""
"""Markiert einen Meilenstein als erledigt."""
milestone = request.milestone
# Speichere Meilenstein
if teacher_id not in _milestones:
_milestones[teacher_id] = []
@@ -391,12 +229,10 @@ async def complete_milestone(
_milestones[teacher_id].append(milestone)
logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}")
# Aktualisiere Kontext
ctx = _get_or_create_context(teacher_id)
ctx = get_or_create_context(teacher_id)
ctx.completed_milestones = _milestones[teacher_id]
_teacher_contexts[teacher_id] = ctx
# Prüfe automatischen Phasen-Übergang
new_phase = _phase_service.check_and_transition(ctx)
if new_phase:
@@ -420,9 +256,7 @@ async def transition_phase(
request: TransitionRequest,
teacher_id: str = Query("demo-teacher")
):
"""
Führt einen manuellen Phasen-Übergang durch.
"""
"""Führt einen manuellen Phasen-Übergang durch."""
try:
target_phase = SchoolYearPhase(request.target_phase)
except ValueError:
@@ -431,16 +265,14 @@ async def transition_phase(
detail=f"Ungültige Phase: {request.target_phase}"
)
ctx = _get_or_create_context(teacher_id)
ctx = get_or_create_context(teacher_id)
# Prüfe ob Übergang erlaubt
if not _phase_service.can_transition_to(ctx, target_phase):
raise HTTPException(
status_code=400,
detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt"
)
# Führe Übergang durch
old_phase = ctx.current_phase
ctx.current_phase = target_phase
ctx.phase_entered_at = datetime.now()
@@ -459,10 +291,8 @@ async def transition_phase(
@router.get("/next-phase")
async def get_next_phase(teacher_id: str = Query("demo-teacher")):
"""
Gibt die nächste Phase und Anforderungen zurück.
"""
ctx = _get_or_create_context(teacher_id)
"""Gibt die nächste Phase und Anforderungen zurück."""
ctx = get_or_create_context(teacher_id)
next_phase = _phase_service.get_next_phase(ctx.current_phase)
if not next_phase:
@@ -475,7 +305,6 @@ async def get_next_phase(teacher_id: str = Query("demo-teacher")):
next_info = get_phase_info(next_phase)
current_info = get_phase_info(ctx.current_phase)
# Fehlende Anforderungen
missing = [
m for m in current_info.required_actions
if m not in ctx.completed_milestones
@@ -505,7 +334,7 @@ async def demo_add_class(
teacher_id: str = Query("demo-teacher")
):
"""Demo: Fügt eine Klasse zum Kontext hinzu."""
ctx = _get_or_create_context(teacher_id)
ctx = get_or_create_context(teacher_id)
ctx.classes.append(ClassSummary(
class_id=str(uuid.uuid4()),
@@ -515,7 +344,6 @@ async def demo_add_class(
subject="Deutsch"
))
ctx.total_students += student_count
_teacher_contexts[teacher_id] = ctx
return {"success": True, "classes": len(ctx.classes)}
@@ -529,7 +357,7 @@ async def demo_add_event(
teacher_id: str = Query("demo-teacher")
):
"""Demo: Fügt ein Event zum Kontext hinzu."""
ctx = _get_or_create_context(teacher_id)
ctx = get_or_create_context(teacher_id)
ctx.upcoming_events.append(Event(
type=event_type,
@@ -538,7 +366,6 @@ async def demo_add_event(
in_days=in_days,
priority="high" if in_days <= 3 else "medium"
))
_teacher_contexts[teacher_id] = ctx
return {"success": True, "events": len(ctx.upcoming_events)}
@@ -554,7 +381,7 @@ async def demo_update_stats(
teacher_id: str = Query("demo-teacher")
):
"""Demo: Aktualisiert Statistiken."""
ctx = _get_or_create_context(teacher_id)
ctx = get_or_create_context(teacher_id)
if learning_units:
ctx.stats.learning_units_created = learning_units
+143
View File
@@ -0,0 +1,143 @@
"""
State Engine API - Pydantic Models und Helper Functions.
"""
import uuid
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from state_engine import (
SchoolYearPhase,
ClassSummary,
Event,
TeacherContext,
TeacherStats,
get_phase_info,
)
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# ============================================================================
_teacher_contexts: Dict[str, TeacherContext] = {}
_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones
# ============================================================================
# Pydantic Models
# ============================================================================
class MilestoneRequest(BaseModel):
"""Request zum Abschließen eines Meilensteins."""
milestone: str = Field(..., description="Name des Meilensteins")
class TransitionRequest(BaseModel):
"""Request für Phasen-Übergang."""
target_phase: str = Field(..., description="Zielphase")
class ContextResponse(BaseModel):
"""Response mit TeacherContext."""
context: Dict[str, Any]
phase_info: Dict[str, Any]
class SuggestionsResponse(BaseModel):
"""Response mit Vorschlägen."""
suggestions: List[Dict[str, Any]]
current_phase: str
phase_display_name: str
priority_counts: Dict[str, int]
class DashboardResponse(BaseModel):
"""Response mit Dashboard-Daten."""
context: Dict[str, Any]
suggestions: List[Dict[str, Any]]
stats: Dict[str, Any]
upcoming_events: List[Dict[str, Any]]
progress: Dict[str, Any]
phases: List[Dict[str, Any]]
# ============================================================================
# Helper Functions
# ============================================================================
def get_or_create_context(teacher_id: str) -> TeacherContext:
"""
Holt oder erstellt TeacherContext.
In Produktion würde dies aus der Datenbank geladen.
"""
if teacher_id not in _teacher_contexts:
now = datetime.now()
school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1)
weeks_since_start = (now - school_year_start).days // 7
month = now.month
if month in [8, 9]:
phase = SchoolYearPhase.SCHOOL_YEAR_START
elif month in [10, 11]:
phase = SchoolYearPhase.TEACHING_SETUP
elif month == 12:
phase = SchoolYearPhase.PERFORMANCE_1
elif month in [1, 2]:
phase = SchoolYearPhase.SEMESTER_END
elif month in [3, 4]:
phase = SchoolYearPhase.TEACHING_2
elif month in [5, 6]:
phase = SchoolYearPhase.PERFORMANCE_2
else:
phase = SchoolYearPhase.YEAR_END
_teacher_contexts[teacher_id] = TeacherContext(
teacher_id=teacher_id,
school_id=str(uuid.uuid4()),
school_year_id=str(uuid.uuid4()),
federal_state="niedersachsen",
school_type="gymnasium",
school_year_start=school_year_start,
current_phase=phase,
phase_entered_at=now - timedelta(days=7),
weeks_since_start=weeks_since_start,
days_in_phase=7,
classes=[],
total_students=0,
upcoming_events=[],
completed_milestones=_milestones.get(teacher_id, []),
pending_milestones=[],
stats=TeacherStats(),
)
return _teacher_contexts[teacher_id]
def update_context_from_services(ctx: TeacherContext) -> TeacherContext:
"""
Aktualisiert Kontext mit Daten aus anderen Services.
In Produktion würde dies von school-service, gradebook etc. laden.
"""
ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days
ctx.completed_milestones = _milestones.get(ctx.teacher_id, [])
phase_info = get_phase_info(ctx.current_phase)
ctx.pending_milestones = [
m for m in phase_info.required_actions
if m not in ctx.completed_milestones
]
return ctx
def get_phase_display_name(phase: str) -> str:
"""Gibt Display-Name für Phase zurück."""
try:
return get_phase_info(SchoolYearPhase(phase)).display_name
except (ValueError, KeyError):
return phase
+27 -180
View File
@@ -16,11 +16,9 @@ Unterstützt:
import logging
import uuid
from datetime import datetime
from typing import List, Dict, Any, Optional
from enum import Enum
from typing import Dict
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException
from generators import (
MultipleChoiceGenerator,
@@ -28,9 +26,22 @@ from generators import (
MindmapGenerator,
QuizGenerator
)
from generators.mc_generator import Difficulty
from generators.cloze_generator import ClozeType
from generators.quiz_generator import QuizType
from worksheets_models import (
ContentType,
GenerateRequest,
MCGenerateRequest,
ClozeGenerateRequest,
MindmapGenerateRequest,
QuizGenerateRequest,
BatchGenerateRequest,
WorksheetContent,
GenerateResponse,
BatchGenerateResponse,
parse_difficulty,
parse_cloze_type,
parse_quiz_types,
)
logger = logging.getLogger(__name__)
@@ -40,89 +51,6 @@ router = APIRouter(
)
# ============================================================================
# Pydantic Models
# ============================================================================
class ContentType(str, Enum):
"""Verfügbare Content-Typen."""
MULTIPLE_CHOICE = "multiple_choice"
CLOZE = "cloze"
MINDMAP = "mindmap"
QUIZ = "quiz"
class GenerateRequest(BaseModel):
"""Basis-Request für Generierung."""
source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
topic: Optional[str] = Field(None, description="Thema/Titel")
subject: Optional[str] = Field(None, description="Fach")
grade_level: Optional[str] = Field(None, description="Klassenstufe")
class MCGenerateRequest(GenerateRequest):
"""Request für Multiple-Choice-Generierung."""
num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
difficulty: str = Field("medium", description="easy, medium, hard")
class ClozeGenerateRequest(GenerateRequest):
"""Request für Lückentext-Generierung."""
num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
difficulty: str = Field("medium", description="easy, medium, hard")
cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
class MindmapGenerateRequest(GenerateRequest):
"""Request für Mindmap-Generierung."""
max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
class QuizGenerateRequest(GenerateRequest):
"""Request für Quiz-Generierung."""
quiz_types: List[str] = Field(
["true_false", "matching"],
description="Typen: true_false, matching, sorting, open_ended"
)
num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
difficulty: str = Field("medium", description="easy, medium, hard")
class BatchGenerateRequest(BaseModel):
"""Request für Batch-Generierung mehrerer Content-Typen."""
source_text: str = Field(..., min_length=50)
content_types: List[str] = Field(..., description="Liste von Content-Typen")
topic: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
difficulty: str = "medium"
class WorksheetContent(BaseModel):
"""Generierter Content."""
id: str
content_type: str
data: Dict[str, Any]
h5p_format: Optional[Dict[str, Any]] = None
created_at: datetime
topic: Optional[str] = None
difficulty: Optional[str] = None
class GenerateResponse(BaseModel):
"""Response mit generiertem Content."""
success: bool
content: Optional[WorksheetContent] = None
error: Optional[str] = None
class BatchGenerateResponse(BaseModel):
"""Response für Batch-Generierung."""
success: bool
contents: List[WorksheetContent] = []
errors: List[str] = []
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# ============================================================================
@@ -134,49 +62,12 @@ _generated_content: Dict[str, WorksheetContent] = {}
# Generator Instances
# ============================================================================
# Generatoren ohne LLM-Client (automatische Generierung)
# In Produktion würde hier der LLM-Client injiziert
mc_generator = MultipleChoiceGenerator()
cloze_generator = ClozeGenerator()
mindmap_generator = MindmapGenerator()
quiz_generator = QuizGenerator()
# ============================================================================
# Helper Functions
# ============================================================================
def _parse_difficulty(difficulty_str: str) -> Difficulty:
"""Konvertiert String zu Difficulty Enum."""
mapping = {
"easy": Difficulty.EASY,
"medium": Difficulty.MEDIUM,
"hard": Difficulty.HARD
}
return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
def _parse_cloze_type(type_str: str) -> ClozeType:
"""Konvertiert String zu ClozeType Enum."""
mapping = {
"fill_in": ClozeType.FILL_IN,
"drag_drop": ClozeType.DRAG_DROP,
"dropdown": ClozeType.DROPDOWN
}
return mapping.get(type_str.lower(), ClozeType.FILL_IN)
def _parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
"""Konvertiert String-Liste zu QuizType Enums."""
mapping = {
"true_false": QuizType.TRUE_FALSE,
"matching": QuizType.MATCHING,
"sorting": QuizType.SORTING,
"open_ended": QuizType.OPEN_ENDED
}
return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]
def _store_content(content: WorksheetContent) -> None:
"""Speichert generierten Content."""
_generated_content[content.id] = content
@@ -188,15 +79,9 @@ def _store_content(content: WorksheetContent) -> None:
@router.post("/generate/multiple-choice", response_model=GenerateResponse)
async def generate_multiple_choice(request: MCGenerateRequest):
"""
Generiert Multiple-Choice-Fragen aus Quelltext.
- **source_text**: Text mit mind. 50 Zeichen
- **num_questions**: Anzahl Fragen (1-20)
- **difficulty**: easy, medium, hard
"""
"""Generiert Multiple-Choice-Fragen aus Quelltext."""
try:
difficulty = _parse_difficulty(request.difficulty)
difficulty = parse_difficulty(request.difficulty)
questions = mc_generator.generate(
source_text=request.source_text,
@@ -212,7 +97,6 @@ async def generate_multiple_choice(request: MCGenerateRequest):
error="Keine Fragen generiert. Text möglicherweise zu kurz."
)
# Konvertiere zu Dict
questions_dict = mc_generator.to_dict(questions)
h5p_format = mc_generator.to_h5p_format(questions)
@@ -227,7 +111,6 @@ async def generate_multiple_choice(request: MCGenerateRequest):
)
_store_content(content)
return GenerateResponse(success=True, content=content)
except Exception as e:
@@ -237,15 +120,9 @@ async def generate_multiple_choice(request: MCGenerateRequest):
@router.post("/generate/cloze", response_model=GenerateResponse)
async def generate_cloze(request: ClozeGenerateRequest):
"""
Generiert Lückentext aus Quelltext.
- **source_text**: Text mit mind. 50 Zeichen
- **num_gaps**: Anzahl Lücken (1-15)
- **cloze_type**: fill_in, drag_drop, dropdown
"""
"""Generiert Lückentext aus Quelltext."""
try:
cloze_type = _parse_cloze_type(request.cloze_type)
cloze_type = parse_cloze_type(request.cloze_type)
cloze = cloze_generator.generate(
source_text=request.source_text,
@@ -275,7 +152,6 @@ async def generate_cloze(request: ClozeGenerateRequest):
)
_store_content(content)
return GenerateResponse(success=True, content=content)
except Exception as e:
@@ -285,12 +161,7 @@ async def generate_cloze(request: ClozeGenerateRequest):
@router.post("/generate/mindmap", response_model=GenerateResponse)
async def generate_mindmap(request: MindmapGenerateRequest):
"""
Generiert Mindmap aus Quelltext.
- **source_text**: Text mit mind. 50 Zeichen
- **max_depth**: Maximale Tiefe (2-5)
"""
"""Generiert Mindmap aus Quelltext."""
try:
mindmap = mindmap_generator.generate(
source_text=request.source_text,
@@ -317,14 +188,13 @@ async def generate_mindmap(request: MindmapGenerateRequest):
"mermaid": mermaid,
"json_tree": json_tree
},
h5p_format=None, # Mindmaps haben kein H5P-Format
h5p_format=None,
created_at=datetime.utcnow(),
topic=request.topic,
difficulty=None
)
_store_content(content)
return GenerateResponse(success=True, content=content)
except Exception as e:
@@ -334,17 +204,10 @@ async def generate_mindmap(request: MindmapGenerateRequest):
@router.post("/generate/quiz", response_model=GenerateResponse)
async def generate_quiz(request: QuizGenerateRequest):
"""
Generiert Quiz mit verschiedenen Fragetypen.
- **source_text**: Text mit mind. 50 Zeichen
- **quiz_types**: Liste von true_false, matching, sorting, open_ended
- **num_items**: Items pro Typ (1-10)
"""
"""Generiert Quiz mit verschiedenen Fragetypen."""
try:
quiz_types = _parse_quiz_types(request.quiz_types)
quiz_types = parse_quiz_types(request.quiz_types)
# Generate quiz for each type and combine results
all_questions = []
quizzes = []
@@ -365,7 +228,6 @@ async def generate_quiz(request: QuizGenerateRequest):
error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz."
)
# Combine all quizzes into a single dict
combined_quiz_dict = {
"quiz_types": [qt.value for qt in quiz_types],
"title": f"Combined Quiz - {request.topic or 'Various Topics'}",
@@ -374,12 +236,10 @@ async def generate_quiz(request: QuizGenerateRequest):
"questions": []
}
# Add questions from each quiz
for quiz in quizzes:
quiz_dict = quiz_generator.to_dict(quiz)
combined_quiz_dict["questions"].extend(quiz_dict.get("questions", []))
# Use first quiz's H5P format as base (or empty if none)
h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {}
content = WorksheetContent(
@@ -393,7 +253,6 @@ async def generate_quiz(request: QuizGenerateRequest):
)
_store_content(content)
return GenerateResponse(success=True, content=content)
except Exception as e:
@@ -403,22 +262,10 @@ async def generate_quiz(request: QuizGenerateRequest):
@router.post("/generate/batch", response_model=BatchGenerateResponse)
async def generate_batch(request: BatchGenerateRequest):
"""
Generiert mehrere Content-Typen aus einem Quelltext.
Ideal für die Erstellung kompletter Arbeitsblätter mit
verschiedenen Übungstypen.
"""
"""Generiert mehrere Content-Typen aus einem Quelltext."""
contents = []
errors = []
type_mapping = {
"multiple_choice": MCGenerateRequest,
"cloze": ClozeGenerateRequest,
"mindmap": MindmapGenerateRequest,
"quiz": QuizGenerateRequest
}
for content_type in request.content_types:
try:
if content_type == "multiple_choice":
+135
View File
@@ -0,0 +1,135 @@
"""
Worksheets API - Pydantic Models und Helpers.
Request-/Response-Models und Hilfsfunktionen fuer die
Arbeitsblatt-Generierungs-API.
"""
import uuid
from datetime import datetime
from typing import List, Dict, Any, Optional
from enum import Enum
from pydantic import BaseModel, Field
from generators.mc_generator import Difficulty
from generators.cloze_generator import ClozeType
from generators.quiz_generator import QuizType
# ============================================================================
# Pydantic Models
# ============================================================================
class ContentType(str, Enum):
"""Verfügbare Content-Typen."""
MULTIPLE_CHOICE = "multiple_choice"
CLOZE = "cloze"
MINDMAP = "mindmap"
QUIZ = "quiz"
class GenerateRequest(BaseModel):
"""Basis-Request für Generierung."""
source_text: str = Field(..., min_length=50, description="Quelltext für Generierung")
topic: Optional[str] = Field(None, description="Thema/Titel")
subject: Optional[str] = Field(None, description="Fach")
grade_level: Optional[str] = Field(None, description="Klassenstufe")
class MCGenerateRequest(GenerateRequest):
"""Request für Multiple-Choice-Generierung."""
num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen")
difficulty: str = Field("medium", description="easy, medium, hard")
class ClozeGenerateRequest(GenerateRequest):
"""Request für Lückentext-Generierung."""
num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken")
difficulty: str = Field("medium", description="easy, medium, hard")
cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown")
class MindmapGenerateRequest(GenerateRequest):
"""Request für Mindmap-Generierung."""
max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe")
class QuizGenerateRequest(GenerateRequest):
"""Request für Quiz-Generierung."""
quiz_types: List[str] = Field(
["true_false", "matching"],
description="Typen: true_false, matching, sorting, open_ended"
)
num_items: int = Field(5, ge=1, le=10, description="Items pro Typ")
difficulty: str = Field("medium", description="easy, medium, hard")
class BatchGenerateRequest(BaseModel):
"""Request für Batch-Generierung mehrerer Content-Typen."""
source_text: str = Field(..., min_length=50)
content_types: List[str] = Field(..., description="Liste von Content-Typen")
topic: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
difficulty: str = "medium"
class WorksheetContent(BaseModel):
"""Generierter Content."""
id: str
content_type: str
data: Dict[str, Any]
h5p_format: Optional[Dict[str, Any]] = None
created_at: datetime
topic: Optional[str] = None
difficulty: Optional[str] = None
class GenerateResponse(BaseModel):
"""Response mit generiertem Content."""
success: bool
content: Optional[WorksheetContent] = None
error: Optional[str] = None
class BatchGenerateResponse(BaseModel):
"""Response für Batch-Generierung."""
success: bool
contents: List[WorksheetContent] = []
errors: List[str] = []
# ============================================================================
# Helper Functions
# ============================================================================
def parse_difficulty(difficulty_str: str) -> Difficulty:
"""Konvertiert String zu Difficulty Enum."""
mapping = {
"easy": Difficulty.EASY,
"medium": Difficulty.MEDIUM,
"hard": Difficulty.HARD
}
return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM)
def parse_cloze_type(type_str: str) -> ClozeType:
"""Konvertiert String zu ClozeType Enum."""
mapping = {
"fill_in": ClozeType.FILL_IN,
"drag_drop": ClozeType.DRAG_DROP,
"dropdown": ClozeType.DROPDOWN
}
return mapping.get(type_str.lower(), ClozeType.FILL_IN)
def parse_quiz_types(type_strs: List[str]) -> List[QuizType]:
"""Konvertiert String-Liste zu QuizType Enums."""
mapping = {
"true_false": QuizType.TRUE_FALSE,
"matching": QuizType.MATCHING,
"sorting": QuizType.SORTING,
"open_ended": QuizType.OPEN_ENDED
}
return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs]