testsAndMisc/python_pkg/archwiki_rag/reindex.py
Krzysztof kuhy Rudnicki 86565bdef6 archwiki_rag: offline semantic search over the Arch Wiki
Indexes the arch-wiki-docs dump that is already on disk and exposes it
to the knowledge-rag MCP server, so Arch questions can be asked by
symptom ("why doesn't my systemd user unit see DISPLAY under i3")
rather than by page title, with no network round-trip at query time.

The one Arch-specific MCP server in the wild fetches pages over the
network and bundles pacman operations that duplicate the local yay-mcp.
This does neither: it only searches, against a local corpus.

install.sh installs its own dependencies, wires the MCP config and
registers a pacman hook so the index follows package updates. The
generated index is machine-specific and regenerable, so it is
gitignored rather than committed.

_reindex_entry runs under the knowledge-rag interpreter, where
mcp_server is importable and this repo's packages are not. Resolving
that import with importlib.import_module states the deferral outright
instead of a function-level import statement that reads as an oversight,
and the backend failure path now re-raises as SystemExit, so the
exit-code translation is explicit rather than a swallowed exception.
Both replace lint suppressions; the package carries none.

pylint runs in its own venv, so beautifulsoup4 and markdownify are
declared there too — being in meta/requirements.txt does not make them
resolvable to the hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00

132 lines
4.3 KiB
Python

"""Trigger a knowledge-rag reindex safely from outside the MCP server.
Two guards wrap the reindex, both of which exist because this runs unattended
from a pacman hook:
* **Instance lock.** knowledge-rag's single-instance lock is taken only by its
``main()``. Importing the orchestrator directly bypasses it, so a reindex
fired while a ``claude-archwiki`` session is live would have two processes
writing one Chroma collection. The lock file is checked explicitly instead.
* **Machine load.** Embedding is CPU-bound -- knowledge-rag ships the CPU-only
``onnxruntime`` build, so despite the 3090 in this box the pass saturates
roughly every core for minutes. If the machine is already working hard,
defer rather than compete: the converted Markdown stays on disk and the next
run picks up exactly where this one stopped.
"""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
from python_pkg.archwiki_rag.constants import (
DATA_SUBDIR,
KNOWLEDGE_RAG_PYTHON,
LOAD_PER_CORE_THRESHOLD,
LOADAVG_PATH,
LOCK_FILENAME,
)
_ENTRY_MODULE = "python_pkg.archwiki_rag._reindex_entry"
def read_lock_pid(store_dir: Path) -> int | None:
"""Read the pid recorded in the knowledge-rag instance lock.
Parameters:
store_dir (Path): Root of the knowledge-rag store.
Returns:
int | None: The pid, or None when there is no readable lock file.
"""
lock_path = store_dir / DATA_SUBDIR / LOCK_FILENAME
try:
first_line = lock_path.read_text(encoding="utf-8").strip().splitlines()[0]
return int(first_line)
except (IndexError, OSError, ValueError):
return None
def is_server_running(store_dir: Path) -> bool:
"""Report whether a knowledge-rag MCP server currently owns this store.
Parameters:
store_dir (Path): Root of the knowledge-rag store.
Returns:
bool: True when the lock names a live process. A lock left behind by a
crashed server is treated as free.
"""
pid = read_lock_pid(store_dir)
return pid is not None and Path(f"/proc/{pid}").exists()
def load_per_core() -> float:
"""Current 1-minute load average, divided by the CPU count.
A single read of ``/proc/loadavg`` -- no forks, per the repo's polling
guidance.
Returns:
float: Load per core, or 0.0 when ``/proc/loadavg`` is unreadable (a
machine that cannot report load is never treated as busy).
"""
try:
first_field = LOADAVG_PATH.read_text(encoding="utf-8").split(maxsplit=1)[0]
load = float(first_field)
except (IndexError, OSError, ValueError):
return 0.0
return load / (os.cpu_count() or 1)
def blocking_reason(store_dir: Path, *, ignore_load: bool = False) -> str | None:
"""Explain why a reindex should not run right now.
Parameters:
store_dir (Path): Root of the knowledge-rag store.
ignore_load (bool): Skip the load check, for an explicitly requested run.
Returns:
str | None: Human-readable reason to defer, or None when clear to proceed.
"""
if is_server_running(store_dir):
return "a knowledge-rag server is already using this store"
if not ignore_load:
load = load_per_core()
if load > LOAD_PER_CORE_THRESHOLD:
return (
f"machine is busy (load {load:.2f}/core); deferring to avoid contention"
)
return None
def run_reindex(store_dir: Path) -> int:
"""Run the reindex under the knowledge-rag interpreter.
Parameters:
store_dir (Path): Root of the knowledge-rag store, passed through as
``KNOWLEDGE_RAG_DIR`` so the child targets the right corpus.
Returns:
int: Exit status of the child process; 127 when its interpreter is absent.
"""
if not KNOWLEDGE_RAG_PYTHON.exists():
return 127
env = dict(os.environ)
env["KNOWLEDGE_RAG_DIR"] = str(store_dir)
# The entry module imports from python_pkg, which is not installed into the
# knowledge-rag venv, so point that interpreter back at this repo.
repo_root = Path(__file__).resolve().parents[2]
env["PYTHONPATH"] = os.pathsep.join(
[str(repo_root), *([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])],
)
completed = subprocess.run(
[str(KNOWLEDGE_RAG_PYTHON), "-m", _ENTRY_MODULE],
env=env,
check=False,
)
return completed.returncode