#!/usr/bin/env python3 """ hermes-doc-rewrite — Vérifie l'état réel du cluster (LECTURE SEULE) et réécrit les docs admin/ dans hermes/daily-work. Commit local uniquement, pas de push. """ import argparse import json import subprocess from datetime import datetime from pathlib import Path REPO_DIR = Path("/srv/data/lab/Funk-lab") BRANCH = "hermes/daily-work" ADMIN_DIR = REPO_DIR / "admin" STATE_FILE = Path("/var/lib/hermes-auto-improve/rewrite-state.json") MAX_FILES = 3 # ── Commandes read-only par fichier (clé = substring du chemin relatif) ────── CONTEXT_MAP = { "ia/litellm": [ "systemctl status litellm --no-pager -n 30", "cat /etc/litellm/config.yaml", ], "ia/hermes.md": [ "systemctl status hermes --no-pager -n 20", "cat /srv/data/hermes/config.yaml 2>/dev/null | head -60", "ls /opt/hermes/ 2>/dev/null", ], "ia/hermes-souls": [ "ls -la ~hermes/ 2>/dev/null", "ls /srv/data/hermes/ 2>/dev/null", "cat /srv/data/hermes/config.yaml 2>/dev/null | grep -A5 'profile' | head -30", ], "ia/hermes-voice": [ "systemctl status hermes-voice 2>/dev/null --no-pager -n 10 || echo 'service hermes-voice absent'", "ls /opt/hermes/ 2>/dev/null", ], "ia/llama_server": [ "systemctl cat llama-server 2>/dev/null | head -40", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'systemctl status llama-server --no-pager -n 15; systemctl status llama-server-cpu llama-server-cpu2 --no-pager -n 5 2>/dev/null' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "ia/rag": [ "systemctl status qdrant --no-pager -n 15", "ls /srv/data/rag/docs/ 2>/dev/null | wc -l", "/usr/local/bin/rag-query 'hermes configuration' 2>&1 | head -20", ], "ia/rocm": [ "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'uname -r; ls /opt/rocm/bin/ 2>/dev/null | head -5; rocm-smi 2>/dev/null | head -15' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "ia/alertmanager-webhook": [ "systemctl status alertmanager-webhook --no-pager -n 15", "cat /etc/alertmanager-webhook/*.yaml 2>/dev/null | head -50", ], "infra/dnsmasq": [ "systemctl status dnsmasq --no-pager -n 10", "cat /etc/dnsmasq.d/funk.conf 2>/dev/null || ls /etc/dnsmasq.d/", "dig @127.0.0.1 compute-01.lab.local +short 2>/dev/null", "dig @127.0.0.1 openwebui.lab.local +short 2>/dev/null", ], "infra/nfs": [ "systemctl status nfs-server --no-pager -n 10", "exportfs -v 2>/dev/null", "showmount -e localhost 2>/dev/null", ], "infra/reseau": [ "ip addr show | grep -E 'inet |^[0-9]+'", "ip route show", "nft list ruleset 2>/dev/null | head -80", ], "infra/ssh": [ "sshd -T 2>/dev/null | grep -E 'permitrootlogin|passwordauth|pubkeyauth|^port '", "cat /etc/ssh/sshd_config.d/*.conf 2>/dev/null | head -30", ], "infra/email": [ "systemctl status postfix --no-pager -n 10", "postconf -n 2>/dev/null | grep -vi 'pass'", ], "install/storage-01": [ "hostnamectl", "df -h", "free -h", "lsblk -o NAME,SIZE,TYPE,MOUNTPOINT", "systemctl list-units --state=failed --no-pager 2>/dev/null | head -10", ], "install/gpu-01": [ "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'hostnamectl; df -h; free -h; uname -r; lsblk -o NAME,SIZE,TYPE,MOUNTPOINT' 2>/dev/null || echo 'gpu-01 inaccessible'", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'systemctl list-units --state=failed --no-pager 2>/dev/null' 2>/dev/null", ], "install/kubernetes": [ "kubectl get nodes -o wide 2>/dev/null", "kubectl version 2>/dev/null | head -4", "kubectl get pods -A --field-selector=status.phase!=Running 2>/dev/null | head -20", ], "k8s/argocd": [ "kubectl get applications -n argocd -o wide 2>/dev/null", "kubectl get pods -n argocd 2>/dev/null", ], "k8s/monitoring": [ "kubectl get pods -n monitoring 2>/dev/null", "kubectl get svc -n monitoring 2>/dev/null", "kubectl get ingress -n monitoring 2>/dev/null", ], "k8s/n8n": [ "kubectl get all -n ai -l app=n8n 2>/dev/null", "kubectl get ingress -n ai 2>/dev/null", ], "k8s/open-webui": [ "kubectl get all -n ai 2>/dev/null", "kubectl get ingress -n ai 2>/dev/null", ], "k8s/talos": [ "kubectl get nodes 2>/dev/null", "talosctl version 2>/dev/null | head -5", ], "k8s/k9s": [ "k9s version 2>/dev/null || echo 'k9s: check depuis storage-01'", "kubectl config get-contexts 2>/dev/null", ], "ops/ansible": [ "ansible --version 2>/dev/null | head -3", "ls /srv/data/lab/Funk-lab/ansible/roles/", "ls /srv/data/lab/Funk-lab/ansible/playbooks/", ], "ops/cluster": [ "kubectl get nodes -o wide 2>/dev/null", "systemctl list-units --state=failed --no-pager 2>/dev/null | head -10", "df -h /srv/data 2>/dev/null", ], "ops/systeme": [ "uname -r", "hostnamectl", "uptime", "df -h", "free -h", "systemctl list-units --state=failed --no-pager 2>/dev/null | head -10", ], "README": [ "ls /srv/data/lab/Funk-lab/admin/", "systemctl is-active litellm hermes dnsmasq nfs-server qdrant postfix 2>/dev/null", ], } DEFAULT_COMMANDS = [ "date", "df -h /srv/data 2>/dev/null", "systemctl list-units --state=failed --no-pager 2>/dev/null | head -5", ] # ── Git ─────────────────────────────────────────────────────────────────────── def git(args): return subprocess.run( ["git", "-C", str(REPO_DIR)] + args, capture_output=True, text=True ) def git_sync(): git(["config", "user.email", "hermes@funk.lab"]) git(["config", "user.name", "Hermes Bot"]) git(["fetch", "origin"]) r = git(["checkout", BRANCH]) if r.returncode != 0: git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"]) git(["pull", "--rebase", "origin", BRANCH]) # ── Cluster inspection (read-only) ─────────────────────────────────────────── def run_cmd(cmd): try: r = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=30 ) out = (r.stdout + r.stderr).strip() return out[:2000] if out else "(aucune sortie)" except subprocess.TimeoutExpired: return "(timeout)" except Exception as e: return f"(erreur: {e})" def get_context_cmds(rel_path): for key, cmds in CONTEXT_MAP.items(): if key in rel_path: return cmds return DEFAULT_COMMANDS def collect_cluster_state(cmds): parts = [] for cmd in cmds: out = run_cmd(cmd) parts.append(f"$ {cmd}\n{out}") return "\n\n".join(parts) # ── Hermes ──────────────────────────────────────────────────────────────────── def ask_hermes(prompt): safe = prompt.replace("'", "'\\''") cmd = [ "sudo", "-i", "-u", "hermes", "bash", "-c", f"HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z '{safe}'" ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) return r.stdout.strip() def rewrite_doc(fpath): rel = str(fpath.relative_to(REPO_DIR)) content = fpath.read_text(encoding="utf-8", errors="replace") cmds = get_context_cmds(rel) print(f" Collecte état cluster ({len(cmds)} cmd)...", flush=True) cluster_state = collect_cluster_state(cmds) prompt = f"""Tu es l'agent de documentation du homelab Funk. MISSION : réécrire ce fichier de documentation en te basant sur l'état RÉEL du système. CONTRAINTE ABSOLUE : lecture seule — tu documentes ce que tu vois, tu ne modifies rien sur le cluster. Fichier : {rel} --- CONTENU ACTUEL --- {content[:3000]} --- FIN CONTENU --- --- ÉTAT RÉEL (commandes read-only exécutées sur le cluster) --- {cluster_state[:3000]} --- FIN ÉTAT --- RÈGLES : 1. Réécris le document en Markdown en corrigeant les informations inexactes ou obsolètes 2. Conserve la structure et le style du document original 3. Base-toi UNIQUEMENT sur ce que tu vois dans l'état réel ci-dessus — n'invente rien 4. Si une section est correcte, garde-la telle quelle 5. Ajoute les informations manquantes que tu observes dans l'état réel 6. Ne mentionne pas cette réécriture dans le contenu du document Réponds UNIQUEMENT avec le contenu Markdown du fichier réécrit, sans JSON, sans introduction.""" print(f" Hermes réécrit le document...", flush=True) result = ask_hermes(prompt) if not result or len(result) < 80: return None, f"réponse trop courte ({len(result)} chars)" stripped = result.lstrip() if stripped.startswith("{") or stripped.startswith("["): return None, "réponse JSON inattendue, ignorée" return result, None # ── State ───────────────────────────────────────────────────────────────────── def load_state(): if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) return {"rewritten": [], "errors": []} def save_state(state): STATE_FILE.parent.mkdir(parents=True, exist_ok=True) STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False)) # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser() parser.add_argument("--all", action="store_true", help="Traiter tous les fichiers en une passe") parser.add_argument("--reset", action="store_true", help="Réinitialiser l'état de progression") parser.add_argument("--dry-run", action="store_true", help="Afficher le plan sans modifier les fichiers") parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ (ex: ia, k8s)") args = parser.parse_args() print(f"hermes-doc-rewrite — {datetime.now().strftime('%Y-%m-%d %H:%M')}", flush=True) print(f"Branche cible : {BRANCH} (pas de push)", flush=True) if not args.dry_run: git_sync() state = load_state() if args.reset: state = {"rewritten": [], "errors": []} save_state(state) print("État réinitialisé.\n") scan_root = ADMIN_DIR / args.subdir if args.subdir else ADMIN_DIR all_md = [ f for f in sorted(scan_root.rglob("*.md")) if "hermes/builtin" not in str(f) ] if args.all: batch = all_md else: todo = [f for f in all_md if str(f) not in state["rewritten"]] if not todo: state["rewritten"] = [] save_state(state) todo = all_md print("Rotation : tous les fichiers retraités depuis le début.") batch = todo[:MAX_FILES] now = datetime.now().strftime("%Y-%m-%d_%H-%M") print(f"\n{len(batch)} fichiers à réécrire\n{'─'*50}", flush=True) changed = [] errors = [] for fpath in batch: rel = str(fpath.relative_to(REPO_DIR)) print(f"\n→ {rel}", flush=True) if args.dry_run: cmds = get_context_cmds(rel) print(f" [dry-run] {len(cmds)} commandes prévues :") for c in cmds: print(f" {c}") continue new_content, err = rewrite_doc(fpath) if err: print(f" ✗ {err}", flush=True) errors.append(rel) continue fpath.write_text(new_content, encoding="utf-8") state["rewritten"].append(str(fpath)) changed.append(rel) print(f" ✓ Réécrit ({len(new_content)} chars)", flush=True) git(["add", rel]) if git(["diff", "--cached", "--quiet"]).returncode != 0: git(["commit", "-m", f"docs(hermes): révision {Path(rel).name} — état cluster {now}\n\n" f"Basé sur inspection read-only du cluster.\n\n" "Co-Authored-By: Hermes "]) print(f" ✓ Commit local — pas de push", flush=True) else: print(f" — Aucun changement détecté par git", flush=True) if not args.dry_run: save_state(state) print(f"\n{'─'*50}") print(f"Réécritures réussies : {len(changed)}/{len(batch)}") if errors: print(f"Erreurs ({len(errors)}) : {', '.join(errors)}") if changed: print("Fichiers mis à jour :") for f in changed: print(f" • {f}") print(f"\nBranche : {BRANCH} (local, pas de push)") print("Pour pusher : git -C /srv/data/lab/Funk-lab push origin hermes/daily-work") if __name__ == "__main__": main()