Files
breakpilot-lehrer/voice-service/services/intent_router.py
Benjamin Admin 9912997187
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 25s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m55s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 18s
refactor: Jitsi/Matrix/Voice von Core übernommen, Camunda/BPMN gelöscht, Kommunikation-Nav
- Voice-Service von Core nach Lehrer verschoben (bp-lehrer-voice-service)
- 4 Jitsi-Services + 2 Synapse-Services in docker-compose.yml aufgenommen
- Camunda komplett gelöscht: workflow pages, workflow-config.ts, bpmn-js deps
- CAMUNDA_URL aus backend-lehrer environment entfernt
- Sidebar: Kategorie "Compliance SDK" + "Katalogverwaltung" entfernt
- Sidebar: Neue Kategorie "Kommunikation" mit Video & Chat, Voice Service, Alerts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:01:47 +01:00

369 lines
11 KiB
Python

"""
Intent Router - Voice Command Classification
Routes detected intents to appropriate handlers
Supports all use case groups:
1. Kurze Notizen (Autofahrt)
2. Arbeitsblatt-Generierung (Zug)
3. Situatives Arbeiten (Schule)
4. Canvas-Editor
5. Korrektur & RAG-Assistenz
6. Follow-up über Tage
"""
import structlog
import re
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from config import settings
from models.task import TaskType
from models.session import TranscriptMessage
logger = structlog.get_logger(__name__)
@dataclass
class DetectedIntent:
"""Detected intent with confidence and parameters."""
type: TaskType
confidence: float
parameters: Dict[str, Any]
is_actionable: bool
# Pattern-based intent detection rules
INTENT_PATTERNS = {
# Gruppe 1: Kurze Notizen
TaskType.STUDENT_OBSERVATION: [
r"notiz\s+zu\s+(\w+)",
r"beobachtung\s+(\w+)",
r"(\w+)\s+hat\s+(gestoert|gestört)",
r"(\w+)\s+braucht",
],
TaskType.REMINDER: [
r"erinner\s+mich",
r"morgen\s+(\d+:\d+)",
r"reminder",
r"nicht\s+vergessen",
],
TaskType.HOMEWORK_CHECK: [
r"hausaufgabe\s+kontrollieren",
r"(\w+)\s+mathe\s+hausaufgabe",
r"ha\s+check",
],
TaskType.CONFERENCE_TOPIC: [
r"thema\s+(lehrerkonferenz|konferenz)",
r"fuer\s+die\s+konferenz",
r"konferenzthema",
],
TaskType.CORRECTION_NOTE: [
r"aufgabe\s+(\d+)",
r"haeufiger\s+fehler",
r"naechste\s+stunde\s+erklaeren",
r"korrekturnotiz",
],
# Gruppe 2: Arbeitsblatt-Generierung
TaskType.WORKSHEET_GENERATE: [
r"arbeitsblatt\s+(erstellen|machen|generieren)",
r"nimm\s+vokabeln",
r"mach\s+(\d+)\s+lueckentexte",
r"uebungsblatt",
],
TaskType.WORKSHEET_DIFFERENTIATE: [
r"differenzierung",
r"zwei\s+schwierigkeitsstufen",
r"basis\s+und\s+plus",
r"leichtere\s+version",
],
# Gruppe 3: Situatives Arbeiten
TaskType.QUICK_ACTIVITY: [
r"(\d+)\s+minuten\s+einstieg",
r"schnelle\s+aktivitaet",
r"warming\s*up",
r"einstiegsaufgabe",
],
TaskType.QUIZ_GENERATE: [
r"vokabeltest",
r"quiz\s+(erstellen|generieren)",
r"(\d+)-minuten\s+test",
r"kurzer\s+test",
],
TaskType.PARENT_LETTER: [
r"elternbrief\s+wegen",
r"elternbrief",
r"brief\s+an\s+eltern",
r"wegen\s+wiederholter?\s+(stoerungen|störungen)",
r"wegen\s+(stoerungen|störungen)",
r"mitteilung\s+an\s+eltern",
],
TaskType.CLASS_MESSAGE: [
r"nachricht\s+an\s+(\d+\w+)",
r"klassen\s*nachricht",
r"info\s+an\s+die\s+klasse",
],
# Gruppe 4: Canvas-Editor
TaskType.CANVAS_EDIT: [
r"ueberschriften?\s+(groesser|kleiner|größer)",
r"bild\s+(\d+)\s+(nach|auf)",
r"pfeil\s+(von|auf)",
r"kasten\s+(hinzufuegen|einfügen)",
],
TaskType.CANVAS_LAYOUT: [
r"auf\s+eine\s+seite",
r"drucklayout\s+a4",
r"layout\s+(aendern|ändern)",
r"alles\s+auf\s+a4",
],
# Gruppe 5: Korrektur & RAG
TaskType.OPERATOR_CHECKLIST: [
r"operatoren[-\s]*checkliste",
r"welche\s+operatoren",
r"operatoren\s+fuer\s+diese\s+aufgabe",
],
TaskType.EH_PASSAGE: [
r"erwartungshorizont",
r"eh\s*passage",
r"was\s+steht\s+im\s+eh",
],
TaskType.FEEDBACK_SUGGEST: [
r"feedback\s*(vorschlag|vorschlagen)",
r"wie\s+formuliere\s+ich",
r"rueckmeldung\s+geben",
],
# Gruppe 6: Follow-up
TaskType.REMINDER_SCHEDULE: [
r"erinner\s+mich\s+morgen",
r"in\s+(\d+)\s+(stunden|tagen)",
r"naechste\s+woche",
],
TaskType.TASK_SUMMARY: [
r"offenen?\s+(aufgaben|tasks)",
r"was\s+steht\s+noch\s+an",
r"zusammenfassung",
r"fasse.+zusammen",
r"diese[rn]?\s+woche",
],
}
class IntentRouter:
"""
Routes voice commands to appropriate task types.
Uses a combination of:
1. Pattern matching for common phrases
2. LLM-based classification for complex queries
3. Context from previous messages for disambiguation
"""
def __init__(self):
self._compiled_patterns: Dict[TaskType, List[re.Pattern]] = {}
self._compile_patterns()
def _compile_patterns(self):
"""Pre-compile regex patterns for performance."""
for task_type, patterns in INTENT_PATTERNS.items():
self._compiled_patterns[task_type] = [
re.compile(pattern, re.IGNORECASE | re.UNICODE)
for pattern in patterns
]
async def detect_intent(
self,
text: str,
context: List[TranscriptMessage] = None,
) -> Optional[DetectedIntent]:
"""
Detect intent from text with optional context.
Args:
text: Input text (transcript)
context: Previous messages for disambiguation
Returns:
DetectedIntent or None if no clear intent
"""
# Normalize text
normalized = self._normalize_text(text)
# Try pattern matching first
pattern_result = self._pattern_match(normalized)
if pattern_result and pattern_result.confidence > 0.6:
logger.info(
"Intent detected via pattern",
type=pattern_result.type.value,
confidence=pattern_result.confidence,
)
return pattern_result
# Fall back to LLM classification
if settings.fallback_llm_provider != "none":
llm_result = await self._llm_classify(normalized, context)
if llm_result and llm_result.confidence > 0.5:
logger.info(
"Intent detected via LLM",
type=llm_result.type.value,
confidence=llm_result.confidence,
)
return llm_result
# Check for context-based disambiguation
if context:
context_result = self._context_disambiguate(normalized, context)
if context_result:
logger.info(
"Intent detected via context",
type=context_result.type.value,
)
return context_result
logger.debug("No intent detected", text=text[:50])
return None
def _normalize_text(self, text: str) -> str:
"""Normalize text for matching."""
# Convert umlauts
text = text.lower()
text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
text = text.replace("ß", "ss")
# Remove extra whitespace
text = " ".join(text.split())
return text
def _pattern_match(self, text: str) -> Optional[DetectedIntent]:
"""Match text against known patterns."""
best_match = None
best_confidence = 0.0
for task_type, patterns in self._compiled_patterns.items():
for pattern in patterns:
match = pattern.search(text)
if match:
# Calculate confidence based on match quality
match_ratio = len(match.group()) / len(text)
confidence = min(0.95, 0.6 + match_ratio * 0.4)
if confidence > best_confidence:
# Extract parameters from groups
parameters = self._extract_parameters(task_type, match, text)
best_match = DetectedIntent(
type=task_type,
confidence=confidence,
parameters=parameters,
is_actionable=self._is_actionable(task_type),
)
best_confidence = confidence
return best_match
def _extract_parameters(
self,
task_type: TaskType,
match: re.Match,
full_text: str,
) -> Dict[str, Any]:
"""Extract parameters from regex match."""
params = {}
# Extract named groups or positional groups
if match.groups():
groups = match.groups()
# Task-specific parameter extraction
if task_type == TaskType.STUDENT_OBSERVATION:
params["student_name"] = groups[0] if groups else None
elif task_type == TaskType.HOMEWORK_CHECK:
params["subject"] = "mathe" if "mathe" in full_text else None
elif task_type == TaskType.QUICK_ACTIVITY:
params["duration_minutes"] = int(groups[0]) if groups else 10
elif task_type == TaskType.QUIZ_GENERATE:
params["duration_minutes"] = int(groups[0]) if groups and groups[0].isdigit() else 10
elif task_type == TaskType.CLASS_MESSAGE:
params["class_name"] = groups[0] if groups else None
# Extract time references
time_match = re.search(r"(\d{1,2}):?(\d{2})?", full_text)
if time_match:
params["time"] = time_match.group()
# Extract content after colon
colon_match = re.search(r":\s*(.+)$", full_text)
if colon_match:
params["content"] = colon_match.group(1).strip()
return params
def _is_actionable(self, task_type: TaskType) -> bool:
"""Check if intent type creates an actionable task."""
# All task types are actionable except queries
query_types = [
TaskType.OPERATOR_CHECKLIST,
TaskType.EH_PASSAGE,
TaskType.TASK_SUMMARY,
]
return task_type not in query_types
async def _llm_classify(
self,
text: str,
context: List[TranscriptMessage] = None,
) -> Optional[DetectedIntent]:
"""Use LLM for intent classification."""
from services.fallback_llm_client import FallbackLLMClient
llm = FallbackLLMClient()
result = await llm.detect_intent(text)
if result.get("type") == "unknown":
return None
try:
task_type = TaskType(result["type"])
return DetectedIntent(
type=task_type,
confidence=result.get("confidence", 0.5),
parameters=result.get("parameters", {}),
is_actionable=result.get("is_actionable", True),
)
except ValueError:
logger.warning("Unknown task type from LLM", type=result.get("type"))
return None
def _context_disambiguate(
self,
text: str,
context: List[TranscriptMessage],
) -> Optional[DetectedIntent]:
"""Disambiguate intent using conversation context."""
if not context:
return None
# Look for continuation patterns
continuation_words = ["ja", "genau", "richtig", "okay", "mach das", "bitte"]
if any(word in text.lower() for word in continuation_words):
# Find the last assistant message with a suggestion
for msg in reversed(context):
if msg.role == "assistant" and msg.intent:
try:
return DetectedIntent(
type=TaskType(msg.intent),
confidence=0.6,
parameters={},
is_actionable=True,
)
except ValueError:
pass
return None