refactor(hermes-doc-enrich): append-only — section État vérifié, jamais de réécriture

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-05 12:12:18 +02:00 committed by Alkatrazz
parent d79ebf7d36
commit 3780c90804

View file

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