feat(hermes-auto-improve): ajouter hermes-doc-rewrite pour réécriture doc basée sur état cluster

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-05 11:45:48 +02:00 committed by Alkatrazz
parent 994a2f1e54
commit e62832fd71

View file

@ -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 <hermes@funk.lab>"])
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()