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

208 lines
5.8 KiB
Python

"""
Conversation retention and deletion policies.
"""
import logging
from pathlib import Path
from typing import Optional, List
from datetime import datetime, timedelta
import sqlite3
logger = logging.getLogger(__name__)
class RetentionPolicy:
"""Defines retention policies for conversations."""
def __init__(self,
max_age_days: int = 90,
max_sessions: int = 1000,
auto_delete: bool = False):
"""
Initialize retention policy.
Args:
max_age_days: Maximum age in days before deletion
max_sessions: Maximum number of sessions to keep
auto_delete: Whether to auto-delete old sessions
"""
self.max_age_days = max_age_days
self.max_sessions = max_sessions
self.auto_delete = auto_delete
def should_delete(self, session_timestamp: datetime) -> bool:
"""
Check if session should be deleted based on age.
Args:
session_timestamp: When session was created
Returns:
True if should be deleted
"""
age = datetime.now() - session_timestamp
return age.days > self.max_age_days
class ConversationRetention:
"""Manages conversation retention and deletion."""
def __init__(self, db_path: Optional[Path] = None, policy: Optional[RetentionPolicy] = None):
"""
Initialize retention manager.
Args:
db_path: Path to conversations database
policy: Retention policy
"""
if db_path is None:
db_path = Path(__file__).parent.parent.parent / "data" / "conversations.db"
self.db_path = db_path
self.policy = policy or RetentionPolicy()
def list_old_sessions(self) -> List[tuple]:
"""
List sessions that should be deleted.
Returns:
List of (session_id, created_at) tuples
"""
if not self.db_path.exists():
return []
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cutoff_date = datetime.now() - timedelta(days=self.policy.max_age_days)
cursor.execute("""
SELECT session_id, created_at
FROM sessions
WHERE created_at < ?
ORDER BY created_at ASC
""", (cutoff_date.isoformat(),))
rows = cursor.fetchall()
conn.close()
return [(row["session_id"], row["created_at"]) for row in rows]
def delete_session(self, session_id: str) -> bool:
"""
Delete a session.
Args:
session_id: Session ID to delete
Returns:
True if deleted successfully
"""
if not self.db_path.exists():
return False
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
try:
# Delete session
cursor.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
# Delete messages
cursor.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.commit()
logger.info(f"Deleted session: {session_id}")
return True
except Exception as e:
logger.error(f"Error deleting session {session_id}: {e}")
conn.rollback()
return False
finally:
conn.close()
def cleanup_old_sessions(self) -> int:
"""
Clean up old sessions based on policy.
Returns:
Number of sessions deleted
"""
if not self.policy.auto_delete:
return 0
old_sessions = self.list_old_sessions()
deleted_count = 0
for session_id, _ in old_sessions:
if self.delete_session(session_id):
deleted_count += 1
logger.info(f"Cleaned up {deleted_count} old sessions")
return deleted_count
def get_session_count(self) -> int:
"""
Get total number of sessions.
Returns:
Number of sessions
"""
if not self.db_path.exists():
return 0
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sessions")
count = cursor.fetchone()[0]
conn.close()
return count
def enforce_max_sessions(self) -> int:
"""
Enforce maximum session limit by deleting oldest sessions.
Returns:
Number of sessions deleted
"""
current_count = self.get_session_count()
if current_count <= self.policy.max_sessions:
return 0
# Get oldest sessions to delete
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT session_id
FROM sessions
ORDER BY created_at ASC
LIMIT ?
""", (current_count - self.policy.max_sessions,))
rows = cursor.fetchall()
conn.close()
deleted_count = 0
for row in rows:
if self.delete_session(row["session_id"]):
deleted_count += 1
logger.info(f"Enforced max sessions: deleted {deleted_count} sessions")
return deleted_count
# Global retention manager
_retention = ConversationRetention()
def get_retention_manager() -> ConversationRetention:
"""Get the global retention manager instance."""
return _retention