Funk-lab/tools/hermes-auto-improve/auto-improve.py
alkatrazz 4a7a5ac9fc feat(auto-improve): workflow Hermes d'analyse et amélioration de la doc
Pipeline complet dans feature/hermes-auto-improve :
- auto-improve.py : analyse admin/ avec hermes --profile funk-ai, applique
  les améliorations, committe dans la branche, ré-ingère dans Qdrant
- trigger-server.py : HTTP 9095 (POST /trigger, /trigger/all, /trigger/subdir,
  /trigger/dry-run) pour déclenchement depuis n8n ou manuellement
- Ansible role hermes_auto_improve : déploie scripts + service systemd sur s01
- n8n workflow "Hermes Auto-Improve" (ID: CX72UUOempE1YtNL) : schedule
  hebdo dimanche 09h + webhook manuel → analyse → résumé LLM → email

Tout reste dans cette branche — rien ne touche main avant review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:53:53 +02:00

221 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
hermes-auto-improve — Analyse les docs admin/ avec Hermes.
Génère des rapports d'amélioration dans la feature branch, ré-ingère dans Qdrant.
Usage :
python3 auto-improve.py [--all] [--subdir ia] [--dry-run]
"""
import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
REPO_DIR = Path("/srv/data/lab/Funk-lab")
BRANCH = "feature/hermes-auto-improve"
ADMIN_DIR = REPO_DIR / "admin"
REPORTS_DIR = REPO_DIR / "tools/hermes-auto-improve/reports"
STATE_FILE = Path("/var/lib/hermes-auto-improve/state.json")
MAX_FILES = 6 # fichiers par run (sans --all)
HERMES_CMD = [
"sudo", "-i", "-u", "hermes", "bash", "-c",
'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z "{prompt}"'
]
def git(args: list, check=False) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(REPO_DIR)] + args,
capture_output=True, text=True, check=check
)
def ask_hermes(prompt: str) -> str:
safe = prompt.replace("'", "'\\''").replace('"', '\\"')
cmd = [
"sudo", "-i", "-u", "hermes", "bash", "-c",
f'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z "{safe}"'
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
return result.stdout.strip()
def analyze_file(fpath: Path) -> dict:
rel = str(fpath.relative_to(REPO_DIR))
content = fpath.read_text(encoding="utf-8")
prompt = f"""Tu es l'agent d'analyse du homelab Funk. Analyse ce fichier de documentation :
Fichier : {rel}
---
{content[:4000]}
---
Réponds en JSON strict (pas de markdown) :
{{
"status": "ok" | "à_améliorer",
"score": 1-10,
"problèmes": ["liste des problèmes concrets"],
"améliorations": ["liste des améliorations prioritaires"],
"contenu_amélioré": "contenu markdown complet si status=à_améliorer, sinon null"
}}
Critères d'évaluation :
- Informations obsolètes ou incorrectes par rapport à l'état actuel du homelab
- Sections manquantes importantes pour un ops
- Commandes ou chemins érronés
- Documentation incomplète pour reproduire une procédure
Si le document est à jour et complet, mettre status=ok et contenu_amélioré=null."""
raw = ask_hermes(prompt)
# Extraire le JSON de la réponse Hermes (qui peut contenir du texte autour)
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(raw[start:end])
except json.JSONDecodeError:
pass
return {
"status": "erreur",
"score": 0,
"problèmes": ["Hermes n'a pas retourné de JSON valide"],
"améliorations": [],
"contenu_amélioré": None,
"raw_response": raw[:500]
}
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {"processed": [], "last_run": None}
def save_state(state: dict):
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--all", action="store_true", help="Traiter tous les fichiers")
parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ à analyser (ex: ia, k8s)")
parser.add_argument("--dry-run", action="store_true", help="Analyser sans commit ni push")
parser.add_argument("--reset", action="store_true", help="Réinitialiser l'état (retraiter tous les fichiers)")
args = parser.parse_args()
# ── Sync branche ────────────────────────────────────────────
git(["fetch", "origin"])
checkout = git(["checkout", BRANCH])
if checkout.returncode != 0:
git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"])
git(["pull", "origin", BRANCH])
# ── Sélection des fichiers ───────────────────────────────────
scan_root = ADMIN_DIR / args.subdir if args.subdir else ADMIN_DIR
all_files = sorted(scan_root.rglob("*.md"))
state = load_state()
if args.reset:
state["processed"] = []
todo = [f for f in all_files if str(f) not in state["processed"]]
if not todo:
state["processed"] = []
todo = all_files
batch = todo if args.all else todo[:MAX_FILES]
# ── Analyse ──────────────────────────────────────────────────
run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M")
results = []
for fpath in batch:
rel = str(fpath.relative_to(REPO_DIR))
print(f" → Analyse : {rel}", flush=True)
analysis = analyze_file(fpath)
analysis["file"] = rel
results.append(analysis)
# Appliquer les améliorations si Hermes en a proposé
if analysis.get("status") == "à_améliorer" and analysis.get("contenu_amélioré"):
if not args.dry_run:
fpath.write_text(analysis["contenu_amélioré"], encoding="utf-8")
print(f" ✓ Améliorations appliquées (score: {analysis.get('score', '?')}/10)", flush=True)
elif analysis.get("status") == "ok":
print(f" ✓ OK (score: {analysis.get('score', '?')}/10)", flush=True)
else:
print(f"{analysis.get('status', '?')}", flush=True)
state["processed"].append(str(fpath))
save_state(state)
# ── Rapport ──────────────────────────────────────────────────
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
report_file = REPORTS_DIR / f"{run_ts}.json"
report_file.write_text(
json.dumps({"run": run_ts, "results": results}, indent=2, ensure_ascii=False)
)
improved = [r["file"] for r in results if r.get("status") == "à_améliorer" and r.get("contenu_amélioré")]
# ── Commit + push ────────────────────────────────────────────
if not args.dry_run:
git(["add", str(report_file.relative_to(REPO_DIR))])
for f in improved:
git(["add", f])
if git(["diff", "--cached", "--quiet"]).returncode != 0:
msg_files = f"\n\nFichiers améliorés :\n" + "\n".join(f" - {f}" for f in improved) if improved else ""
git(["commit", "-m",
f"docs(auto): analyse Hermes {run_ts}{msg_files}\n\n"
f"Co-Authored-By: Hermes <hermes@funk.lab>"])
git(["push", "origin", BRANCH])
print(f"\n✓ Commit + push → {BRANCH}", flush=True)
# ── Ré-ingestion Qdrant ──────────────────────────────────
if improved:
print("\n⏳ Ré-ingestion Qdrant...", flush=True)
ingest = subprocess.run(
["/usr/local/bin/rag-ingest"],
capture_output=True, text=True, timeout=300
)
print(f" {ingest.stdout.strip()[-200:] if ingest.stdout else 'OK'}", flush=True)
# ── Résumé JSON (lu par n8n) ─────────────────────────────────
summary = {
"run": run_ts,
"branch": BRANCH,
"analyzed": len(results),
"improved": len(improved),
"ok": len([r for r in results if r.get("status") == "ok"]),
"errors": len([r for r in results if r.get("status") == "erreur"]),
"files_improved": improved,
"dry_run": args.dry_run,
"details": [
{
"file": r["file"],
"status": r.get("status"),
"score": r.get("score"),
"issues": r.get("problèmes", []),
"suggestions": r.get("améliorations", [])
}
for r in results
]
}
print("\n" + json.dumps(summary, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())