Funk-lab/tools/hermes-auto-improve/auto-improve.py

337 lines
12 KiB
Python

#!/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 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": [], "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("--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
]
# 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 <hermes@funk.lab>"])
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()