feat(platform): add new services, tools, tests and crews modules
New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
This commit is contained in:
220
services/memory-service/app/integration_endpoints.py
Normal file
220
services/memory-service/app/integration_endpoints.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
DAARION Memory Service - Integration API Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
from .integrations import obsidian_integrator, gdrive_integrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/integrations", tags=["integrations"])
|
||||
|
||||
|
||||
class SetVaultRequest(BaseModel):
|
||||
vault_path: str
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
output_dir: Optional[str] = "/tmp/daarion_sync"
|
||||
include_attachments: Optional[bool] = False
|
||||
folder_filter: Optional[List[str]] = None
|
||||
tag_filter: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GDriveSyncRequest(BaseModel):
|
||||
output_dir: Optional[str] = "/tmp/daarion_sync"
|
||||
folder_ids: Optional[List[str]] = None
|
||||
file_extensions: Optional[List[str]] = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_integrations_status():
|
||||
"""Get status of all integrations"""
|
||||
obsidian_status = obsidian_integrator.get_status()
|
||||
gdrive_status = gdrive_integrator.get_status()
|
||||
|
||||
return {
|
||||
"obsidian": obsidian_status,
|
||||
"google_drive": gdrive_status
|
||||
}
|
||||
|
||||
|
||||
# =====================
|
||||
# OBSIDIAN ENDPOINTS
|
||||
# =====================
|
||||
|
||||
@router.post("/obsidian/set-vault")
|
||||
async def set_obsidian_vault(request: SetVaultRequest):
|
||||
"""Set Obsidian vault path"""
|
||||
success = obsidian_integrator.set_vault_path(request.vault_path)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Invalid vault path")
|
||||
return {"status": "ok", "vault_path": request.vault_path}
|
||||
|
||||
|
||||
@router.post("/obsidian/scan")
|
||||
async def scan_obsidian_vault():
|
||||
"""Scan Obsidian vault for notes"""
|
||||
if not obsidian_integrator.vault_path:
|
||||
raise HTTPException(status_code=400, detail="Vault path not set")
|
||||
|
||||
stats = obsidian_integrator.scan_vault()
|
||||
return {
|
||||
"status": "ok",
|
||||
"stats": stats
|
||||
}
|
||||
|
||||
|
||||
@router.get("/obsidian/search")
|
||||
async def search_obsidian_notes(query: str, limit: int = 10):
|
||||
"""Search Obsidian notes"""
|
||||
if not obsidian_integrator.notes_cache:
|
||||
raise HTTPException(status_code=400, detail="Vault not scanned")
|
||||
|
||||
results = obsidian_integrator.search_notes(query, limit=limit)
|
||||
return {
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"title": r["title"],
|
||||
"path": r["path"],
|
||||
"tags": r["tags"],
|
||||
"match_score": r.get("match_score", 0),
|
||||
"preview": r["content"][:200] + "..." if len(r["content"]) > 200 else r["content"]
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/obsidian/sync")
|
||||
async def sync_obsidian_vault(request: SyncRequest):
|
||||
"""Sync Obsidian vault to DAARION"""
|
||||
if not obsidian_integrator.vault_path:
|
||||
raise HTTPException(status_code=400, detail="Vault path not set")
|
||||
|
||||
if not obsidian_integrator.notes_cache:
|
||||
obsidian_integrator.scan_vault()
|
||||
|
||||
from pathlib import Path
|
||||
output_path = Path(request.output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stats = obsidian_integrator.sync_to_daarion(
|
||||
output_path,
|
||||
include_attachments=request.include_attachments,
|
||||
folder_filter=request.folder_filter,
|
||||
tag_filter=request.tag_filter
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"stats": stats
|
||||
}
|
||||
|
||||
|
||||
@router.get("/obsidian/tags")
|
||||
async def get_obsidian_tags():
|
||||
"""Get all tags from Obsidian vault"""
|
||||
if not obsidian_integrator.tags_index:
|
||||
raise HTTPException(status_code=400, detail="Vault not scanned")
|
||||
|
||||
return {
|
||||
"tags": [
|
||||
{"name": tag, "count": len(notes)}
|
||||
for tag, notes in sorted(
|
||||
obsidian_integrator.tags_index.items(),
|
||||
key=lambda x: len(x[1]),
|
||||
reverse=True
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/obsidian/graph")
|
||||
async def get_obsidian_graph():
|
||||
"""Get note connection graph"""
|
||||
if not obsidian_integrator.links_graph:
|
||||
raise HTTPException(status_code=400, detail="Vault not scanned")
|
||||
|
||||
nodes = []
|
||||
links = []
|
||||
|
||||
for note_title, note_data in obsidian_integrator.notes_cache.items():
|
||||
nodes.append({
|
||||
"id": note_title,
|
||||
"title": note_title,
|
||||
"tags": note_data["tags"],
|
||||
"size": note_data["size"]
|
||||
})
|
||||
|
||||
for note_title, graph_data in obsidian_integrator.links_graph.items():
|
||||
for linked_note in graph_data["outbound"]:
|
||||
if linked_note in obsidian_integrator.notes_cache:
|
||||
links.append({
|
||||
"source": note_title,
|
||||
"target": linked_note
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": links
|
||||
}
|
||||
|
||||
|
||||
# =====================
|
||||
# GOOGLE DRIVE ENDPOINTS
|
||||
# =====================
|
||||
|
||||
@router.post("/google-drive/auth")
|
||||
async def authenticate_google_drive():
|
||||
"""Authenticate with Google Drive"""
|
||||
success = gdrive_integrator.authenticate()
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication failed. Check client_secrets.json"
|
||||
)
|
||||
return {"status": "ok", "authenticated": True}
|
||||
|
||||
|
||||
@router.get("/google-drive/files")
|
||||
async def list_google_drive_files(
|
||||
folder_id: Optional[str] = None,
|
||||
max_results: int = 50
|
||||
):
|
||||
"""List files from Google Drive"""
|
||||
files = gdrive_integrator.list_files(folder_id=folder_id, max_results=max_results)
|
||||
return {
|
||||
"files": files,
|
||||
"count": len(files)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/google-drive/sync")
|
||||
async def sync_google_drive(request: GDriveSyncRequest):
|
||||
"""Sync files from Google Drive"""
|
||||
from pathlib import Path
|
||||
output_path = Path(request.output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stats = gdrive_integrator.sync_to_daarion(
|
||||
output_path,
|
||||
folder_ids=request.folder_ids,
|
||||
file_extensions=request.file_extensions
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"stats": stats
|
||||
}
|
||||
|
||||
|
||||
@router.get("/google-drive/folders")
|
||||
async def get_google_drive_folders():
|
||||
"""Get folder structure from Google Drive"""
|
||||
structure = gdrive_integrator.get_folder_structure()
|
||||
return {"structure": structure}
|
||||
482
services/memory-service/app/integrations.py
Normal file
482
services/memory-service/app/integrations.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
DAARION Memory Service - Integrations
|
||||
Obsidian та Google Drive інтеграції
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import shutil
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Set, Optional, Any, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObsidianIntegrator:
|
||||
"""Obsidian інтегратор для Memory Service"""
|
||||
|
||||
HOST_VAULT_MAPPINGS = {
|
||||
'/vault/rd': '/Users/apple/Desktop/R&D',
|
||||
'/vault/obsidian': '/Users/apple/Documents/Obsidian Vault',
|
||||
}
|
||||
|
||||
def __init__(self, vault_path: str = None):
|
||||
self.vault_path = self._resolve_vault_path(vault_path) if vault_path else None
|
||||
self.notes_cache = {}
|
||||
self.links_graph = {}
|
||||
self.tags_index = {}
|
||||
|
||||
def _resolve_vault_path(self, path: str) -> Path:
|
||||
"""Resolve vault path, handling Docker-to-host mappings"""
|
||||
path_obj = Path(path)
|
||||
if path_obj.exists():
|
||||
return path_obj
|
||||
if path in self.HOST_VAULT_MAPPINGS:
|
||||
resolved = Path(self.HOST_VAULT_MAPPINGS[path])
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
return path_obj
|
||||
|
||||
def find_vault(self) -> Optional[Path]:
|
||||
"""Автоматично знайти Obsidian vault"""
|
||||
possible_paths = [
|
||||
Path.home() / "Documents" / "Obsidian Vault",
|
||||
Path.home() / "Documents" / "Notes",
|
||||
Path.home() / "Desktop" / "Obsidian Vault",
|
||||
Path.home() / "Obsidian",
|
||||
Path.home() / "Notes",
|
||||
]
|
||||
|
||||
documents_path = Path.home() / "Documents"
|
||||
if documents_path.exists():
|
||||
for item in documents_path.iterdir():
|
||||
if item.is_dir() and (item / ".obsidian").exists():
|
||||
possible_paths.append(item)
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists() and (path / ".obsidian").exists():
|
||||
logger.info(f"Found Obsidian vault at: {path}")
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def set_vault_path(self, vault_path: str) -> bool:
|
||||
resolved = self._resolve_vault_path(vault_path)
|
||||
if not resolved.exists():
|
||||
logger.error(f"Vault path does not exist: {vault_path} (resolved: {resolved})")
|
||||
return False
|
||||
|
||||
if not (resolved / ".obsidian").exists():
|
||||
logger.error(f"Not a valid Obsidian vault: {vault_path} (resolved: {resolved})")
|
||||
return False
|
||||
|
||||
self.vault_path = resolved
|
||||
logger.info(f"Vault path set to: {resolved}")
|
||||
return True
|
||||
|
||||
def scan_vault(self) -> Dict[str, Any]:
|
||||
if not self.vault_path:
|
||||
return {}
|
||||
|
||||
stats = {
|
||||
'total_notes': 0,
|
||||
'total_attachments': 0,
|
||||
'total_links': 0,
|
||||
'total_tags': 0,
|
||||
'folders': set(),
|
||||
'file_types': {},
|
||||
'notes': []
|
||||
}
|
||||
|
||||
for file_path in self.vault_path.rglob('*'):
|
||||
if file_path.is_file() and not file_path.name.startswith('.'):
|
||||
suffix = file_path.suffix.lower()
|
||||
stats['file_types'][suffix] = stats['file_types'].get(suffix, 0) + 1
|
||||
|
||||
relative_folder = file_path.parent.relative_to(self.vault_path)
|
||||
if relative_folder != Path('.'):
|
||||
stats['folders'].add(str(relative_folder))
|
||||
|
||||
if suffix == '.md':
|
||||
note_data = self._parse_note(file_path)
|
||||
if note_data:
|
||||
self.notes_cache[file_path.stem] = note_data
|
||||
stats['notes'].append(note_data)
|
||||
stats['total_notes'] += 1
|
||||
stats['total_links'] += len(note_data['links'])
|
||||
stats['total_tags'] += len(note_data['tags'])
|
||||
else:
|
||||
stats['total_attachments'] += 1
|
||||
|
||||
self._build_links_graph()
|
||||
self._build_tags_index()
|
||||
|
||||
stats['folders'] = list(stats['folders'])
|
||||
return stats
|
||||
|
||||
def _parse_note(self, file_path: Path) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
note_data = {
|
||||
'title': file_path.stem,
|
||||
'path': str(file_path.relative_to(self.vault_path)),
|
||||
'full_path': str(file_path),
|
||||
'size': len(content),
|
||||
'created': datetime.fromtimestamp(file_path.stat().st_ctime),
|
||||
'modified': datetime.fromtimestamp(file_path.stat().st_mtime),
|
||||
'content': content,
|
||||
'content_hash': hashlib.md5(content.encode()).hexdigest(),
|
||||
'frontmatter': {},
|
||||
'headings': [],
|
||||
'links': [],
|
||||
'tags': [],
|
||||
'backlinks': [],
|
||||
'blocks': []
|
||||
}
|
||||
|
||||
frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
||||
if frontmatter_match:
|
||||
try:
|
||||
import yaml
|
||||
note_data['frontmatter'] = yaml.safe_load(frontmatter_match.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headings = re.findall(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE)
|
||||
note_data['headings'] = [(len(h[0]), h[1].strip()) for h in headings]
|
||||
|
||||
internal_links = re.findall(r'\[\[([^\]]+)\]\]', content)
|
||||
note_data['links'] = [link.split('|')[0].strip() for link in internal_links]
|
||||
|
||||
tags = re.findall(r'(?:^|\s)#([\w\-\/]+)', content)
|
||||
note_data['tags'] = list(set(tags))
|
||||
|
||||
blocks = re.findall(r'\^([\w\-]+)', content)
|
||||
note_data['blocks'] = blocks
|
||||
|
||||
return note_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing note {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _build_links_graph(self):
|
||||
self.links_graph = {}
|
||||
|
||||
for note_title, note_data in self.notes_cache.items():
|
||||
self.links_graph[note_title] = {
|
||||
'outbound': note_data['links'],
|
||||
'inbound': []
|
||||
}
|
||||
|
||||
for note_title, note_data in self.notes_cache.items():
|
||||
for linked_note in note_data['links']:
|
||||
if linked_note in self.links_graph:
|
||||
self.links_graph[linked_note]['inbound'].append(note_title)
|
||||
if linked_note in self.notes_cache:
|
||||
self.notes_cache[linked_note]['backlinks'].append(note_title)
|
||||
|
||||
def _build_tags_index(self):
|
||||
self.tags_index = {}
|
||||
|
||||
for note_title, note_data in self.notes_cache.items():
|
||||
for tag in note_data['tags']:
|
||||
if tag not in self.tags_index:
|
||||
self.tags_index[tag] = []
|
||||
self.tags_index[tag].append(note_title)
|
||||
|
||||
def search_notes(self, query: str, search_content: bool = True) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
query_lower = query.lower()
|
||||
|
||||
for note_title, note_data in self.notes_cache.items():
|
||||
match_score = 0
|
||||
|
||||
if query_lower in note_title.lower():
|
||||
match_score += 10
|
||||
|
||||
for tag in note_data['tags']:
|
||||
if query_lower in tag.lower():
|
||||
match_score += 5
|
||||
|
||||
if search_content and query_lower in note_data['content'].lower():
|
||||
match_score += 1
|
||||
|
||||
if match_score > 0:
|
||||
result = note_data.copy()
|
||||
result['match_score'] = match_score
|
||||
results.append(result)
|
||||
|
||||
results.sort(key=lambda x: x['match_score'], reverse=True)
|
||||
return results
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'available': True,
|
||||
'vault_configured': self.vault_path is not None,
|
||||
'vault_path': str(self.vault_path) if self.vault_path else None,
|
||||
'notes_count': len(self.notes_cache),
|
||||
'tags_count': len(self.tags_index)
|
||||
}
|
||||
|
||||
|
||||
class GoogleDriveIntegrator:
|
||||
"""Google Drive інтегратор для Memory Service"""
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
|
||||
CREDENTIALS_DIR = Path.home() / '.daarion'
|
||||
CREDENTIALS_FILE = CREDENTIALS_DIR / 'google_credentials.json'
|
||||
TOKEN_FILE = CREDENTIALS_DIR / 'google_token.json'
|
||||
|
||||
SUPPORTED_MIMETYPES = {
|
||||
'application/vnd.google-apps.document': 'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': None,
|
||||
'application/pdf': None,
|
||||
'text/plain': None,
|
||||
'text/markdown': None,
|
||||
'application/vnd.google-apps.spreadsheet': 'text/csv',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.service = None
|
||||
self.CREDENTIALS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
except ImportError:
|
||||
logger.warning("Google API libraries not installed")
|
||||
return False
|
||||
|
||||
creds = None
|
||||
|
||||
if self.TOKEN_FILE.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(self.TOKEN_FILE), self.SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
client_secrets = self.CREDENTIALS_DIR / 'client_secrets.json'
|
||||
if not client_secrets.exists():
|
||||
logger.warning("Google client_secrets.json not found")
|
||||
return False
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
str(client_secrets), self.SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
with open(self.TOKEN_FILE, 'w') as token:
|
||||
token.write(creds.to_json())
|
||||
|
||||
self.service = build('drive', 'v3', credentials=creds)
|
||||
logger.info("Google Drive API authenticated")
|
||||
return True
|
||||
|
||||
def list_files(self, folder_id: Optional[str] = None,
|
||||
max_results: int = 100) -> List[Dict]:
|
||||
if not self.service:
|
||||
if not self.authenticate():
|
||||
return []
|
||||
|
||||
search_query = []
|
||||
|
||||
if folder_id:
|
||||
search_query.append(f"'{folder_id}' in parents")
|
||||
|
||||
mime_conditions = []
|
||||
for mime_type in self.SUPPORTED_MIMETYPES.keys():
|
||||
mime_conditions.append(f"mimeType='{mime_type}'")
|
||||
|
||||
if mime_conditions:
|
||||
search_query.append(f"({' or '.join(mime_conditions)})")
|
||||
|
||||
search_query.extend([
|
||||
"trashed=false",
|
||||
"mimeType!='application/vnd.google-apps.folder'"
|
||||
])
|
||||
|
||||
final_query = ' and '.join(search_query)
|
||||
|
||||
try:
|
||||
results = self.service.files().list(
|
||||
q=final_query,
|
||||
pageSize=max_results,
|
||||
fields="nextPageToken, files(id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink)"
|
||||
).execute()
|
||||
|
||||
return results.get('files', [])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Google Drive files: {e}")
|
||||
return []
|
||||
|
||||
def download_file(self, file_id: str, mime_type: str) -> Optional[str]:
|
||||
if not self.service:
|
||||
return None
|
||||
|
||||
try:
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
export_mime_type = self.SUPPORTED_MIMETYPES.get(mime_type)
|
||||
|
||||
if export_mime_type:
|
||||
request = self.service.files().export_media(
|
||||
fileId=file_id,
|
||||
mimeType=export_mime_type
|
||||
)
|
||||
else:
|
||||
request = self.service.files().get_media(fileId=file_id)
|
||||
|
||||
file_io = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(file_io, request)
|
||||
|
||||
done = False
|
||||
while done is False:
|
||||
status, done = downloader.next_chunk()
|
||||
|
||||
content = file_io.getvalue()
|
||||
|
||||
for encoding in ['utf-8', 'utf-16', 'latin-1']:
|
||||
try:
|
||||
return content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file {file_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
available = False
|
||||
authenticated = False
|
||||
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
from google.oauth2.credentials import Credentials
|
||||
available = True
|
||||
|
||||
if self.TOKEN_FILE.exists():
|
||||
authenticated = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {
|
||||
'available': available,
|
||||
'authenticated': authenticated,
|
||||
'credentials_configured': self.CREDENTIALS_DIR.exists()
|
||||
}
|
||||
|
||||
def sync_to_daarion(self, output_dir: Path,
|
||||
folder_ids: List[str] = None,
|
||||
file_extensions: List[str] = None) -> Dict[str, Any]:
|
||||
"""Sync files from Google Drive to DAARION"""
|
||||
stats = {
|
||||
'total_files': 0,
|
||||
'downloaded': 0,
|
||||
'errors': 0,
|
||||
'skipped': 0,
|
||||
'files': []
|
||||
}
|
||||
|
||||
all_files = []
|
||||
if folder_ids:
|
||||
for folder_id in folder_ids:
|
||||
files = self.list_files(folder_id=folder_id)
|
||||
all_files.extend(files)
|
||||
else:
|
||||
all_files = self.list_files()
|
||||
|
||||
stats['total_files'] = len(all_files)
|
||||
|
||||
for file_data in all_files:
|
||||
file_id = file_data['id']
|
||||
file_name = file_data['name']
|
||||
mime_type = file_data['mimeType']
|
||||
|
||||
if file_extensions:
|
||||
file_ext = Path(file_name).suffix.lower()
|
||||
if file_ext not in file_extensions:
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
content = self.download_file(file_id, mime_type)
|
||||
|
||||
if content:
|
||||
safe_filename = "".join(c for c in file_name if c.isalnum() or c in (' ', '-', '_', '.')).rstrip()
|
||||
file_path = output_dir / f"gdrive_{file_id}_{safe_filename}.txt"
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# Google Drive: {file_name}\n")
|
||||
f.write(f"Source: {file_data.get('webViewLink', 'N/A')}\n")
|
||||
f.write(f"Modified: {file_data.get('modifiedTime', 'N/A')}\n")
|
||||
f.write(f"MIME Type: {mime_type}\n\n")
|
||||
f.write("---\n\n")
|
||||
f.write(content)
|
||||
|
||||
stats['downloaded'] += 1
|
||||
stats['files'].append({
|
||||
'original_name': file_name,
|
||||
'saved_path': str(file_path),
|
||||
'file_id': file_id,
|
||||
'size': len(content),
|
||||
'url': file_data.get('webViewLink')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving {file_name}: {e}")
|
||||
stats['errors'] += 1
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_folder_structure(self, folder_id: str = None, level: int = 0) -> Dict:
|
||||
"""Get Google Drive folder structure"""
|
||||
if not self.service:
|
||||
if not self.authenticate():
|
||||
return {}
|
||||
|
||||
try:
|
||||
query = "mimeType='application/vnd.google-apps.folder' and trashed=false"
|
||||
if folder_id:
|
||||
query += f" and '{folder_id}' in parents"
|
||||
|
||||
results = self.service.files().list(
|
||||
q=query,
|
||||
fields="files(id, name, parents)"
|
||||
).execute()
|
||||
|
||||
folders = results.get('files', [])
|
||||
structure = {}
|
||||
|
||||
for folder in folders:
|
||||
folder_name = folder['name']
|
||||
fid = folder['id']
|
||||
structure[folder_name] = {
|
||||
'id': fid,
|
||||
'subfolders': self.get_folder_structure(fid, level + 1) if level < 3 else {}
|
||||
}
|
||||
|
||||
return structure
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting folder structure: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
obsidian_integrator = ObsidianIntegrator()
|
||||
gdrive_integrator = GoogleDriveIntegrator()
|
||||
680
services/memory-service/app/voice_endpoints.py
Normal file
680
services/memory-service/app/voice_endpoints.py
Normal file
@@ -0,0 +1,680 @@
|
||||
"""
|
||||
DAARION Memory Service — Voice Endpoints
|
||||
STT: faster-whisper (Docker/Linux) → mlx-audio (macOS) → whisper-cli
|
||||
TTS: edge-tts Python API (primary, pure Python, no ffmpeg needed)
|
||||
→ piper (fallback, if model present)
|
||||
→ espeak-ng (offline Linux fallback)
|
||||
→ macOS say (fallback, macOS-only)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||
|
||||
MODELS_CACHE: dict = {}
|
||||
|
||||
# ── Prometheus metrics (optional — skip if not installed) ─────────────────────
|
||||
try:
|
||||
from prometheus_client import Counter, Histogram
|
||||
|
||||
_tts_compute_hist = Histogram(
|
||||
"voice_tts_compute_ms",
|
||||
"TTS synthesis compute time in ms",
|
||||
["engine", "voice"],
|
||||
buckets=[50, 100, 250, 500, 1000, 2000, 5000],
|
||||
)
|
||||
_tts_bytes_hist = Histogram(
|
||||
"voice_tts_audio_bytes",
|
||||
"TTS audio output size in bytes",
|
||||
["engine"],
|
||||
buckets=[5000, 15000, 30000, 60000, 120000],
|
||||
)
|
||||
_tts_errors_total = Counter(
|
||||
"voice_tts_errors_total",
|
||||
"TTS engine errors",
|
||||
["engine", "error_type"],
|
||||
)
|
||||
_stt_compute_hist = Histogram(
|
||||
"voice_stt_compute_ms",
|
||||
"STT transcription time in ms",
|
||||
["engine"],
|
||||
buckets=[200, 500, 1000, 2000, 5000, 10000],
|
||||
)
|
||||
_PROM_OK = True
|
||||
except ImportError:
|
||||
_PROM_OK = False
|
||||
_tts_compute_hist = None
|
||||
_tts_bytes_hist = None
|
||||
_tts_errors_total = None
|
||||
_stt_compute_hist = None
|
||||
|
||||
|
||||
def _prom_tts_observe(engine: str, voice: str, ms: float, audio_bytes: int) -> None:
|
||||
if not _PROM_OK:
|
||||
return
|
||||
try:
|
||||
_tts_compute_hist.labels(engine=engine, voice=voice).observe(ms)
|
||||
_tts_bytes_hist.labels(engine=engine).observe(audio_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _prom_tts_error(engine: str, error_type: str) -> None:
|
||||
if not _PROM_OK:
|
||||
return
|
||||
try:
|
||||
_tts_errors_total.labels(engine=engine, error_type=error_type).inc()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _prom_stt_observe(engine: str, ms: float) -> None:
|
||||
if not _PROM_OK:
|
||||
return
|
||||
try:
|
||||
_stt_compute_hist.labels(engine=engine).observe(ms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Voice mapping ─────────────────────────────────────────────────────────────
|
||||
# Maps UI voice id → edge-tts voice name
|
||||
_EDGE_VOICES: dict[str, str] = {
|
||||
"default": "uk-UA-PolinaNeural",
|
||||
"Polina": "uk-UA-PolinaNeural",
|
||||
"uk-UA-Polina": "uk-UA-PolinaNeural",
|
||||
"uk-UA-PolinaNeural": "uk-UA-PolinaNeural",
|
||||
"Ostap": "uk-UA-OstapNeural",
|
||||
"uk-UA-Ostap": "uk-UA-OstapNeural",
|
||||
"uk-UA-OstapNeural": "uk-UA-OstapNeural",
|
||||
# English voices — used for English-language segments
|
||||
"en-US-GuyNeural": "en-US-GuyNeural",
|
||||
"en-US-JennyNeural": "en-US-JennyNeural",
|
||||
"en": "en-US-GuyNeural",
|
||||
# macOS-only names: map to closest Ukrainian voice
|
||||
"Milena": "uk-UA-PolinaNeural",
|
||||
"Yuri": "uk-UA-OstapNeural",
|
||||
"af_heart": "uk-UA-PolinaNeural",
|
||||
}
|
||||
|
||||
def _edge_voice(name: str | None) -> str:
|
||||
"""Allow any valid edge-tts voice name to pass through directly."""
|
||||
n = name or "default"
|
||||
# If already a valid neural voice name (contains "Neural"), pass through
|
||||
if "Neural" in n or n == "en":
|
||||
return _EDGE_VOICES.get(n, n)
|
||||
return _EDGE_VOICES.get(n, "uk-UA-PolinaNeural")
|
||||
|
||||
|
||||
def _ffmpeg_available() -> bool:
|
||||
try:
|
||||
result = subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=3)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _espeak_available() -> bool:
|
||||
try:
|
||||
result = subprocess.run(["espeak-ng", "--version"], capture_output=True, timeout=3)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: Optional[str] = "default"
|
||||
speed: Optional[float] = 1.0
|
||||
model: Optional[str] = "auto"
|
||||
|
||||
|
||||
# ── Status & Live Health ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health")
|
||||
async def voice_health():
|
||||
"""Live health check — actually synthesizes a short test phrase via edge-tts.
|
||||
Returns edge_tts=ok/error with details; used by preflight to detect 403/blocked.
|
||||
"""
|
||||
import importlib.metadata
|
||||
import time
|
||||
|
||||
result: dict = {}
|
||||
|
||||
# edge-tts version
|
||||
try:
|
||||
ver = importlib.metadata.version("edge-tts")
|
||||
except Exception:
|
||||
ver = "unknown"
|
||||
result["edge_tts_version"] = ver
|
||||
|
||||
# Live synthesis test for each required Neural voice
|
||||
live_voices: list[dict] = []
|
||||
test_text = "Test" # Minimal — just enough to trigger actual API call
|
||||
for voice_id in ("uk-UA-PolinaNeural", "uk-UA-OstapNeural"):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
import edge_tts
|
||||
comm = edge_tts.Communicate(test_text, voice_id)
|
||||
byte_count = 0
|
||||
async for chunk in comm.stream():
|
||||
if chunk["type"] == "audio":
|
||||
byte_count += len(chunk["data"])
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
live_voices.append({"voice": voice_id, "status": "ok",
|
||||
"bytes": byte_count, "ms": elapsed_ms})
|
||||
except Exception as e:
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
live_voices.append({"voice": voice_id, "status": "error",
|
||||
"error": str(e)[:150], "ms": elapsed_ms})
|
||||
|
||||
all_ok = all(v["status"] == "ok" for v in live_voices)
|
||||
result["edge_tts"] = "ok" if all_ok else "error"
|
||||
result["voices"] = live_voices
|
||||
|
||||
# STT check (import only — no actual transcription in health)
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
result["faster_whisper"] = "ok"
|
||||
except ImportError:
|
||||
result["faster_whisper"] = "unavailable"
|
||||
|
||||
result["ok"] = all_ok
|
||||
|
||||
# ── Repro pack (incident diagnosis) ──────────────────────────────────────
|
||||
import os as _os
|
||||
import socket as _socket
|
||||
result["repro"] = {
|
||||
"node_id": _os.getenv("NODE_ID", _socket.gethostname()),
|
||||
"service_name": _os.getenv("MEMORY_SERVICE_NAME", "memory-service"),
|
||||
"image_digest": _os.getenv("IMAGE_DIGEST", "unknown"), # set via docker label
|
||||
"memory_service_url": _os.getenv("MEMORY_SERVICE_URL", "http://localhost:8000"),
|
||||
"tts_max_chars": 700,
|
||||
"canary_test_text": test_text,
|
||||
"canary_audio_bytes": {
|
||||
v["voice"]: v.get("bytes", 0) for v in live_voices
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def voice_status():
|
||||
edge_ok = False
|
||||
try:
|
||||
import edge_tts # noqa: F401
|
||||
edge_ok = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
espeak_ok = _espeak_available()
|
||||
|
||||
fw_ok = False
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
fw_ok = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
mlx_ok = False
|
||||
try:
|
||||
import mlx_audio # noqa: F401
|
||||
mlx_ok = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"tts_engine": "edge-tts" if edge_ok else ("espeak-ng" if espeak_ok else "piper/say"),
|
||||
"stt_engine": ("faster-whisper" if fw_ok else "") + ("mlx-audio" if mlx_ok else ""),
|
||||
"edge_tts": edge_ok,
|
||||
"espeak_ng": espeak_ok,
|
||||
"faster_whisper": fw_ok,
|
||||
"mlx_audio": mlx_ok,
|
||||
"ffmpeg": _ffmpeg_available(),
|
||||
"voices": list(_EDGE_VOICES.keys()),
|
||||
}
|
||||
|
||||
|
||||
# ── TTS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _tts_edge(text: str, voice_name: str, speed: float = 1.0) -> bytes:
|
||||
"""
|
||||
edge-tts pure Python API — no subprocess, no ffmpeg.
|
||||
Returns MP3 bytes directly (browsers play MP3 natively).
|
||||
"""
|
||||
import edge_tts
|
||||
rate_str = f"+{int((speed - 1.0) * 50)}%" if speed != 1.0 else "+0%"
|
||||
communicate = edge_tts.Communicate(text, voice_name, rate=rate_str)
|
||||
buf = io.BytesIO()
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio":
|
||||
buf.write(chunk["data"])
|
||||
buf.seek(0)
|
||||
data = buf.read()
|
||||
if not data:
|
||||
raise RuntimeError("edge-tts returned empty audio")
|
||||
return data
|
||||
|
||||
|
||||
async def _tts_piper(text: str) -> bytes | None:
|
||||
"""Piper TTS — returns WAV bytes or None if unavailable."""
|
||||
model_path = os.path.expanduser("~/.local/share/piper-voices/uk-UA-low/uk-UA-low.onnx")
|
||||
if not Path(model_path).exists():
|
||||
return None
|
||||
try:
|
||||
import piper as piper_mod
|
||||
voice = piper_mod.PiperVoice.load(model_path)
|
||||
buf = io.BytesIO()
|
||||
voice.synthesize(text, buf)
|
||||
buf.seek(0)
|
||||
data = buf.read()
|
||||
return data if data else None
|
||||
except Exception as e:
|
||||
logger.debug("Piper TTS failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _tts_macos_say(text: str, voice: str = "Milena") -> bytes | None:
|
||||
"""macOS say — only works outside Docker. Returns WAV bytes or None."""
|
||||
try:
|
||||
tmp_id = uuid.uuid4().hex[:8]
|
||||
aiff_path = f"/tmp/tts_{tmp_id}.aiff"
|
||||
wav_path = f"/tmp/tts_{tmp_id}.wav"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"say", "-v", voice, "-o", aiff_path, text,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=15)
|
||||
if not Path(aiff_path).exists() or Path(aiff_path).stat().st_size == 0:
|
||||
return None
|
||||
# Convert to WAV only if ffmpeg available
|
||||
if _ffmpeg_available():
|
||||
subprocess.run(["ffmpeg", "-y", "-i", aiff_path, "-ar", "22050", "-ac", "1", wav_path],
|
||||
capture_output=True, timeout=10)
|
||||
Path(aiff_path).unlink(missing_ok=True)
|
||||
if Path(wav_path).exists() and Path(wav_path).stat().st_size > 0:
|
||||
data = Path(wav_path).read_bytes()
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
return data
|
||||
# Return AIFF if no ffmpeg — most browsers won't play it but at least we tried
|
||||
data = Path(aiff_path).read_bytes()
|
||||
Path(aiff_path).unlink(missing_ok=True)
|
||||
return data if data else None
|
||||
except Exception as e:
|
||||
logger.debug("macOS say failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _tts_espeak(text: str, voice: str = "uk", speed: float = 1.0) -> bytes | None:
|
||||
"""espeak-ng offline fallback for Linux. Returns WAV bytes or None."""
|
||||
if not _espeak_available():
|
||||
return None
|
||||
try:
|
||||
tmp_id = uuid.uuid4().hex[:8]
|
||||
wav_path = f"/tmp/tts_espeak_{tmp_id}.wav"
|
||||
rate = max(120, min(240, int((speed or 1.0) * 170)))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"espeak-ng", "-v", voice, "-s", str(rate), "-w", wav_path, text,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
if proc.returncode != 0:
|
||||
logger.debug("espeak-ng failed rc=%s stderr=%s", proc.returncode, (stderr or b"")[:200])
|
||||
return None
|
||||
p = Path(wav_path)
|
||||
if not p.exists() or p.stat().st_size == 0:
|
||||
return None
|
||||
data = p.read_bytes()
|
||||
p.unlink(missing_ok=True)
|
||||
return data if data else None
|
||||
except Exception as e:
|
||||
logger.debug("espeak-ng TTS failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/tts")
|
||||
async def text_to_speech(request: TTSRequest):
|
||||
"""
|
||||
TTS pipeline:
|
||||
1. edge-tts (primary — pure Python, returns MP3, works anywhere)
|
||||
2. piper (if model file present)
|
||||
3. espeak-ng (offline Linux fallback)
|
||||
4. macOS say (macOS-only fallback)
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
text = (request.text or "").strip()[:700]
|
||||
if not text:
|
||||
raise HTTPException(400, "Empty text")
|
||||
|
||||
edge_voice = _edge_voice(request.voice)
|
||||
errors: list[str] = []
|
||||
|
||||
# ── 1. edge-tts (MP3, no ffmpeg needed) ──────────────────────────────
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
_tts_edge(text, edge_voice, speed=request.speed or 1.0),
|
||||
timeout=20.0,
|
||||
)
|
||||
_compute_ms = int((_time.monotonic() - _t0) * 1000)
|
||||
logger.info("TTS edge-tts OK: voice=%s len=%d ms=%d", edge_voice, len(data), _compute_ms)
|
||||
_prom_tts_observe("edge-tts", edge_voice, _compute_ms, len(data))
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type="audio/mpeg",
|
||||
headers={"Content-Disposition": "inline; filename=speech.mp3",
|
||||
"X-TTS-Engine": "edge-tts",
|
||||
"X-TTS-Voice": edge_voice,
|
||||
"X-TTS-Compute-MS": str(_compute_ms),
|
||||
"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception as e:
|
||||
_prom_tts_error("edge-tts", type(e).__name__)
|
||||
errors.append(f"edge-tts: {e}")
|
||||
logger.warning("edge-tts failed: %s", e)
|
||||
|
||||
# ── 2. piper ──────────────────────────────────────────────────────────
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
data = await asyncio.wait_for(_tts_piper(text), timeout=15.0)
|
||||
if data:
|
||||
_compute_ms = int((_time.monotonic() - _t0) * 1000)
|
||||
logger.info("TTS piper OK len=%d ms=%d", len(data), _compute_ms)
|
||||
_prom_tts_observe("piper", "uk-UA", _compute_ms, len(data))
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type="audio/wav",
|
||||
headers={"Content-Disposition": "inline; filename=speech.wav",
|
||||
"X-TTS-Engine": "piper",
|
||||
"X-TTS-Compute-MS": str(_compute_ms),
|
||||
"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception as e:
|
||||
_prom_tts_error("piper", type(e).__name__)
|
||||
errors.append(f"piper: {e}")
|
||||
logger.debug("piper failed: %s", e)
|
||||
|
||||
# ── 3. espeak-ng (offline Linux) ─────────────────────────────────────
|
||||
espeak_voice = "en-us" if str(request.voice or "").startswith("en") else "uk"
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
data = await asyncio.wait_for(_tts_espeak(text, espeak_voice, request.speed or 1.0), timeout=12.0)
|
||||
if data:
|
||||
_compute_ms = int((_time.monotonic() - _t0) * 1000)
|
||||
logger.info("TTS espeak-ng OK voice=%s len=%d ms=%d", espeak_voice, len(data), _compute_ms)
|
||||
_prom_tts_observe("espeak-ng", espeak_voice, _compute_ms, len(data))
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type="audio/wav",
|
||||
headers={"Content-Disposition": "inline; filename=speech.wav",
|
||||
"X-TTS-Engine": "espeak-ng",
|
||||
"X-TTS-Voice": espeak_voice,
|
||||
"X-TTS-Compute-MS": str(_compute_ms),
|
||||
"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception as e:
|
||||
_prom_tts_error("espeak-ng", type(e).__name__)
|
||||
errors.append(f"espeak-ng: {e}")
|
||||
logger.debug("espeak-ng failed: %s", e)
|
||||
|
||||
# ── 4. macOS say ──────────────────────────────────────────────────────
|
||||
say_voice = "Milena" if request.voice in (None, "default", "Polina", "Milena") else "Yuri"
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
data = await asyncio.wait_for(_tts_macos_say(text, say_voice), timeout=20.0)
|
||||
if data:
|
||||
_compute_ms = int((_time.monotonic() - _t0) * 1000)
|
||||
mime = "audio/wav" if data[:4] == b"RIFF" else "audio/aiff"
|
||||
logger.info("TTS macOS say OK voice=%s len=%d ms=%d", say_voice, len(data), _compute_ms)
|
||||
_prom_tts_observe("macos-say", say_voice, _compute_ms, len(data))
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": f"inline; filename=speech.{'wav' if mime=='audio/wav' else 'aiff'}",
|
||||
"X-TTS-Engine": "macos-say",
|
||||
"X-TTS-Compute-MS": str(_compute_ms),
|
||||
"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception as e:
|
||||
_prom_tts_error("macos-say", type(e).__name__)
|
||||
errors.append(f"say: {e}")
|
||||
logger.debug("macOS say failed: %s", e)
|
||||
|
||||
logger.error("All TTS engines failed: %s", errors)
|
||||
raise HTTPException(503, f"All TTS engines failed: {'; '.join(errors)}")
|
||||
|
||||
|
||||
# ── STT ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _convert_audio_to_wav(input_path: str, output_path: str) -> bool:
|
||||
"""Convert audio to WAV using ffmpeg if available."""
|
||||
if not _ffmpeg_available():
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", input_path, "-ar", "16000", "-ac", "1", output_path],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
return result.returncode == 0 and Path(output_path).exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _stt_faster_whisper_sync(wav_path: str, language: str | None) -> str:
|
||||
"""faster-whisper STT — sync, runs in executor — works in Docker/Linux."""
|
||||
from faster_whisper import WhisperModel
|
||||
# Use 'small' for better Ukrainian accuracy (still fast on CPU)
|
||||
model_size = os.getenv("WHISPER_MODEL", "small")
|
||||
cache_key = f"faster_whisper_{model_size}"
|
||||
if cache_key not in MODELS_CACHE:
|
||||
logger.info("Loading faster-whisper model=%s (first call)...", model_size)
|
||||
MODELS_CACHE[cache_key] = WhisperModel(
|
||||
model_size, device="cpu", compute_type="int8",
|
||||
)
|
||||
model = MODELS_CACHE[cache_key]
|
||||
segments, info = model.transcribe(wav_path, language=language or "uk", beam_size=5)
|
||||
text = " ".join(seg.text for seg in segments).strip()
|
||||
logger.info("faster-whisper OK: lang=%s text_len=%d", info.language, len(text))
|
||||
return text
|
||||
|
||||
|
||||
def _stt_mlx_audio_sync(wav_path: str, language: str | None) -> str:
|
||||
"""mlx-audio STT — sync, runs in executor — macOS Apple Silicon only."""
|
||||
from mlx_audio.stt.utils import load_model
|
||||
if "mlx_whisper" not in MODELS_CACHE:
|
||||
logger.info("Loading mlx-audio whisper model (first call)...")
|
||||
MODELS_CACHE["mlx_whisper"] = load_model(
|
||||
"mlx-community/whisper-large-v3-turbo-asr-fp16"
|
||||
)
|
||||
model = MODELS_CACHE["mlx_whisper"]
|
||||
result = model.generate(wav_path, language=language)
|
||||
return result.text if hasattr(result, "text") else str(result)
|
||||
|
||||
|
||||
async def _stt_whisper_cli(wav_path: str, language: str | None) -> str:
|
||||
"""whisper CLI fallback."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"whisper", wav_path,
|
||||
"--language", language or "uk",
|
||||
"--model", "base",
|
||||
"--output_format", "txt",
|
||||
"--output_dir", "/tmp",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=90)
|
||||
txt_path = Path(wav_path).with_suffix(".txt")
|
||||
if txt_path.exists():
|
||||
return txt_path.read_text().strip()
|
||||
raise RuntimeError("whisper CLI produced no output")
|
||||
|
||||
|
||||
@router.post("/stt")
|
||||
async def speech_to_text(
|
||||
audio: UploadFile = File(...),
|
||||
model: str = Query("auto", description="STT model: auto|faster-whisper|mlx-audio|whisper-cli"),
|
||||
language: Optional[str] = Query(None, description="Language code (auto-detect if None)"),
|
||||
):
|
||||
"""
|
||||
STT pipeline:
|
||||
1. Convert audio to WAV via ffmpeg (if available; skip if already WAV)
|
||||
2. faster-whisper (primary — Docker/Linux)
|
||||
3. mlx-audio (macOS Apple Silicon)
|
||||
4. whisper CLI (last resort)
|
||||
"""
|
||||
tmp_path: str | None = None
|
||||
wav_path: str | None = None
|
||||
|
||||
try:
|
||||
content = await audio.read()
|
||||
if not content:
|
||||
raise HTTPException(400, "Empty audio file")
|
||||
|
||||
# Detect MIME type
|
||||
fname = audio.filename or "audio.webm"
|
||||
suffix = Path(fname).suffix or ".webm"
|
||||
if audio.content_type and "wav" in audio.content_type:
|
||||
suffix = ".wav"
|
||||
elif audio.content_type and "ogg" in audio.content_type:
|
||||
suffix = ".ogg"
|
||||
|
||||
tmp_id = uuid.uuid4().hex[:8]
|
||||
tmp_path = f"/tmp/stt_in_{tmp_id}{suffix}"
|
||||
wav_path = f"/tmp/stt_wav_{tmp_id}.wav"
|
||||
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Convert to WAV (required by whisper models)
|
||||
converted = False
|
||||
if suffix == ".wav":
|
||||
import shutil
|
||||
shutil.copy(tmp_path, wav_path)
|
||||
converted = True
|
||||
else:
|
||||
converted = await _convert_audio_to_wav(tmp_path, wav_path)
|
||||
if not converted:
|
||||
# No ffmpeg — try to use input directly (faster-whisper accepts many formats)
|
||||
import shutil
|
||||
shutil.copy(tmp_path, wav_path)
|
||||
converted = True
|
||||
|
||||
if not Path(wav_path).exists():
|
||||
raise HTTPException(500, "Audio conversion failed — ffmpeg missing and no WAV input")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# ── 1. faster-whisper ─────────────────────────────────────────────
|
||||
if model in ("auto", "faster-whisper"):
|
||||
_t0_stt = asyncio.get_event_loop().time()
|
||||
try:
|
||||
_wpath = wav_path # capture for lambda
|
||||
_lang = language
|
||||
text = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, _stt_faster_whisper_sync, _wpath, _lang),
|
||||
timeout=60.0,
|
||||
)
|
||||
_stt_ms = int((asyncio.get_event_loop().time() - _t0_stt) * 1000)
|
||||
_prom_stt_observe("faster-whisper", _stt_ms)
|
||||
return {"text": text, "model": "faster-whisper", "language": language,
|
||||
"compute_ms": _stt_ms}
|
||||
except Exception as e:
|
||||
errors.append(f"faster-whisper: {e}")
|
||||
logger.warning("faster-whisper failed: %s", e)
|
||||
|
||||
# ── 2. mlx-audio (macOS) ─────────────────────────────────────────
|
||||
if model in ("auto", "mlx-audio"):
|
||||
try:
|
||||
_wpath = wav_path
|
||||
_lang = language
|
||||
text = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, _stt_mlx_audio_sync, _wpath, _lang),
|
||||
timeout=60.0,
|
||||
)
|
||||
return {"text": text, "model": "mlx-audio", "language": language}
|
||||
except Exception as e:
|
||||
errors.append(f"mlx-audio: {e}")
|
||||
logger.warning("mlx-audio failed: %s", e)
|
||||
|
||||
# ── 3. whisper CLI ────────────────────────────────────────────────
|
||||
if model in ("auto", "whisper-cli"):
|
||||
try:
|
||||
text = await asyncio.wait_for(
|
||||
_stt_whisper_cli(wav_path, language), timeout=90.0
|
||||
)
|
||||
return {"text": text, "model": "whisper-cli", "language": language}
|
||||
except Exception as e:
|
||||
errors.append(f"whisper-cli: {e}")
|
||||
logger.warning("whisper-cli failed: %s", e)
|
||||
|
||||
raise HTTPException(503, f"All STT engines failed: {'; '.join(str(e)[:80] for e in errors)}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("STT error: %s", e)
|
||||
raise HTTPException(500, str(e)[:200])
|
||||
finally:
|
||||
for p in [tmp_path, wav_path]:
|
||||
if p:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── Voices list ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/voices")
|
||||
async def list_voices():
|
||||
edge_voices = []
|
||||
try:
|
||||
import edge_tts # noqa: F401
|
||||
edge_voices = [
|
||||
{"id": "default", "name": "Polina Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
|
||||
{"id": "Polina", "name": "Polina Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
|
||||
{"id": "Ostap", "name": "Ostap Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
|
||||
]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
piper_voices = []
|
||||
if Path(os.path.expanduser("~/.local/share/piper-voices/uk-UA-low/uk-UA-low.onnx")).exists():
|
||||
piper_voices = [{"id": "uk-UA-low", "name": "Ukrainian Low (uk-UA)", "lang": "uk-UA", "engine": "piper"}]
|
||||
|
||||
macos_voices = []
|
||||
if os.path.exists("/usr/bin/say") or os.path.exists("/usr/local/bin/say"):
|
||||
macos_voices = [
|
||||
{"id": "Milena", "name": "Milena (uk-UA, macOS)", "lang": "uk-UA", "engine": "say"},
|
||||
{"id": "Yuri", "name": "Yuri (uk-UA, macOS)", "lang": "uk-UA", "engine": "say"},
|
||||
]
|
||||
|
||||
espeak_voices = []
|
||||
if _espeak_available():
|
||||
espeak_voices = [
|
||||
{"id": "uk", "name": "Ukrainian (espeak-ng)", "lang": "uk-UA", "engine": "espeak-ng"},
|
||||
{"id": "en-us", "name": "English US (espeak-ng)", "lang": "en-US", "engine": "espeak-ng"},
|
||||
]
|
||||
|
||||
return {
|
||||
"edge": edge_voices,
|
||||
"piper": piper_voices,
|
||||
"macos": macos_voices,
|
||||
"espeak": espeak_voices,
|
||||
}
|
||||
19
services/memory-service/start-local.sh
Executable file
19
services/memory-service/start-local.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Sofiia Memory Service Startup Script
|
||||
# NODA2 Development Environment
|
||||
|
||||
cd /Users/apple/github-projects/microdao-daarion/services/memory-service
|
||||
|
||||
export MEMORY_QDRANT_HOST=localhost
|
||||
export MEMORY_QDRANT_PORT=6333
|
||||
export MEMORY_POSTGRES_HOST=localhost
|
||||
export MEMORY_POSTGRES_PORT=5433
|
||||
export MEMORY_POSTGRES_USER=daarion
|
||||
export MEMORY_POSTGRES_PASSWORD=daarion_secret_node2
|
||||
export MEMORY_POSTGRES_DB=daarion_memory
|
||||
export MEMORY_COHERE_API_KEY=nOdOXnuepLku2ipJWpe6acWgAsJCsDhMO0RnaEJB
|
||||
|
||||
echo "🚀 Starting Sofiia Memory Service..."
|
||||
source venv/bin/activate
|
||||
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
10
services/memory-service/static/sofiia-avatar.svg
Normal file
10
services/memory-service/static/sofiia-avatar.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f093fb"/>
|
||||
<stop offset="100%" style="stop-color:#f5576c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="95" fill="url(#grad)"/>
|
||||
<text x="100" y="130" font-family="Arial, sans-serif" font-size="100" font-weight="bold" fill="white" text-anchor="middle">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
1141
services/memory-service/static/sofiia-ui.html
Normal file
1141
services/memory-service/static/sofiia-ui.html
Normal file
File diff suppressed because it is too large
Load Diff
206
services/memory-service/static/test-ui.html
Normal file
206
services/memory-service/static/test-ui.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sofiia Test</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, sans-serif; background: #1a1a1f; color: #e0e0e0; padding: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { text-align: center; margin-bottom: 20px; color: #c9a87c; }
|
||||
#chat { height: 400px; overflow-y: auto; background: #27272a; border-radius: 12px; padding: 15px; margin-bottom: 15px; }
|
||||
.msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 12px; max-width: 85%; }
|
||||
.user { background: rgba(201,168,124,0.2); margin-left: auto; }
|
||||
.ai { background: rgba(255,255,255,0.05); }
|
||||
.input-row { display: flex; gap: 10px; }
|
||||
input { flex: 1; padding: 12px; background: #27272a; border: 1px solid #3f3f46; border-radius: 8px; color: #e0e0e0; font-size: 16px; }
|
||||
input:focus { outline: none; border-color: #c9a87c; }
|
||||
button { padding: 12px 20px; background: linear-gradient(135deg, #c9a87c, #8b7355); border: none; border-radius: 8px; color: #1a1a1f; cursor: pointer; font-weight: 500; }
|
||||
button:hover { opacity: 0.9; }
|
||||
#voiceBtn { width: 50px; padding: 12px; }
|
||||
#voiceBtn.recording { background: #ef4444; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 10px rgba(239,68,68,0); } }
|
||||
#status { text-align: center; padding: 10px; color: #888; font-size: 14px; }
|
||||
.controls { display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
select { padding: 8px; background: #27272a; border: 1px solid #3f3f46; color: #e0e0e0; border-radius: 6px; }
|
||||
label { display: flex; align-items: center; gap: 5px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>SOFIIA</h1>
|
||||
<p class="subtitle">CTO DAARION | AI Architect</p>
|
||||
</div>
|
||||
<div id="status" style="text-align:center;padding:8px;color:#888;font-size:14px;">Перевірка сервісів...</div>
|
||||
<div class="controls" style="display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap;padding:0 20px;">
|
||||
<select id="model" style="padding:8px;background:#27272a;border:1px solid #3f3f46;color:#e0e0e0;border-radius:6px;">
|
||||
<option value="glm-4.7-flash:32k">GLM-4.7 Flash 32K</option>
|
||||
<option value="mistral-nemo:12b">Mistral Nemo 12B</option>
|
||||
<option value="qwen3-coder:30b">Qwen3 Coder 30B</option>
|
||||
<option value="deepseek-r1:70b">DeepSeek R1 70B</option>
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:5px;font-size:14px;"><input type="checkbox" id="autoSpeak" checked> 🔊 TTS</label>
|
||||
<label style="display:flex;align-items:center;gap:5px;font-size:14px;"><input type="checkbox" id="vMode"> 🎙️ Безперервний</label>
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<div class="input-row">
|
||||
<input type="text" id="input" placeholder="Напишіть повідомлення..." onkeypress="if(event.key==='Enter')send()">
|
||||
<button id="voiceBtn" onclick="toggleVoice()">🎤</button>
|
||||
<button onclick="send()">Надіслати</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const OLLAMA = 'http://localhost:11434';
|
||||
const SERVICE = 'http://localhost:8001';
|
||||
const OLLAMA = 'http://localhost:11434';
|
||||
let history = [];
|
||||
let recording = false;
|
||||
let voiceMode = false;
|
||||
let mediaRecorder, chunks;
|
||||
|
||||
const SYSTEM_PROMPT = 'Ти Sofiia — Chief AI Architect та Technical Sovereign екосистеми DAARION.city. Координуєш R&D, архітектуру, безпеку та еволюцію платформи. Маєш доступ до нод: NODA1 (production), NODA2 (development), NODA3 (AI/ML). Відповідай українською, професійно, структуровано. Користувайся своєю Identity з AGENTS.md.';
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const r = await fetch(SERVICE + '/health');
|
||||
const d = await r.json();
|
||||
document.getElementById('status').textContent = '✓ Memory: ' + (d.vector_store?.memories?.points_count || 0) + ' | Voice: OK';
|
||||
} catch(e) {
|
||||
document.getElementById('status').textContent = '✗ Помилка: ' + e.message;
|
||||
}
|
||||
}
|
||||
check();
|
||||
setInterval(check, 30000);
|
||||
|
||||
function add(text, sender) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + sender;
|
||||
div.textContent = text;
|
||||
document.getElementById('chat').appendChild(div);
|
||||
document.getElementById('chat').scrollTop = 9999;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const input = document.getElementById('input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
input.value = '';
|
||||
add(text, 'user');
|
||||
|
||||
const model = document.getElementById('model').value;
|
||||
history.push({role: 'user', content: text});
|
||||
|
||||
try {
|
||||
const r = await fetch(`${OLLAMA}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{role: 'system', content: SYSTEM_PROMPT}, ...history.slice(-8)],
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
const d = await r.json();
|
||||
const reply = d.message?.content || 'Помилка';
|
||||
add(reply, 'ai');
|
||||
history.push({role: 'assistant', content: reply});
|
||||
|
||||
if (document.getElementById('autoSpeak').checked) {
|
||||
await speak(reply);
|
||||
}
|
||||
|
||||
// Auto-restart listening in voice mode
|
||||
if (voiceMode && !recording) {
|
||||
setTimeout(() => startListening(), 500);
|
||||
}
|
||||
} catch(e) {
|
||||
add('Помилка: ' + e.message, 'ai');
|
||||
}
|
||||
}
|
||||
|
||||
async function speak(text) {
|
||||
try {
|
||||
const r = await fetch(`${SERVICE}/voice/tts`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: text.substring(0, 300)})
|
||||
});
|
||||
if (r.ok) {
|
||||
const blob = await r.blob();
|
||||
const audio = new Audio(URL.createObjectURL(blob));
|
||||
await audio.play();
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('TTS error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startListening() {
|
||||
if (recording) return;
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
chunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = e => chunks.push(e.data);
|
||||
mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const blob = new Blob(chunks, {type: 'audio/webm'});
|
||||
const fd = new FormData();
|
||||
fd.append('audio', blob, 'audio.webm');
|
||||
|
||||
document.getElementById('status').textContent = '🔄 Розпізнаю...';
|
||||
|
||||
try {
|
||||
const r = await fetch(`${SERVICE}/voice/stt`, {method: 'POST', body: fd});
|
||||
const d = await r.json();
|
||||
if (d.text) {
|
||||
document.getElementById('input').value = d.text;
|
||||
send();
|
||||
} else {
|
||||
document.getElementById('status').textContent = '✓ Готовий';
|
||||
if (voiceMode) startListening();
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('STT error:', e);
|
||||
document.getElementById('status').textContent = '✓ Готовий';
|
||||
if (voiceMode) startListening();
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
recording = true;
|
||||
document.getElementById('voiceBtn').classList.add('recording');
|
||||
document.getElementById('status').textContent = '🎙️ Слухаю...';
|
||||
|
||||
// Auto-stop after silence or 10 seconds
|
||||
setTimeout(() => {
|
||||
if (recording && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
recording = false;
|
||||
document.getElementById('voiceBtn').classList.remove('recording');
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function toggleVoice() {
|
||||
voiceMode = !voiceMode;
|
||||
|
||||
if (voiceMode) {
|
||||
startListening();
|
||||
} else {
|
||||
if (recording && mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
recording = false;
|
||||
document.getElementById('voiceBtn').classList.remove('recording');
|
||||
}
|
||||
document.getElementById('status').textContent = '✓ Голосовий режим вимкнено';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user