[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:
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>□ {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>□ {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>"
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")],
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user