feat: brain-engine + brain-ui + docs — template full stack standalone

- brain-engine: server, embed, search, RAG, MCP, start.sh (standalone)
- brain-ui: source React complète, build.sh, DocsView avec tier colors
- docs: 14 pages guides humains (getting-started, architecture, sessions, workflows, agents, vues tier)
- brain-compose.yml v0.9.0: tier featured ajouté, sessions/agents par tier, coach_level, API key schema
- DISTRIBUTION_CHECKLIST v1.2: brain-engine + brain-ui + docs dans la checklist
This commit is contained in:
2026-03-20 20:25:40 +01:00
parent c249d417f5
commit 8244a07881
93 changed files with 12088 additions and 34 deletions

401
brain-engine/distill.py Normal file
View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
brain-engine/distill.py — BE-5 Session memory distillation
Distille une session BSI (.jsonl Claude) en chunks indexés dans brain.db.
Usage :
python3 brain-engine/distill.py <session.jsonl> → distille la session
python3 brain-engine/distill.py <session.jsonl> --dry-run → aperçu sans écriture
python3 brain-engine/distill.py --last → distille la dernière session Claude
Point de substitution LLM : fonction summarize() — Ollama local (pro tier).
Pour tier full : remplacer summarize() par un appel API Claude/OpenAI.
Scope : work — les distillats sont accessibles via brain_search (MCP + owner).
"""
import os
import sys
import json
import re
import argparse
import sqlite3
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from embed import connect, upsert_chunk, get_embedding, chunk_id, OLLAMA_URL
# ── Config ─────────────────────────────────────────────────────────────────────
BRAIN_ROOT = Path(__file__).parent.parent
DISTILL_MODEL = os.getenv('DISTILL_MODEL', 'mistral:7b') # LLM local pour résumé
SCOPE = 'work'
# Sessions Claude — chemin par défaut
CLAUDE_SESSIONS_DIR = Path.home() / '.claude' / 'projects'
# Taille max du contexte envoyé au LLM (chars) — réduit pour garder le format few-shot (BE-5d)
MAX_CONTEXT_CHARS = 12_000
# Max messages récents envoyés au LLM — évite les narratives anglaises sur grandes sessions (BE-5d)
MAX_MESSAGES = 50
# Seuil minimum — sessions trop courtes ne contiennent que le brief, pas de vraies décisions (BE-5d)
MIN_MESSAGES = 10
# Levier 2 — max chunks par aspect (Stratégie A, split post-LLM)
CHUNK_LIMITS = {'decisions': 10, 'code': 5, 'todos': 5}
# ── Extraction session ─────────────────────────────────────────────────────────
def extract_messages(jsonl_path: Path) -> list[dict]:
"""Extrait les messages human/assistant du .jsonl Claude."""
messages = []
try:
with open(jsonl_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
msg = entry.get('message', {})
role = msg.get('role')
if role not in ('user', 'assistant'):
continue
content = msg.get('content', '')
if isinstance(content, list):
# Extraire le texte des blocs content
parts = [b.get('text', '') for b in content
if isinstance(b, dict) and b.get('type') == 'text']
content = '\n'.join(parts)
if content and content.strip():
messages.append({'role': role, 'content': content.strip()})
except FileNotFoundError:
sys.exit(f'❌ Fichier introuvable : {jsonl_path}')
return messages
def build_context(messages: list[dict], max_chars: int = MAX_CONTEXT_CHARS) -> str:
"""Construit un contexte tronqué pour le LLM.
Priorise les N derniers messages (MAX_MESSAGES) pour garder le LLM dans le format few-shot.
"""
# Bug 2 fix — prioriser les messages récents sur grandes sessions
if len(messages) > MAX_MESSAGES:
messages = messages[-MAX_MESSAGES:]
lines = []
total = 0
# On prend les messages les plus récents en priorité
for msg in reversed(messages):
prefix = 'USER' if msg['role'] == 'user' else 'ASSISTANT'
line = f'[{prefix}] {msg["content"][:500]}'
if total + len(line) > max_chars:
break
lines.append(line)
total += len(line)
lines.reverse()
return '\n\n'.join(lines)
# ── LLM — point de substitution ───────────────────────────────────────────────
def summarize(context: str, aspect: str) -> str | None:
"""
Résume le contexte selon l'aspect demandé.
POINT DE SUBSTITUTION : remplacer par API Claude/OpenAI pour tier full.
aspect : 'decisions' | 'code' | 'todos'
"""
prompts = {
'decisions': (
'Tu es un extracteur de mémoire technique. '
'Extrait les décisions architecturales et techniques prises dans cette session.\n\n'
'FORMAT OBLIGATOIRE : une décision par ligne, commençant par "- ".\n'
'Si aucune décision : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "On a choisi mistral:7b parce que mistral-small était trop lent"\n'
'\n'
'- Modèle LLM distillation : mistral:7b retenu (mistral-small écarté — latence)\n\n'
'Session : "On garde 3 chunks par session, max 10 decisions, 5 code, 5 todos"\n'
'\n'
'- Chunking BE-5 : 3 aspects (decisions/code/todos), caps 10/5/5\n\n'
'Session : "Finalement on utilise SQLite plutôt que Postgres pour brain.db"\n'
'\n'
'- Stockage brain.db : SQLite retenu (Postgres écarté — overhead opérationnel)\n\n'
'Réponds dans la même langue que la session. Max 15 mots par bullet.\n\n'
'Session :\n'
),
'code': (
'Tu es un extracteur de mémoire technique. '
'Extrait les fichiers créés ou modifiés, les fonctions clés implémentées, et les bugs corrigés.\n\n'
'FORMAT OBLIGATOIRE : une entrée par ligne, commençant par "- ".\n'
'Si rien de notable : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "On a créé distill.py avec les fonctions extract_messages, build_context et summarize"\n'
'\n'
'- brain-engine/distill.py créé — pipeline distillation : extract_messages(), build_context(), summarize()\n\n'
'Session : "J\'ai corrigé le timeout dans embed.py, maintenant c\'est 90s au lieu de 60s"\n'
'\n'
'- embed.py:get_embedding() — fix timeout 60s → 90s\n\n'
'Session : "On a ajouté CHUNK_LIMITS et parse_bullets dans distill.py"\n'
'\n'
'- distill.py — ajout CHUNK_LIMITS (10/5/5) + parse_bullets() stratégie A\n\n'
'Réponds dans la même langue que la session. Sois concis.\n\n'
'Session :\n'
),
'todos': (
'Tu es un extracteur de mémoire technique. '
'Extrait les tâches ouvertes, blockers et prochaines étapes mentionnés dans cette session.\n\n'
'FORMAT OBLIGATOIRE : une tâche par ligne, commençant par "- ".\n'
'Si aucune tâche : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "Il faudra tester deepseek-coder pour l\'aspect code plus tard"\n'
'\n'
'- Tester deepseek-coder:6.7b pour aspect "code" (levier 3 BE-5)\n\n'
'Session : "Le cron VPS n\'est pas viable tant qu\'Ollama ne tourne pas sur le VPS"\n'
'\n'
'- Installer Ollama sur VPS pour activer cron distillation automatique\n\n'
'Session : "On fera l\'externalisation des prompts en BE-5c si nécessaire"\n'
'\n'
'- BE-5c (optionnel) : externaliser prompts distill dans brain-engine/prompts/*.txt\n\n'
'Réponds dans la même langue que la session. Sois concis.\n\n'
'Session :\n'
),
}
prompt = prompts[aspect] + context
url = f'{OLLAMA_URL}/api/generate'
payload = json.dumps({
'model': DISTILL_MODEL,
'prompt': prompt,
'stream': False,
'options': {'temperature': 0.1, 'num_predict': 400},
}).encode()
req = urllib.request.Request(url, data=payload,
headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
return data.get('response', '').strip()
except (urllib.error.URLError, TimeoutError) as e:
print(f'⚠️ Ollama indisponible ({OLLAMA_URL}) : {e}', file=sys.stderr)
return None
# ── Parsing bullets (Stratégie A — post-split) ────────────────────────────────
def parse_bullets(text: str) -> list[str]:
"""
Extrait les bullets d'une réponse LLM.
Reconnaît : '- ', '', '* ', ' ' en début de ligne.
Gère les continuations (ligne indentée sans préfixe = suite du bullet précédent).
"""
bullets: list[str] = []
current: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
# Préfixes reconnus : tiret court, puce, astérisque, tiret long
is_bullet = (
stripped[:2] in ('- ', '', '* ')
or (stripped[0] == '' and len(stripped) > 1 and stripped[1] == ' ')
)
if is_bullet:
if current:
bullets.append(' '.join(current))
# Extraire le texte après le préfixe (1 ou 2 chars)
prefix_len = 2 if stripped[:2] in ('- ', '', '* ') else 2
current = [stripped[prefix_len:].strip()]
elif current:
# Continuation d'un bullet multi-ligne
current.append(stripped)
if current:
bullets.append(' '.join(current))
return [b for b in bullets if b]
# ── Summarisation 2 passes (BE-5e) ────────────────────────────────────────────
def summarize_2pass(messages: list[dict], aspect: str) -> str | None:
"""
Summarisation en 2 passes pour grandes sessions (BE-5e).
Pass 1 : résumé de chaque bloc de MAX_MESSAGES messages.
Pass 2 : résumé final sur la concaténation des résumés partiels.
"""
blocks = [messages[i:i + MAX_MESSAGES] for i in range(0, len(messages), MAX_MESSAGES)]
partial_summaries = []
for idx, block in enumerate(blocks):
context = build_context(block)
partial = summarize(context, aspect)
if partial and partial.strip().lower() not in ('none', 'aucune', 'aucun', 'ninguno', 'ninguna', ''):
partial_summaries.append(f'# Bloc {idx + 1}/{len(blocks)}\n{partial}')
if not partial_summaries:
return None
combined = '\n\n'.join(partial_summaries)
# Pass 2 : résumé final
return summarize(combined[:MAX_CONTEXT_CHARS], aspect)
# ── Distillation ──────────────────────────────────────────────────────────────
def distill_session(jsonl_path: Path, dry_run: bool = False) -> int:
"""
Distille une session en chunks granulaires (1 bullet = 1 chunk).
Caps : decisions ≤ 10, code ≤ 5, todos ≤ 5.
Retourne le nombre de chunks indexés.
"""
print(f'📖 Lecture : {jsonl_path.name}')
messages = extract_messages(jsonl_path)
if not messages:
print('⚠️ Aucun message extractible — session vide ou format inconnu.')
return 0
print(f' {len(messages)} messages extraits')
# Bug 1 fix — filtre micro-sessions (brief bootstrap seul, pas de vraies décisions)
if len(messages) < MIN_MESSAGES:
print(f'⚠️ Session trop courte ({len(messages)} messages < {MIN_MESSAGES}) — skip.')
return 0
is_large = len(messages) > MAX_MESSAGES
context = build_context(messages) if not is_large else None
if is_large:
print(f' ⚡ Grande session ({len(messages)} msg) — mode 2-pass activé')
sess_id = jsonl_path.stem # ex: c22807f5-04df-...
date_str = datetime.now().strftime('%Y-%m-%d')
conn = connect() if not dry_run else None
total = 0
# Bug 3 fix — purger les anciens chunks sans suffixe numérique (format pré-BE-5b)
if conn:
cur = conn.cursor()
cur.execute(
'DELETE FROM embeddings WHERE filepath LIKE ? AND filepath NOT LIKE ?',
(f'sessions/{sess_id}/%', f'sessions/{sess_id}/%/%'),
)
purged = cur.rowcount
if purged:
print(f' 🧹 {purged} anciens chunk(s) purgés (format pré-BE-5b)')
conn.commit()
for aspect in ('decisions', 'code', 'todos'):
limit = CHUNK_LIMITS[aspect]
if is_large:
print(f' 🧠 Distillation [{aspect}] (2-pass)...', end=' ', flush=True)
summary = summarize_2pass(messages, aspect)
else:
print(f' 🧠 Distillation [{aspect}]...', end=' ', flush=True)
summary = summarize(context, aspect)
if not summary or summary.strip().lower() in ('aucune', 'aucun', 'none', 'ninguno', 'ninguna', ''):
print('vide — ignoré')
continue
bullets = parse_bullets(summary)
if not bullets:
# Fallback : LLM n'a pas suivi le format — 1 chunk brut plutôt que perdre l'info
bullets = [summary.strip()]
# Filtrer les bullets "none" parasites (LLM met parfois "none:" au lieu du sentinel)
_none_words = {'none', 'aucune', 'aucun', 'ninguno', 'ninguna'}
bullets = [b for b in bullets
if b.strip().lower().split()[0].rstrip(':') not in _none_words]
bullets = bullets[:limit]
print(f'{len(bullets)} bullet(s)')
for i, bullet in enumerate(bullets):
filepath = f'sessions/{sess_id}/{aspect}/{i:02d}'
title = f'Session {date_str}{aspect} #{i+1:02d}'
chunk = {
'filepath': filepath,
'title': title,
'text': f'# {title}\n\nSource : {jsonl_path.name}\n\n- {bullet}',
'scope': SCOPE,
}
if dry_run:
print(f' [{aspect}/{i:02d}] {bullet[:100]}')
total += 1
continue
vector = get_embedding(chunk['text'])
if vector:
upsert_chunk(conn, chunk, vector)
conn.commit()
total += 1
else:
print(f'⚠️ embed échoué [{aspect}/{i:02d}] — stocké sans vecteur')
upsert_chunk(conn, chunk, None)
conn.commit()
if conn:
conn.close()
return total
# ── Helpers ───────────────────────────────────────────────────────────────────
def find_last_session() -> Path | None:
"""Trouve le .jsonl de la dernière session Claude dans ~/.claude/projects."""
jsonl_files = list(CLAUDE_SESSIONS_DIR.glob('**/*.jsonl'))
if not jsonl_files:
return None
return max(jsonl_files, key=lambda p: p.stat().st_mtime)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description='brain-engine distill — BE-5 session memory distillation'
)
parser.add_argument('session', nargs='?', type=Path,
help='Chemin vers le .jsonl de session Claude')
parser.add_argument('--last', action='store_true',
help='Distille la dernière session Claude automatiquement')
parser.add_argument('--dry-run', action='store_true',
help='Aperçu sans écriture dans brain.db')
args = parser.parse_args()
if args.last:
jsonl = find_last_session()
if not jsonl:
sys.exit('❌ Aucune session trouvée dans ~/.claude/projects/')
print(f'📌 Dernière session : {jsonl}')
elif args.session:
jsonl = args.session
else:
parser.print_help()
sys.exit(1)
mode = ' (dry-run)' if args.dry_run else ''
print(f'\n🔬 Distillation BE-5{mode}\n')
n = distill_session(jsonl, dry_run=args.dry_run)
if n == 0:
print('\n⚠️ Aucun chunk produit — session vide ou Ollama indisponible.')
sys.exit(2)
print(f'\n{n} chunk(s) distillé(s) → brain.db (scope: {SCOPE})')
if not args.dry_run:
print(' → brain_search "session précédente" pour retrouver ce contexte')
if __name__ == '__main__':
main()