A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
7.5 KiB
Python
249 lines
7.5 KiB
Python
"""
|
|
Fallback LLM Client - Ollama Integration
|
|
Text-only fallback when PersonaPlex is not available
|
|
|
|
Used in development on Mac Mini with:
|
|
- qwen2.5:32b for conversation
|
|
- Local processing (DSGVO-konform)
|
|
"""
|
|
import structlog
|
|
import httpx
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from config import settings
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class FallbackLLMClient:
|
|
"""
|
|
Ollama LLM client for text-only processing.
|
|
|
|
When PersonaPlex is not available (development mode),
|
|
this client provides:
|
|
- Intent detection (text-based)
|
|
- Response generation
|
|
- Task execution assistance
|
|
|
|
Note: Audio transcription requires a separate ASR service
|
|
(e.g., Whisper) when using this fallback.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._base_url = settings.ollama_base_url
|
|
self._model = settings.ollama_voice_model
|
|
self._timeout = settings.ollama_timeout
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
"""Get or create HTTP client."""
|
|
if self._client is None:
|
|
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
return self._client
|
|
|
|
async def generate(
|
|
self,
|
|
prompt: str,
|
|
system_prompt: Optional[str] = None,
|
|
temperature: float = 0.7,
|
|
max_tokens: int = 500,
|
|
) -> str:
|
|
"""
|
|
Generate text completion.
|
|
|
|
Args:
|
|
prompt: User prompt
|
|
system_prompt: Optional system instructions
|
|
temperature: Sampling temperature
|
|
max_tokens: Maximum tokens to generate
|
|
|
|
Returns:
|
|
Generated text
|
|
"""
|
|
if settings.fallback_llm_provider == "none":
|
|
logger.warning("No LLM provider configured")
|
|
return "LLM nicht verfügbar"
|
|
|
|
client = await self._get_client()
|
|
|
|
# Build messages
|
|
messages = []
|
|
if system_prompt:
|
|
messages.append({"role": "system", "content": system_prompt})
|
|
messages.append({"role": "user", "content": prompt})
|
|
|
|
try:
|
|
response = await client.post(
|
|
f"{self._base_url}/api/chat",
|
|
json={
|
|
"model": self._model,
|
|
"messages": messages,
|
|
"options": {
|
|
"temperature": temperature,
|
|
"num_predict": max_tokens,
|
|
},
|
|
"stream": False,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
return data.get("message", {}).get("content", "")
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error("Ollama request failed", error=str(e))
|
|
return "Fehler bei der Verarbeitung"
|
|
except Exception as e:
|
|
logger.error("Unexpected error", error=str(e))
|
|
return "Unerwarteter Fehler"
|
|
|
|
async def detect_intent(self, text: str) -> Dict[str, Any]:
|
|
"""
|
|
Detect intent from text using LLM.
|
|
|
|
Returns:
|
|
{
|
|
"type": "student_observation" | "reminder" | ...,
|
|
"confidence": 0.0-1.0,
|
|
"parameters": {...},
|
|
"is_actionable": bool
|
|
}
|
|
"""
|
|
system_prompt = """Du bist ein Intent-Detektor für Lehrer-Sprachbefehle.
|
|
Analysiere den Text und bestimme die Absicht.
|
|
|
|
Mögliche Intents:
|
|
- student_observation: Beobachtung zu einem Schüler
|
|
- reminder: Erinnerung an etwas
|
|
- homework_check: Hausaufgaben kontrollieren
|
|
- conference_topic: Thema für Konferenz
|
|
- correction_note: Notiz zur Korrektur
|
|
- worksheet_generate: Arbeitsblatt erstellen
|
|
- worksheet_differentiate: Differenzierung
|
|
- quick_activity: Schnelle Aktivität
|
|
- quiz_generate: Quiz erstellen
|
|
- parent_letter: Elternbrief
|
|
- class_message: Nachricht an Klasse
|
|
- canvas_edit: Canvas bearbeiten
|
|
- canvas_layout: Layout ändern
|
|
- operator_checklist: Operatoren-Checkliste
|
|
- eh_passage: EH-Passage suchen
|
|
- feedback_suggest: Feedback vorschlagen
|
|
- reminder_schedule: Erinnerung planen
|
|
- task_summary: Aufgaben zusammenfassen
|
|
- unknown: Unbekannt
|
|
|
|
Antworte NUR mit JSON:
|
|
{"type": "intent_name", "confidence": 0.0-1.0, "parameters": {...}, "is_actionable": true/false}"""
|
|
|
|
result = await self.generate(
|
|
prompt=f"Text: {text}",
|
|
system_prompt=system_prompt,
|
|
temperature=0.1,
|
|
max_tokens=200,
|
|
)
|
|
|
|
try:
|
|
# Parse JSON from response
|
|
import json
|
|
# Find JSON in response
|
|
start = result.find("{")
|
|
end = result.rfind("}") + 1
|
|
if start >= 0 and end > start:
|
|
return json.loads(result[start:end])
|
|
except Exception as e:
|
|
logger.warning("Intent parsing failed", error=str(e))
|
|
|
|
return {
|
|
"type": "unknown",
|
|
"confidence": 0.0,
|
|
"parameters": {},
|
|
"is_actionable": False,
|
|
}
|
|
|
|
async def process_audio_description(self, audio_data: bytes) -> str:
|
|
"""
|
|
Process audio by describing it (placeholder for ASR).
|
|
|
|
In production, this would use Whisper or similar.
|
|
For MVP, this returns a placeholder.
|
|
"""
|
|
# Calculate audio duration
|
|
samples = len(audio_data) // 2 # 16-bit = 2 bytes
|
|
duration_sec = samples / settings.audio_sample_rate
|
|
|
|
logger.debug(
|
|
"Audio received (no ASR in fallback mode)",
|
|
duration_sec=duration_sec,
|
|
bytes=len(audio_data),
|
|
)
|
|
|
|
# Placeholder - in production, integrate with Whisper
|
|
return ""
|
|
|
|
async def chat(
|
|
self,
|
|
messages: List[Dict[str, str]],
|
|
temperature: float = 0.7,
|
|
) -> str:
|
|
"""
|
|
Multi-turn conversation.
|
|
|
|
Args:
|
|
messages: List of {"role": "user"|"assistant", "content": "..."}
|
|
temperature: Sampling temperature
|
|
|
|
Returns:
|
|
Assistant response
|
|
"""
|
|
if settings.fallback_llm_provider == "none":
|
|
return "LLM nicht verfügbar"
|
|
|
|
client = await self._get_client()
|
|
|
|
# Add system prompt
|
|
system_prompt = """Du bist Breakpilot, ein hilfreicher Assistent für Lehrer.
|
|
Du hilfst bei:
|
|
- Notizen und Beobachtungen
|
|
- Unterrichtsvorbereitung
|
|
- Elternkommunikation
|
|
- Korrekturunterstützung
|
|
|
|
Antworte kurz und präzise. Halte Antworten unter 100 Wörtern."""
|
|
|
|
full_messages = [{"role": "system", "content": system_prompt}] + messages
|
|
|
|
try:
|
|
response = await client.post(
|
|
f"{self._base_url}/api/chat",
|
|
json={
|
|
"model": self._model,
|
|
"messages": full_messages,
|
|
"options": {
|
|
"temperature": temperature,
|
|
"num_predict": 300,
|
|
},
|
|
"stream": False,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
return data.get("message", {}).get("content", "")
|
|
|
|
except Exception as e:
|
|
logger.error("Chat failed", error=str(e))
|
|
return "Entschuldigung, ein Fehler ist aufgetreten."
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Check if Ollama is available."""
|
|
if settings.fallback_llm_provider == "none":
|
|
return False
|
|
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.get(f"{self._base_url}/api/tags")
|
|
return response.status_code == 200
|
|
except Exception:
|
|
return False
|