refactor: voice-service entfernt (verschoben nach breakpilot-core)
This commit is contained in:
@@ -1,382 +0,0 @@
|
||||
"""
|
||||
Task Orchestrator - Task State Machine
|
||||
Manages task lifecycle and routes to Breakpilot modules
|
||||
|
||||
The TaskOrchestrator is the agent orchestration layer that:
|
||||
1. Receives intents from voice input
|
||||
2. Creates and manages tasks
|
||||
3. Routes to appropriate Breakpilot modules
|
||||
4. Maintains conversation context
|
||||
5. Handles follow-up queries
|
||||
|
||||
Note: This is a safe, internal task router with no shell access,
|
||||
no email capabilities, and no external API access beyond internal services.
|
||||
"""
|
||||
import structlog
|
||||
import httpx
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from config import settings
|
||||
from models.task import Task, TaskState, TaskType, is_valid_transition
|
||||
from models.session import TranscriptMessage
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Intent:
|
||||
"""Detected intent from voice input."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type: TaskType,
|
||||
confidence: float,
|
||||
parameters: Dict[str, Any],
|
||||
is_actionable: bool = True,
|
||||
):
|
||||
self.type = type
|
||||
self.confidence = confidence
|
||||
self.parameters = parameters
|
||||
self.is_actionable = is_actionable
|
||||
|
||||
|
||||
class TaskOrchestrator:
|
||||
"""
|
||||
Task orchestration and state machine management.
|
||||
|
||||
Handles the full lifecycle of voice-initiated tasks:
|
||||
1. Intent -> Task creation
|
||||
2. Task queuing and execution
|
||||
3. Result handling
|
||||
4. Follow-up context
|
||||
|
||||
Security: This orchestrator only routes to internal Breakpilot services
|
||||
via HTTP. It has NO access to shell commands, emails, calendars, or
|
||||
external APIs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
self._session_tasks: Dict[str, List[str]] = {} # session_id -> task_ids
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
return self._http_client
|
||||
|
||||
async def queue_task(self, task: Task):
|
||||
"""
|
||||
Queue a task for processing.
|
||||
Transitions from DRAFT to QUEUED.
|
||||
"""
|
||||
if task.state != TaskState.DRAFT:
|
||||
logger.warning("Task not in DRAFT state", task_id=task.id[:8])
|
||||
return
|
||||
|
||||
task.transition_to(TaskState.QUEUED, "queued_for_processing")
|
||||
|
||||
# Store task
|
||||
self._tasks[task.id] = task
|
||||
|
||||
# Add to session tasks
|
||||
if task.session_id not in self._session_tasks:
|
||||
self._session_tasks[task.session_id] = []
|
||||
self._session_tasks[task.session_id].append(task.id)
|
||||
|
||||
logger.info(
|
||||
"Task queued",
|
||||
task_id=task.id[:8],
|
||||
type=task.type.value,
|
||||
)
|
||||
|
||||
# Auto-process certain task types
|
||||
auto_process_types = [
|
||||
TaskType.STUDENT_OBSERVATION,
|
||||
TaskType.REMINDER,
|
||||
TaskType.HOMEWORK_CHECK,
|
||||
]
|
||||
|
||||
if task.type in auto_process_types:
|
||||
await self.process_task(task)
|
||||
|
||||
async def process_task(self, task: Task):
|
||||
"""
|
||||
Process a queued task.
|
||||
Routes to appropriate Breakpilot module.
|
||||
"""
|
||||
if task.state != TaskState.QUEUED:
|
||||
logger.warning("Task not in QUEUED state", task_id=task.id[:8])
|
||||
return
|
||||
|
||||
task.transition_to(TaskState.RUNNING, "processing_started")
|
||||
|
||||
try:
|
||||
# Route to appropriate handler
|
||||
result = await self._route_task(task)
|
||||
|
||||
# Store result
|
||||
task.result_ref = result
|
||||
|
||||
# Transition to READY
|
||||
task.transition_to(TaskState.READY, "processing_complete")
|
||||
|
||||
logger.info(
|
||||
"Task processed",
|
||||
task_id=task.id[:8],
|
||||
type=task.type.value,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Task processing failed", task_id=task.id[:8], error=str(e))
|
||||
task.error_message = str(e)
|
||||
task.transition_to(TaskState.READY, "processing_failed")
|
||||
|
||||
async def _route_task(self, task: Task) -> str:
|
||||
"""
|
||||
Route task to appropriate Breakpilot module.
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
# Task type to endpoint mapping
|
||||
routes = {
|
||||
# Worksheet generation
|
||||
TaskType.WORKSHEET_GENERATE: f"{settings.klausur_service_url}/api/v1/worksheets/generate",
|
||||
TaskType.WORKSHEET_DIFFERENTIATE: f"{settings.klausur_service_url}/api/v1/worksheets/differentiate",
|
||||
|
||||
# Quick activities
|
||||
TaskType.QUICK_ACTIVITY: f"{settings.klausur_service_url}/api/v1/activities/generate",
|
||||
TaskType.QUIZ_GENERATE: f"{settings.klausur_service_url}/api/v1/quizzes/generate",
|
||||
|
||||
# Korrektur assistance
|
||||
TaskType.OPERATOR_CHECKLIST: f"{settings.klausur_service_url}/api/v1/corrections/operators",
|
||||
TaskType.EH_PASSAGE: f"{settings.klausur_service_url}/api/v1/corrections/eh-passage",
|
||||
TaskType.FEEDBACK_SUGGEST: f"{settings.klausur_service_url}/api/v1/corrections/feedback",
|
||||
}
|
||||
|
||||
# Check if this task type needs API routing
|
||||
if task.type in routes:
|
||||
try:
|
||||
response = await client.post(
|
||||
routes[task.type],
|
||||
json={
|
||||
"task_id": task.id,
|
||||
"namespace_id": task.namespace_id,
|
||||
"parameters": task.parameters,
|
||||
},
|
||||
timeout=settings.ollama_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("result", "")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("API call failed", url=routes[task.type], error=str(e))
|
||||
raise
|
||||
|
||||
# Handle local tasks (no API call needed)
|
||||
if task.type in [TaskType.STUDENT_OBSERVATION, TaskType.REMINDER, TaskType.HOMEWORK_CHECK]:
|
||||
return await self._handle_note_task(task)
|
||||
|
||||
if task.type in [TaskType.CONFERENCE_TOPIC, TaskType.CORRECTION_NOTE]:
|
||||
return await self._handle_note_task(task)
|
||||
|
||||
if task.type == TaskType.PARENT_LETTER:
|
||||
return await self._generate_parent_letter(task)
|
||||
|
||||
if task.type == TaskType.CLASS_MESSAGE:
|
||||
return await self._generate_class_message(task)
|
||||
|
||||
if task.type in [TaskType.CANVAS_EDIT, TaskType.CANVAS_LAYOUT]:
|
||||
return await self._handle_canvas_command(task)
|
||||
|
||||
if task.type == TaskType.REMINDER_SCHEDULE:
|
||||
return await self._schedule_reminder(task)
|
||||
|
||||
if task.type == TaskType.TASK_SUMMARY:
|
||||
return await self._generate_task_summary(task)
|
||||
|
||||
logger.warning("Unknown task type", task_type=task.type.value)
|
||||
return "Task type not implemented"
|
||||
|
||||
async def _handle_note_task(self, task: Task) -> str:
|
||||
"""Handle simple note/observation tasks."""
|
||||
# These are stored encrypted, no further processing needed
|
||||
return "Notiz gespeichert"
|
||||
|
||||
async def _generate_parent_letter(self, task: Task) -> str:
|
||||
"""Generate a parent letter using LLM."""
|
||||
from services.fallback_llm_client import FallbackLLMClient
|
||||
|
||||
llm = FallbackLLMClient()
|
||||
|
||||
prompt = f"""Erstelle einen neutralen, professionellen Elternbrief basierend auf:
|
||||
Anlass: {task.parameters.get('reason', 'Allgemeine Information')}
|
||||
Kontext: {task.parameters.get('context', '')}
|
||||
|
||||
Der Brief soll:
|
||||
- Sachlich und respektvoll formuliert sein
|
||||
- Keine Schuldzuweisungen enthalten
|
||||
- Konstruktiv auf Lösungen ausgerichtet sein
|
||||
- In der Ich-Form aus Lehrersicht geschrieben sein
|
||||
|
||||
Bitte nur den Brieftext ausgeben, ohne Metakommentare."""
|
||||
|
||||
result = await llm.generate(prompt)
|
||||
return result
|
||||
|
||||
async def _generate_class_message(self, task: Task) -> str:
|
||||
"""Generate a class message."""
|
||||
from services.fallback_llm_client import FallbackLLMClient
|
||||
|
||||
llm = FallbackLLMClient()
|
||||
|
||||
prompt = f"""Erstelle eine kurze Klassennachricht:
|
||||
Inhalt: {task.parameters.get('content', '')}
|
||||
Klasse: {task.parameters.get('class_ref', 'Klasse')}
|
||||
|
||||
Die Nachricht soll:
|
||||
- Kurz und klar formuliert sein
|
||||
- Freundlich aber verbindlich klingen
|
||||
- Alle wichtigen Informationen enthalten
|
||||
|
||||
Nur die Nachricht ausgeben."""
|
||||
|
||||
result = await llm.generate(prompt)
|
||||
return result
|
||||
|
||||
async def _handle_canvas_command(self, task: Task) -> str:
|
||||
"""Handle Canvas editor commands."""
|
||||
# Parse canvas commands and generate JSON instructions
|
||||
command = task.parameters.get('command', '')
|
||||
|
||||
# Map natural language to Canvas actions
|
||||
canvas_actions = []
|
||||
|
||||
if 'groesser' in command.lower() or 'größer' in command.lower():
|
||||
canvas_actions.append({"action": "resize", "target": "headings", "scale": 1.2})
|
||||
|
||||
if 'kleiner' in command.lower():
|
||||
canvas_actions.append({"action": "resize", "target": "spacing", "scale": 0.8})
|
||||
|
||||
if 'links' in command.lower():
|
||||
canvas_actions.append({"action": "move", "direction": "left"})
|
||||
|
||||
if 'rechts' in command.lower():
|
||||
canvas_actions.append({"action": "move", "direction": "right"})
|
||||
|
||||
if 'a4' in command.lower() or 'drucklayout' in command.lower():
|
||||
canvas_actions.append({"action": "layout", "format": "A4"})
|
||||
|
||||
return str(canvas_actions)
|
||||
|
||||
async def _schedule_reminder(self, task: Task) -> str:
|
||||
"""Schedule a reminder for later."""
|
||||
# In production, this would use a scheduler service
|
||||
reminder_time = task.parameters.get('time', 'tomorrow')
|
||||
reminder_content = task.parameters.get('content', '')
|
||||
|
||||
return f"Erinnerung geplant für {reminder_time}: {reminder_content}"
|
||||
|
||||
async def _generate_task_summary(self, task: Task) -> str:
|
||||
"""Generate a summary of pending tasks."""
|
||||
session_tasks = self._session_tasks.get(task.session_id, [])
|
||||
|
||||
pending = []
|
||||
for task_id in session_tasks:
|
||||
t = self._tasks.get(task_id)
|
||||
if t and t.state not in [TaskState.COMPLETED, TaskState.EXPIRED]:
|
||||
pending.append(f"- {t.type.value}: {t.state.value}")
|
||||
|
||||
if not pending:
|
||||
return "Keine offenen Aufgaben"
|
||||
|
||||
return "Offene Aufgaben:\n" + "\n".join(pending)
|
||||
|
||||
async def execute_task(self, task: Task):
|
||||
"""Execute an approved task."""
|
||||
if task.state != TaskState.APPROVED:
|
||||
logger.warning("Task not approved", task_id=task.id[:8])
|
||||
return
|
||||
|
||||
# Mark as completed
|
||||
task.transition_to(TaskState.COMPLETED, "user_approved")
|
||||
|
||||
logger.info("Task completed", task_id=task.id[:8])
|
||||
|
||||
async def get_session_tasks(
|
||||
self,
|
||||
session_id: str,
|
||||
state: Optional[TaskState] = None,
|
||||
) -> List[Task]:
|
||||
"""Get tasks for a session, optionally filtered by state."""
|
||||
task_ids = self._session_tasks.get(session_id, [])
|
||||
tasks = []
|
||||
|
||||
for task_id in task_ids:
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
if state is None or task.state == state:
|
||||
tasks.append(task)
|
||||
|
||||
return tasks
|
||||
|
||||
async def create_task_from_intent(
|
||||
self,
|
||||
session_id: str,
|
||||
namespace_id: str,
|
||||
intent: Intent,
|
||||
transcript: str,
|
||||
) -> Task:
|
||||
"""Create a task from a detected intent."""
|
||||
task = Task(
|
||||
session_id=session_id,
|
||||
namespace_id=namespace_id,
|
||||
type=intent.type,
|
||||
intent_text=transcript,
|
||||
parameters=intent.parameters,
|
||||
)
|
||||
|
||||
await self.queue_task(task)
|
||||
return task
|
||||
|
||||
async def generate_response(
|
||||
self,
|
||||
session_messages: List[TranscriptMessage],
|
||||
intent: Optional[Intent],
|
||||
namespace_id: str,
|
||||
) -> str:
|
||||
"""Generate a conversational response."""
|
||||
from services.fallback_llm_client import FallbackLLMClient
|
||||
|
||||
llm = FallbackLLMClient()
|
||||
|
||||
# Build conversation context
|
||||
context = "\n".join([
|
||||
f"{msg.role}: {msg.content}"
|
||||
for msg in session_messages[-5:] # Last 5 messages
|
||||
])
|
||||
|
||||
# Generate response based on intent
|
||||
if intent:
|
||||
if intent.type in [TaskType.STUDENT_OBSERVATION, TaskType.REMINDER]:
|
||||
return "Verstanden, ich habe mir das notiert."
|
||||
|
||||
if intent.type == TaskType.WORKSHEET_GENERATE:
|
||||
return "Ich erstelle das Arbeitsblatt. Das kann einen Moment dauern."
|
||||
|
||||
if intent.type == TaskType.PARENT_LETTER:
|
||||
return "Ich bereite einen Elternbrief vor."
|
||||
|
||||
if intent.type == TaskType.QUIZ_GENERATE:
|
||||
return "Ich generiere den Quiz. Einen Moment bitte."
|
||||
|
||||
# Default: use LLM for conversational response
|
||||
prompt = f"""Du bist ein hilfreicher Assistent für Lehrer.
|
||||
Konversation:
|
||||
{context}
|
||||
|
||||
Antworte kurz und hilfreich auf die letzte Nachricht des Nutzers.
|
||||
Halte die Antwort unter 50 Wörtern."""
|
||||
|
||||
response = await llm.generate(prompt)
|
||||
return response
|
||||
Reference in New Issue
Block a user