Ctrl K
ComunidadGuías…

scout/doctor.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""环境诊断 — scout doctor 子命令.

一次性检查运行 Scout 所需的环境、配置、依赖与运行时状态,
并汇总近 7 天 LLM 缓存命中率与预估节省成本。

退出码:
    0 = 全部通过
    1 = 有警告(可运行,但建议修复)
    2 = 有错误(缺少关键配置/依赖,无法正常运行)
"""

from __future__ import annotations

import importlib
import os
import socket
import sys
from pathlib import Path

from scout.config.paths import DATA_DIR as _SCOUT_DATA_DIR

from rich.console import Console
from rich.table import Table

console = Console()

# 必需环境变量: (变量名, 说明)
REQUIRED_ENV_VARS = [
    ("SCOUT_LLM_API_KEY", "LLM API Key(后端推理模型)"),
    ("SCOUT_LLM_MODEL", "LLM 模型名(如 qwen-plus / deepseek-chat)"),
    ("SCOUT_LLM_PROVIDER", "LLM 提供方(如 dashscope / deepseek / openai)"),
]

# 核心依赖: (模块名, pip 包名)
CORE_DEPENDENCIES = [
    ("httpx", "httpx"),
    ("dotenv", "python-dotenv"),
    ("rich", "rich"),
    ("fastapi", "fastapi"),
    ("uvicorn", "uvicorn"),
    ("sqlite3", "标准库"),
    ("aiohttp", "aiohttp"),
]



def _project_root() -> Path:
    """scout/doctor.py -> scout -> 项目根."""
    return Path(__file__).resolve().parent.parent


def _data_dir() -> Path:
    env_dir = os.getenv("SCOUT_DATA_DIR")
    if env_dir:
        return Path(env_dir).resolve()
    return _SCOUT_DATA_DIR


def _has_saved_key() -> bool:
    """是否已通过 scout key 加密存储了任一 provider 的 Key."""
    try:
        from scout.config.manager import ConfigManager
        mgr = ConfigManager()
        keys = mgr.list_provider_keys()
        return any(has for has in keys.values())
    except Exception:  # noqa: BLE001
        return False


def _check_env(report: list[tuple[str, str, str]]) -> None:
    """检查 .env 文件与必需变量.

    注: 后台/Web 模式可通过 `scout key --add` 加密存储 Key(不依赖 .env);
    仅 CLI 直接推理模式需要 .env 中的环境变量。因此 env 缺失但已存 Key 时
    降级为 WARN 而非 FAIL。
    """
    saved_key = _has_saved_key()
    env_paths = [
        Path.cwd() / ".env",
        _project_root() / ".env",
        Path.home() / "scout-agent" / ".env",
    ]
    found = next((p for p in env_paths if p.exists()), None)
    if found is None:
        report.append((
            "环境文件 .env",
            "WARN" if saved_key else "FAIL",
            "未找到;若已用 scout key 配置可忽略,否则复制 .env.example 为 .env 并填写",
        ))
    else:
        report.append(("环境文件 .env", "OK", str(found)))

    missing = [name for name, _ in REQUIRED_ENV_VARS if not os.getenv(name)]
    if missing:
        report.append((
            "必需环境变量",
            "WARN" if saved_key else "FAIL",
            f"缺失: {', '.join(missing)}" + ("(已通过 scout key 配置,仅 CLI 直接推理需补齐)" if saved_key else ""),
        ))
    else:
        model = os.getenv("SCOUT_LLM_MODEL", "")
        provider = os.getenv("SCOUT_LLM_PROVIDER", "")
        report.append(("LLM 配置", "OK", f"{provider} / {model}"))


def _check_api_keys(report: list[tuple[str, str, str]]) -> None:
    """检查多 Provider Key 加密存储(scout key)."""
    try:
        from scout.config.manager import ConfigManager
        mgr = ConfigManager()
        keys = mgr.list_provider_keys()
        if not keys:
            report.append(("API Key 存储", "WARN", "未保存任何 Key,可用 scout key --add <provider> <key>"))
            return
        active = mgr.load().provider
        saved = [p for p, has in sorted(keys.items()) if has]
        report.append(("API Key 存储", "OK", f"已保存 {len(saved)} 个: {', '.join(saved)}; 激活: {active}"))
    except Exception as e:  # noqa: BLE001
        report.append(("API Key 存储", "WARN", f"读取失败: {e}"))


def _check_embedding_model(report: list[tuple[str, str, str]]) -> None:
    """检查嵌入配置(默认纯文本检索,无本地模型依赖)."""
    provider = os.getenv("SCOUT_EMBEDDING_PROVIDER", "").strip().lower()
    if provider == "api":
        report.append(("嵌入模型", "OK", "API 嵌入(需配置 SCOUT_EMBEDDING_API_KEY)"))
    elif provider in ("", "off", "none", "disabled"):
        report.append(("嵌入模型", "OK", "纯文本检索(默认,无需模型与密钥)"))
    elif provider == "hash":
        report.append(("嵌入模型", "OK", "哈希嵌入(开发/测试用)"))
    elif provider in ("local", "local_onnx", "onnx", "bge-small-zh-v1.5"):
        report.append(("嵌入模型", "WARN", "本地 ONNX 嵌入已移除,请将配置改为 API 嵌入模型名或留空(纯文本)"))
    else:
        report.append(("嵌入模型", "OK", f"API 嵌入({provider})"))


def _check_data_dir(report: list[tuple[str, str, str]]) -> None:
    """检查数据目录与 usage.db 可写性."""
    data = _data_dir()
    try:
        data.mkdir(parents=True, exist_ok=True)
        probe = data / ".write_test"
        probe.write_text("ok")
        probe.unlink(missing_ok=True)
        db_ok = (data / "usage.db").exists()
        report.append(("数据目录", "OK", f"{data}{'(usage.db 已存在)' if db_ok else ''}"))
    except OSError as e:
        report.append(("数据目录", "FAIL", f"{data} 不可写: {e}"))


def _check_data_format(report: list[tuple[str, str, str]]) -> None:
    """检查数据目录格式版本兼容性(升级/迁移能力)."""
    try:
        from scout.config.manifest import (
            DATA_FORMAT_VERSION,
            check_data_format,
            get_data_format_version,
        )

        compatible, msg = check_data_format()
        if compatible:
            report.append((
                "数据格式版本",
                "OK" if get_data_format_version() >= DATA_FORMAT_VERSION else "WARN",
                msg,
            ))
        else:
            report.append(("数据格式版本", "FAIL", msg))
    except Exception as e:  # noqa: BLE001
        report.append(("数据格式版本", "WARN", f"检查失败: {e}"))


def _check_schema_versions(report: list[tuple[str, str, str]]) -> None:
    """检查各数据库 schema 版本是否与当前代码一致(升级/迁移能力)."""
    try:
        from scout.config.paths import DATA_DIR
        from scout.storage.schema import SCHEMA_VERSION, get_schema_version

        dbs = [
            "sessions.db",
            "runs.db",
            "usage.db",
            "goals.db",
            "observability.db",
            "memory.db",
            "vector_memory.db",
        ]
        mismatches = []
        for name in dbs:
            path = DATA_DIR / name
            if not path.exists():
                continue
            try:
                import sqlite3
                conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
                try:
                    version = get_schema_version(conn)
                finally:
                    conn.close()
                if version < SCHEMA_VERSION:
                    mismatches.append(f"{name}(v{version}→v{SCHEMA_VERSION})")
            except Exception:  # noqa: BLE001
                mismatches.append(f"{name}(读取失败)")
        if mismatches:
            report.append((
                "数据库 Schema",
                "WARN",
                f"{'、'.join(mismatches)} — 启动时将自动迁移",
            ))
        else:
            report.append(("数据库 Schema", "OK", f"全部为当前版本 v{SCHEMA_VERSION}"))
    except Exception as e:  # noqa: BLE001
        report.append(("数据库 Schema", "WARN", f"检查失败: {e}"))


def _check_dependencies(report: list[tuple[str, str, str]]) -> None:
    """检查核心依赖是否可导入."""
    failed = []
    for module, pkg in CORE_DEPENDENCIES:
        try:
            importlib.import_module(module)
        except Exception:  # noqa: BLE001
            failed.append(pkg)
    if failed:
        report.append(("核心依赖", "FAIL", f"缺失: {', '.join(failed)} → pip install {', '.join(failed)}"))
    else:
        report.append(("核心依赖", "OK", f"{len(CORE_DEPENDENCIES)} 项全部可导入"))


def _check_service(report: list[tuple[str, str, str]]) -> None:
    """检查后台服务状态与 Web 端口."""
    pid_file = _project_root() / ".scout.pid"
    port_busy = False
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        try:
            s.bind(("127.0.0.1", 8848))
        except OSError:
            port_busy = True

    if pid_file.exists():
        try:
            pid = int(pid_file.read_text().strip())
            alive = os.path.exists(f"/proc/{pid}") if sys.platform.startswith("linux") else True
            report.append(("后台服务", "OK" if alive else "WARN", f"PID {pid}({ '运行中' if alive else '已失效,运行 scout stop 清理' })"))
        except ValueError:
            report.append(("后台服务", "WARN", "pid 文件损坏"))
    else:
        report.append(("后台服务", "WARN" if port_busy else "OK", "未运行(端口 8848 空闲)" if not port_busy else "端口 8848 被占用(可能有其他服务)"))


def _check_cache_stats(report: list[tuple[str, str, str]]) -> None:
    """汇总近 7 天缓存命中率与预估节省成本."""
    try:
        from scout.llm.tracker import LLMUsageTracker
        tracker = LLMUsageTracker()
        summary = tracker.get_summary("week")
        total = summary["total"] or {}
        prompt = total.get("prompt_tokens") or 0
        cached = total.get("cached_tokens") or 0
        if not total.get("call_count"):
            report.append(("缓存命中(7 天)", "WARN", "暂无调用记录"))
            return
        rate = round(cached / prompt * 100, 1) if prompt else 0.0
        cost = total.get("estimated_cost_cny", 0.0)
        saved = total.get("estimated_saved_cny", 0.0)
        detail = f"{total.get('call_count')} 次调用 / 命中率 {rate}% / 缓存节省 ¥{saved:.4f} / 实际成本 ¥{cost:.4f}"
        report.append(("缓存命中(7 天)", "OK" if rate >= 10 else "WARN", detail))
    except Exception as e:  # noqa: BLE001
        report.append(("缓存命中(7 天)", "WARN", f"统计失败: {e}"))


def run_doctor() -> int:
    """执行全部检查,返回退出码."""
    report: list[tuple[str, str, str]] = []

    py = sys.version_info
    report.append(("Python 版本", "OK" if py >= (3, 11) else "WARN", f"{py.major}.{py.minor}.{py.micro}(推荐 3.11+)"))

    _check_env(report)
    _check_api_keys(report)
    _check_embedding_model(report)
    _check_data_dir(report)
    _check_data_format(report)
    _check_schema_versions(report)
    _check_dependencies(report)
    _check_service(report)
    _check_cache_stats(report)

    # ── 输出表格 ──
    table = Table(title="🧭 Scout 环境诊断", show_lines=True)
    table.add_column("检查项", style="bold")
    table.add_column("状态", justify="center")
    table.add_column("详情", style="dim")

    n_fail = n_warn = 0
    for name, status, detail in report:
        style = {"OK": "green", "WARN": "yellow", "FAIL": "red"}.get(status, "white")
        table.add_row(name, f"[{style}]{status}[/]", detail)
        if status == "FAIL":
            n_fail += 1
        elif status == "WARN":
            n_warn += 1

    console.print()
    console.print(table)
    console.print()

    if n_fail:
        console.print(f"[bold red]发现 {n_fail} 个错误、{n_warn} 个警告,请修复后重试[/]")
        return 2
    if n_warn:
        console.print(f"[bold yellow]全部关键项通过,但有 {n_warn} 个警告(不影响基本运行)[/]")
        return 1
    console.print("[bold green]所有检查通过 ✅[/]")
    return 0


if __name__ == "__main__":
    sys.exit(run_doctor())