From 3780c90804126e7567ba4f5f3364c3ee54bfabd6 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Fri, 5 Jun 2026 12:12:18 +0200 Subject: [PATCH] =?UTF-8?q?refactor(hermes-doc-enrich):=20append-only=20?= =?UTF-8?q?=E2=80=94=20section=20=C3=89tat=20v=C3=A9rifi=C3=A9,=20jamais?= =?UTF-8?q?=20de=20r=C3=A9=C3=A9criture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le script n'écrase plus les docs existants. Pour chaque fichier admin/ : - Inspecte le cluster en read-only (systemctl, kubectl, nft, etc.) - Demande à Hermes d'écrire UNIQUEMENT une section "## État vérifié — DATE" - Appende cette section au doc (ou remplace la précédente) - Commit local dans hermes/daily-work, pas de push - rag-ingest Qdrant à la fin du batch Co-Authored-By: Claude Sonnet 4.6 --- tools/hermes-auto-improve/doc-rewrite.py | 240 +++++++++++++---------- 1 file changed, 133 insertions(+), 107 deletions(-) diff --git a/tools/hermes-auto-improve/doc-rewrite.py b/tools/hermes-auto-improve/doc-rewrite.py index bc13ea4..78050e3 100644 --- a/tools/hermes-auto-improve/doc-rewrite.py +++ b/tools/hermes-auto-improve/doc-rewrite.py @@ -1,8 +1,9 @@ #!/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. +hermes-doc-enrich — Hermes inspecte le cluster (LECTURE SEULE) et ajoute +une section "## État vérifié" à chaque doc admin/. +Jamais de suppression ni de réécriture — ajout uniquement. +Commit local dans hermes/daily-work + rag-ingest Qdrant à la fin. """ import argparse @@ -14,84 +15,76 @@ 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") +RAG_DOCS = Path("/srv/data/rag/docs") +STATE_FILE = Path("/var/lib/hermes-auto-improve/enrich-state.json") MAX_FILES = 3 +SECTION_MARKER = "## État vérifié" # ── Commandes read-only par fichier (clé = substring du chemin relatif) ────── CONTEXT_MAP = { "ia/litellm": [ - "systemctl status litellm --no-pager -n 30", + "systemctl status litellm --no-pager -n 5", "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", + "systemctl status hermes --no-pager -n 5", + "cat /srv/data/hermes/config.yaml 2>/dev/null | head -40", ], "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", + "cat /srv/data/hermes/config.yaml 2>/dev/null | grep -A3 'profile' | head -20", ], "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", + "systemctl status hermes-voice 2>/dev/null --no-pager -n 5 || echo 'service hermes-voice absent'", ], "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'", + "systemctl cat llama-server 2>/dev/null | grep -E 'ExecStart|Environment|Restart' | head -10", + "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'systemctl status llama-server --no-pager -n 5' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "ia/rag": [ - "systemctl status qdrant --no-pager -n 15", + "systemctl status qdrant --no-pager -n 5", "ls /srv/data/rag/docs/ 2>/dev/null | wc -l", - "/usr/local/bin/rag-query 'hermes configuration' 2>&1 | head -20", + "/usr/local/bin/rag-query 'hermes' 2>&1 | head -10", ], "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'", + "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'uname -r; rocm-smi 2>/dev/null | head -10' 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", + "systemctl status alertmanager-webhook --no-pager -n 5", + "cat /etc/alertmanager-webhook/*.yaml 2>/dev/null | head -30", ], "infra/dnsmasq": [ - "systemctl status dnsmasq --no-pager -n 10", - "cat /etc/dnsmasq.d/funk.conf 2>/dev/null || ls /etc/dnsmasq.d/", + "systemctl status dnsmasq --no-pager -n 5", "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", + "systemctl status nfs-server --no-pager -n 5", "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", + "ip addr show | grep 'inet '", + "nft list ruleset 2>/dev/null | grep -E 'chain|policy|iif|oif|dport' | head -30", ], "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'", + "systemctl status postfix --no-pager -n 5", + "postconf -n 2>/dev/null | grep -vi 'pass' | grep -E 'relay|tls|sasl'", ], "install/storage-01": [ - "hostnamectl", - "df -h", + "hostnamectl | grep -E 'Hostname|OS|Kernel'", + "df -h | grep -v tmpfs", "free -h", - "lsblk -o NAME,SIZE,TYPE,MOUNTPOINT", - "systemctl list-units --state=failed --no-pager 2>/dev/null | head -10", + "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$'", ], "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", + "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'hostnamectl | grep -E \"Hostname|OS|Kernel\"; df -h | grep -v tmpfs; systemctl list-units --state=failed --no-pager 2>/dev/null' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "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", + "kubectl get pods -A --field-selector=status.phase!=Running 2>/dev/null | head -10", ], "k8s/argocd": [ "kubectl get applications -n argocd -o wide 2>/dev/null", @@ -99,7 +92,6 @@ CONTEXT_MAP = { ], "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": [ @@ -112,40 +104,35 @@ CONTEXT_MAP = { ], "k8s/talos": [ "kubectl get nodes 2>/dev/null", - "talosctl version 2>/dev/null | head -5", + "talosctl version 2>/dev/null | head -4", ], "k8s/k9s": [ - "k9s version 2>/dev/null || echo 'k9s: check depuis storage-01'", + "k9s version 2>/dev/null || echo 'k9s non installé sur 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", + "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -10", ], "ops/systeme": [ "uname -r", - "hostnamectl", "uptime", - "df -h", + "df -h | grep -v tmpfs", "free -h", - "systemctl list-units --state=failed --no-pager 2>/dev/null | head -10", + "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | 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", + "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -5", ] @@ -176,7 +163,7 @@ def run_cmd(cmd): cmd, shell=True, capture_output=True, text=True, timeout=30 ) out = (r.stdout + r.stderr).strip() - return out[:2000] if out else "(aucune sortie)" + return out[:1500] if out else "(aucune sortie)" except subprocess.TimeoutExpired: return "(timeout)" except Exception as e: @@ -210,48 +197,81 @@ def ask_hermes(prompt): return r.stdout.strip() -def rewrite_doc(fpath): +def build_verified_section(fpath, cluster_state, today): rel = str(fpath.relative_to(REPO_DIR)) content = fpath.read_text(encoding="utf-8", errors="replace") + prompt = f"""Tu es l'agent de documentation du homelab Funk. +MISSION : écrire UNIQUEMENT une nouvelle section à ajouter à la fin de ce document. +CONTRAINTE ABSOLUE : ne touche pas au contenu existant — ajout uniquement. + +Fichier concerné : {rel} +--- ÉTAT RÉEL DU CLUSTER (commandes read-only) --- +{cluster_state[:3000]} +--- FIN --- + +Écris une section Markdown intitulée exactement : +## État vérifié — {today} + +Cette section doit contenir : +- État des services concernés (actif/inactif/dégradé) +- Versions observées si pertinent +- Toute divergence notable entre la doc et l'état réel +- Tout élément opérationnel utile observé (ex: pods crashés, export NFS manquant, config inattendue) + +RÈGLES : +- Sois concis (5-15 lignes max) +- Ne reproduis pas ce qui est déjà dans le document +- Si tout est conforme, dis-le en une phrase +- Réponds UNIQUEMENT avec le contenu Markdown de la section, rien d'autre""" + + result = ask_hermes(prompt) + + # S'assure que la section commence bien par le bon titre + if SECTION_MARKER not in result: + result = f"{SECTION_MARKER} — {today}\n\n{result}" + + return result, content + + +def enrich_file(fpath, today): + rel = str(fpath.relative_to(REPO_DIR)) + 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. + print(f" Hermes génère la section...", flush=True) + new_section, original_content = build_verified_section(fpath, cluster_state, today) -Fichier : {rel} ---- CONTENU ACTUEL --- -{content[:3000]} ---- FIN CONTENU --- + if not new_section or len(new_section) < 30: + return False, f"section trop courte ({len(new_section)} chars)" ---- ÉTAT RÉEL (commandes read-only exécutées sur le cluster) --- -{cluster_state[:3000]} ---- FIN ÉTAT --- + # Retire l'ancienne section "État vérifié" si elle existe déjà + if SECTION_MARKER in original_content: + # Coupe au premier SECTION_MARKER et remplace par la nouvelle section + idx = original_content.index(SECTION_MARKER) + base = original_content[:idx].rstrip() + else: + base = original_content.rstrip() -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 + updated = base + "\n\n---\n\n" + new_section.strip() + "\n" + fpath.write_text(updated, encoding="utf-8") + return True, None -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) +# ── Qdrant re-ingest ────────────────────────────────────────────────────────── - 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 +def rag_ingest(): + subprocess.run( + ["rsync", "-a", "--delete", str(ADMIN_DIR) + "/", str(RAG_DOCS) + "/"], + check=True + ) + r = subprocess.run( + ["/usr/local/bin/rag-ingest"], + capture_output=True, text=True, timeout=300 + ) + return (r.stdout + r.stderr).strip()[-300:] # ── State ───────────────────────────────────────────────────────────────────── @@ -259,7 +279,7 @@ Réponds UNIQUEMENT avec le contenu Markdown du fichier réécrit, sans JSON, sa def load_state(): if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) - return {"rewritten": [], "errors": []} + return {"enriched": [], "errors": []} def save_state(state): @@ -275,18 +295,21 @@ def main(): 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)") + parser.add_argument("--no-rag", action="store_true", help="Ne pas relancer rag-ingest à la fin") 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) + today = datetime.now().strftime("%Y-%m-%d") + now = datetime.now().strftime("%Y-%m-%d_%H-%M") + + print(f"hermes-doc-enrich — {now}", flush=True) + print(f"Branche : {BRANCH} (local, pas de push)", flush=True) if not args.dry_run: git_sync() state = load_state() - if args.reset: - state = {"rewritten": [], "errors": []} + state = {"enriched": [], "errors": []} save_state(state) print("État réinitialisé.\n") @@ -299,66 +322,69 @@ def main(): if args.all: batch = all_md else: - todo = [f for f in all_md if str(f) not in state["rewritten"]] + todo = [f for f in all_md if str(f) not in state["enriched"]] if not todo: - state["rewritten"] = [] + state["enriched"] = [] save_state(state) todo = all_md - print("Rotation : tous les fichiers retraités depuis le début.") + print("Rotation complète — tous les fichiers repassés.") 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) + print(f"\n{len(batch)} fichiers à enrichir\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 :") + print(f" [dry-run] {len(cmds)} commandes :") for c in cmds: print(f" {c}") continue - new_content, err = rewrite_doc(fpath) - - if err: + ok, err = enrich_file(fpath, today) + if not ok: print(f" ✗ {err}", flush=True) - errors.append(rel) + state["errors"].append(rel) continue - fpath.write_text(new_content, encoding="utf-8") - state["rewritten"].append(str(fpath)) + state["enriched"].append(str(fpath)) changed.append(rel) - print(f" ✓ Réécrit ({len(new_content)} chars)", flush=True) + print(f" ✓ Section ajoutée", 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" + f"docs(hermes): état vérifié {Path(rel).name} — {today}\n\n" + f"Section '## État vérifié' ajoutée/mise à jour.\n" + f"Inspection read-only du cluster, aucune modification de service.\n\n" "Co-Authored-By: Hermes "]) - print(f" ✓ Commit local — pas de push", flush=True) + print(f" ✓ Commit local", flush=True) else: - print(f" — Aucun changement détecté par git", flush=True) + print(f" — Aucun changement détecté (section déjà à jour)", flush=True) if not args.dry_run: save_state(state) + # ── Qdrant re-ingest ────────────────────────────────────────────────────── + if not args.dry_run and changed and not args.no_rag: + print(f"\n⏳ Qdrant re-ingest ({len(changed)} docs mis à jour)...", flush=True) + try: + out = rag_ingest() + print(f" ✓ {out}", flush=True) + except Exception as e: + print(f" ✗ rag-ingest échoué : {e}", flush=True) + print(f"\n{'─'*50}") - print(f"Réécritures réussies : {len(changed)}/{len(batch)}") - if errors: - print(f"Erreurs ({len(errors)}) : {', '.join(errors)}") + print(f"Enrichissements : {len(changed)}/{len(batch)}") 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") + print("Pour créer la PR : hermes-auto-improve --daily-pr") if __name__ == "__main__":