#!/usr/bin/env python3 """ 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. 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 = "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 = 5 # fichiers analysés par run # ── Git helpers ────────────────────────────────────────────────── def git(args: list) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", str(REPO_DIR)] + args, capture_output=True, text=True ) def git_sync(): """S'assure qu'on est sur hermes/daily-work à jour.""" 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]) # ── 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", errors="replace") 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 UNIQUEMENT en JSON valide (aucun texte avant ou après) : {{ "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) 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 { "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 _get_open_hermes_pr(): """Retourne le numéro et l'URL de la PR Hermes ouverte, ou None.""" r = subprocess.run( ["gh", "pr", "list", "--repo", "Alkatrazz24/Funk-lab", "--head", BRANCH, "--state", "open", "--json", "number,url"], capture_output=True, text=True, timeout=30, cwd=str(REPO_DIR) ) if r.returncode != 0: return None try: prs = json.loads(r.stdout) return prs[0] if prs else None except (json.JSONDecodeError, IndexError): return None 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" title = f"[Hermes] Rapport documentation — {today}" 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 quand tout semble correct (squash merge configuré) > Généré par `hermes-doc-worker` · branche `{BRANCH}` """ existing = _get_open_hermes_pr() if existing: # Met à jour le titre et le corps de la PR existante subprocess.run( ["gh", "pr", "edit", str(existing["number"]), "--repo", "Alkatrazz24/Funk-lab", "--title", title, "--body", body], capture_output=True, text=True, timeout=30, cwd=str(REPO_DIR) ) return {"html_url": existing["url"], "number": existing["number"], "title": title, "updated": True} r = subprocess.run( ["gh", "pr", "create", "--title", title, "--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": title} 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": [], "runs_today": 0, "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("--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("--all", action="store_true", help="Analyser tous les fichiers en une passe (ignore MAX_FILES)") parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ (ex: ia, k8s)") args = parser.parse_args() now = datetime.now() run_ts = now.strftime("%Y-%m-%d_%H-%M") today = now.strftime("%Y-%m-%d") # ── Sync branche ───────────────────────────────────────────── if not args.dry_run: git_sync() state = load_state() # ── 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 ] if args.all: batch = all_md state["processed"] = [] else: # 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_md batch = todo[:MAX_FILES] results = [] # ── Analyse ─────────────────────────────────────────────────── for fpath in batch: rel = str(fpath.relative_to(REPO_DIR)) print(f" → {rel}", flush=True) analysis = analyze_file(fpath) results.append(analysis) state["processed"].append(str(fpath)) print(f" {analysis.get('statut','?')} — {analysis.get('score_qualité','?')}/10", flush=True) save_state(state) if not results: print(json.dumps({"error": "aucun fichier à analyser"})) return # ── É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") if not args.dry_run: 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: git(["commit", "-m", 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"✓ Push → {BRANCH}", 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 pour n8n ───────────────────────────────────── summary = { "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["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)) if __name__ == "__main__": main()