From e62832fd7138eefa03aebe41db7642ebb1b767f3 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Fri, 5 Jun 2026 11:45:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(hermes-auto-improve):=20ajouter=20hermes-d?= =?UTF-8?q?oc-rewrite=20pour=20r=C3=A9=C3=A9criture=20doc=20bas=C3=A9e=20s?= =?UTF-8?q?ur=20=C3=A9tat=20cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Script hermes-doc-rewrite : inspecte l'état réel du cluster (read-only) et demande à Hermes de réécrire chaque fichier admin/ avec des infos exactes. Commit local dans hermes/daily-work, pas de push automatique. Co-Authored-By: Claude Sonnet 4.6 --- tools/hermes-auto-improve/doc-rewrite.py | 365 +++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 tools/hermes-auto-improve/doc-rewrite.py diff --git a/tools/hermes-auto-improve/doc-rewrite.py b/tools/hermes-auto-improve/doc-rewrite.py new file mode 100644 index 0000000..bc13ea4 --- /dev/null +++ b/tools/hermes-auto-improve/doc-rewrite.py @@ -0,0 +1,365 @@ +#!/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()