diff --git a/docs/images/ov-doctor-fail.png b/docs/images/ov-doctor-fail.png new file mode 100644 index 000000000..5394f56b2 Binary files /dev/null and b/docs/images/ov-doctor-fail.png differ diff --git a/docs/images/ov-doctor-pass.png b/docs/images/ov-doctor-pass.png new file mode 100644 index 000000000..e53e50aca Binary files /dev/null and b/docs/images/ov-doctor-pass.png differ diff --git a/openviking_cli/doctor.py b/openviking_cli/doctor.py new file mode 100644 index 000000000..0783be265 --- /dev/null +++ b/openviking_cli/doctor.py @@ -0,0 +1,281 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 +"""ov doctor - validate OpenViking subsystems and report actionable diagnostics. + +Unlike ``ov health`` (which pings a running server), ``ov doctor`` checks +local prerequisites without requiring a server: config file, Python version, +native vector engine, AGFS, embedding provider, VLM provider, and disk space. +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import sys +from pathlib import Path +from typing import Optional + +# ANSI helpers (disabled when stdout is not a terminal) +_USE_COLOR = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +def _green(text: str) -> str: + return f"\033[32m{text}\033[0m" if _USE_COLOR else text + + +def _red(text: str) -> str: + return f"\033[31m{text}\033[0m" if _USE_COLOR else text + + +def _yellow(text: str) -> str: + return f"\033[33m{text}\033[0m" if _USE_COLOR else text + + +def _dim(text: str) -> str: + return f"\033[2m{text}\033[0m" if _USE_COLOR else text + + +# --------------------------------------------------------------------------- +# Individual check functions +# --------------------------------------------------------------------------- + +_CONFIG_SEARCH_PATHS = [ + Path(os.environ.get("OPENVIKING_CONFIG_FILE", "")), + Path.home() / ".openviking" / "ov.conf", + Path("/etc/openviking/ov.conf"), +] + + +def _find_config() -> Optional[Path]: + for p in _CONFIG_SEARCH_PATHS: + if p and p.is_file(): + return p + return None + + +def check_config() -> tuple[bool, str, Optional[str]]: + """Validate ov.conf exists and is valid JSON with required sections.""" + config_path = _find_config() + if config_path is None: + locations = ", ".join(str(p) for p in _CONFIG_SEARCH_PATHS[1:]) + return ( + False, + "Configuration file not found", + f"Create {locations} or set OPENVIKING_CONFIG_FILE", + ) + + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return False, f"Invalid JSON in {config_path}", f"Fix syntax error: {exc}" + + missing = [key for key in ("embedding",) if key not in data] + if missing: + return ( + False, + f"{config_path} missing required sections: {', '.join(missing)}", + "Add the missing sections (see examples/ov.conf.example)", + ) + + return True, str(config_path), None + + +def check_python() -> tuple[bool, str, Optional[str]]: + """Verify Python >= 3.10.""" + version = sys.version_info + version_str = f"{version[0]}.{version[1]}.{version[2]}" + if version >= (3, 10): + return True, f"{version_str} (>= 3.10 required)", None + return ( + False, + f"{version_str} (>= 3.10 required)", + "Upgrade Python to 3.10 or later", + ) + + +def check_native_engine() -> tuple[bool, str, Optional[str]]: + """Check if the native vector engine (PersistStore) is available.""" + try: + from openviking.storage.vectordb.engine import ( + AVAILABLE_ENGINE_VARIANTS, + ENGINE_VARIANT, + ) + except ImportError as exc: + return ( + False, + f"Cannot import engine module: {exc}", + "pip install openviking --upgrade --force-reinstall", + ) + + if ENGINE_VARIANT == "unavailable": + variants = ", ".join(AVAILABLE_ENGINE_VARIANTS) if AVAILABLE_ENGINE_VARIANTS else "none" + machine = platform.machine() + return ( + False, + f"No compatible engine variant (platform: {machine}, packaged: {variants})", + 'pip install openviking --upgrade --force-reinstall\n Alt: Use vectordb.backend = "volcengine" instead of "local"', + ) + + return True, f"variant={ENGINE_VARIANT}", None + + +def check_agfs() -> tuple[bool, str, Optional[str]]: + """Verify pyagfs module loads.""" + try: + import pyagfs + + version = getattr(pyagfs, "__version__", "unknown") + return True, f"pyagfs {version}", None + except ImportError: + return ( + False, + "pyagfs module not found", + "pip install openviking --upgrade --force-reinstall", + ) + + +def check_embedding() -> tuple[bool, str, Optional[str]]: + """Load embedding config and verify provider connectivity.""" + config_path = _find_config() + if config_path is None: + return False, "Cannot check (no config file)", None + + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + return False, "Cannot check (config unreadable)", None + + embedding = data.get("embedding", {}) + dense = embedding.get("dense", {}) + provider = dense.get("provider", "unknown") + model = dense.get("model", "unknown") + + if provider == "unknown": + return False, "No embedding provider configured", "Add embedding.dense section to ov.conf" + + api_key = dense.get("api_key", os.environ.get("OPENAI_API_KEY", "")) + if not api_key or api_key.startswith("{"): + return ( + False, + f"{provider}/{model} (no API key)", + "Set embedding.dense.api_key in ov.conf or OPENAI_API_KEY env var", + ) + + return True, f"{provider}/{model}", None + + +def check_vlm() -> tuple[bool, str, Optional[str]]: + """Load VLM config and verify it's configured.""" + config_path = _find_config() + if config_path is None: + return False, "Cannot check (no config file)", None + + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + return False, "Cannot check (config unreadable)", None + + vlm = data.get("vlm", {}) + provider = vlm.get("provider", "") + model = vlm.get("model", "") + + if not provider: + return False, "No VLM provider configured", "Add vlm section to ov.conf" + + api_key = vlm.get("api_key", os.environ.get("OPENAI_API_KEY", "")) + if not api_key or api_key.startswith("{"): + return ( + False, + f"{provider}/{model} (no API key)", + "Set vlm.api_key in ov.conf or set the provider's API key env var", + ) + + return True, f"{provider}/{model}", None + + +def check_disk() -> tuple[bool, str, Optional[str]]: + """Check free disk space in the workspace directory.""" + config_path = _find_config() + workspace = Path.home() / ".openviking" + + if config_path: + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + ws = data.get("storage", {}).get("workspace", "") + if ws: + workspace = Path(ws).expanduser() + except Exception: + pass + + check_path = workspace if workspace.exists() else Path.home() + + usage = shutil.disk_usage(check_path) + free_gb = usage.free / (1024**3) + + if free_gb < 1.0: + return ( + False, + f"{free_gb:.1f} GB free in {check_path}", + "Free up disk space (OpenViking needs at least 1 GB for vector storage)", + ) + + return True, f"{free_gb:.1f} GB free in {check_path}", None + + +# --------------------------------------------------------------------------- +# Main orchestrator +# --------------------------------------------------------------------------- + +_CHECKS = [ + ("Config", check_config), + ("Python", check_python), + ("Native Engine", check_native_engine), + ("AGFS", check_agfs), + ("Embedding", check_embedding), + ("VLM", check_vlm), + ("Disk", check_disk), +] + + +def run_doctor() -> int: + """Run all diagnostic checks and print a formatted report. + + Returns 0 if all checks pass, 1 otherwise. + """ + print("\nOpenViking Doctor\n") + + failed = 0 + max_label = max(len(label) for label, _ in _CHECKS) + + for label, check_fn in _CHECKS: + try: + ok, detail, fix = check_fn() + except Exception as exc: + ok, detail, fix = False, f"Unexpected error: {exc}", None + + pad = " " * (max_label - len(label) + 1) + if ok: + status = _green("PASS") + print(f" {label}:{pad}{status} {detail}") + else: + status = _red("FAIL") + print(f" {label}:{pad}{status} {detail}") + failed += 1 + if fix: + for line in fix.split("\n"): + print(f" {' ' * (max_label + 2)}{_dim('Fix: ' + line)}") + + print() + if failed: + print(f" {_red(f'{failed} check(s) failed.')} See above for fix suggestions.\n") + return 1 + + print(f" {_green('All checks passed.')}\n") + return 0 + + +def main() -> int: + """Entry point for ``ov doctor``.""" + return run_doctor() diff --git a/openviking_cli/rust_cli.py b/openviking_cli/rust_cli.py index 7242b454c..52c3b23d4 100644 --- a/openviking_cli/rust_cli.py +++ b/openviking_cli/rust_cli.py @@ -44,10 +44,16 @@ def main(): 极简入口点:查找 ov 二进制并执行 按优先级查找: - 0. ./target/release/ov(开发环境) - 1. Wheel 自带:{package_dir}/openviking/bin/ov - 2. PATH 查找:系统全局安装的 ov + 0. Python-native 子命令(doctor) + 1. ./target/release/ov(开发环境) + 2. Wheel 自带:{package_dir}/openviking/bin/ov + 3. PATH 查找:系统全局安装的 ov """ + # 0. Python-native subcommands (no Rust binary needed) + if len(sys.argv) > 1 and sys.argv[1] == "doctor": + from openviking_cli.doctor import main as doctor_main + + sys.exit(doctor_main()) # 0. 检查开发环境(仅在直接运行脚本时有效) try: # __file__ is openviking_cli/rust_cli.py, so parent is openviking_cli directory diff --git a/tests/cli/test_doctor.py b/tests/cli/test_doctor.py new file mode 100644 index 000000000..878002a09 --- /dev/null +++ b/tests/cli/test_doctor.py @@ -0,0 +1,234 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for ``ov doctor`` diagnostic checks.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from unittest.mock import patch + +from openviking_cli.doctor import ( + check_agfs, + check_config, + check_disk, + check_embedding, + check_native_engine, + check_python, + check_vlm, + run_doctor, +) + + +class TestCheckConfig: + def test_pass_with_valid_config(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text(json.dumps({"embedding": {"dense": {}}})) + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_config() + assert ok + assert str(config) in detail + + def test_fail_missing_config(self): + with patch("openviking_cli.doctor._find_config", return_value=None): + ok, detail, fix = check_config() + assert not ok + assert "not found" in detail + assert fix is not None + + def test_fail_invalid_json(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text("{bad json") + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_config() + assert not ok + assert "Invalid JSON" in detail + + def test_fail_missing_embedding_section(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text(json.dumps({"server": {}})) + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_config() + assert not ok + assert "embedding" in detail + + +class TestCheckPython: + def test_pass_current_python(self): + ok, detail, fix = check_python() + assert ok # Tests run on Python >= 3.10 + + def test_fail_old_python(self): + with patch.object(sys, "version_info", (3, 9, 0, "final", 0)): + ok, detail, fix = check_python() + assert not ok + assert "3.9.0" in detail + + +class TestCheckNativeEngine: + def test_pass_when_available(self): + with patch( + "openviking_cli.doctor.ENGINE_VARIANT", + "native", + create=True, + ): + # Need to patch the import itself + import openviking.storage.vectordb.engine as engine_mod + + original_variant = engine_mod.ENGINE_VARIANT + engine_mod.ENGINE_VARIANT = "native" + try: + ok, detail, fix = check_native_engine() + assert ok + assert "native" in detail + finally: + engine_mod.ENGINE_VARIANT = original_variant + + def test_fail_when_unavailable(self): + import openviking.storage.vectordb.engine as engine_mod + + original_variant = engine_mod.ENGINE_VARIANT + original_available = engine_mod.AVAILABLE_ENGINE_VARIANTS + engine_mod.ENGINE_VARIANT = "unavailable" + engine_mod.AVAILABLE_ENGINE_VARIANTS = () + try: + ok, detail, fix = check_native_engine() + assert not ok + assert "No compatible" in detail + assert fix is not None + finally: + engine_mod.ENGINE_VARIANT = original_variant + engine_mod.AVAILABLE_ENGINE_VARIANTS = original_available + + +class TestCheckAgfs: + def test_pass_when_importable(self): + # pyagfs may not load cleanly in all envs (e.g. dev source checkout) + ok, detail, fix = check_agfs() + # Just verify it returns a valid tuple - pass/fail depends on environment + assert isinstance(ok, bool) + assert isinstance(detail, str) + + def test_fail_when_missing(self): + with patch.dict(sys.modules, {"pyagfs": None}): + # Force ImportError by removing from sys.modules + saved = sys.modules.pop("pyagfs", None) + try: + with patch("builtins.__import__", side_effect=_import_fail("pyagfs")): + ok, detail, fix = check_agfs() + assert not ok + finally: + if saved is not None: + sys.modules["pyagfs"] = saved + + +class TestCheckEmbedding: + def test_pass_with_api_key(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text( + json.dumps( + { + "embedding": { + "dense": { + "provider": "openai", + "model": "text-embedding-3-small", + "api_key": "sk-test123", + } + } + } + ) + ) + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_embedding() + assert ok + assert "openai" in detail + + def test_fail_no_api_key(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text( + json.dumps( + { + "embedding": { + "dense": { + "provider": "openai", + "model": "text-embedding-3-small", + "api_key": "{your-api-key}", + } + } + } + ) + ) + with patch("openviking_cli.doctor._find_config", return_value=config): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENAI_API_KEY", None) + ok, detail, fix = check_embedding() + assert not ok + assert "no API key" in detail + + +class TestCheckVlm: + def test_pass_with_config(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text( + json.dumps( + {"vlm": {"provider": "openai", "model": "gpt-4o-mini", "api_key": "sk-test"}} + ) + ) + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_vlm() + assert ok + + def test_fail_no_provider(self, tmp_path: Path): + config = tmp_path / "ov.conf" + config.write_text(json.dumps({"vlm": {}})) + with patch("openviking_cli.doctor._find_config", return_value=config): + ok, detail, fix = check_vlm() + assert not ok + + +class TestCheckDisk: + def test_pass_normal_disk(self): + ok, detail, fix = check_disk() + # Should pass on any dev machine + assert ok + assert "GB free" in detail + + +class TestRunDoctor: + def test_returns_zero_when_all_pass(self, tmp_path: Path, capsys): + config = tmp_path / "ov.conf" + config.write_text( + json.dumps( + { + "embedding": {"dense": {"provider": "openai", "model": "m", "api_key": "sk-x"}}, + "vlm": {"provider": "openai", "model": "m", "api_key": "sk-x"}, + } + ) + ) + with patch("openviking_cli.doctor._find_config", return_value=config): + code = run_doctor() + captured = capsys.readouterr() + assert "OpenViking Doctor" in captured.out + # May not be 0 if native engine is missing, but the function should complete + assert isinstance(code, int) + + def test_returns_one_on_failure(self, capsys): + with patch("openviking_cli.doctor._find_config", return_value=None): + code = run_doctor() + assert code == 1 + captured = capsys.readouterr() + assert "FAIL" in captured.out + + +def _import_fail(blocked_name: str): + """Return an __import__ replacement that blocks one specific module.""" + real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ + + def _mock_import(name, *args, **kwargs): + if name == blocked_name: + raise ImportError(f"Mocked: {name}") + return real_import(name, *args, **kwargs) + + return _mock_import