feat(memory): projection Markdown durable + commit Git auto a chaque record_lesson
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Projection Markdown durable des memory_entries + commit Git automatique.
|
||||
SQLite (models.py) reste la source servie en instantane par l'API/MCP ;
|
||||
ce module ne fait que projeter et versionner, jamais l'inverse (on ne relit
|
||||
jamais le .md pour repondre a une requete).
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models import list_memory_entries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITEA_INTERNAL_URL = "http://172.17.0.1:3232" # jamais l'IP LAN depuis un container
|
||||
GITEA_REPO = "bolbol/context-hub"
|
||||
GITEA_TOKEN_ENV = "GITEA_TOKEN"
|
||||
|
||||
TYPE_ORDER = ["decision", "constraint", "best-practice", "common-error", "do-not-use"]
|
||||
TYPE_LABELS = {
|
||||
"decision": "Decisions",
|
||||
"constraint": "Contraintes",
|
||||
"best-practice": "Bonnes pratiques",
|
||||
"common-error": "Erreurs communes",
|
||||
"do-not-use": "A ne pas reessayer",
|
||||
}
|
||||
|
||||
|
||||
def _render_entry(e: Dict[str, Any]) -> str:
|
||||
tags = ", ".join(e.get("tags") or [])
|
||||
tags_line = f" — tags: {tags}" if tags else ""
|
||||
return (
|
||||
f"- **[{e['id']}]** {e['title']}\n"
|
||||
f" {e['body']}\n"
|
||||
f" _cree par {e['created_by']} le {e['created_at']}{tags_line}_\n"
|
||||
)
|
||||
|
||||
|
||||
def render_scope_markdown(scope: str, entries: List[Dict[str, Any]]) -> str:
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
lines = [
|
||||
f"# Memoire {scope} — genere automatiquement, ne pas editer a la main",
|
||||
"",
|
||||
"> Source de verite operationnelle : table `memory_entries` dans context-hub (SQLite + API/MCP).",
|
||||
"> Ce fichier est une projection durable et versionnee, regeneree a chaque `record_lesson`.",
|
||||
f"> Derniere generation : {generated_at}",
|
||||
"",
|
||||
]
|
||||
|
||||
by_project: Dict[Any, List[Dict[str, Any]]] = {}
|
||||
for e in entries:
|
||||
by_project.setdefault(e.get("project"), []).append(e)
|
||||
|
||||
projects = sorted(by_project.keys(), key=lambda p: (p is not None, p or ""))
|
||||
|
||||
if not entries:
|
||||
lines.append("_Aucune entree active pour ce scope._")
|
||||
|
||||
for project in projects:
|
||||
heading = "## Transverse" if project is None else f"## Projet: {project}"
|
||||
lines.append(heading)
|
||||
lines.append("")
|
||||
by_type: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for e in by_project[project]:
|
||||
by_type.setdefault(e["type"], []).append(e)
|
||||
for type_ in TYPE_ORDER:
|
||||
if type_ not in by_type:
|
||||
continue
|
||||
lines.append(f"### {TYPE_LABELS[type_]}")
|
||||
lines.append("")
|
||||
for e in sorted(by_type[type_], key=lambda x: x["created_at"], reverse=True):
|
||||
lines.append(_render_entry(e))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def push_scope_markdown(scope: str) -> Dict[str, Any]:
|
||||
token = os.environ.get(GITEA_TOKEN_ENV)
|
||||
if not token:
|
||||
logger.warning("GITEA_TOKEN absent, projection %s.md non poussee", scope)
|
||||
return {"pushed": False, "reason": "GITEA_TOKEN absent"}
|
||||
|
||||
entries = await list_memory_entries(scope, status="active")
|
||||
content = render_scope_markdown(scope, entries)
|
||||
path = f"docs/context-memory/{scope}.md"
|
||||
url = f"{GITEA_INTERNAL_URL}/api/v1/repos/{GITEA_REPO}/contents/{path}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
get_resp = await client.get(url, headers=headers)
|
||||
sha = get_resp.json().get("sha") if get_resp.status_code == 200 else None
|
||||
|
||||
import base64
|
||||
payload = {
|
||||
"message": f"memory: regeneration {scope}.md ({len(entries)} entrees actives)",
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"branch": "main",
|
||||
}
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
put_resp = await client.put(url, headers=headers, json=payload)
|
||||
else:
|
||||
put_resp = await client.post(url, headers=headers, json=payload)
|
||||
|
||||
if put_resp.status_code not in (200, 201):
|
||||
logger.error("Push Gitea echoue pour %s: %s %s", path, put_resp.status_code, put_resp.text[:300])
|
||||
return {"pushed": False, "reason": f"HTTP {put_resp.status_code}"}
|
||||
|
||||
commit_sha = put_resp.json().get("commit", {}).get("sha", "")[:8]
|
||||
logger.info("Projection %s poussee, commit %s", path, commit_sha)
|
||||
return {"pushed": True, "commit": commit_sha, "path": path}
|
||||
Reference in New Issue
Block a user