diff --git a/ansible/roles/hermes_auto_improve/tasks/main.yml b/ansible/roles/hermes_auto_improve/tasks/main.yml index e4d0f55..0536858 100644 --- a/ansible/roles/hermes_auto_improve/tasks/main.yml +++ b/ansible/roles/hermes_auto_improve/tasks/main.yml @@ -1,4 +1,18 @@ --- +- name: Add GitHub CLI repo + ansible.builtin.yum_repository: + name: gh-cli + description: GitHub CLI + baseurl: https://cli.github.com/packages/rpm + gpgkey: https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x23F3D4EA75716059 + gpgcheck: true + enabled: true + +- name: Install gh CLI + ansible.builtin.dnf: + name: gh + state: present + - name: Create state directory ansible.builtin.file: path: "{{ hermes_auto_improve_state_dir }}" diff --git a/tools/hermes-auto-improve/auto-improve.py b/tools/hermes-auto-improve/auto-improve.py index 38ddd4d..b9ba6a4 100644 --- a/tools/hermes-auto-improve/auto-improve.py +++ b/tools/hermes-auto-improve/auto-improve.py @@ -1,80 +1,89 @@ #!/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. +hermes-doc-worker — Hermes analyse admin/, écrit ses rapports dans +admin/hermes/builtin/ et push dans sa branche hermes/daily-work. +Ré-ingère tout admin/ dans Qdrant à chaque run. -Usage : - python3 auto-improve.py [--all] [--subdir ia] [--dry-run] +Modes : + (défaut) : analyse un batch de fichiers, écrit rapport, push, ré-ingère + --daily-pr : crée une PR hermes/daily-work → main sur GitHub + retourne l'URL + --dry-run : analyse sans écrire ni push """ import argparse import json +import os 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" +REPO_DIR = Path("/srv/data/lab/Funk-lab") +BRANCH = "hermes/daily-work" +ADMIN_DIR = REPO_DIR / "admin" +BUILTIN_DIR = REPO_DIR / "admin" / "hermes" / "builtin" +RAG_DOCS = Path("/srv/data/rag/docs") 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}"' -] +MAX_FILES = 5 # fichiers analysés par run -def git(args: list, check=False) -> subprocess.CompletedProcess: +# ── Git helpers ────────────────────────────────────────────────── + +def git(args: list) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", str(REPO_DIR)] + args, - capture_output=True, text=True, check=check + capture_output=True, text=True ) -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 git_sync(): + """S'assure qu'on est sur hermes/daily-work à jour.""" + git(["fetch", "origin"]) + r = git(["checkout", BRANCH]) + if r.returncode != 0: + git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"]) + git(["pull", "--rebase", "origin", BRANCH]) +# ── Hermes ─────────────────────────────────────────────────────── + +def ask_hermes(prompt: str) -> str: + 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=180) + return r.stdout.strip() + + +# ── Analyse d'un fichier ───────────────────────────────────────── + def analyze_file(fpath: Path) -> dict: - rel = str(fpath.relative_to(REPO_DIR)) - content = fpath.read_text(encoding="utf-8") + rel = str(fpath.relative_to(REPO_DIR)) + content = fpath.read_text(encoding="utf-8", errors="replace") - prompt = f"""Tu es l'agent d'analyse du homelab Funk. Analyse ce fichier de documentation : + prompt = f"""Tu es l'agent de documentation du homelab Funk. +Analyse ce fichier de documentation et produis un rapport structuré. Fichier : {rel} --- {content[:4000]} --- -Réponds en JSON strict (pas de markdown) : +Réponds UNIQUEMENT en JSON valide (aucun texte avant ou après) : {{ - "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.""" + "fichier": "{rel}", + "statut": "ok" | "à_jour" | "obsolète" | "incomplet" | "erreur", + "score_qualité": 1-10, + "résumé": "une phrase sur l'état de ce document", + "problèmes": ["liste des problèmes concrets trouvés"], + "suggestions": ["liste des améliorations prioritaires"], + "état_réel": "ce que tu sais de l'état actuel de ce composant dans Funk (ou 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 @@ -84,19 +93,84 @@ Si le document est à jour et complet, mettre status=ok et contenu_amélioré=nu 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] + "fichier": rel, + "statut": "erreur_parse", + "score_qualité": 0, + "résumé": "Hermes n'a pas retourné de JSON valide", + "problèmes": [], + "suggestions": [], + "état_réel": None, + "_raw": raw[:300] } +# ── Ré-ingestion Qdrant ────────────────────────────────────────── + +def rag_ingest() -> str: + # Sync admin/ du clone vers /srv/data/rag/docs/ + 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:] + + +# ── Créer la PR GitHub ─────────────────────────────────────────── + +def create_github_pr(summary: str) -> dict: + today = datetime.now().strftime("%Y-%m-%d") + diff = git(["diff", "--stat", "origin/main...HEAD"]) + stats = diff.stdout.strip() or "Aucune modification depuis main" + + body = f"""## Rapport quotidien Hermes — {today} + +Ce Pull Request est créé automatiquement par Hermes à 22h. + +### Ce que Hermes a fait aujourd'hui +{summary} + +### Diff depuis main +``` +{stats[:2000]} +``` + +### Comment reviewer +1. Vérifier les rapports dans `admin/hermes/builtin/` +2. Merger si tout semble correct, ou fermer pour recommencer demain + +> Généré par `hermes-doc-worker` · branche `{BRANCH}` +""" + + r = subprocess.run( + [ + "gh", "pr", "create", + "--title", f"[Hermes] Rapport documentation — {today}", + "--body", body, + "--head", BRANCH, + "--base", "main", + "--repo", "Alkatrazz24/Funk-lab", + ], + capture_output=True, text=True, timeout=30, + cwd=str(REPO_DIR), + ) + if r.returncode == 0: + pr_url = r.stdout.strip() + return {"html_url": pr_url, "title": f"[Hermes] Rapport documentation — {today}"} + return {"error": r.stderr.strip() or "gh pr create a échoué"} + + +# ── Main ───────────────────────────────────────────────────────── + def load_state() -> dict: if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) - return {"processed": [], "last_run": None} + return {"processed": [], "runs_today": 0, "last_run": None} def save_state(state: dict): @@ -106,116 +180,156 @@ def save_state(state: dict): 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)") + parser.add_argument("--daily-pr", action="store_true", help="Créer PR GitHub + résumé journalier") + parser.add_argument("--dry-run", action="store_true", help="Analyser sans écrire ni push") + parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ (ex: ia, k8s)") 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]) + now = datetime.now() + run_ts = now.strftime("%Y-%m-%d_%H-%M") + today = now.strftime("%Y-%m-%d") - # ── Sélection des fichiers ─────────────────────────────────── - scan_root = ADMIN_DIR / args.subdir if args.subdir else ADMIN_DIR - all_files = sorted(scan_root.rglob("*.md")) + # ── Sync branche ───────────────────────────────────────────── + if not args.dry_run: + git_sync() state = load_state() - if args.reset: - state["processed"] = [] - todo = [f for f in all_files if str(f) not in state["processed"]] + # ── Mode PR journalier ──────────────────────────────────────── + if args.daily_pr: + # Résumé des runs du jour + builtin_today = sorted(BUILTIN_DIR.glob(f"{today}_*.md")) if BUILTIN_DIR.exists() else [] + summary_parts = [] + for rpt in builtin_today[-5:]: + summary_parts.append(rpt.read_text()[:500]) + daily_summary = "\n\n---\n\n".join(summary_parts) or "Aucun rapport généré aujourd'hui." + + pr = create_github_pr(daily_summary) + result = { + "action": "daily_pr", + "pr_url": pr.get("html_url", ""), + "pr_number": pr.get("number", 0), + "pr_title": pr.get("title", ""), + "reports_today": len(builtin_today), + "error": pr.get("error", "") + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + # ── Sélection des fichiers à analyser ───────────────────────── + 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) # ne pas analyser ses propres rapports + ] + + # Rotation : reprend là où il s'est arrêté + todo = [f for f in all_md if str(f) not in state["processed"]] if not todo: state["processed"] = [] - todo = all_files + todo = all_md - batch = todo if args.all else todo[:MAX_FILES] - - # ── Analyse ────────────────────────────────────────────────── - run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M") + batch = todo[:MAX_FILES] results = [] + # ── Analyse ─────────────────────────────────────────────────── for fpath in batch: rel = str(fpath.relative_to(REPO_DIR)) - print(f" → Analyse : {rel}", flush=True) + print(f" → {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)) + print(f" {analysis.get('statut','?')} — {analysis.get('score_qualité','?')}/10", flush=True) 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) - ) + if not results: + print(json.dumps({"error": "aucun fichier à analyser"})) + return - improved = [r["file"] for r in results if r.get("status") == "à_améliorer" and r.get("contenu_amélioré")] + # ── Écriture du rapport dans admin/hermes/builtin/ ──────────── + BUILTIN_DIR.mkdir(parents=True, exist_ok=True) + report_path = BUILTIN_DIR / f"{run_ts}.md" + + lines = [f"# Rapport Hermes — {run_ts}\n"] + lines += [f"> Branche : `{BRANCH}` · Fichiers analysés : {len(results)}\n"] + lines += ["\n---\n"] + + ok_count = sum(1 for r in results if r.get("statut") in ("ok", "à_jour")) + bad_count = len(results) - ok_count + + lines += [f"## Résumé\n", + f"- ✅ À jour : {ok_count}\n", + f"- ⚠️ À améliorer : {bad_count}\n\n"] + + for r in results: + icon = "✅" if r.get("statut") in ("ok", "à_jour") else "⚠️" + lines.append(f"## {icon} `{r['fichier']}` — {r.get('score_qualité', '?')}/10\n\n") + lines.append(f"**Statut** : {r.get('statut', '?')} \n") + lines.append(f"**Résumé** : {r.get('résumé', '')} \n\n") + + if r.get("problèmes"): + lines.append("**Problèmes :**\n") + for p in r["problèmes"]: + lines.append(f"- {p}\n") + lines.append("\n") + + if r.get("suggestions"): + lines.append("**Suggestions :**\n") + for s in r["suggestions"]: + lines.append(f"- {s}\n") + lines.append("\n") + + if r.get("état_réel"): + lines.append(f"**État réel connu de Hermes :** \n{r['état_réel']}\n\n") + + lines.append("---\n\n") - # ── Commit + push ──────────────────────────────────────────── if not args.dry_run: - git(["add", str(report_file.relative_to(REPO_DIR))]) - for f in improved: - git(["add", f]) + report_path.write_text("".join(lines), encoding="utf-8") + print(f"\n✓ Rapport : {report_path.relative_to(REPO_DIR)}", flush=True) + + # ── Commit + push ───────────────────────────────────────────── + if not args.dry_run: + git(["add", str(report_path.relative_to(REPO_DIR))]) 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 "]) + f"docs(hermes): rapport analyse {run_ts} — {len(results)} fichiers\n\n" + f"Analysés : {', '.join(r['fichier'].split('/')[-1] for r in results)}\n" + f"À améliorer : {bad_count}/{len(results)}\n\n" + "Co-Authored-By: Hermes "]) git(["push", "origin", BRANCH]) - print(f"\n✓ Commit + push → {BRANCH}", flush=True) + print(f"✓ 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é-ingestion Qdrant (toujours) ──────────────────────── + print("\n⏳ Qdrant re-ingest...", flush=True) + ingest_out = rag_ingest() + print(f" {ingest_out}", flush=True) - # ── Résumé JSON (lu par n8n) ───────────────────────────────── + # ── Résumé JSON pour 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": [ + "run": run_ts, + "branch": BRANCH, + "analyzed": len(results), + "ok": ok_count, + "issues": bad_count, + "dry_run": args.dry_run, + "report": str(report_path.relative_to(REPO_DIR)) if not args.dry_run else None, + "details": [ { - "file": r["file"], - "status": r.get("status"), - "score": r.get("score"), - "issues": r.get("problèmes", []), - "suggestions": r.get("améliorations", []) + "file": r["fichier"], + "statut": r.get("statut"), + "score": r.get("score_qualité"), + "résumé": r.get("résumé", ""), } for r in results ] } print("\n" + json.dumps(summary, indent=2, ensure_ascii=False)) - return 0 if __name__ == "__main__": - sys.exit(main()) + main()