"""Backend adapter implementations for engine integration ports.""" from __future__ import annotations import re from pathlib import Path from typing import Any, Dict, List, Optional import aiohttp from loguru import logger from app.config import settings try: import yaml except ImportError: # pragma: no cover - validated when local YAML source is enabled yaml = None _ASSISTANT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") def _assistant_error(code: str, assistant_id: str) -> Dict[str, Any]: return {"__error_code": code, "assistantId": str(assistant_id or "")} class NullBackendAdapter: """No-op adapter for engine-only runtime without backend dependencies.""" async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: _ = assistant_id return None async def create_call_record( self, *, user_id: int, assistant_id: Optional[str], source: str = "debug", ) -> Optional[str]: _ = (user_id, assistant_id, source) return None async def add_transcript( self, *, call_id: str, turn_index: int, speaker: str, content: str, start_ms: int, end_ms: int, confidence: Optional[float] = None, duration_ms: Optional[int] = None, ) -> bool: _ = (call_id, turn_index, speaker, content, start_ms, end_ms, confidence, duration_ms) return False async def finalize_call_record( self, *, call_id: str, status: str, duration_seconds: int, ) -> bool: _ = (call_id, status, duration_seconds) return False async def search_knowledge_context( self, *, kb_id: str, query: str, n_results: int = 5, ) -> List[Dict[str, Any]]: _ = (kb_id, query, n_results) return [] async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: _ = tool_id return None class HistoryDisabledBackendAdapter: """Adapter wrapper that disables history writes while keeping reads available.""" def __init__(self, delegate: HttpBackendAdapter | NullBackendAdapter): self._delegate = delegate async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: return await self._delegate.fetch_assistant_config(assistant_id) async def create_call_record( self, *, user_id: int, assistant_id: Optional[str], source: str = "debug", ) -> Optional[str]: _ = (user_id, assistant_id, source) return None async def add_transcript( self, *, call_id: str, turn_index: int, speaker: str, content: str, start_ms: int, end_ms: int, confidence: Optional[float] = None, duration_ms: Optional[int] = None, ) -> bool: _ = (call_id, turn_index, speaker, content, start_ms, end_ms, confidence, duration_ms) return False async def finalize_call_record( self, *, call_id: str, status: str, duration_seconds: int, ) -> bool: _ = (call_id, status, duration_seconds) return False async def search_knowledge_context( self, *, kb_id: str, query: str, n_results: int = 5, ) -> List[Dict[str, Any]]: return await self._delegate.search_knowledge_context( kb_id=kb_id, query=query, n_results=n_results, ) async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: return await self._delegate.fetch_tool_resource(tool_id) class LocalYamlAssistantConfigAdapter(NullBackendAdapter): """Load assistant runtime config from local YAML files.""" def __init__(self, config_dir: str): self._config_dir = self._resolve_base_dir(config_dir) @staticmethod def _resolve_base_dir(config_dir: str) -> Path: raw = Path(str(config_dir or "").strip() or "engine/config/agents") if raw.is_absolute(): return raw.resolve() cwd_candidate = (Path.cwd() / raw).resolve() if cwd_candidate.exists(): return cwd_candidate engine_dir = Path(__file__).resolve().parent.parent engine_candidate = (engine_dir / raw).resolve() if engine_candidate.exists(): return engine_candidate parts = raw.parts if parts and parts[0] == "engine" and len(parts) > 1: trimmed_candidate = (engine_dir / Path(*parts[1:])).resolve() if trimmed_candidate.exists(): return trimmed_candidate return cwd_candidate def _resolve_config_file(self, assistant_id: str) -> Optional[Path]: normalized = str(assistant_id or "").strip() if not _ASSISTANT_ID_PATTERN.match(normalized): return None yaml_path = self._config_dir / f"{normalized}.yaml" yml_path = self._config_dir / f"{normalized}.yml" if yaml_path.exists(): return yaml_path if yml_path.exists(): return yml_path return None @staticmethod def _as_str(value: Any) -> Optional[str]: if value is None: return None text = str(value).strip() return text or None @classmethod def _translate_agent_schema(cls, assistant_id: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Translate legacy `agent:` YAML schema into runtime assistant metadata.""" agent = payload.get("agent") if not isinstance(agent, dict): return None runtime: Dict[str, Any] = { "assistantId": str(assistant_id), "services": {}, } llm = agent.get("llm") if isinstance(llm, dict): llm_runtime: Dict[str, Any] = {} if cls._as_str(llm.get("provider")): llm_runtime["provider"] = cls._as_str(llm.get("provider")) if cls._as_str(llm.get("model")): llm_runtime["model"] = cls._as_str(llm.get("model")) if cls._as_str(llm.get("api_key")): llm_runtime["apiKey"] = cls._as_str(llm.get("api_key")) if cls._as_str(llm.get("api_url")): llm_runtime["baseUrl"] = cls._as_str(llm.get("api_url")) if llm_runtime: runtime["services"]["llm"] = llm_runtime tts = agent.get("tts") if isinstance(tts, dict): tts_runtime: Dict[str, Any] = {} if cls._as_str(tts.get("provider")): tts_runtime["provider"] = cls._as_str(tts.get("provider")) if cls._as_str(tts.get("model")): tts_runtime["model"] = cls._as_str(tts.get("model")) if cls._as_str(tts.get("api_key")): tts_runtime["apiKey"] = cls._as_str(tts.get("api_key")) if cls._as_str(tts.get("api_url")): tts_runtime["baseUrl"] = cls._as_str(tts.get("api_url")) if cls._as_str(tts.get("voice")): tts_runtime["voice"] = cls._as_str(tts.get("voice")) if cls._as_str(tts.get("app_id")): tts_runtime["appId"] = cls._as_str(tts.get("app_id")) if cls._as_str(tts.get("resource_id")): tts_runtime["resourceId"] = cls._as_str(tts.get("resource_id")) if cls._as_str(tts.get("cluster")): tts_runtime["cluster"] = cls._as_str(tts.get("cluster")) if cls._as_str(tts.get("uid")): tts_runtime["uid"] = cls._as_str(tts.get("uid")) if tts.get("speed") is not None: tts_runtime["speed"] = tts.get("speed") dashscope_mode = cls._as_str(tts.get("dashscope_mode")) or cls._as_str(tts.get("mode")) if dashscope_mode: tts_runtime["mode"] = dashscope_mode if tts_runtime: runtime["services"]["tts"] = tts_runtime asr = agent.get("asr") if isinstance(asr, dict): asr_runtime: Dict[str, Any] = {} if cls._as_str(asr.get("provider")): asr_runtime["provider"] = cls._as_str(asr.get("provider")) if cls._as_str(asr.get("model")): asr_runtime["model"] = cls._as_str(asr.get("model")) if cls._as_str(asr.get("api_key")): asr_runtime["apiKey"] = cls._as_str(asr.get("api_key")) if cls._as_str(asr.get("api_url")): asr_runtime["baseUrl"] = cls._as_str(asr.get("api_url")) if cls._as_str(asr.get("app_id")): asr_runtime["appId"] = cls._as_str(asr.get("app_id")) if cls._as_str(asr.get("resource_id")): asr_runtime["resourceId"] = cls._as_str(asr.get("resource_id")) if cls._as_str(asr.get("cluster")): asr_runtime["cluster"] = cls._as_str(asr.get("cluster")) if cls._as_str(asr.get("uid")): asr_runtime["uid"] = cls._as_str(asr.get("uid")) if isinstance(asr.get("request_params"), dict): asr_runtime["requestParams"] = dict(asr.get("request_params") or {}) if asr.get("enable_interim") is not None: asr_runtime["enableInterim"] = asr.get("enable_interim") if asr.get("interim_interval_ms") is not None: asr_runtime["interimIntervalMs"] = asr.get("interim_interval_ms") if asr.get("min_audio_ms") is not None: asr_runtime["minAudioMs"] = asr.get("min_audio_ms") if asr_runtime: runtime["services"]["asr"] = asr_runtime duplex = agent.get("duplex") if isinstance(duplex, dict): if cls._as_str(duplex.get("system_prompt")): runtime["systemPrompt"] = cls._as_str(duplex.get("system_prompt")) if duplex.get("greeting") is not None: runtime["greeting"] = duplex.get("greeting") barge_in = agent.get("barge_in") if isinstance(barge_in, dict): runtime["bargeIn"] = {} if barge_in.get("min_duration_ms") is not None: runtime["bargeIn"]["minDurationMs"] = barge_in.get("min_duration_ms") if barge_in.get("silence_tolerance_ms") is not None: runtime["bargeIn"]["silenceToleranceMs"] = barge_in.get("silence_tolerance_ms") if not runtime["bargeIn"]: runtime.pop("bargeIn", None) if isinstance(agent.get("tools"), list): runtime["tools"] = agent.get("tools") if not runtime.get("services"): runtime.pop("services", None) return runtime async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: config_file = self._resolve_config_file(assistant_id) if config_file is None: return _assistant_error("assistant.not_found", assistant_id) if yaml is None: logger.warning( "Local assistant config requested but PyYAML is unavailable (assistant_id={})", assistant_id, ) return _assistant_error("assistant.config_unavailable", assistant_id) try: with config_file.open("r", encoding="utf-8") as handle: payload = yaml.safe_load(handle) or {} except Exception as exc: logger.warning( "Failed to read local assistant config {} (assistant_id={}): {}", config_file, assistant_id, exc, ) return _assistant_error("assistant.config_unavailable", assistant_id) if not isinstance(payload, dict): logger.warning( "Local assistant config is not an object (assistant_id={}, file={})", assistant_id, config_file, ) return _assistant_error("assistant.config_unavailable", assistant_id) translated = self._translate_agent_schema(assistant_id, payload) if translated is not None: payload = translated # Accept either backend-like payload shape or a direct assistant metadata object. if isinstance(payload.get("assistant"), dict) or isinstance(payload.get("sessionStartMetadata"), dict): normalized_payload = dict(payload) else: normalized_payload = {"assistant": dict(payload)} assistant_obj = normalized_payload.get("assistant") if isinstance(assistant_obj, dict): resolved_assistant_id = assistant_obj.get("assistantId") or assistant_obj.get("id") or assistant_id assistant_obj["assistantId"] = str(resolved_assistant_id) else: normalized_payload["assistant"] = {"assistantId": str(assistant_id)} normalized_payload.setdefault("assistantId", str(assistant_id)) normalized_payload.setdefault("configVersionId", f"local:{config_file.name}") return normalized_payload class AssistantConfigSourceAdapter: """Route assistant config reads by backend availability without changing other APIs.""" def __init__( self, *, delegate: HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter, local_delegate: LocalYamlAssistantConfigAdapter, use_backend_assistant_config: bool, ): self._delegate = delegate self._local_delegate = local_delegate self._use_backend_assistant_config = bool(use_backend_assistant_config) async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: if self._use_backend_assistant_config: return await self._delegate.fetch_assistant_config(assistant_id) return await self._local_delegate.fetch_assistant_config(assistant_id) async def create_call_record( self, *, user_id: int, assistant_id: Optional[str], source: str = "debug", ) -> Optional[str]: return await self._delegate.create_call_record( user_id=user_id, assistant_id=assistant_id, source=source, ) async def add_transcript( self, *, call_id: str, turn_index: int, speaker: str, content: str, start_ms: int, end_ms: int, confidence: Optional[float] = None, duration_ms: Optional[int] = None, ) -> bool: return await self._delegate.add_transcript( call_id=call_id, turn_index=turn_index, speaker=speaker, content=content, start_ms=start_ms, end_ms=end_ms, confidence=confidence, duration_ms=duration_ms, ) async def finalize_call_record( self, *, call_id: str, status: str, duration_seconds: int, ) -> bool: return await self._delegate.finalize_call_record( call_id=call_id, status=status, duration_seconds=duration_seconds, ) async def search_knowledge_context( self, *, kb_id: str, query: str, n_results: int = 5, ) -> List[Dict[str, Any]]: return await self._delegate.search_knowledge_context( kb_id=kb_id, query=query, n_results=n_results, ) async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: return await self._delegate.fetch_tool_resource(tool_id) class HttpBackendAdapter: """HTTP implementation of backend integration ports.""" def __init__(self, backend_url: str, timeout_sec: int = 10): base_url = str(backend_url or "").strip().rstrip("/") if not base_url: raise ValueError("backend_url is required for HttpBackendAdapter") self._base_url = base_url self._timeout_sec = timeout_sec def _timeout(self) -> aiohttp.ClientTimeout: return aiohttp.ClientTimeout(total=self._timeout_sec) async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: """Fetch assistant config payload from backend API. Expected response shape: { "assistant": {...}, "voice": {...} | null } """ url = f"{self._base_url}/api/assistants/{assistant_id}/config" try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.get(url) as resp: if resp.status == 404: logger.warning(f"Assistant config not found: {assistant_id}") return {"__error_code": "assistant.not_found", "assistantId": assistant_id} resp.raise_for_status() payload = await resp.json() if not isinstance(payload, dict): logger.warning("Assistant config payload is not a dict; ignoring") return {"__error_code": "assistant.config_unavailable", "assistantId": assistant_id} return payload except Exception as exc: logger.warning(f"Failed to fetch assistant config ({assistant_id}): {exc}") return {"__error_code": "assistant.config_unavailable", "assistantId": assistant_id} async def create_call_record( self, *, user_id: int, assistant_id: Optional[str], source: str = "debug", ) -> Optional[str]: """Create a call record via backend history API and return call_id.""" url = f"{self._base_url}/api/history" payload: Dict[str, Any] = { "user_id": user_id, "assistant_id": assistant_id, "source": source, "status": "connected", } try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.post(url, json=payload) as resp: resp.raise_for_status() data = await resp.json() call_id = str((data or {}).get("id") or "") return call_id or None except Exception as exc: logger.warning(f"Failed to create history call record: {exc}") return None async def add_transcript( self, *, call_id: str, turn_index: int, speaker: str, content: str, start_ms: int, end_ms: int, confidence: Optional[float] = None, duration_ms: Optional[int] = None, ) -> bool: """Append a transcript segment to backend history.""" if not call_id: return False url = f"{self._base_url}/api/history/{call_id}/transcripts" payload: Dict[str, Any] = { "turn_index": turn_index, "speaker": speaker, "content": content, "confidence": confidence, "start_ms": start_ms, "end_ms": end_ms, "duration_ms": duration_ms, } try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.post(url, json=payload) as resp: resp.raise_for_status() return True except Exception as exc: logger.warning(f"Failed to append history transcript (call_id={call_id}, turn={turn_index}): {exc}") return False async def finalize_call_record( self, *, call_id: str, status: str, duration_seconds: int, ) -> bool: """Finalize a call record with status and duration.""" if not call_id: return False url = f"{self._base_url}/api/history/{call_id}" payload: Dict[str, Any] = { "status": status, "duration_seconds": duration_seconds, } try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.put(url, json=payload) as resp: resp.raise_for_status() return True except Exception as exc: logger.warning(f"Failed to finalize history call record ({call_id}): {exc}") return False async def search_knowledge_context( self, *, kb_id: str, query: str, n_results: int = 5, ) -> List[Dict[str, Any]]: """Search backend knowledge base and return retrieval results.""" if not kb_id or not query.strip(): return [] try: safe_n_results = max(1, int(n_results)) except (TypeError, ValueError): safe_n_results = 5 url = f"{self._base_url}/api/knowledge/search" payload: Dict[str, Any] = { "kb_id": kb_id, "query": query, "nResults": safe_n_results, } try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.post(url, json=payload) as resp: if resp.status == 404: logger.warning(f"Knowledge base not found for retrieval: {kb_id}") return [] resp.raise_for_status() data = await resp.json() if not isinstance(data, dict): return [] results = data.get("results", []) if not isinstance(results, list): return [] return [r for r in results if isinstance(r, dict)] except Exception as exc: logger.warning(f"Knowledge search failed (kb_id={kb_id}): {exc}") return [] async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: """Fetch tool resource configuration from backend API.""" if not tool_id: return None url = f"{self._base_url}/api/tools/resources/{tool_id}" try: async with aiohttp.ClientSession(timeout=self._timeout()) as session: async with session.get(url) as resp: if resp.status == 404: return None resp.raise_for_status() data = await resp.json() return data if isinstance(data, dict) else None except Exception as exc: logger.warning(f"Failed to fetch tool resource ({tool_id}): {exc}") return None def build_backend_adapter( *, backend_url: Optional[str], backend_mode: str = "auto", history_enabled: bool = True, timeout_sec: int = 10, assistant_local_config_dir: str = "engine/config/agents", ) -> AssistantConfigSourceAdapter: """Create backend adapter implementation based on runtime settings.""" mode = str(backend_mode or "auto").strip().lower() has_url = bool(str(backend_url or "").strip()) base_adapter: HttpBackendAdapter | NullBackendAdapter using_http_backend = False if mode in {"disabled", "off", "none", "null", "engine_only", "engine-only"}: base_adapter = NullBackendAdapter() elif mode == "http": if has_url: base_adapter = HttpBackendAdapter(backend_url=str(backend_url), timeout_sec=timeout_sec) using_http_backend = True else: logger.warning("BACKEND_MODE=http but BACKEND_URL is empty; falling back to NullBackendAdapter") base_adapter = NullBackendAdapter() else: if has_url: base_adapter = HttpBackendAdapter(backend_url=str(backend_url), timeout_sec=timeout_sec) using_http_backend = True else: base_adapter = NullBackendAdapter() runtime_adapter: HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter if not history_enabled: runtime_adapter = HistoryDisabledBackendAdapter(base_adapter) else: runtime_adapter = base_adapter return AssistantConfigSourceAdapter( delegate=runtime_adapter, local_delegate=LocalYamlAssistantConfigAdapter(assistant_local_config_dir), use_backend_assistant_config=using_http_backend, ) def build_backend_adapter_from_settings() -> AssistantConfigSourceAdapter: """Create backend adapter using current app settings.""" return build_backend_adapter( backend_url=settings.backend_url, backend_mode=settings.backend_mode, history_enabled=settings.history_enabled, timeout_sec=settings.backend_timeout_sec, assistant_local_config_dir=settings.assistant_local_config_dir, )