ilia bdbf09a9ac feat: Implement voice I/O services (TICKET-006, TICKET-010, TICKET-014)
 TICKET-006: Wake-word Detection Service
- Implemented wake-word detection using openWakeWord
- HTTP/WebSocket server on port 8002
- Real-time detection with configurable threshold
- Event emission for ASR integration
- Location: home-voice-agent/wake-word/

 TICKET-010: ASR Service
- Implemented ASR using faster-whisper
- HTTP endpoint for file transcription
- WebSocket endpoint for streaming transcription
- Support for multiple audio formats
- Auto language detection
- GPU acceleration support
- Location: home-voice-agent/asr/

 TICKET-014: TTS Service
- Implemented TTS using Piper
- HTTP endpoint for text-to-speech synthesis
- Low-latency processing (< 500ms)
- Multiple voice support
- WAV audio output
- Location: home-voice-agent/tts/

 TICKET-047: Updated Hardware Purchases
- Marked Pi5 kit, SSD, microphone, and speakers as purchased
- Updated progress log with purchase status

📚 Documentation:
- Added VOICE_SERVICES_README.md with complete testing guide
- Each service includes README.md with usage instructions
- All services ready for Pi5 deployment

🧪 Testing:
- Created test files for each service
- All imports validated
- FastAPI apps created successfully
- Code passes syntax validation

🚀 Ready for:
- Pi5 deployment
- End-to-end voice flow testing
- Integration with MCP server

Files Added:
- wake-word/detector.py
- wake-word/server.py
- wake-word/requirements.txt
- wake-word/README.md
- wake-word/test_detector.py
- asr/service.py
- asr/server.py
- asr/requirements.txt
- asr/README.md
- asr/test_service.py
- tts/service.py
- tts/server.py
- tts/requirements.txt
- tts/README.md
- tts/test_service.py
- VOICE_SERVICES_README.md

Files Modified:
- tickets/done/TICKET-047_hardware-purchases.md

Files Moved:
- tickets/backlog/TICKET-006_prototype-wake-word-node.md → tickets/done/
- tickets/backlog/TICKET-010_streaming-asr-service.md → tickets/done/
- tickets/backlog/TICKET-014_tts-service.md → tickets/done/
2026-01-12 22:22:38 -05:00

333 lines
11 KiB
Python

