| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- # -*- coding: utf-8 -*-
- """
- Conversation Session - Einzelne Dialog-Session.
- """
- from dataclasses import dataclass, field
- from datetime import datetime, timedelta
- from enum import Enum
- from typing import Any
- import uuid
- class SessionState(Enum):
- """Zustand einer Conversation-Session."""
- CREATED = "created" # Session erstellt
- AWAITING_INPUT = "awaiting" # Warte auf User-Input
- PROCESSING = "processing" # Verarbeite Input
- RESPONDING = "responding" # Antwort wird generiert
- FOLLOW_UP = "follow_up" # Rückfrage gestellt
- COMPLETED = "completed" # Session abgeschlossen
- TIMEOUT = "timeout" # Session durch Timeout beendet
- CANCELLED = "cancelled" # Session abgebrochen
- class TurnType(Enum):
- """Typ eines Dialog-Turns."""
- USER_SPEECH = "user_speech" # User-Spracheingabe
- USER_TEXT = "user_text" # User-Texteingabe
- ASSISTANT_SPEECH = "assistant_speech" # Assistenten-Sprachausgabe
- ASSISTANT_TEXT = "assistant_text" # Assistenten-Text
- FOLLOW_UP = "follow_up" # Rückfrage
- SYSTEM = "system" # System-Nachricht
- @dataclass
- class SessionTurn:
- """Ein Turn (Gesprächsbeitrag) in der Session."""
- turn_id: str
- turn_type: TurnType
- timestamp: datetime
- content: str # Text-Inhalt (transkribiert oder generiert)
- # Audio-Daten (optional)
- audio_data: bytes | None = None
- audio_duration_seconds: float = 0.0
- # Metadaten
- metadata: dict[str, Any] = field(default_factory=dict)
- # Intent/Entity-Erkennung
- intent: str | None = None
- entities: dict[str, Any] = field(default_factory=dict)
- confidence: float = 0.0
- # Für Rückfragen
- requires_response: bool = False
- response_turn_id: str | None = None
- def to_dict(self) -> dict:
- """Konvertiert zu Dictionary."""
- return {
- "turn_id": self.turn_id,
- "turn_type": self.turn_type.value,
- "timestamp": self.timestamp.isoformat(),
- "content": self.content,
- "audio_duration": self.audio_duration_seconds,
- "intent": self.intent,
- "entities": self.entities,
- "confidence": self.confidence,
- "requires_response": self.requires_response,
- "metadata": self.metadata,
- }
- @dataclass
- class SessionConfig:
- """Konfiguration für Conversation-Sessions."""
- # Timeouts
- input_timeout_seconds: float = 60.0 # Timeout für User-Input
- follow_up_timeout_seconds: float = 60.0 # Timeout für Rückfrage-Antwort
- session_timeout_seconds: float = 300.0 # Gesamt-Session-Timeout
- # Limits
- max_turns: int = 20 # Maximale Turns pro Session
- max_follow_ups: int = 5 # Maximale Rückfragen
- # Verhalten
- auto_complete_on_timeout: bool = True # Bei Timeout automatisch abschließen
- class ConversationSession:
- """
- Eine Conversation-Session.
- Repräsentiert einen kompletten Dialog von Wakeword bis Abschluss.
- Trackt alle Turns (User-Eingaben, Assistenten-Antworten, Rückfragen).
- """
- def __init__(
- self,
- session_id: str | None = None,
- satellite_id: str | None = None,
- wakeword_type: str | None = None,
- config: SessionConfig | None = None,
- ):
- """
- Initialisiert die Session.
- Args:
- session_id: Session-ID (wird generiert wenn None)
- satellite_id: ID des auslösenden Satellites
- wakeword_type: Typ des erkannten Wakewords
- config: Session-Konfiguration
- """
- self._session_id = session_id or f"conv-{uuid.uuid4().hex[:12]}"
- self._satellite_id = satellite_id
- self._wakeword_type = wakeword_type
- self._config = config or SessionConfig()
- # Timestamps
- self._created_at = datetime.now()
- self._started_at: datetime | None = None
- self._completed_at: datetime | None = None
- self._last_activity = datetime.now()
- # State
- self._state = SessionState.CREATED
- self._turns: list[SessionTurn] = []
- self._follow_up_count = 0
- # Kontext für diese Session
- self._context: dict[str, Any] = {}
- # Pending Follow-Up
- self._pending_follow_up: SessionTurn | None = None
- # === Properties ===
- @property
- def session_id(self) -> str:
- """Session-ID."""
- return self._session_id
- @property
- def satellite_id(self) -> str | None:
- """Satellite-ID."""
- return self._satellite_id
- @property
- def wakeword_type(self) -> str | None:
- """Wakeword-Typ."""
- return self._wakeword_type
- @property
- def config(self) -> SessionConfig:
- """Konfiguration."""
- return self._config
- @property
- def state(self) -> SessionState:
- """Aktueller Zustand."""
- return self._state
- @property
- def is_active(self) -> bool:
- """Prüft ob Session aktiv ist."""
- return self._state in (
- SessionState.CREATED,
- SessionState.AWAITING_INPUT,
- SessionState.PROCESSING,
- SessionState.RESPONDING,
- SessionState.FOLLOW_UP,
- )
- @property
- def is_completed(self) -> bool:
- """Prüft ob Session abgeschlossen ist."""
- return self._state in (
- SessionState.COMPLETED,
- SessionState.TIMEOUT,
- SessionState.CANCELLED,
- )
- @property
- def created_at(self) -> datetime:
- """Erstellungszeit."""
- return self._created_at
- @property
- def started_at(self) -> datetime | None:
- """Startzeit (erster User-Turn)."""
- return self._started_at
- @property
- def completed_at(self) -> datetime | None:
- """Abschlusszeit."""
- return self._completed_at
- @property
- def last_activity(self) -> datetime:
- """Letzte Aktivität."""
- return self._last_activity
- @property
- def duration_seconds(self) -> float:
- """Session-Dauer in Sekunden."""
- end = self._completed_at or datetime.now()
- return (end - self._created_at).total_seconds()
- @property
- def turns(self) -> list[SessionTurn]:
- """Alle Turns."""
- return list(self._turns)
- @property
- def turn_count(self) -> int:
- """Anzahl Turns."""
- return len(self._turns)
- @property
- def user_turns(self) -> list[SessionTurn]:
- """Nur User-Turns."""
- return [t for t in self._turns if t.turn_type in (
- TurnType.USER_SPEECH, TurnType.USER_TEXT
- )]
- @property
- def assistant_turns(self) -> list[SessionTurn]:
- """Nur Assistenten-Turns."""
- return [t for t in self._turns if t.turn_type in (
- TurnType.ASSISTANT_SPEECH, TurnType.ASSISTANT_TEXT, TurnType.FOLLOW_UP
- )]
- @property
- def follow_up_count(self) -> int:
- """Anzahl Rückfragen."""
- return self._follow_up_count
- @property
- def has_pending_follow_up(self) -> bool:
- """Prüft ob Rückfrage aussteht."""
- return self._pending_follow_up is not None
- @property
- def context(self) -> dict[str, Any]:
- """Session-Kontext."""
- return self._context
- # === Turn Management ===
- def add_user_turn(
- self,
- content: str,
- audio_data: bytes | None = None,
- audio_duration: float = 0.0,
- intent: str | None = None,
- entities: dict | None = None,
- confidence: float = 0.0,
- metadata: dict | None = None,
- ) -> SessionTurn:
- """
- Fügt User-Turn hinzu.
- Args:
- content: Transkribierter Text
- audio_data: Original-Audio (optional)
- audio_duration: Audio-Dauer
- intent: Erkannter Intent
- entities: Extrahierte Entities
- confidence: Erkennungs-Confidence
- metadata: Zusätzliche Metadaten
- Returns:
- Erstellter Turn
- """
- self._check_can_add_turn()
- turn = SessionTurn(
- turn_id=self._generate_turn_id(),
- turn_type=TurnType.USER_SPEECH if audio_data else TurnType.USER_TEXT,
- timestamp=datetime.now(),
- content=content,
- audio_data=audio_data,
- audio_duration_seconds=audio_duration,
- intent=intent,
- entities=entities or {},
- confidence=confidence,
- metadata=metadata or {},
- )
- self._add_turn(turn)
- # Erster User-Turn startet die Session
- if self._started_at is None:
- self._started_at = turn.timestamp
- # Wenn Follow-Up beantwortet wurde
- if self._pending_follow_up:
- self._pending_follow_up.response_turn_id = turn.turn_id
- self._pending_follow_up = None
- self._state = SessionState.PROCESSING
- return turn
- def add_assistant_turn(
- self,
- content: str,
- audio_data: bytes | None = None,
- audio_duration: float = 0.0,
- is_follow_up: bool = False,
- metadata: dict | None = None,
- ) -> SessionTurn:
- """
- Fügt Assistenten-Turn hinzu.
- Args:
- content: Antwort-Text
- audio_data: Generiertes Audio (optional)
- audio_duration: Audio-Dauer
- is_follow_up: Ist dies eine Rückfrage?
- metadata: Zusätzliche Metadaten
- Returns:
- Erstellter Turn
- """
- turn_type = TurnType.FOLLOW_UP if is_follow_up else TurnType.ASSISTANT_SPEECH
- turn = SessionTurn(
- turn_id=self._generate_turn_id(),
- turn_type=turn_type,
- timestamp=datetime.now(),
- content=content,
- audio_data=audio_data,
- audio_duration_seconds=audio_duration,
- metadata=metadata or {},
- requires_response=is_follow_up,
- )
- self._add_turn(turn)
- if is_follow_up:
- self._follow_up_count += 1
- self._pending_follow_up = turn
- self._state = SessionState.FOLLOW_UP
- else:
- self._state = SessionState.RESPONDING
- return turn
- def add_system_turn(
- self,
- content: str,
- metadata: dict | None = None,
- ) -> SessionTurn:
- """
- Fügt System-Turn hinzu.
- Args:
- content: System-Nachricht
- metadata: Zusätzliche Metadaten
- Returns:
- Erstellter Turn
- """
- turn = SessionTurn(
- turn_id=self._generate_turn_id(),
- turn_type=TurnType.SYSTEM,
- timestamp=datetime.now(),
- content=content,
- metadata=metadata or {},
- )
- self._add_turn(turn)
- return turn
- def _add_turn(self, turn: SessionTurn) -> None:
- """Interne Methode zum Hinzufügen eines Turns."""
- self._turns.append(turn)
- self._last_activity = turn.timestamp
- def _check_can_add_turn(self) -> None:
- """Prüft ob Turn hinzugefügt werden kann."""
- if self.is_completed:
- raise RuntimeError(f"Session {self._session_id} ist bereits abgeschlossen")
- if len(self._turns) >= self._config.max_turns:
- raise RuntimeError(f"Maximale Turns ({self._config.max_turns}) erreicht")
- def _generate_turn_id(self) -> str:
- """Generiert Turn-ID."""
- return f"{self._session_id}-t{len(self._turns) + 1}"
- # === Lifecycle ===
- def start(self) -> None:
- """Startet die Session."""
- if self._state != SessionState.CREATED:
- return
- self._started_at = datetime.now()
- self._state = SessionState.AWAITING_INPUT
- def complete(self, reason: str | None = None) -> None:
- """
- Schließt die Session ab.
- Args:
- reason: Abschlussgrund (optional)
- """
- if self.is_completed:
- return
- self._state = SessionState.COMPLETED
- self._completed_at = datetime.now()
- if reason:
- self.add_system_turn(f"Session beendet: {reason}")
- def timeout(self) -> None:
- """Beendet Session wegen Timeout."""
- if self.is_completed:
- return
- self._state = SessionState.TIMEOUT
- self._completed_at = datetime.now()
- self.add_system_turn("Session-Timeout")
- def cancel(self, reason: str | None = None) -> None:
- """
- Bricht Session ab.
- Args:
- reason: Abbruchgrund
- """
- if self.is_completed:
- return
- self._state = SessionState.CANCELLED
- self._completed_at = datetime.now()
- if reason:
- self.add_system_turn(f"Session abgebrochen: {reason}")
- def check_timeout(self) -> bool:
- """
- Prüft ob Session getimeoutet ist.
- Returns:
- True wenn Timeout erreicht
- """
- now = datetime.now()
- # Gesamt-Timeout
- if self.duration_seconds >= self._config.session_timeout_seconds:
- if self._config.auto_complete_on_timeout:
- self.timeout()
- return True
- # Input-Timeout (wenn auf Input gewartet wird)
- if self._state == SessionState.AWAITING_INPUT:
- idle_seconds = (now - self._last_activity).total_seconds()
- if idle_seconds >= self._config.input_timeout_seconds:
- if self._config.auto_complete_on_timeout:
- self.timeout()
- return True
- # Follow-Up-Timeout
- if self._state == SessionState.FOLLOW_UP:
- idle_seconds = (now - self._last_activity).total_seconds()
- if idle_seconds >= self._config.follow_up_timeout_seconds:
- if self._config.auto_complete_on_timeout:
- self.timeout()
- return True
- return False
- # === Context ===
- def set_context(self, key: str, value: Any) -> None:
- """Setzt Kontext-Variable."""
- self._context[key] = value
- def get_context(self, key: str, default: Any = None) -> Any:
- """Gibt Kontext-Variable zurück."""
- return self._context.get(key, default)
- def clear_context(self) -> None:
- """Löscht Kontext."""
- self._context.clear()
- # === Serialization ===
- def to_dict(self) -> dict:
- """Konvertiert zu Dictionary."""
- return {
- "session_id": self._session_id,
- "satellite_id": self._satellite_id,
- "wakeword_type": self._wakeword_type,
- "state": self._state.value,
- "created_at": self._created_at.isoformat(),
- "started_at": self._started_at.isoformat() if self._started_at else None,
- "completed_at": self._completed_at.isoformat() if self._completed_at else None,
- "duration_seconds": self.duration_seconds,
- "turn_count": len(self._turns),
- "follow_up_count": self._follow_up_count,
- "turns": [t.to_dict() for t in self._turns],
- "context": self._context,
- }
- def get_transcript(self) -> str:
- """
- Gibt Transkript der Session zurück.
- Returns:
- Formatiertes Transkript
- """
- lines = []
- for turn in self._turns:
- if turn.turn_type == TurnType.SYSTEM:
- lines.append(f"[System] {turn.content}")
- elif turn.turn_type in (TurnType.USER_SPEECH, TurnType.USER_TEXT):
- lines.append(f"User: {turn.content}")
- elif turn.turn_type == TurnType.FOLLOW_UP:
- lines.append(f"Assistent (Rückfrage): {turn.content}")
- else:
- lines.append(f"Assistent: {turn.content}")
- return "\n".join(lines)
|