diff --git a/README.md b/README.md index 195c879..762bb8a 100644 --- a/README.md +++ b/README.md @@ -853,6 +853,21 @@ other nodes can reach `OLLAMA_HOST=http://192.168.100.73:11434`: ./jeannie website-ollama-listen ``` +`jeannie` also has a backstage local AI helper for doctor commands. +It is not a separate CLI action; when `ai_gateway.enabled` is true in +`homelab.yml`, doctor commands try the configured Ollama model and silently +skip the helper if Ollama is down. The default lightweight model is: + +```bash +ollama pull qwen2.5:0.5b +``` + +Disable it for low-CPU sessions with: + +```bash +LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge +``` + The CV page has two client-side presentation modes: - `Elegant`: dark, minimal, terminal-inspired styling with a square profile diff --git a/README.md.tmpl b/README.md.tmpl index bd2242c..443e8f2 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -853,6 +853,21 @@ other nodes can reach `OLLAMA_HOST=http://192.168.100.73:11434`: ./{{ main_script }} website-ollama-listen ``` +`{{ main_script }}` also has a backstage local AI helper for doctor commands. +It is not a separate CLI action; when `ai_gateway.enabled` is true in +`homelab.yml`, doctor commands try the configured Ollama model and silently +skip the helper if Ollama is down. The default lightweight model is: + +```bash +ollama pull qwen2.5:0.5b +``` + +Disable it for low-CPU sessions with: + +```bash +LAB_BACKSTAGE_BRAIN_ENABLED=false ./{{ main_script }} doctor-edge +``` + The CV page has two client-side presentation modes: - `Elegant`: dark, minimal, terminal-inspired styling with a square profile diff --git a/homelab.yml b/homelab.yml index 8252efd..10ce5c0 100644 --- a/homelab.yml +++ b/homelab.yml @@ -117,3 +117,10 @@ pimox: worker_base_vmid: 9010 default_worker_count: 1 worker_name_prefix: pimox-worker + +ai_gateway: + provider: ollama + enabled: true + url: http://192.168.100.73:11434 + model: qwen2.5:0.5b + timeout_seconds: 20 diff --git a/jeannie b/jeannie index c9ca3a9..c9b5e1c 100755 --- a/jeannie +++ b/jeannie @@ -60,6 +60,11 @@ mapping = { "services.local_registry.endpoint": "LAB_REGISTRY_ENDPOINT", "services.rpi_dns.pihole_web_port": "PIHOLE_WEB_PORT", "services.rpi_dns.uptime_kuma_port": "UPTIME_KUMA_PORT", + "ai_gateway.provider": "LAB_AI_GATEWAY_PROVIDER", + "ai_gateway.enabled": "LAB_BACKSTAGE_BRAIN_ENABLED", + "ai_gateway.url": "LAB_AI_GATEWAY_URL", + "ai_gateway.model": "LAB_AI_GATEWAY_MODEL", + "ai_gateway.timeout_seconds": "LAB_AI_GATEWAY_TIMEOUT_SECONDS", "pimox.worker_base_vmid": "LAB_PIMOX_WORKER_BASE_VMID", "pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT", } @@ -3822,6 +3827,89 @@ status_http() { fi } +backstage_brain_enabled() { + local provider="${LAB_AI_GATEWAY_PROVIDER:-ollama}" + local enabled="${LAB_BACKSTAGE_BRAIN_ENABLED:-false}" + + [[ "${provider,,}" == "ollama" ]] && truthy "${enabled}" +} + +backstage_brain_note() { + local topic="$1" + local endpoint="${LAB_AI_GATEWAY_URL:-${LAB_OLLAMA_URL:-http://127.0.0.1:11434}}" + local model="${LAB_AI_GATEWAY_MODEL:-qwen2.5:0.5b}" + local timeout="${LAB_AI_GATEWAY_TIMEOUT_SECONDS:-20}" + local context + + if ! backstage_brain_enabled; then + return 0 + fi + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + + context="$(cat)" + BACKSTAGE_CONTEXT="${context}" python3 - "${endpoint}" "${model}" "${timeout}" "${topic}" <<'PY' || true +import json +import os +import sys +import urllib.error +import urllib.request + +endpoint, model, timeout, topic = sys.argv[1:5] +context = os.environ.get("BACKSTAGE_CONTEXT", "").strip() +try: + timeout_seconds = int(timeout) +except ValueError: + timeout_seconds = 20 + +prompt = f"""You are the backstage helper for a personal homelab script named jeannie. +Be concise and operational. Do not invent facts. Use only the supplied context. +Return at most 5 bullets. Focus on likely cause, next command, and risk. + +Topic: {topic} + +Context: +{context} +""" + +payload = { + "model": model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1, + "num_predict": 220, + }, +} + +try: + tags_request = urllib.request.Request(f"{endpoint.rstrip('/')}/api/tags") + with urllib.request.urlopen(tags_request, timeout=3) as response: + tags = json.loads(response.read().decode("utf-8")) + available = {item.get("name", "") for item in tags.get("models", [])} + if model not in available and f"{model}:latest" not in available: + print(f"\n== Backstage Notes ==\nOllama is reachable, but model '{model}' is not pulled. Run: ollama pull {model}") + raise SystemExit(0) + + request = urllib.request.Request( + f"{endpoint.rstrip('/')}/api/generate", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + result = json.loads(response.read().decode("utf-8")) +except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError): + raise SystemExit(0) + +note = (result.get("response") or "").strip() +if note: + print("\n== Backstage Notes ==") + print(note) +PY +} + status_systemd_units() { local unit @@ -4104,6 +4192,16 @@ Next steps: - If only Gitea is unreachable, run ./jeannie doctor-gitea. - If both are unreachable from OCI, check Tailscale routes and bootstrap/edge. - Runbook: docs/runbooks/edge-failures.md +EOF + + backstage_brain_note "doctor-edge" <