diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml index 867edc5f..e4203f91 100644 --- a/backends/advanced/docker-compose-test.yml +++ b/backends/advanced/docker-compose-test.yml @@ -14,7 +14,7 @@ services: - ./data/test_audio_chunks:/app/audio_chunks - ./data/test_debug_dir:/app/debug_dir - ./data/test_data:/app/data - - ${CONFIG_FILE:-../../config/config.yml}:/app/config.yml:ro # Mount config.yml for model registry and memory settings + - ${CONFIG_FILE:-../../config/config.yml}:/app/config.yml # Mount config.yml for model registry and memory settings (writable for admin config updates) environment: # Override with test-specific settings - MONGODB_URI=mongodb://mongo-test:27017/test_db @@ -160,7 +160,7 @@ services: - ./data/test_audio_chunks:/app/audio_chunks - ./data/test_debug_dir:/app/debug_dir - ./data/test_data:/app/data - - ${CONFIG_FILE:-../../config/config.yml}:/app/config.yml:ro # Mount config.yml for model registry and memory settings + - ${CONFIG_FILE:-../../config/config.yml}:/app/config.yml # Mount config.yml for model registry and memory settings (writable for admin config updates) environment: # Same environment as backend - MONGODB_URI=mongodb://mongo-test:27017/test_db @@ -283,4 +283,4 @@ networks: # - --force-recreate for clean state # - Volume cleanup between test runs # - Environment variables can be injected via GitHub secrets -# - Health checks ensure services are ready before tests run \ No newline at end of file +# - Health checks ensure services are ready before tests run diff --git a/backends/advanced/src/advanced_omi_backend/chat_service.py b/backends/advanced/src/advanced_omi_backend/chat_service.py index de92a4b9..16cba331 100644 --- a/backends/advanced/src/advanced_omi_backend/chat_service.py +++ b/backends/advanced/src/advanced_omi_backend/chat_service.py @@ -22,6 +22,7 @@ from advanced_omi_backend.database import get_database from advanced_omi_backend.llm_client import get_llm_client +from advanced_omi_backend.model_registry import get_models_registry from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.services.memory.base import MemoryEntry from advanced_omi_backend.services.obsidian_service import ( @@ -133,7 +134,7 @@ def from_dict(cls, data: Dict) -> "ChatSession": class ChatService: """Service for managing chat sessions and memory-enhanced conversations.""" - + def __init__(self): self.db = None self.sessions_collection: Optional[AsyncIOMotorCollection] = None @@ -142,6 +143,33 @@ def __init__(self): self.memory_service = None self._initialized = False + def _get_system_prompt(self) -> str: + """ + Get system prompt from config with fallback to default. + + Returns: + str: System prompt for chat interactions + """ + try: + reg = get_models_registry() + if reg and hasattr(reg, 'chat'): + chat_config = reg.chat + prompt = chat_config.get('system_prompt') + if prompt: + logger.info(f"ā Loaded chat system prompt from config (length: {len(prompt)} chars)") + logger.debug(f"System prompt: {prompt[:100]}...") + return prompt + except Exception as e: + logger.warning(f"Failed to load chat system prompt from config: {e}") + + # Fallback to default + logger.info("ā ļø Using default chat system prompt (config not found)") + return """You are a helpful AI assistant with access to the user's personal memories and conversation history. + +Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. + +If no relevant memories are available, respond normally based on the conversation context.""" + async def initialize(self): """Initialize the chat service with database connections.""" if self._initialized: @@ -392,12 +420,8 @@ async def generate_response_stream( "timestamp": time.time() } - # Create system prompt - system_prompt = """You are a helpful AI assistant with access to the user's personal memories and conversation history. - -Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. - -If no relevant memories are available, respond normally based on the conversation context.""" + # Get system prompt from config + system_prompt = self._get_system_prompt() # Prepare full prompt full_prompt = f"{system_prompt}\n\n{context}" diff --git a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py index 17b9cbcf..aced763f 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py @@ -455,3 +455,103 @@ async def set_memory_provider(provider: str): except Exception as e: logger.exception("Error setting memory provider") raise e + + +# Chat Configuration Management Functions + +async def get_chat_config_yaml() -> str: + """Get chat system prompt as plain text.""" + try: + config_path = _find_config_path() + + default_prompt = """You are a helpful AI assistant with access to the user's personal memories and conversation history. + +Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. + +If no relevant memories are available, respond normally based on the conversation context.""" + + if not os.path.exists(config_path): + return default_prompt + + with open(config_path, 'r') as f: + full_config = yaml.safe_load(f) or {} + + chat_config = full_config.get('chat', {}) + system_prompt = chat_config.get('system_prompt', default_prompt) + + # Return just the prompt text, not the YAML structure + return system_prompt + + except Exception as e: + logger.error(f"Error loading chat config: {e}") + raise + + +async def save_chat_config_yaml(prompt_text: str) -> dict: + """Save chat system prompt from plain text.""" + try: + config_path = _find_config_path() + + # Validate plain text prompt + if not prompt_text or not isinstance(prompt_text, str): + raise ValueError("Prompt must be a non-empty string") + + prompt_text = prompt_text.strip() + if len(prompt_text) < 10: + raise ValueError("Prompt too short (minimum 10 characters)") + if len(prompt_text) > 10000: + raise ValueError("Prompt too long (maximum 10000 characters)") + + # Create chat config dict + chat_config = {'system_prompt': prompt_text} + + # Load full config + if os.path.exists(config_path): + with open(config_path, 'r') as f: + full_config = yaml.safe_load(f) or {} + else: + full_config = {} + + # Backup existing config + if os.path.exists(config_path): + backup_path = str(config_path) + '.backup' + shutil.copy2(config_path, backup_path) + logger.info(f"Created config backup at {backup_path}") + + # Update chat section + full_config['chat'] = chat_config + + # Save + with open(config_path, 'w') as f: + yaml.dump(full_config, f, default_flow_style=False, allow_unicode=True) + + # Reload config in memory (hot-reload) + load_models_config(force_reload=True) + + logger.info("Chat configuration updated successfully") + + return {"success": True, "message": "Chat configuration updated successfully"} + + except Exception as e: + logger.error(f"Error saving chat config: {e}") + raise + + +async def validate_chat_config_yaml(prompt_text: str) -> dict: + """Validate chat system prompt plain text.""" + try: + # Validate plain text prompt + if not isinstance(prompt_text, str): + return {"valid": False, "error": "Prompt must be a string"} + + prompt_text = prompt_text.strip() + if len(prompt_text) < 10: + return {"valid": False, "error": "Prompt too short (minimum 10 characters)"} + if len(prompt_text) > 10000: + return {"valid": False, "error": "Prompt too long (maximum 10000 characters)"} + + return {"valid": True, "message": "Configuration is valid"} + + except Exception as e: + logger.error(f"Error validating chat config: {e}") + return {"valid": False, "error": f"Validation error: {str(e)}"} diff --git a/backends/advanced/src/advanced_omi_backend/model_registry.py b/backends/advanced/src/advanced_omi_backend/model_registry.py index 53d919ca..18f464ae 100644 --- a/backends/advanced/src/advanced_omi_backend/model_registry.py +++ b/backends/advanced/src/advanced_omi_backend/model_registry.py @@ -160,15 +160,15 @@ def validate_model(self) -> ModelDef: class AppModels(BaseModel): """Application models registry. - + Contains default model selections and all available model definitions. """ - + model_config = ConfigDict( extra='allow', validate_assignment=True, ) - + defaults: Dict[str, str] = Field( default_factory=dict, description="Default model names for each model_type" @@ -185,6 +185,10 @@ class AppModels(BaseModel): default_factory=dict, description="Speaker recognition service configuration" ) + chat: Dict[str, Any] = Field( + default_factory=dict, + description="Chat service configuration including system prompt" + ) def get_by_name(self, name: str) -> Optional[ModelDef]: """Get a model by its unique name. @@ -318,6 +322,7 @@ def load_models_config(force_reload: bool = False) -> Optional[AppModels]: model_list = raw.get("models", []) or [] memory_settings = raw.get("memory", {}) or {} speaker_recognition_cfg = raw.get("speaker_recognition", {}) or {} + chat_settings = raw.get("chat", {}) or {} # Parse and validate models using Pydantic models: Dict[str, ModelDef] = {} @@ -336,7 +341,8 @@ def load_models_config(force_reload: bool = False) -> Optional[AppModels]: defaults=defaults, models=models, memory=memory_settings, - speaker_recognition=speaker_recognition_cfg + speaker_recognition=speaker_recognition_cfg, + chat=chat_settings ) return _REGISTRY diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py index ead61ffa..0c261675 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py @@ -7,7 +7,8 @@ import logging from typing import Optional -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel from advanced_omi_backend.auth import current_active_user, current_superuser @@ -128,6 +129,53 @@ async def delete_all_user_memories(current_user: User = Depends(current_active_u return await system_controller.delete_all_user_memories(current_user) +# Chat Configuration Management Endpoints + +@router.get("/admin/chat/config", response_class=Response) +async def get_chat_config(current_user: User = Depends(current_superuser)): + """Get chat configuration as YAML. Admin only.""" + try: + yaml_content = await system_controller.get_chat_config_yaml() + return Response(content=yaml_content, media_type="text/plain") + except Exception as e: + logger.error(f"Failed to get chat config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/admin/chat/config") +async def save_chat_config( + request: Request, + current_user: User = Depends(current_superuser) +): + """Save chat configuration from YAML. Admin only.""" + try: + yaml_content = await request.body() + yaml_str = yaml_content.decode('utf-8') + result = await system_controller.save_chat_config_yaml(yaml_str) + return JSONResponse(content=result) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to save chat config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/admin/chat/config/validate") +async def validate_chat_config( + request: Request, + current_user: User = Depends(current_superuser) +): + """Validate chat configuration YAML. Admin only.""" + try: + yaml_content = await request.body() + yaml_str = yaml_content.decode('utf-8') + result = await system_controller.validate_chat_config_yaml(yaml_str) + return JSONResponse(content=result) + except Exception as e: + logger.error(f"Failed to validate chat config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/streaming/status") async def get_streaming_status(request: Request, current_user: User = Depends(current_superuser)): """Get status of active streaming sessions and Redis Streams health. Admin only.""" diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py index cf153472..85ee200a 100644 --- a/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py @@ -171,19 +171,19 @@ async def search_memories(self, query_embedding: List[float], user_id: str, limi # For cosine similarity, scores range from -1 to 1, where 1 is most similar search_params = { "collection_name": self.collection_name, - "query_vector": query_embedding, + "query": query_embedding, "query_filter": search_filter, "limit": limit } - + if score_threshold > 0.0: search_params["score_threshold"] = score_threshold memory_logger.debug(f"Using similarity threshold: {score_threshold}") - - results = await self.client.search(**search_params) - + + response = await self.client.query_points(**search_params) + memories = [] - for result in results: + for result in response.points: memory = MemoryEntry( id=str(result.id), content=result.payload.get("content", ""), diff --git a/backends/advanced/start.sh b/backends/advanced/start.sh index 40fa4abf..5cc79635 100755 --- a/backends/advanced/start.sh +++ b/backends/advanced/start.sh @@ -10,7 +10,8 @@ echo "š Starting Chronicle Backend..." # Function to handle shutdown shutdown() { echo "š Shutting down services..." - pkill -TERM -P $$ + # Kill the backend process if running + [ -n "$BACKEND_PID" ] && kill -TERM $BACKEND_PID 2>/dev/null || true wait echo "ā All services stopped" exit 0 diff --git a/backends/advanced/webui/src/components/ChatSettings.tsx b/backends/advanced/webui/src/components/ChatSettings.tsx new file mode 100644 index 00000000..1acad362 --- /dev/null +++ b/backends/advanced/webui/src/components/ChatSettings.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect } from 'react' +import { MessageSquare, RefreshCw, CheckCircle, Save, RotateCcw, AlertCircle } from 'lucide-react' +import { systemApi } from '../services/api' +import { useAuth } from '../contexts/AuthContext' + +interface ChatSettingsProps { + className?: string +} + +export default function ChatSettings({ className }: ChatSettingsProps) { + const [configYaml, setConfigYaml] = useState('') + const [loading, setLoading] = useState(false) + const [validating, setValidating] = useState(false) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const { isAdmin } = useAuth() + + useEffect(() => { + loadChatConfig() + }, []) + + const loadChatConfig = async () => { + setLoading(true) + setError('') + setMessage('') + + try { + const response = await systemApi.getChatConfigRaw() + setConfigYaml(response.data.config_yaml || response.data) + setMessage('Configuration loaded successfully') + setTimeout(() => setMessage(''), 3000) + } catch (err: any) { + const status = err.response?.status + if (status === 401) { + setError('Unauthorized: admin privileges required') + } else { + setError(err.response?.data?.error || 'Failed to load configuration') + } + } finally { + setLoading(false) + } + } + + const validateConfig = async () => { + if (!configYaml.trim()) { + setError('Configuration cannot be empty') + return + } + + setValidating(true) + setError('') + setMessage('') + + try { + const response = await systemApi.validateChatConfig(configYaml) + if (response.data.valid) { + setMessage('ā Configuration is valid') + } else { + setError(response.data.error || 'Validation failed') + } + setTimeout(() => setMessage(''), 3000) + } catch (err: any) { + setError(err.response?.data?.error || 'Validation failed') + } finally { + setValidating(false) + } + } + + const saveConfig = async () => { + if (!configYaml.trim()) { + setError('Configuration cannot be empty') + return + } + + setSaving(true) + setError('') + setMessage('') + + try { + await systemApi.updateChatConfigRaw(configYaml) + setMessage('ā Configuration saved successfully') + setTimeout(() => setMessage(''), 5000) + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to save configuration') + } finally { + setSaving(false) + } + } + + const resetConfig = () => { + loadChatConfig() + setMessage('Configuration reset to file version') + setTimeout(() => setMessage(''), 3000) + } + + if (!isAdmin) { + return null + } + + return ( +
{message}
+{error}
+