146 lines
3.7 KiB
Python
146 lines
3.7 KiB
Python
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
|
|
|
|
class TurnState(StrEnum):
|
|
LISTENING = "listening"
|
|
PROCESSING = "processing"
|
|
SPEAKING = "speaking"
|
|
COMPLETED = "completed"
|
|
INTERRUPTED = "interrupted"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Turn:
|
|
turn_id: int
|
|
state: TurnState = TurnState.LISTENING
|
|
partial_text: str = ""
|
|
final_text: str | None = None
|
|
|
|
|
|
class TurnManager:
|
|
def __init__(self) -> None:
|
|
self._active_turn: Turn | None = None
|
|
|
|
@property
|
|
def active_turn(self) -> Turn | None:
|
|
return self._active_turn
|
|
|
|
def start(self, turn_id: int) -> Turn:
|
|
if self._active_turn is not None:
|
|
raise RuntimeError(
|
|
"A turn is already active."
|
|
)
|
|
|
|
self._active_turn = Turn(
|
|
turn_id=turn_id,
|
|
state=TurnState.LISTENING,
|
|
)
|
|
|
|
return self._active_turn
|
|
|
|
def on_partial(
|
|
self,
|
|
turn_id: int,
|
|
text: str,
|
|
) -> None:
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
if turn.state != TurnState.LISTENING:
|
|
raise RuntimeError(
|
|
f"Cannot receive partial transcript while "
|
|
f"turn is {turn.state.value}."
|
|
)
|
|
|
|
turn.partial_text = text
|
|
|
|
def on_final(
|
|
self,
|
|
turn_id: int,
|
|
text: str,
|
|
) -> None:
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
if turn.state != TurnState.LISTENING:
|
|
raise RuntimeError(
|
|
f"Cannot receive final transcript while "
|
|
f"turn is {turn.state.value}."
|
|
)
|
|
|
|
turn.final_text = text
|
|
turn.partial_text = text
|
|
turn.state = TurnState.PROCESSING
|
|
|
|
def on_agent_response(
|
|
self,
|
|
turn_id: int,
|
|
) -> None:
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
if turn.state != TurnState.PROCESSING:
|
|
raise RuntimeError(
|
|
f"Cannot start speaking while "
|
|
f"turn is {turn.state.value}."
|
|
)
|
|
|
|
turn.state = TurnState.SPEAKING
|
|
|
|
def on_tts_complete(
|
|
self,
|
|
turn_id: int,
|
|
) -> None:
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
if turn.state != TurnState.SPEAKING:
|
|
raise RuntimeError(
|
|
f"Cannot complete TTS while "
|
|
f"turn is {turn.state.value}."
|
|
)
|
|
|
|
turn.state = TurnState.COMPLETED
|
|
|
|
def stop(self, turn_id: int) -> Turn:
|
|
"""
|
|
Return the active turn when its complete lifecycle has finished.
|
|
|
|
A turn may only be stopped after the TTS stage has completed and
|
|
the semantic state has reached COMPLETED.
|
|
"""
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
if turn.state != TurnState.COMPLETED:
|
|
raise RuntimeError(
|
|
f"Cannot stop turn while turn is {turn.state.value}."
|
|
)
|
|
|
|
return turn
|
|
|
|
def interrupt(self, turn_id: int) -> Turn:
|
|
turn = self._require_active_turn(turn_id)
|
|
|
|
turn.state = TurnState.INTERRUPTED
|
|
return turn
|
|
|
|
def clear(self, turn_id: int) -> None:
|
|
"""
|
|
Clear the active turn after its lifecycle has been handled.
|
|
|
|
Turn identity validation is delegated to _require_active_turn().
|
|
"""
|
|
self._require_active_turn(turn_id)
|
|
self._active_turn = None
|
|
|
|
|
|
def _require_active_turn(self, turn_id: int) -> Turn:
|
|
if self._active_turn is None:
|
|
raise RuntimeError(
|
|
"There is no active turn."
|
|
)
|
|
|
|
if self._active_turn.turn_id != turn_id:
|
|
raise ValueError(
|
|
f"Unexpected turn_id={turn_id}; "
|
|
f"active_turn_id={self._active_turn.turn_id}."
|
|
)
|
|
|
|
return self._active_turn |