You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
310 lines
11 KiB
310 lines
11 KiB
"""Check whether this machine can run plenum, and report what is missing. |
|
|
|
python scripts/doctor.py |
|
|
|
Every check prints OK, WARN or FAIL with the actual value it found, so the output can |
|
be pasted somewhere or read by an assistant deciding what to do next. Nothing is |
|
changed — this only looks. |
|
|
|
Exit code is 1 if any check FAILed, so it can gate a setup script. |
|
""" |
|
from __future__ import annotations |
|
|
|
import os |
|
import shutil |
|
import socket |
|
import subprocess |
|
import sys |
|
from pathlib import Path |
|
from urllib.parse import urlparse |
|
|
|
ROOT = Path(__file__).resolve().parents[1] |
|
sys.path.insert(0, str(ROOT)) |
|
|
|
OK, WARN, FAIL = "OK ", "WARN", "FAIL" |
|
_results: list[str] = [] |
|
|
|
|
|
def report(status: str, check: str, detail: str = "") -> None: |
|
_results.append(status) |
|
print(f" [{status}] {check}" + (f" — {detail}" if detail else "")) |
|
|
|
|
|
def section(title: str) -> None: |
|
print(f"\n{title}") |
|
|
|
|
|
def _port_open(host: str, port: int, timeout: float = 2.0) -> bool: |
|
try: |
|
with socket.create_connection((host, port), timeout=timeout): |
|
return True |
|
except OSError: |
|
return False |
|
|
|
|
|
def _http_ok(url: str, timeout: float = 5.0) -> tuple[bool, str]: |
|
try: |
|
import requests |
|
|
|
r = requests.get(url, timeout=timeout) |
|
return r.status_code < 500, f"HTTP {r.status_code}" |
|
except Exception as exc: |
|
return False, type(exc).__name__ |
|
|
|
|
|
# ── environment ─────────────────────────────────────────────────────────────── |
|
|
|
|
|
def check_python() -> None: |
|
section("Python") |
|
v = sys.version_info |
|
if (v.major, v.minor) >= (3, 10): |
|
report(OK, "version", f"{v.major}.{v.minor}.{v.micro}") |
|
else: |
|
report(FAIL, "version", f"{v.major}.{v.minor} — plenum needs 3.10 or newer") |
|
|
|
missing = [m for m in ("fastapi", "psycopg2", "openai", "yaml", "pgvector") |
|
if not _importable(m)] |
|
if missing: |
|
report(FAIL, "dependencies", f"missing {missing} — run: pip install -e '.[dev]'") |
|
else: |
|
report(OK, "dependencies", "installed") |
|
|
|
|
|
def _importable(name: str) -> bool: |
|
import importlib.util |
|
|
|
return importlib.util.find_spec(name) is not None |
|
|
|
|
|
def check_config() -> None: |
|
section("Configuration") |
|
env = ROOT / ".env" |
|
if env.exists(): |
|
report(OK, ".env", str(env)) |
|
else: |
|
report(FAIL, ".env", "missing — copy .env.example to .env") |
|
return |
|
|
|
try: |
|
from dotenv import load_dotenv |
|
|
|
load_dotenv(env) |
|
except Exception as exc: |
|
report(WARN, ".env", f"could not load: {exc}") |
|
|
|
try: |
|
from parliament import PARLIAMENT |
|
|
|
report(OK, "parliament config", |
|
f"{PARLIAMENT.meta.get('name')} ({PARLIAMENT.meta.get('country')}), " |
|
f"fts={PARLIAMENT.language.fts_config}, " |
|
f"embeddings={PARLIAMENT.embeddings.dimension}d") |
|
except Exception as exc: |
|
report(FAIL, "parliament config", str(exc)[:120]) |
|
|
|
|
|
# ── database ────────────────────────────────────────────────────────────────── |
|
|
|
|
|
def check_database() -> None: |
|
section("PostgreSQL") |
|
host = os.getenv("PG_HOST", "localhost") |
|
port = int(os.getenv("PG_PORT", 5432)) |
|
if not _port_open(host, port): |
|
report(FAIL, "reachable", f"nothing listening on {host}:{port}") |
|
return |
|
report(OK, "reachable", f"{host}:{port}") |
|
|
|
try: |
|
from postgres_client import pg |
|
|
|
ver = pg.execute("SELECT version() AS v")[0]["v"].split(",")[0] |
|
report(OK, "connected", ver) |
|
except Exception as exc: |
|
report(FAIL, "connected", f"{type(exc).__name__}: {str(exc).strip()[:90]}") |
|
return |
|
|
|
exts = {r["extname"] for r in pg.execute("SELECT extname FROM pg_extension")} |
|
for ext in ("vector", "pg_trgm"): |
|
if ext in exts: |
|
report(OK, f"extension {ext}", "installed") |
|
else: |
|
report(FAIL, f"extension {ext}", f'missing — run: CREATE EXTENSION {ext};') |
|
|
|
try: |
|
cfg = pg.execute("SELECT current_setting('app.fts_config') AS c")[0]["c"] |
|
from parliament import PARLIAMENT |
|
|
|
if cfg == PARLIAMENT.language.fts_config: |
|
report(OK, "app.fts_config", cfg) |
|
else: |
|
report(FAIL, "app.fts_config", |
|
f"database says {cfg!r}, config says {PARLIAMENT.language.fts_config!r}") |
|
except Exception: |
|
report(FAIL, "app.fts_config", |
|
"not set — ALTER DATABASE <db> SET app.fts_config = '<language>';") |
|
|
|
tables = pg.execute( |
|
"SELECT count(*) AS n FROM information_schema.tables " |
|
"WHERE table_schema='public' AND table_type='BASE TABLE'" |
|
)[0]["n"] |
|
if tables >= 22: |
|
report(OK, "schema", f"{tables} tables") |
|
elif tables == 0: |
|
report(FAIL, "schema", "empty — psql -f _postgres/schema.sql") |
|
else: |
|
report(WARN, "schema", f"only {tables} tables; expected 22") |
|
|
|
try: |
|
n = pg.execute("SELECT count(*) AS n FROM speeches")[0]["n"] |
|
report(OK if n else WARN, "data", |
|
f"{n:,} speeches" if n else "no data yet — run ingest.cli") |
|
except Exception: |
|
report(WARN, "data", "tables not queryable yet") |
|
|
|
|
|
# ── models ──────────────────────────────────────────────────────────────────── |
|
|
|
|
|
def check_chat_model() -> None: |
|
section("Chat model") |
|
url = os.getenv("LLM_DIRECT_URL") |
|
if not url: |
|
report(FAIL, "LLM_DIRECT_URL", "not set — chat and research will not work") |
|
return |
|
report(OK, "LLM_DIRECT_URL", url) |
|
|
|
ok, detail = _http_ok(url.rstrip("/") + "/models") |
|
if not ok: |
|
report(FAIL, "reachable", f"{detail} — is the server running?") |
|
return |
|
report(OK, "reachable", detail) |
|
|
|
model = os.getenv("LLM_MODEL_SMART") or os.getenv("LLM_MODEL") |
|
if not model: |
|
report(WARN, "LLM_MODEL_SMART", "not set") |
|
return |
|
|
|
try: |
|
from packages.llm import LLM |
|
|
|
llm = LLM(base_url=url, model=model, api_key=os.getenv("LLM_BEARER") or None, |
|
silent=True) |
|
r = llm.generate(messages=[{"role": "user", "content": "Reply with OK."}], |
|
think=False, max_tokens=16) |
|
if isinstance(r, str): |
|
report(FAIL, f"model {model}", r[:100]) |
|
else: |
|
report(OK, f"model {model}", "responds") |
|
except Exception as exc: |
|
report(FAIL, f"model {model}", str(exc)[:100]) |
|
|
|
|
|
def check_tool_calling() -> None: |
|
"""A model can converse fine and still never call a tool — which here means |
|
confident answers with no sources.""" |
|
section("Tool calling (required for grounded answers)") |
|
url, model = os.getenv("LLM_DIRECT_URL"), os.getenv("LLM_MODEL_SMART") or os.getenv("LLM_MODEL") |
|
if not (url and model): |
|
report(WARN, "skipped", "no model configured") |
|
return |
|
try: |
|
from packages.llm import LLM, get_tools, register_tool |
|
|
|
@register_tool |
|
def _doctor_probe(topic: str) -> str: |
|
"""Look up a topic. |
|
|
|
Args: |
|
topic: What to look up. |
|
""" |
|
return f"result for {topic}" |
|
|
|
llm = LLM(base_url=url, model=model, api_key=os.getenv("LLM_BEARER") or None, |
|
tools=get_tools(["_doctor_probe"]), silent=True) |
|
llm.generate(messages=[{"role": "user", |
|
"content": "Use the tool to look up 'energy'."}], think=False) |
|
if [m for m in llm.messages if m.get("role") == "tool"]: |
|
report(OK, "tool calling", "works") |
|
else: |
|
report(FAIL, "tool calling", |
|
f"{model} did not call the tool — chat will answer without sources") |
|
except Exception as exc: |
|
report(WARN, "tool calling", str(exc)[:100]) |
|
|
|
|
|
def check_embeddings() -> None: |
|
section("Embeddings") |
|
url = os.getenv("EMBEDDING_BASE_URL") |
|
if not url: |
|
report(WARN, "EMBEDDING_BASE_URL", "not set — semantic search unavailable") |
|
return |
|
report(OK, "EMBEDDING_BASE_URL", url) |
|
try: |
|
from parliament import PARLIAMENT |
|
from postgres_client import pg |
|
|
|
vec = pg.make_embeddings(["doctor test"])[0] |
|
want = PARLIAMENT.embeddings.dimension |
|
if len(vec) == want: |
|
report(OK, "dimension", f"{len(vec)} matches parliament.yaml") |
|
else: |
|
report(FAIL, "dimension", |
|
f"model returns {len(vec)}, config expects {want} — fix before ingesting") |
|
except Exception as exc: |
|
report(FAIL, "embedding call", str(exc)[:100]) |
|
|
|
|
|
# ── host ────────────────────────────────────────────────────────────────────── |
|
|
|
|
|
def check_host() -> None: |
|
section("Host") |
|
for tool, why in (("psql", "applying the schema"), ("node", "building the frontend"), |
|
("npm", "building the frontend")): |
|
path = shutil.which(tool) |
|
report(OK if path else WARN, tool, path or f"not found — needed for {why}") |
|
|
|
try: |
|
gpu = subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total", |
|
"--format=csv,noheader"], |
|
capture_output=True, text=True, timeout=8) |
|
if gpu.returncode == 0 and gpu.stdout.strip(): |
|
report(OK, "GPU", gpu.stdout.strip().replace("\n", "; ")) |
|
else: |
|
report(WARN, "GPU", "none detected — use Ollama on CPU, or a hosted provider") |
|
except Exception: |
|
report(WARN, "GPU", "nvidia-smi not available") |
|
|
|
if shutil.which("systemctl"): |
|
can_sudo = subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0 |
|
report(OK if can_sudo else WARN, "sudo", |
|
"passwordless" if can_sudo else "will prompt — needed to install services") |
|
|
|
section("Ports") |
|
for port, what in ((8000, "API / vLLM"), (5432, "PostgreSQL"), (11434, "Ollama"), |
|
(8003, "embeddings"), (8001, "MCP server"), (8005, "eval scorer")): |
|
report(OK if _port_open("127.0.0.1", port, 0.4) else WARN, f"{port}", |
|
f"{what}: " + ("in use" if _port_open("127.0.0.1", port, 0.4) else "free")) |
|
|
|
|
|
def main() -> int: |
|
print("plenum doctor — checking this machine. Nothing will be changed.") |
|
for check in (check_python, check_config, check_database, check_chat_model, |
|
check_tool_calling, check_embeddings, check_host): |
|
try: |
|
check() |
|
except Exception as exc: # a broken check must not hide the others |
|
report(WARN, check.__name__, f"check itself failed: {exc}") |
|
|
|
failed = _results.count(FAIL) |
|
warned = _results.count(WARN) |
|
print(f"\n{_results.count(OK)} ok, {warned} warnings, {failed} failures") |
|
if failed: |
|
print("Fix the FAIL lines before continuing; see docs/SETUP.md.") |
|
return 1 if failed else 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main())
|
|
|