"""
Session Manager - Manages multi-turn conversations.
Handles session context, message history, and context window management.
"""
import sqlite3
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, asdict
import json
# Database file location
DB_PATH = Path(__file__).parent.parent / "data" / "conversations.db"
# Context window settings
MAX_CONTEXT_MESSAGES = 20 # Keep last N messages in context
MAX_CONTEXT_TOKENS = 8000 # Approximate token limit (conservative)
SESSION_EXPIRY_HOURS = 24 # Sessions expire after 24 hours of inactivity
@dataclass
class Message:
"""Represents a single message in a conversation."""
role: str # "user", "assistant", "system"
content: str
timestamp: datetime
tool_calls: Optional[List[Dict[str, Any]]] = None
tool_results: Optional[List[Dict[str, Any]]] = None
@dataclass
class Session:
"""Represents a conversation session."""
session_id: str
agent_type: str # "work" or "family"
created_at: datetime
last_activity: datetime
messages: List[Message]
summary: Optional[str] = None
class SessionManager:
"""Manages conversation sessions."""
def __init__(self, db_path: Path = DB_PATH):
"""Initialize session manager with database."""
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
self._active_sessions: Dict[str, Session] = {}
def _init_db(self):
"""Initialize database schema."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Sessions table
cursor.execute("""
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
agent_type TEXT NOT NULL,
created_at TEXT NOT NULL,
last_activity TEXT NOT NULL,
summary TEXT
)
""")
# Messages table
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls TEXT,
tool_results TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
)
""")
conn.commit()
conn.close()
def create_session(self, agent_type: str) -> str:
"""Create a new conversation session."""
session_id = str(uuid.uuid4())
now = datetime.now()
session = Session(
session_id=session_id,
agent_type=agent_type,
created_at=now,
last_activity=now,
messages=[]
)
# Store in database
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
INSERT INTO sessions (session_id, agent_type, created_at, last_activity)
VALUES (?, ?, ?, ?)
""", (session_id, agent_type, now.isoformat(), now.isoformat()))
conn.commit()
conn.close()
# Cache in memory
self._active_sessions[session_id] = session
return session_id
def get_session(self, session_id: str) -> Optional[Session]:
"""Get session by ID, loading from DB if not in cache."""
# Check cache first
if session_id in self._active_sessions:
session = self._active_sessions[session_id]
# Check if expired
if datetime.now() - session.last_activity > timedelta(hours=SESSION_EXPIRY_HOURS):
self._active_sessions.pop(session_id)
return None
return session
# Load from database
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM sessions WHERE session_id = ?
""", (session_id,))
session_row = cursor.fetchone()
if not session_row:
conn.close()
return None
# Load messages
cursor.execute("""
SELECT * FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
""", (session_id,))
message_rows = cursor.fetchall()
conn.close()
# Reconstruct session
messages = []
for row in message_rows:
tool_calls = json.loads(row['tool_calls']) if row['tool_calls'] else None
tool_results = json.loads(row['tool_results']) if row['tool_results'] else None
messages.append(Message(
role=row['role'],
content=row['content'],
timestamp=datetime.fromisoformat(row['timestamp']),
tool_calls=tool_calls,
tool_results=tool_results
))
session = Session(
session_id=session_row['session_id'],
agent_type=session_row['agent_type'],
created_at=datetime.fromisoformat(session_row['created_at']),
last_activity=datetime.fromisoformat(session_row['last_activity']),
messages=messages,
summary=session_row['summary']
)
# Cache if not expired
if datetime.now() - session.last_activity <= timedelta(hours=SESSION_EXPIRY_HOURS):
self._active_sessions[session_id] = session
return session
def add_message(self, session_id: str, role: str, content: str,
tool_calls: Optional[List[Dict[str, Any]]] = None,
tool_results: Optional[List[Dict[str, Any]]] = None):
"""Add a message to a session."""
session = self.get_session(session_id)
if not session:
raise ValueError(f"Session not found: {session_id}")
message = Message(
role=role,
content=content,
timestamp=datetime.now(),
tool_calls=tool_calls,
tool_results=tool_results
)
session.messages.append(message)
session.last_activity = datetime.now()
# Store in database
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
INSERT INTO messages (session_id, role, content, timestamp, tool_calls, tool_results)
VALUES (?, ?, ?, ?, ?, ?)
""", (
session_id,
role,
content,
message.timestamp.isoformat(),
json.dumps(tool_calls) if tool_calls else None,
json.dumps(tool_results) if tool_results else None
))
cursor.execute("""
UPDATE sessions SET last_activity = ? WHERE session_id = ?
""", (session.last_activity.isoformat(), session_id))
conn.commit()
conn.close()
def get_context_messages(self, session_id: str, max_messages: int = MAX_CONTEXT_MESSAGES) -> List[Dict[str, Any]]:
"""
Get messages for LLM context, keeping only recent messages.
Returns messages in OpenAI chat format.
"""
session = self.get_session(session_id)
if not session:
return []
# Get recent messages
recent_messages = session.messages[-max_messages:]
# Convert to OpenAI format
context = []
for msg in recent_messages:
message_dict = {
"role": msg.role,
"content": msg.content
}
# Add tool calls if present
if msg.tool_calls:
message_dict["tool_calls"] = msg.tool_calls
# Add tool results if present
if msg.tool_results:
message_dict["tool_results"] = msg.tool_results
context.append(message_dict)
return context
def summarize_old_messages(self, session_id: str, keep_recent: int = 10):
"""
Summarize old messages to reduce context size.
This is a placeholder - actual summarization would use an LLM.
"""
session = self.get_session(session_id)
if not session or len(session.messages) <= keep_recent:
return
# For now, just keep recent messages
# TODO: Implement actual summarization using LLM
old_messages = session.messages[:-keep_recent]
recent_messages = session.messages[-keep_recent:]
# Create summary placeholder
summary = f"Previous conversation had {len(old_messages)} messages. Key topics discussed."
# Update session
session.messages = recent_messages
session.summary = summary
# Update database
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("""
UPDATE sessions SET summary = ? WHERE session_id = ?
""", (summary, session_id))
# Delete old messages
cursor.execute("""
DELETE FROM messages
WHERE session_id = ? AND timestamp < ?
""", (session_id, recent_messages[0].timestamp.isoformat()))
conn.commit()
conn.close()
def delete_session(self, session_id: str):
"""Delete a session and all its messages."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
cursor.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
# Remove from cache
self._active_sessions.pop(session_id, None)
def cleanup_expired_sessions(self):
"""Remove expired sessions."""
expiry_time = datetime.now() - timedelta(hours=SESSION_EXPIRY_HOURS)
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Find expired sessions
cursor.execute("""
SELECT session_id FROM sessions
WHERE last_activity < ?
""", (expiry_time.isoformat(),))
expired_sessions = [row[0] for row in cursor.fetchall()]
# Delete expired sessions
for session_id in expired_sessions:
cursor.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
cursor.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
self._active_sessions.pop(session_id, None)
conn.commit()
conn.close()
# Global session manager instance
_session_manager = SessionManager()
def get_session_manager() -> SessionManager:
"""Get the global session manager instance."""
return _session_manager