feat(hermes-auto-improve): remplacer API urllib par gh CLI pour les PR

Plus besoin de GITHUB_TOKEN dans le vault ni dans le service systemd.
gh auth login root une seule fois sur s01 suffit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-03 00:14:09 +02:00
parent 4e0b683cbd
commit cf2c8ecc81
2 changed files with 252 additions and 124 deletions

View file

@ -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 - name: Create state directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ hermes_auto_improve_state_dir }}" path: "{{ hermes_auto_improve_state_dir }}"

View file

@ -1,80 +1,89 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
hermes-auto-improve Analyse les docs admin/ avec Hermes. hermes-doc-worker Hermes analyse admin/, écrit ses rapports dans
Génère des rapports d'amélioration dans la feature branch, ré-ingère dans Qdrant. admin/hermes/builtin/ et push dans sa branche hermes/daily-work.
-ingère tout admin/ dans Qdrant à chaque run.
Usage : Modes :
python3 auto-improve.py [--all] [--subdir ia] [--dry-run] (défaut) : analyse un batch de fichiers, écrit rapport, push, -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 argparse
import json import json
import os
import subprocess import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
REPO_DIR = Path("/srv/data/lab/Funk-lab") REPO_DIR = Path("/srv/data/lab/Funk-lab")
BRANCH = "feature/hermes-auto-improve" BRANCH = "hermes/daily-work"
ADMIN_DIR = REPO_DIR / "admin" ADMIN_DIR = REPO_DIR / "admin"
REPORTS_DIR = REPO_DIR / "tools/hermes-auto-improve/reports" BUILTIN_DIR = REPO_DIR / "admin" / "hermes" / "builtin"
RAG_DOCS = Path("/srv/data/rag/docs")
STATE_FILE = Path("/var/lib/hermes-auto-improve/state.json") STATE_FILE = Path("/var/lib/hermes-auto-improve/state.json")
MAX_FILES = 6 # fichiers par run (sans --all) MAX_FILES = 5 # fichiers analysés par run
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: # ── Git helpers ──────────────────────────────────────────────────
def git(args: list) -> subprocess.CompletedProcess:
return subprocess.run( return subprocess.run(
["git", "-C", str(REPO_DIR)] + args, ["git", "-C", str(REPO_DIR)] + args,
capture_output=True, text=True, check=check capture_output=True, text=True
) )
def ask_hermes(prompt: str) -> str: def git_sync():
safe = prompt.replace("'", "'\\''").replace('"', '\\"') """S'assure qu'on est sur hermes/daily-work à jour."""
cmd = [ git(["fetch", "origin"])
"sudo", "-i", "-u", "hermes", "bash", "-c", r = git(["checkout", BRANCH])
f'HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z "{safe}"' if r.returncode != 0:
] git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) git(["pull", "--rebase", "origin", BRANCH])
return result.stdout.strip()
# ── 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: def analyze_file(fpath: Path) -> dict:
rel = str(fpath.relative_to(REPO_DIR)) rel = str(fpath.relative_to(REPO_DIR))
content = fpath.read_text(encoding="utf-8") 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} Fichier : {rel}
--- ---
{content[:4000]} {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", "fichier": "{rel}",
"score": 1-10, "statut": "ok" | "à_jour" | "obsolète" | "incomplet" | "erreur",
"problèmes": ["liste des problèmes concrets"], "score_qualité": 1-10,
"améliorations": ["liste des améliorations prioritaires"], "résumé": "une phrase sur l'état de ce document",
"contenu_amélioré": "contenu markdown complet si status=à_améliorer, sinon null" "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)"
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) raw = ask_hermes(prompt)
# Extraire le JSON de la réponse Hermes (qui peut contenir du texte autour)
try: try:
start = raw.find("{") start = raw.find("{")
end = raw.rfind("}") + 1 end = raw.rfind("}") + 1
@ -84,19 +93,84 @@ Si le document est à jour et complet, mettre status=ok et contenu_amélioré=nu
pass pass
return { return {
"status": "erreur", "fichier": rel,
"score": 0, "statut": "erreur_parse",
"problèmes": ["Hermes n'a pas retourné de JSON valide"], "score_qualité": 0,
"améliorations": [], "résumé": "Hermes n'a pas retourné de JSON valide",
"contenu_amélioré": None, "problèmes": [],
"raw_response": raw[:500] "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: def load_state() -> dict:
if STATE_FILE.exists(): if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text()) 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): def save_state(state: dict):
@ -106,116 +180,156 @@ def save_state(state: dict):
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--all", action="store_true", help="Traiter tous les fichiers") parser.add_argument("--daily-pr", action="store_true", help="Créer PR GitHub + résumé journalier")
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 écrire ni push")
parser.add_argument("--dry-run", action="store_true", help="Analyser sans commit ni push") parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ (ex: ia, k8s)")
parser.add_argument("--reset", action="store_true", help="Réinitialiser l'état (retraiter tous les fichiers)")
args = parser.parse_args() args = parser.parse_args()
# ── Sync branche ──────────────────────────────────────────── now = datetime.now()
git(["fetch", "origin"]) run_ts = now.strftime("%Y-%m-%d_%H-%M")
checkout = git(["checkout", BRANCH]) today = now.strftime("%Y-%m-%d")
if checkout.returncode != 0:
git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"])
git(["pull", "origin", BRANCH])
# ── Sélection des fichiers ─────────────────────────────────── # ── Sync branche ─────────────────────────────────────────────
scan_root = ADMIN_DIR / args.subdir if args.subdir else ADMIN_DIR if not args.dry_run:
all_files = sorted(scan_root.rglob("*.md")) git_sync()
state = load_state() 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: if not todo:
state["processed"] = [] state["processed"] = []
todo = all_files todo = all_md
batch = todo if args.all else todo[:MAX_FILES] batch = todo[:MAX_FILES]
# ── Analyse ──────────────────────────────────────────────────
run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M")
results = [] results = []
# ── Analyse ───────────────────────────────────────────────────
for fpath in batch: for fpath in batch:
rel = str(fpath.relative_to(REPO_DIR)) rel = str(fpath.relative_to(REPO_DIR))
print(f" → Analyse : {rel}", flush=True) print(f"{rel}", flush=True)
analysis = analyze_file(fpath) analysis = analyze_file(fpath)
analysis["file"] = rel
results.append(analysis) 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)) state["processed"].append(str(fpath))
print(f" {analysis.get('statut','?')}{analysis.get('score_qualité','?')}/10", flush=True)
save_state(state) save_state(state)
# ── Rapport ────────────────────────────────────────────────── if not results:
REPORTS_DIR.mkdir(parents=True, exist_ok=True) print(json.dumps({"error": "aucun fichier à analyser"}))
report_file = REPORTS_DIR / f"{run_ts}.json" return
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é")] # ── É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: if not args.dry_run:
git(["add", str(report_file.relative_to(REPO_DIR))]) report_path.write_text("".join(lines), encoding="utf-8")
for f in improved: print(f"\n✓ Rapport : {report_path.relative_to(REPO_DIR)}", flush=True)
git(["add", f])
# ── Commit + push ─────────────────────────────────────────────
if not args.dry_run:
git(["add", str(report_path.relative_to(REPO_DIR))])
if git(["diff", "--cached", "--quiet"]).returncode != 0: 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", git(["commit", "-m",
f"docs(auto): analyse Hermes {run_ts}{msg_files}\n\n" f"docs(hermes): rapport analyse {run_ts}{len(results)} fichiers\n\n"
f"Co-Authored-By: Hermes <hermes@funk.lab>"]) 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 <hermes@funk.lab>"])
git(["push", "origin", BRANCH]) git(["push", "origin", BRANCH])
print(f"\n✓ Commit + push → {BRANCH}", flush=True) print(f"✓ Push → {BRANCH}", flush=True)
# ── Ré-ingestion Qdrant ────────────────────────────────── # ── Ré-ingestion Qdrant (toujours) ────────────────────────
if improved: print("\n⏳ Qdrant re-ingest...", flush=True)
print("\n⏳ Ré-ingestion Qdrant...", flush=True) ingest_out = rag_ingest()
ingest = subprocess.run( print(f" {ingest_out}", flush=True)
["/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) ───────────────────────────────── # ── Résumé JSON pour n8n ─────────────────────────────────────
summary = { summary = {
"run": run_ts, "run": run_ts,
"branch": BRANCH, "branch": BRANCH,
"analyzed": len(results), "analyzed": len(results),
"improved": len(improved), "ok": ok_count,
"ok": len([r for r in results if r.get("status") == "ok"]), "issues": bad_count,
"errors": len([r for r in results if r.get("status") == "erreur"]), "dry_run": args.dry_run,
"files_improved": improved, "report": str(report_path.relative_to(REPO_DIR)) if not args.dry_run else None,
"dry_run": args.dry_run, "details": [
"details": [
{ {
"file": r["file"], "file": r["fichier"],
"status": r.get("status"), "statut": r.get("statut"),
"score": r.get("score"), "score": r.get("score_qualité"),
"issues": r.get("problèmes", []), "résumé": r.get("résumé", ""),
"suggestions": r.get("améliorations", [])
} }
for r in results for r in results
] ]
} }
print("\n" + json.dumps(summary, indent=2, ensure_ascii=False)) print("\n" + json.dumps(summary, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) main()