- 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
191 lines
6.7 KiB
Python
191 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
brain-engine/rag.py — Couche RAG BE-3a
|
|
Enrichit le contexte Claude au boot avec des chunks additifs (non redondants avec helloWorld).
|
|
|
|
Usage :
|
|
python3 brain-engine/rag.py → boot queries (3 ciblées, skip helloWorld)
|
|
python3 brain-engine/rag.py "query custom" → query ad-hoc (compact)
|
|
python3 brain-engine/rag.py "query" --full → chunks complets
|
|
python3 brain-engine/rag.py --json → JSON brut (boot)
|
|
python3 brain-engine/rag.py "query" --json → JSON brut (ad-hoc)
|
|
python3 brain-engine/rag.py "query" --top 10 → top-10 résultats
|
|
|
|
Output : bloc markdown prêt à injection dans le contexte Claude.
|
|
Silencieux si aucun résultat ou Ollama indisponible (exit 0).
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
# Import search depuis le même répertoire
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from search import search as semantic_search
|
|
|
|
|
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
|
|
|
# Fichiers déjà chargés par helloWorld — ignorés dans les résultats boot
|
|
# pour éviter de dupliquer le contexte déjà présent.
|
|
HELLOWORLD_SKIP = frozenset({
|
|
'focus.md',
|
|
'KERNEL.md',
|
|
'BRAIN-INDEX.md',
|
|
'agents/helloWorld.md',
|
|
'agents/secrets-guardian.md',
|
|
'agents/coach.md',
|
|
'profil/collaboration.md',
|
|
})
|
|
|
|
# Queries ciblées au boot — surface ce qu'helloWorld ne charge pas.
|
|
# Chaque tuple : (query, top_k)
|
|
RAG_BOOT_QUERIES = [
|
|
("décisions architecturales récentes", 3), # ADRs, choix archi
|
|
("todos prioritaires backlog actif", 3), # todo/*.md au-delà du README
|
|
("sprint en cours workspace actif", 2), # workspace/shadow-*/
|
|
]
|
|
|
|
# Seuil minimum au boot — évite le bruit des chunks peu pertinents
|
|
BOOT_MIN_SCORE = 0.30
|
|
|
|
|
|
# ── Core ───────────────────────────────────────────────────────────────────────
|
|
|
|
def run_boot_queries(allowed_scopes: list[str] | None = None) -> list[dict]:
|
|
"""
|
|
Exécute les 3 queries boot en séquence.
|
|
Déduplique par filepath, filtre les fichiers helloWorld.
|
|
Conserve la query source dans le champ '_query' pour le formatage.
|
|
"""
|
|
seen_filepaths: set[str] = set()
|
|
results: list[dict] = []
|
|
|
|
for query, top_k in RAG_BOOT_QUERIES:
|
|
hits = semantic_search(query, top_k=top_k, min_score=BOOT_MIN_SCORE,
|
|
allowed_scopes=allowed_scopes)
|
|
for hit in hits:
|
|
fp = hit['filepath']
|
|
if fp in HELLOWORLD_SKIP:
|
|
continue
|
|
if fp in seen_filepaths:
|
|
continue
|
|
seen_filepaths.add(fp)
|
|
results.append({**hit, '_query': query})
|
|
|
|
return results
|
|
|
|
|
|
def run_single_query(query: str, top_k: int = 5,
|
|
allowed_scopes: list[str] | None = None) -> list[dict]:
|
|
"""Query ad-hoc — pas de skip helloWorld, pas de déduplication inter-queries."""
|
|
hits = semantic_search(query, top_k=top_k, min_score=0.0,
|
|
allowed_scopes=allowed_scopes)
|
|
return [{**h, '_query': query} for h in hits]
|
|
|
|
|
|
# ── Formatage ──────────────────────────────────────────────────────────────────
|
|
|
|
def format_compact(results: list[dict], label: str = 'RAG boot') -> str:
|
|
"""
|
|
Format A (défaut) — filepath + extrait de 120 chars.
|
|
~100 tokens par chunk, lean pour injection boot.
|
|
"""
|
|
if not results:
|
|
return ''
|
|
|
|
lines = [f'## Brain context ({label})\n']
|
|
current_query: str | None = None
|
|
|
|
for r in results:
|
|
q = r.get('_query', '')
|
|
if q and q != current_query:
|
|
current_query = q
|
|
lines.append(f'\n### {q}\n')
|
|
|
|
fp = r['filepath']
|
|
score = r['score']
|
|
title = r.get('title') or ''
|
|
excerpt = r['chunk_text'].replace('\n', ' ')[:120].strip()
|
|
if title:
|
|
excerpt = f'[{title}] {excerpt}'
|
|
|
|
lines.append(f'- `{fp}` *(score: {score:.2f})* — {excerpt}…\n')
|
|
|
|
return ''.join(lines)
|
|
|
|
|
|
def format_full(results: list[dict], label: str = 'RAG — full') -> str:
|
|
"""
|
|
Format B (--full) — chunks complets.
|
|
Pour queries ad-hoc profondes où l'extrait est insuffisant.
|
|
"""
|
|
if not results:
|
|
return ''
|
|
|
|
lines = [f'## Brain context ({label})\n']
|
|
for r in results:
|
|
fp = r['filepath']
|
|
score = r['score']
|
|
title = r.get('title') or ''
|
|
chunk = r['chunk_text']
|
|
|
|
header = f'### `{fp}`'
|
|
if title:
|
|
header += f' — {title}'
|
|
header += f' *(score: {score:.2f})*'
|
|
|
|
lines.append(f'\n{header}\n\n{chunk}\n')
|
|
|
|
return ''.join(lines)
|
|
|
|
|
|
def format_json(results: list[dict]) -> str:
|
|
out = [{
|
|
'score': round(r['score'], 4),
|
|
'filepath': r['filepath'],
|
|
'title': r.get('title') or '',
|
|
'chunk_text': r['chunk_text'],
|
|
'query': r.get('_query', ''),
|
|
} for r in results]
|
|
return json.dumps(out, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ── CLI ────────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='brain-engine RAG — BE-3a')
|
|
parser.add_argument('query', nargs='?',
|
|
help='Query ad-hoc (sans arg = mode boot)')
|
|
parser.add_argument('--full', action='store_true',
|
|
help='Chunks complets (défaut: compact)')
|
|
parser.add_argument('--top', type=int, default=5,
|
|
help='Top-K pour query ad-hoc (défaut: 5)')
|
|
parser.add_argument('--json', action='store_true',
|
|
help='Output JSON brut')
|
|
args = parser.parse_args()
|
|
|
|
# Mode boot si aucune query fournie
|
|
if not args.query:
|
|
results = run_boot_queries()
|
|
label = 'RAG boot'
|
|
else:
|
|
results = run_single_query(args.query, top_k=args.top)
|
|
label = f'RAG — {args.query}'
|
|
|
|
# Silencieux si aucun résultat — ne pas polluer le contexte
|
|
if not results:
|
|
sys.exit(0)
|
|
|
|
if args.json:
|
|
print(format_json(results))
|
|
elif args.full:
|
|
print(format_full(results, label=label))
|
|
else:
|
|
print(format_compact(results, label=label))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|