mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 18:44:43 +02:00
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>
391 lines
14 KiB
Python
391 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
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
|
|
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"
|
|
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 5",
|
|
"cat /etc/litellm/config.yaml",
|
|
],
|
|
"ia/hermes.md": [
|
|
"systemctl status hermes --no-pager -n 5",
|
|
"cat /srv/data/hermes/config.yaml 2>/dev/null | head -40",
|
|
],
|
|
"ia/hermes-souls": [
|
|
"ls /srv/data/hermes/ 2>/dev/null",
|
|
"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 5 || echo 'service hermes-voice absent'",
|
|
],
|
|
"ia/llama_server": [
|
|
"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 5",
|
|
"ls /srv/data/rag/docs/ 2>/dev/null | wc -l",
|
|
"/usr/local/bin/rag-query 'hermes' 2>&1 | head -10",
|
|
],
|
|
"ia/rocm": [
|
|
"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 5",
|
|
"cat /etc/alertmanager-webhook/*.yaml 2>/dev/null | head -30",
|
|
],
|
|
"infra/dnsmasq": [
|
|
"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 5",
|
|
"exportfs -v 2>/dev/null",
|
|
],
|
|
"infra/reseau": [
|
|
"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 '",
|
|
],
|
|
"infra/email": [
|
|
"systemctl status postfix --no-pager -n 5",
|
|
"postconf -n 2>/dev/null | grep -vi 'pass' | grep -E 'relay|tls|sasl'",
|
|
],
|
|
"install/storage-01": [
|
|
"hostnamectl | grep -E 'Hostname|OS|Kernel'",
|
|
"df -h | grep -v tmpfs",
|
|
"free -h",
|
|
"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 | 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 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",
|
|
"kubectl get pods -n argocd 2>/dev/null",
|
|
],
|
|
"k8s/monitoring": [
|
|
"kubectl get pods -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 -4",
|
|
],
|
|
"k8s/k9s": [
|
|
"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/",
|
|
],
|
|
"ops/cluster": [
|
|
"kubectl get nodes -o wide 2>/dev/null",
|
|
"systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -10",
|
|
],
|
|
"ops/systeme": [
|
|
"uname -r",
|
|
"uptime",
|
|
"df -h | grep -v tmpfs",
|
|
"free -h",
|
|
"systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -10",
|
|
],
|
|
"README": [
|
|
"systemctl is-active litellm hermes dnsmasq nfs-server qdrant postfix 2>/dev/null",
|
|
],
|
|
}
|
|
|
|
DEFAULT_COMMANDS = [
|
|
"date",
|
|
"systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | 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[:1500] 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 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)
|
|
|
|
print(f" Hermes génère la section...", flush=True)
|
|
new_section, original_content = build_verified_section(fpath, cluster_state, today)
|
|
|
|
if not new_section or len(new_section) < 30:
|
|
return False, f"section trop courte ({len(new_section)} chars)"
|
|
|
|
# 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()
|
|
|
|
updated = base + "\n\n---\n\n" + new_section.strip() + "\n"
|
|
fpath.write_text(updated, encoding="utf-8")
|
|
return True, None
|
|
|
|
|
|
# ── Qdrant re-ingest ──────────────────────────────────────────────────────────
|
|
|
|
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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
def load_state():
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text())
|
|
return {"enriched": [], "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)")
|
|
parser.add_argument("--no-rag", action="store_true", help="Ne pas relancer rag-ingest à la fin")
|
|
args = parser.parse_args()
|
|
|
|
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 = {"enriched": [], "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["enriched"]]
|
|
if not todo:
|
|
state["enriched"] = []
|
|
save_state(state)
|
|
todo = all_md
|
|
print("Rotation complète — tous les fichiers repassés.")
|
|
batch = todo[:MAX_FILES]
|
|
|
|
print(f"\n{len(batch)} fichiers à enrichir\n{'─'*50}", flush=True)
|
|
|
|
changed = []
|
|
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 :")
|
|
for c in cmds:
|
|
print(f" {c}")
|
|
continue
|
|
|
|
ok, err = enrich_file(fpath, today)
|
|
if not ok:
|
|
print(f" ✗ {err}", flush=True)
|
|
state["errors"].append(rel)
|
|
continue
|
|
|
|
state["enriched"].append(str(fpath))
|
|
changed.append(rel)
|
|
print(f" ✓ Section ajoutée", flush=True)
|
|
|
|
git(["add", rel])
|
|
if git(["diff", "--cached", "--quiet"]).returncode != 0:
|
|
git(["commit", "-m",
|
|
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", flush=True)
|
|
else:
|
|
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"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 créer la PR : hermes-auto-improve --daily-pr")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|