diff --git a/backends/advanced/init.py b/backends/advanced/init.py index eaf9f92f..eb6c3af6 100644 --- a/backends/advanced/init.py +++ b/backends/advanced/init.py @@ -324,9 +324,8 @@ def setup_transcription(self): elif choice == "2": self.console.print("[blue][INFO][/blue] Offline Parakeet ASR selected") - parakeet_url = self.prompt_value( - "Parakeet ASR URL (without http:// prefix)", "host.docker.internal:8767" - ) + existing_parakeet_url = read_env_value('.env', 'PARAKEET_ASR_URL') or "http://host.docker.internal:8767" + parakeet_url = self.prompt_value("Parakeet ASR URL", existing_parakeet_url) # Write URL to .env for ${PARAKEET_ASR_URL} placeholder in config.yml self.config["PARAKEET_ASR_URL"] = parakeet_url @@ -345,13 +344,9 @@ def setup_transcription(self): ) elif choice == "3": - self.console.print( - "[blue][INFO][/blue] Offline VibeVoice ASR selected (built-in speaker diarization)" - ) - vibevoice_url = self.prompt_value( - "VibeVoice ASR URL (without http:// prefix)", - "host.docker.internal:8767", - ) + self.console.print("[blue][INFO][/blue] Offline VibeVoice ASR selected (built-in speaker diarization)") + existing_vibevoice_url = read_env_value('.env', 'VIBEVOICE_ASR_URL') or "http://host.docker.internal:8767" + vibevoice_url = self.prompt_value("VibeVoice ASR URL", existing_vibevoice_url) # Write URL to .env for ${VIBEVOICE_ASR_URL} placeholder in config.yml self.config["VIBEVOICE_ASR_URL"] = vibevoice_url @@ -371,12 +366,10 @@ def setup_transcription(self): ) elif choice == "4": - self.console.print( - "[blue][INFO][/blue] Qwen3-ASR selected (52 languages, streaming + batch via vLLM)" - ) - qwen3_url = self.prompt_value( - "Qwen3-ASR URL", "http://host.docker.internal:8767" - ) + self.console.print("[blue][INFO][/blue] Qwen3-ASR selected (52 languages, streaming + batch via vLLM)") + existing_qwen3_url_raw = read_env_value('.env', 'QWEN3_ASR_URL') + existing_qwen3_url = f"http://{existing_qwen3_url_raw}" if existing_qwen3_url_raw else "http://host.docker.internal:8767" + qwen3_url = self.prompt_value("Qwen3-ASR URL", existing_qwen3_url) # Write URL to .env for ${QWEN3_ASR_URL} placeholder in config.yml self.config["QWEN3_ASR_URL"] = qwen3_url.replace("http://", "").rstrip("/") @@ -527,21 +520,32 @@ def setup_streaming_provider(self): def setup_llm(self): """Configure LLM provider - updates config.yml and .env""" - self.print_section("LLM Provider Configuration") - - self.console.print( - "[blue][INFO][/blue] LLM configuration will be saved to config.yml" - ) - self.console.print() + # Check if LLM provider was provided via command line (from wizard.py) + if hasattr(self.args, 'llm_provider') and self.args.llm_provider: + provider = self.args.llm_provider + self.console.print(f"[green]✅[/green] LLM provider: {provider} (configured via wizard)") + choice = {"openai": "1", "ollama": "2", "none": "3"}.get(provider, "1") + else: + # Standalone init.py run — read existing config as default + existing_choice = "1" + full_config = self.config_manager.get_full_config() + existing_llm = full_config.get("defaults", {}).get("llm", "") + if existing_llm == "local-llm": + existing_choice = "2" + elif existing_llm == "openai-llm": + existing_choice = "1" + + self.print_section("LLM Provider Configuration") + self.console.print("[blue][INFO][/blue] LLM configuration will be saved to config.yml") + self.console.print() - choices = { - "1": "OpenAI (GPT-4, GPT-3.5 - requires API key)", - "2": "Ollama (local models - runs locally)", - "3": "OpenAI-Compatible (custom endpoint - Groq, Together AI, LM Studio, etc.)", - "4": "Skip (no memory extraction)", - } + choices = { + "1": "OpenAI (GPT-4, GPT-3.5 - requires API key)", + "2": "Ollama (local models - runs locally)", + "3": "Skip (no memory extraction)" + } - choice = self.prompt_choice("Which LLM provider will you use?", choices, "1") + choice = self.prompt_choice("Which LLM provider will you use?", choices, existing_choice) if choice == "1": self.console.print("[blue][INFO][/blue] OpenAI selected") @@ -717,14 +721,27 @@ def setup_llm(self): def setup_memory(self): """Configure memory provider - updates config.yml""" - self.print_section("Memory Storage Configuration") + # Check if memory provider was provided via command line (from wizard.py) + if hasattr(self.args, 'memory_provider') and self.args.memory_provider: + provider = self.args.memory_provider + self.console.print(f"[green]✅[/green] Memory provider: {provider} (configured via wizard)") + choice = {"chronicle": "1", "openmemory_mcp": "2"}.get(provider, "1") + else: + # Standalone init.py run — read existing config as default + existing_choice = "1" + full_config = self.config_manager.get_full_config() + existing_provider = full_config.get("memory", {}).get("provider", "chronicle") + if existing_provider == "openmemory_mcp": + existing_choice = "2" - choices = { - "1": "Chronicle Native (Qdrant + custom extraction)", - "2": "OpenMemory MCP (cross-client compatible, external server)", - } + self.print_section("Memory Storage Configuration") + + choices = { + "1": "Chronicle Native (Qdrant + custom extraction)", + "2": "OpenMemory MCP (cross-client compatible, external server)", + } - choice = self.prompt_choice("Choose your memory storage backend:", choices, "1") + choice = self.prompt_choice("Choose your memory storage backend:", choices, existing_choice) if choice == "1": self.console.print( @@ -852,13 +869,20 @@ def setup_neo4j(self): def setup_obsidian(self): """Configure Obsidian integration (optional feature flag only - Neo4j credentials handled by setup_neo4j)""" - if hasattr(self.args, "enable_obsidian") and self.args.enable_obsidian: + has_enable = hasattr(self.args, 'enable_obsidian') and self.args.enable_obsidian + has_disable = hasattr(self.args, 'no_obsidian') and self.args.no_obsidian + + if has_enable: enable_obsidian = True - self.console.print( - f"[green]✅[/green] Obsidian: enabled (configured via wizard)" - ) + self.console.print(f"[green]✅[/green] Obsidian: enabled (configured via wizard)") + elif has_disable: + enable_obsidian = False + self.console.print(f"[blue][INFO][/blue] Obsidian: disabled (configured via wizard)") else: - # Interactive prompt (fallback) + # Standalone init.py run — read existing config as default + full_config = self.config_manager.get_full_config() + existing_enabled = full_config.get("memory", {}).get("obsidian", {}).get("enabled", False) + self.console.print() self.console.print("[bold cyan]Obsidian Integration (Optional)[/bold cyan]") self.console.print( @@ -867,12 +891,10 @@ def setup_obsidian(self): self.console.print() try: - enable_obsidian = Confirm.ask( - "Enable Obsidian integration?", default=False - ) + enable_obsidian = Confirm.ask("Enable Obsidian integration?", default=existing_enabled) except EOFError: - self.console.print("Using default: No") - enable_obsidian = False + self.console.print(f"Using default: {'Yes' if existing_enabled else 'No'}") + enable_obsidian = existing_enabled if enable_obsidian: self.config_manager.update_memory_config( @@ -887,12 +909,20 @@ def setup_obsidian(self): def setup_knowledge_graph(self): """Configure Knowledge Graph (Neo4j-based entity/relationship extraction - enabled by default)""" - if ( - hasattr(self.args, "enable_knowledge_graph") - and self.args.enable_knowledge_graph - ): + has_enable = hasattr(self.args, 'enable_knowledge_graph') and self.args.enable_knowledge_graph + has_disable = hasattr(self.args, 'no_knowledge_graph') and self.args.no_knowledge_graph + + if has_enable: enable_kg = True + self.console.print(f"[green]✅[/green] Knowledge Graph: enabled (configured via wizard)") + elif has_disable: + enable_kg = False + self.console.print(f"[blue][INFO][/blue] Knowledge Graph: disabled (configured via wizard)") else: + # Standalone init.py run — read existing config as default + full_config = self.config_manager.get_full_config() + existing_enabled = full_config.get("memory", {}).get("knowledge_graph", {}).get("enabled", True) + self.console.print() self.console.print( "[bold cyan]Knowledge Graph (Entity Extraction)[/bold cyan]" @@ -903,10 +933,10 @@ def setup_knowledge_graph(self): self.console.print() try: - enable_kg = Confirm.ask("Enable Knowledge Graph?", default=True) + enable_kg = Confirm.ask("Enable Knowledge Graph?", default=existing_enabled) except EOFError: - self.console.print("Using default: Yes") - enable_kg = True + self.console.print(f"Using default: {'Yes' if existing_enabled else 'No'}") + enable_kg = existing_enabled if enable_kg: self.config_manager.update_memory_config( @@ -1404,65 +1434,44 @@ def run(self): def main(): """Main entry point""" parser = argparse.ArgumentParser(description="Chronicle Advanced Backend Setup") - parser.add_argument( - "--speaker-service-url", - help="Speaker Recognition service URL (default: prompt user)", - ) - parser.add_argument( - "--parakeet-asr-url", help="Parakeet ASR service URL (default: prompt user)" - ) - parser.add_argument( - "--transcription-provider", - choices=["deepgram", "parakeet", "vibevoice", "qwen3-asr", "smallest", "none"], - help="Transcription provider (default: prompt user)", - ) - parser.add_argument( - "--enable-https", - action="store_true", - help="Enable HTTPS configuration (default: prompt user)", - ) - parser.add_argument( - "--server-ip", - help="Server IP/domain for SSL certificate (default: prompt user)", - ) - parser.add_argument( - "--enable-obsidian", - action="store_true", - help="Enable Obsidian/Neo4j integration (default: prompt user)", - ) - parser.add_argument( - "--enable-knowledge-graph", - action="store_true", - help="Enable Knowledge Graph entity extraction (default: prompt user)", - ) - parser.add_argument( - "--neo4j-password", help="Neo4j password (default: prompt user)" - ) - parser.add_argument( - "--ts-authkey", - help="Tailscale auth key for Docker integration (default: prompt user)", - ) - parser.add_argument( - "--langfuse-public-key", - help="LangFuse project public key (from langfuse init or external)", - ) - parser.add_argument( - "--langfuse-secret-key", - help="LangFuse project secret key (from langfuse init or external)", - ) - parser.add_argument( - "--langfuse-host", - help="LangFuse host URL (default: http://langfuse-web:3000 for local)", - ) - parser.add_argument( - "--langfuse-public-url", - help="LangFuse browser-accessible URL for deep-links (default: http://localhost:3002)", - ) - parser.add_argument( - "--streaming-provider", - choices=["deepgram", "smallest", "qwen3-asr"], - help="Streaming provider when different from batch (enables batch re-transcription)", - ) + parser.add_argument("--speaker-service-url", + help="Speaker Recognition service URL (default: prompt user)") + parser.add_argument("--parakeet-asr-url", + help="Parakeet ASR service URL (default: prompt user)") + parser.add_argument("--transcription-provider", + choices=["deepgram", "parakeet", "vibevoice", "qwen3-asr", "smallest", "none"], + help="Transcription provider (default: prompt user)") + parser.add_argument("--enable-https", action="store_true", + help="Enable HTTPS configuration (default: prompt user)") + parser.add_argument("--server-ip", + help="Server IP/domain for SSL certificate (default: prompt user)") + parser.add_argument("--enable-obsidian", action="store_true", + help="Enable Obsidian/Neo4j integration (default: prompt user)") + parser.add_argument("--enable-knowledge-graph", action="store_true", + help="Enable Knowledge Graph entity extraction (default: prompt user)") + parser.add_argument("--neo4j-password", + help="Neo4j password (default: prompt user)") + parser.add_argument("--ts-authkey", + help="Tailscale auth key for Docker integration (default: prompt user)") + parser.add_argument("--langfuse-public-key", + help="LangFuse project public key (from langfuse init or external)") + parser.add_argument("--langfuse-secret-key", + help="LangFuse project secret key (from langfuse init or external)") + parser.add_argument("--langfuse-host", + help="LangFuse host URL (default: http://langfuse-web:3000 for local)") + parser.add_argument("--streaming-provider", + choices=["deepgram", "smallest", "qwen3-asr"], + help="Streaming provider when different from batch (enables batch re-transcription)") + parser.add_argument("--llm-provider", + choices=["openai", "ollama", "none"], + help="LLM provider for memory extraction (default: prompt user)") + parser.add_argument("--memory-provider", + choices=["chronicle", "openmemory_mcp"], + help="Memory storage backend (default: prompt user)") + parser.add_argument("--no-obsidian", action="store_true", + help="Explicitly disable Obsidian integration (complementary to --enable-obsidian)") + parser.add_argument("--no-knowledge-graph", action="store_true", + help="Explicitly disable Knowledge Graph (complementary to --enable-knowledge-graph)") args = parser.parse_args() diff --git a/tests/unit/test_wizard_defaults.py b/tests/unit/test_wizard_defaults.py new file mode 100644 index 00000000..3d4a1f45 --- /dev/null +++ b/tests/unit/test_wizard_defaults.py @@ -0,0 +1,259 @@ +"""Test wizard.py helper functions for loading previous config as defaults. + +Tests for the functions that read config/config.yml to pre-populate wizard +prompts with previously-configured values, so re-runs default to existing +settings. +""" + +import pytest +import yaml +from pathlib import Path +from unittest.mock import patch, MagicMock + + +# --------------------------------------------------------------------------- +# Import the pure helper functions directly from wizard.py. +# wizard.py lives at the project root, not inside a package, so we import +# via importlib with an explicit path to avoid adding the root to sys.path +# permanently. +# --------------------------------------------------------------------------- + +import importlib.util +import sys + +WIZARD_PATH = Path(__file__).parent.parent.parent / "wizard.py" +PROJECT_ROOT = str(WIZARD_PATH.parent) + + +def _load_wizard(): + # wizard.py and setup_utils.py both live in the project root. + # Add the root to sys.path so the relative import resolves. + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + spec = importlib.util.spec_from_file_location("wizard", WIZARD_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Load once and reuse +_wizard = _load_wizard() + +read_config_yml = _wizard.read_config_yml +get_existing_stt_provider = _wizard.get_existing_stt_provider +get_existing_stream_provider = _wizard.get_existing_stream_provider +select_llm_provider = _wizard.select_llm_provider +select_memory_provider = _wizard.select_memory_provider +select_knowledge_graph = _wizard.select_knowledge_graph + + +# --------------------------------------------------------------------------- +# read_config_yml +# --------------------------------------------------------------------------- + +def test_read_config_yml_missing_file(tmp_path, monkeypatch): + """Returns empty dict when config/config.yml does not exist.""" + monkeypatch.chdir(tmp_path) + result = read_config_yml() + assert result == {} + + +def test_read_config_yml_valid_file(tmp_path, monkeypatch): + """Parses and returns dict from a valid YAML file.""" + monkeypatch.chdir(tmp_path) + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "config.yml").write_text( + "defaults:\n llm: openai-llm\n stt: stt-deepgram\n" + ) + result = read_config_yml() + assert result["defaults"]["llm"] == "openai-llm" + assert result["defaults"]["stt"] == "stt-deepgram" + + +def test_read_config_yml_empty_file(tmp_path, monkeypatch): + """Returns empty dict for an empty YAML file (yaml.safe_load returns None).""" + monkeypatch.chdir(tmp_path) + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "config.yml").write_text("") + result = read_config_yml() + assert result == {} + + +def test_read_config_yml_comment_only_file(tmp_path, monkeypatch): + """Returns empty dict when the file contains only YAML comments.""" + monkeypatch.chdir(tmp_path) + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "config.yml").write_text("# just a comment\n") + result = read_config_yml() + assert result == {} + + +# --------------------------------------------------------------------------- +# get_existing_stt_provider +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("stt_value, expected", [ + ("stt-deepgram", "deepgram"), + ("stt-deepgram-stream", "deepgram"), + ("stt-parakeet-batch", "parakeet"), + ("stt-vibevoice", "vibevoice"), + ("stt-qwen3-asr", "qwen3-asr"), + ("stt-smallest", "smallest"), + ("stt-smallest-stream", "smallest"), +]) +def test_get_existing_stt_provider_known_values(stt_value, expected): + """Maps known config.yml stt values to wizard provider names.""" + config = {"defaults": {"stt": stt_value}} + assert get_existing_stt_provider(config) == expected + + +def test_get_existing_stt_provider_unknown_returns_none(): + """Returns None for unknown stt values (e.g. custom providers).""" + config = {"defaults": {"stt": "stt-unknown-provider"}} + assert get_existing_stt_provider(config) is None + + +def test_get_existing_stt_provider_missing_key(): + """Returns None when defaults.stt key is absent.""" + assert get_existing_stt_provider({}) is None + assert get_existing_stt_provider({"defaults": {}}) is None + + +# --------------------------------------------------------------------------- +# get_existing_stream_provider +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("stt_stream_value, expected", [ + ("stt-deepgram-stream", "deepgram"), + ("stt-smallest-stream", "smallest"), + ("stt-qwen3-asr", "qwen3-asr"), + ("stt-qwen3-asr-stream", "qwen3-asr"), +]) +def test_get_existing_stream_provider_known_values(stt_stream_value, expected): + """Maps known config.yml stt_stream values to wizard streaming provider names.""" + config = {"defaults": {"stt_stream": stt_stream_value}} + assert get_existing_stream_provider(config) == expected + + +def test_get_existing_stream_provider_unknown_returns_none(): + """Returns None for unknown stt_stream values.""" + config = {"defaults": {"stt_stream": "stt-unknown"}} + assert get_existing_stream_provider(config) is None + + +def test_get_existing_stream_provider_missing_key(): + """Returns None when defaults.stt_stream is absent.""" + assert get_existing_stream_provider({}) is None + assert get_existing_stream_provider({"defaults": {}}) is None + + +# --------------------------------------------------------------------------- +# select_llm_provider — test default resolution logic via EOFError path +# --------------------------------------------------------------------------- + +def _select_llm_with_eof(config_yml): + """Drive select_llm_provider in non-interactive mode by injecting EOFError.""" + with patch.object(_wizard, "Prompt") as mock_prompt: + mock_prompt.ask.side_effect = EOFError + return select_llm_provider(config_yml) + + +def test_select_llm_provider_defaults_to_openai_when_no_config(): + """Defaults to openai when config is empty.""" + result = _select_llm_with_eof({}) + assert result == "openai" + + +def test_select_llm_provider_defaults_to_openai_for_openai_llm(): + """Picks openai when existing config has defaults.llm = openai-llm.""" + config = {"defaults": {"llm": "openai-llm"}} + result = _select_llm_with_eof(config) + assert result == "openai" + + +def test_select_llm_provider_defaults_to_ollama_for_local_llm(): + """Picks ollama when existing config has defaults.llm = local-llm.""" + config = {"defaults": {"llm": "local-llm"}} + result = _select_llm_with_eof(config) + assert result == "ollama" + + +def test_select_llm_provider_none_config(): + """Treats None config_yml as empty dict (defaults to openai).""" + result = _select_llm_with_eof(None) + assert result == "openai" + + +# --------------------------------------------------------------------------- +# select_memory_provider — test default resolution logic via EOFError path +# --------------------------------------------------------------------------- + +def _select_memory_with_eof(config_yml): + with patch.object(_wizard, "Prompt") as mock_prompt: + mock_prompt.ask.side_effect = EOFError + return select_memory_provider(config_yml) + + +def test_select_memory_provider_defaults_to_chronicle_when_no_config(): + """Defaults to chronicle when config is empty.""" + result = _select_memory_with_eof({}) + assert result == "chronicle" + + +def test_select_memory_provider_defaults_to_chronicle(): + """Picks chronicle when existing config has memory.provider = chronicle.""" + config = {"memory": {"provider": "chronicle"}} + result = _select_memory_with_eof(config) + assert result == "chronicle" + + +def test_select_memory_provider_defaults_to_openmemory_mcp(): + """Picks openmemory_mcp when existing config has memory.provider = openmemory_mcp.""" + config = {"memory": {"provider": "openmemory_mcp"}} + result = _select_memory_with_eof(config) + assert result == "openmemory_mcp" + + +def test_select_memory_provider_none_config(): + """Treats None config_yml as empty dict (defaults to chronicle).""" + result = _select_memory_with_eof(None) + assert result == "chronicle" + + +# --------------------------------------------------------------------------- +# select_knowledge_graph — test default resolution logic via EOFError path +# --------------------------------------------------------------------------- + +def _select_kg_with_eof(config_yml): + with patch.object(_wizard, "Confirm") as mock_confirm: + mock_confirm.ask.side_effect = EOFError + return select_knowledge_graph(config_yml) + + +def test_select_knowledge_graph_defaults_to_true_when_no_config(): + """Defaults to True (enabled) when config is empty.""" + result = _select_kg_with_eof({}) + assert result is True + + +def test_select_knowledge_graph_respects_existing_true(): + """Returns True when existing config has knowledge_graph.enabled = True.""" + config = {"memory": {"knowledge_graph": {"enabled": True}}} + result = _select_kg_with_eof(config) + assert result is True + + +def test_select_knowledge_graph_respects_existing_false(): + """Returns False when existing config has knowledge_graph.enabled = False.""" + config = {"memory": {"knowledge_graph": {"enabled": False}}} + result = _select_kg_with_eof(config) + assert result is False + + +def test_select_knowledge_graph_none_config(): + """Treats None config_yml as empty dict (defaults to True).""" + result = _select_kg_with_eof(None) + assert result is True diff --git a/wizard.py b/wizard.py index c3884120..42497742 100755 --- a/wizard.py +++ b/wizard.py @@ -27,6 +27,48 @@ console = Console() + +def read_config_yml() -> dict: + """Read config/config.yml and return parsed dict, or empty dict if not found. + + Used to load existing configuration as defaults for wizard prompts so that + re-runs default to previously configured values. + """ + config_path = Path("config/config.yml") + if not config_path.exists(): + return {} + with open(config_path, 'r') as f: + result = yaml.safe_load(f) + return result if result else {} + + +def get_existing_stt_provider(config_yml: dict): + """Map config.yml defaults.stt value back to wizard provider name, or None.""" + stt = config_yml.get("defaults", {}).get("stt", "") + mapping = { + "stt-deepgram": "deepgram", + "stt-deepgram-stream": "deepgram", + "stt-parakeet-batch": "parakeet", + "stt-vibevoice": "vibevoice", + "stt-qwen3-asr": "qwen3-asr", + "stt-smallest": "smallest", + "stt-smallest-stream": "smallest", + } + return mapping.get(stt) + + +def get_existing_stream_provider(config_yml: dict): + """Map config.yml defaults.stt_stream value back to wizard streaming provider name, or None.""" + stt_stream = config_yml.get("defaults", {}).get("stt_stream", "") + mapping = { + "stt-deepgram-stream": "deepgram", + "stt-smallest-stream": "smallest", + "stt-qwen3-asr": "qwen3-asr", + "stt-qwen3-asr-stream": "qwen3-asr", + } + return mapping.get(stt_stream) + + SERVICES = { "backend": { "advanced": { @@ -152,9 +194,9 @@ def check_service_exists(service_name, service_config): return True, "OK" - -def select_services(transcription_provider=None): +def select_services(transcription_provider=None, config_yml=None, memory_provider=None): """Let user select which services to setup""" + config_yml = config_yml or {} console.print("🚀 [bold cyan]Chronicle Service Setup[/bold cyan]") console.print("Select which services to configure:\n") @@ -195,8 +237,19 @@ def select_services(transcription_provider=None): console.print(f" ⏸️ {service_config['description']} - [dim]{msg}[/dim]") continue - # Speaker recognition is recommended by default - default_enable = service_name == "speaker-recognition" + # Determine smart default based on existing config + if service_name == 'speaker-recognition': + # Default to True if speaker-recognition .env exists and has a valid (non-placeholder) HF_TOKEN + speaker_env = 'extras/speaker-recognition/.env' + existing_hf = read_env_value(speaker_env, 'HF_TOKEN') + default_enable = bool(existing_hf and not is_placeholder( + existing_hf, 'your_huggingface_token_here', 'your-huggingface-token-here', 'hf_xxxxx' + )) + elif service_name == 'openmemory-mcp': + # Default to True if memory provider was selected as openmemory_mcp + default_enable = (memory_provider == "openmemory_mcp") + else: + default_enable = False try: enable_service = Confirm.ask( @@ -230,28 +283,14 @@ def cleanup_unselected_services(selected_services): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_file = service_path / f".env.backup.{timestamp}.unselected" env_file.rename(backup_file) - console.print( - f"🧹 [dim]Backed up {service_name} configuration to {backup_file.name} (service not selected)[/dim]" - ) - - -def run_service_setup( - service_name, - selected_services, - https_enabled=False, - server_ip=None, - obsidian_enabled=False, - neo4j_password=None, - hf_token=None, - transcription_provider="deepgram", - admin_email=None, - admin_password=None, - langfuse_public_key=None, - langfuse_secret_key=None, - langfuse_host=None, - streaming_provider=None, - hardware_profile=None, -): + console.print(f"🧹 [dim]Backed up {service_name} configuration to {backup_file.name} (service not selected)[/dim]") + +def run_service_setup(service_name, selected_services, https_enabled=False, server_ip=None, + obsidian_enabled=False, neo4j_password=None, hf_token=None, + transcription_provider='deepgram', admin_email=None, admin_password=None, + langfuse_public_key=None, langfuse_secret_key=None, langfuse_host=None, + streaming_provider=None, llm_provider=None, memory_provider=None, + knowledge_graph_enabled=None): """Execute individual service setup script""" if service_name == "advanced": service = SERVICES["backend"][service_name] @@ -279,9 +318,25 @@ def run_service_setup( if neo4j_password: cmd.extend(["--neo4j-password", neo4j_password]) - # Add Obsidian configuration + # Always pass obsidian choice to avoid double-ask if obsidian_enabled: - cmd.extend(["--enable-obsidian"]) + cmd.extend(['--enable-obsidian']) + else: + cmd.extend(['--no-obsidian']) + + # Always pass knowledge graph choice to avoid double-ask + if knowledge_graph_enabled is True: + cmd.extend(['--enable-knowledge-graph']) + elif knowledge_graph_enabled is False: + cmd.extend(['--no-knowledge-graph']) + + # Pass LLM provider choice + if llm_provider: + cmd.extend(['--llm-provider', llm_provider]) + + # Pass memory provider choice + if memory_provider: + cmd.extend(['--memory-provider', memory_provider]) # Pass LangFuse keys from langfuse init or external config if langfuse_public_key and langfuse_secret_key: @@ -753,15 +808,27 @@ def setup_config_file(): STREAMING_CAPABLE = {"deepgram", "smallest", "qwen3-asr"} -def select_transcription_provider(): +def select_transcription_provider(config_yml: dict = None): """Ask user which transcription provider they want (batch/primary).""" + config_yml = config_yml or {} + existing_provider = get_existing_stt_provider(config_yml) + + provider_to_choice = { + "deepgram": "1", "parakeet": "2", "vibevoice": "3", + "qwen3-asr": "4", "smallest": "5", "none": "6", + } + choice_to_provider = {v: k for k, v in provider_to_choice.items()} + default_choice = provider_to_choice.get(existing_provider, "1") + console.print("\n🎤 [bold cyan]Transcription Provider[/bold cyan]") - console.print( - "Choose your speech-to-text provider (used for [bold]batch[/bold]/high-quality transcription):" - ) - console.print( - "[dim]If it also supports streaming, it will be used for real-time too by default.[/dim]" - ) + console.print("Choose your speech-to-text provider (used for [bold]batch[/bold]/high-quality transcription):") + console.print("[dim]If it also supports streaming, it will be used for real-time too by default.[/dim]") + if existing_provider: + provider_labels = { + "deepgram": "Deepgram", "parakeet": "Parakeet ASR", "vibevoice": "VibeVoice ASR", + "qwen3-asr": "Qwen3-ASR", "smallest": "Smallest.ai Pulse", + } + console.print(f"[blue][INFO][/blue] Current: {provider_labels.get(existing_provider, existing_provider)}") console.print() choices = { @@ -774,34 +841,22 @@ def select_transcription_provider(): } for key, desc in choices.items(): - console.print(f" {key}) {desc}") + marker = " [dim](current)[/dim]" if key == default_choice else "" + console.print(f" {key}) {desc}{marker}") console.print() while True: try: - choice = Prompt.ask("Enter choice", default="1") + choice = Prompt.ask("Enter choice", default=default_choice) if choice in choices: - if choice == "1": - return "deepgram" - elif choice == "2": - return "parakeet" - elif choice == "3": - return "vibevoice" - elif choice == "4": - return "qwen3-asr" - elif choice == "5": - return "smallest" - elif choice == "6": - return "none" - console.print( - f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]" - ) + return choice_to_provider[choice] + console.print(f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]") except EOFError: - console.print("Using default: Deepgram") - return "deepgram" + console.print(f"Using default: {choices.get(default_choice, 'Deepgram')}") + return choice_to_provider.get(default_choice, "deepgram") -def select_streaming_provider(batch_provider): +def select_streaming_provider(batch_provider, config_yml: dict = None): """Ask if user wants a different provider for real-time streaming. If the batch provider supports streaming, offer to use the same (saves a step). @@ -810,17 +865,20 @@ def select_streaming_provider(batch_provider): Returns: Streaming provider name if different from batch, or None (same / skipped). """ + config_yml = config_yml or {} if batch_provider in ("none", None): return None + existing_stream = get_existing_stream_provider(config_yml) + if batch_provider in STREAMING_CAPABLE: # Batch provider can already stream — just confirm + # Default to "use different" if a different streaming provider was previously configured + has_different_stream = bool(existing_stream and existing_stream != batch_provider) console.print(f"\n🔊 [bold cyan]Streaming[/bold cyan]") console.print(f"{batch_provider} supports both batch and streaming.") try: - use_different = Confirm.ask( - "Use a different provider for real-time streaming?", default=False - ) + use_different = Confirm.ask("Use a different provider for real-time streaming?", default=has_different_stream) except EOFError: return None if not use_different: @@ -851,13 +909,22 @@ def select_streaming_provider(batch_provider): streaming_choices[skip_key] = "Skip (no real-time streaming)" provider_map[skip_key] = None + # Pre-select the default based on existing config + default_stream_choice = "1" + if existing_stream and existing_stream != batch_provider: + for k, v in provider_map.items(): + if v == existing_stream: + default_stream_choice = k + break + for key, desc in streaming_choices.items(): - console.print(f" {key}) {desc}") + marker = " [dim](current)[/dim]" if key == default_stream_choice else "" + console.print(f" {key}) {desc}{marker}") console.print() while True: try: - choice = Prompt.ask("Enter choice", default="1") + choice = Prompt.ask("Enter choice", default=default_stream_choice) if choice in streaming_choices: result = provider_map[choice] if result: @@ -1007,6 +1074,103 @@ def select_hardware_profile( return None +def select_llm_provider(config_yml: dict = None) -> str: + """Ask user which LLM provider to use for memory extraction. + + Returns: + "openai", "ollama", or "none" + """ + config_yml = config_yml or {} + existing_llm = config_yml.get("defaults", {}).get("llm", "") + llm_to_choice = {"openai-llm": "1", "local-llm": "2"} + default_choice = llm_to_choice.get(existing_llm, "1") + + console.print("\n🤖 [bold cyan]LLM Provider[/bold cyan]") + console.print("Choose your language model provider for memory extraction and analysis:") + console.print() + + choices = { + "1": "OpenAI (GPT-4o-mini, requires API key)", + "2": "Ollama (local models, runs on your machine)", + "3": "None (skip memory extraction)", + } + + for key, desc in choices.items(): + marker = " [dim](current)[/dim]" if key == default_choice else "" + console.print(f" {key}) {desc}{marker}") + console.print() + + while True: + try: + choice = Prompt.ask("Enter choice", default=default_choice) + if choice in choices: + return {"1": "openai", "2": "ollama", "3": "none"}[choice] + console.print(f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]") + except EOFError: + console.print(f"Using default: {choices.get(default_choice, 'OpenAI')}") + return {"1": "openai", "2": "ollama", "3": "none"}.get(default_choice, "openai") + + +def select_memory_provider(config_yml: dict = None) -> str: + """Ask user which memory storage backend to use. + + This is separate from the 'Setup OpenMemory MCP server?' service question. + That question is about running the extra service; this is about the backend provider. + + Returns: + "chronicle" or "openmemory_mcp" + """ + config_yml = config_yml or {} + existing_provider = config_yml.get("memory", {}).get("provider", "chronicle") + default_choice = "2" if existing_provider == "openmemory_mcp" else "1" + + console.print("\n🧠 [bold cyan]Memory Storage Backend[/bold cyan]") + console.print("Choose where your memories and conversation facts are stored:") + console.print() + + choices = { + "1": "Chronicle Native (Qdrant vector database, self-hosted)", + "2": "OpenMemory MCP (cross-client compatible, requires openmemory-mcp service)", + } + + for key, desc in choices.items(): + marker = " [dim](current)[/dim]" if key == default_choice else "" + console.print(f" {key}) {desc}{marker}") + console.print() + + while True: + try: + choice = Prompt.ask("Enter choice", default=default_choice) + if choice in choices: + return {"1": "chronicle", "2": "openmemory_mcp"}[choice] + console.print(f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]") + except EOFError: + return {"1": "chronicle", "2": "openmemory_mcp"}.get(default_choice, "chronicle") + + +def select_knowledge_graph(config_yml: dict = None) -> bool: + """Ask user if Knowledge Graph should be enabled. + + Returns: + True if Knowledge Graph should be enabled, False otherwise. + """ + config_yml = config_yml or {} + existing_enabled = config_yml.get("memory", {}).get("knowledge_graph", {}).get("enabled", True) + + console.print("\n🕸️ [bold cyan]Knowledge Graph[/bold cyan]") + console.print("Extracts people, places, organizations, events, and tasks from conversations") + console.print("Uses Neo4j (included in the stack)") + console.print() + + try: + enabled = Confirm.ask("Enable Knowledge Graph?", default=existing_enabled) + except EOFError: + console.print(f"Using default: {'Yes' if existing_enabled else 'No'}") + enabled = existing_enabled + + return enabled + + def main(): """Main orchestration logic""" console.print("🎉 [bold green]Welcome to Chronicle![/bold green]\n") @@ -1027,14 +1191,23 @@ def main(): # Show what's available show_service_status() + # Read existing config.yml once — used as defaults for ALL wizard questions below + config_yml = read_config_yml() + # Ask about transcription provider FIRST (determines which services are needed) - transcription_provider = select_transcription_provider() + transcription_provider = select_transcription_provider(config_yml) # Ask about streaming provider (if batch provider doesn't stream, or user wants a different one) - streaming_provider = select_streaming_provider(transcription_provider) + streaming_provider = select_streaming_provider(transcription_provider, config_yml) + + # LLM Provider selection (asked once here, passed to init.py — avoids double-ask) + llm_provider = select_llm_provider(config_yml) + + # Memory Provider selection (asked once here, passed to init.py — avoids double-ask) + memory_provider = select_memory_provider(config_yml) # Service Selection (pass transcription_provider so we skip asking about ASR when already chosen) - selected_services = select_services(transcription_provider) + selected_services = select_services(transcription_provider, config_yml, memory_provider) # Auto-add asr-services if any local ASR was chosen (batch or streaming) local_asr_providers = ("parakeet", "vibevoice", "qwen3-asr") @@ -1052,6 +1225,13 @@ def main(): ) selected_services.append("asr-services") + # Auto-add openmemory-mcp service if openmemory_mcp was selected as memory provider + if memory_provider == "openmemory_mcp" and 'openmemory-mcp' not in selected_services: + exists, _ = check_service_exists('openmemory-mcp', SERVICES['extras']['openmemory-mcp']) + if exists: + console.print("[blue][INFO][/blue] Memory provider is OpenMemory MCP — auto-adding openmemory-mcp service") + selected_services.append('openmemory-mcp') + if not selected_services: console.print("\n[yellow]No services selected. Exiting.[/yellow]") return @@ -1085,13 +1265,15 @@ def main(): "HTTPS enables microphone access in browsers and secure connections" ) + # Default to existing HTTPS_ENABLED setting + existing_https = read_env_value('backends/advanced/.env', 'HTTPS_ENABLED') + default_https = existing_https == "true" + try: - https_enabled = Confirm.ask( - "Enable HTTPS for selected services?", default=True - ) + https_enabled = Confirm.ask("Enable HTTPS for selected services?", default=default_https) except EOFError: - console.print("Using default: Yes") - https_enabled = True + console.print(f"Using default: {'Yes' if default_https else 'No'}") + https_enabled = default_https if https_enabled: # Try to auto-detect Tailscale address @@ -1155,6 +1337,7 @@ def main(): # Neo4j Configuration (always required - used by Knowledge Graph) neo4j_password = None obsidian_enabled = False + knowledge_graph_enabled = None if "advanced" in selected_services: console.print("\n🗄️ [bold cyan]Neo4j Configuration[/bold cyan]") @@ -1163,19 +1346,15 @@ def main(): ) console.print() - # Prompt for Neo4j password (remembers previous value on re-run) - try: - neo4j_password = prompt_with_existing_masked( - "Neo4j password (min 8 chars)", - env_file_path="backends/advanced/.env", - env_key="NEO4J_PASSWORD", - placeholders=["", "your-neo4j-password"], - is_password=True, - default="neo4jpassword", - ) - except (EOFError, KeyboardInterrupt): - neo4j_password = "neo4jpassword" - console.print("Using default password") + # Read existing Neo4j password and use as default (masked prompt) + existing_neo4j_pw = read_env_value('backends/advanced/.env', 'NEO4J_PASSWORD') + neo4j_password = prompt_with_existing_masked( + prompt_text="Neo4j password (min 8 chars)", + existing_value=existing_neo4j_pw, + placeholders=['neo4jpassword', 'your_neo4j_password', 'your-neo4j-password'], + is_password=True, + default="neo4jpassword" + ) if not neo4j_password: neo4j_password = "neo4jpassword" @@ -1188,17 +1367,20 @@ def main(): ) console.print() + # Load existing obsidian enabled state from config.yml as default + existing_obsidian = config_yml.get("memory", {}).get("obsidian", {}).get("enabled", False) try: - obsidian_enabled = Confirm.ask( - "Enable Obsidian integration?", default=False - ) + obsidian_enabled = Confirm.ask("Enable Obsidian integration?", default=existing_obsidian) except EOFError: - console.print("Using default: No") - obsidian_enabled = False + console.print(f"Using default: {'Yes' if existing_obsidian else 'No'}") + obsidian_enabled = existing_obsidian if obsidian_enabled: console.print("[green]✅[/green] Obsidian integration will be configured") + # Knowledge Graph configuration (asked here once, passed to init.py) + knowledge_graph_enabled = select_knowledge_graph(config_yml) + # Pure Delegation - Run Each Service Setup console.print(f"\n📋 [bold]Setting up {len(selected_services)} services...[/bold]") @@ -1231,23 +1413,13 @@ def main(): wizard_admin_password = read_env_value(backend_env_path, "ADMIN_PASSWORD") for service in setup_order: - if run_service_setup( - service, - selected_services, - https_enabled, - server_ip, - obsidian_enabled, - neo4j_password, - hf_token, - transcription_provider, - admin_email=wizard_admin_email, - admin_password=wizard_admin_password, - langfuse_public_key=langfuse_public_key, - langfuse_secret_key=langfuse_secret_key, - langfuse_host=langfuse_host, - streaming_provider=streaming_provider, - hardware_profile=hardware_profile, - ): + if run_service_setup(service, selected_services, https_enabled, server_ip, + obsidian_enabled, neo4j_password, hf_token, transcription_provider, + admin_email=wizard_admin_email, admin_password=wizard_admin_password, + langfuse_public_key=langfuse_public_key, langfuse_secret_key=langfuse_secret_key, + langfuse_host=langfuse_host, streaming_provider=streaming_provider, + llm_provider=llm_provider, memory_provider=memory_provider, + knowledge_graph_enabled=knowledge_graph_enabled): success_count += 1 # After local langfuse setup, read generated API keys for backend