Download src/memory/rag.py from Frankenstein-Labs/Cortex-0: direct link, hf CLI and curl.
- Browser
- Download file 9.37 kB
-
https://huggingface.co/Frankenstein-Labs/Cortex-0/resolve/main/src/memory/rag.py
- Command line
-
hf download hf://Frankenstein-Labs/Cortex-0/src/memory/rag.py
-
curl -L -o rag.py https://huggingface.co/Frankenstein-Labs/Cortex-0/resolve/main/src/memory/rag.py
9.37 kB
| """Phase 11 — mémoire / « apprendre des cours », écrit à la main. | |
| Deux modes, implémentés **séparément** : | |
| **Mode A — RAG (réaliste).** Les cours (texte ; et pour les vidéos/audio, leurs | |
| transcriptions + légendes de frames clés) sont découpés en passages, encodés en | |
| vecteurs et indexés dans une base vectorielle maison (numPy, similarité cosinus). | |
| À la question, on récupère les passages pertinents et on répond à partir d'eux. | |
| **Mode B — apprentissage permanent (EXPÉRIMENTAL ET INSTABLE).** Mise à jour des | |
| poids pendant l'« ingestion » d'un cours (quelques pas de gradient), avec un tampon | |
| de rejeu pour limiter l'oubli catastrophique. Voir `src/memory/permanent.py` et | |
| docs/architecture.md §10. On ne promet rien d'irréaliste : cela mémorise un texte | |
| court, ne remplace pas un vrai fine-tuning, et peut dégrader les autres capacités. | |
| Aucun modèle pré-entraîné, aucun service externe : embeddings par *feature hashing* | |
| déterministe (pur Python), ou embeddings du décodeur maison si l'on veut. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import re | |
| from dataclasses import dataclass, field | |
| import numpy as np | |
| _WORD_RE = re.compile(r"[\wàâäéèêëîïôöùûüç'-]+", re.UNICODE) | |
| _SENT_RE = re.compile(r"(?<=[.!?])\s+|\n+") | |
| # --------------------------------------------------------------------------- | |
| # Embeddings maison (feature hashing) — déterministe, sans entraînement | |
| # --------------------------------------------------------------------------- | |
| def _hash(word: str, dim: int) -> tuple[int, float]: | |
| """Hash stable d'un mot vers (index, signe) pour une projection aléatoire.""" | |
| h = int.from_bytes(hashlib.blake2b(word.encode("utf-8"), digest_size=8).digest(), "big") | |
| return h % dim, 1.0 if (h >> 63) & 1 else -1.0 | |
| def hashed_embed(text: str, dim: int = 256, idf: dict[str, float] | None = None) -> np.ndarray: | |
| """Vecteur L2-normalisé d'un texte par feature hashing (sac de mots signé). | |
| Aucun paramètre appris : deux textes proches lexicalement le sont aussi | |
| vectoriellement. Suffisant et honnête pour un RAG jouet à petite échelle. | |
| """ | |
| vec = np.zeros(dim, dtype=np.float32) | |
| for m in _WORD_RE.finditer(text.lower()): | |
| w = m.group(0) | |
| idx, sign = _hash(w, dim) | |
| weight = idf.get(w, 1.0) if idf else 1.0 | |
| vec[idx] += sign * weight | |
| norm = np.linalg.norm(vec) | |
| return vec / norm if norm > 0 else vec | |
| def compute_idf(texts: list[str]) -> dict[str, float]: | |
| n = max(1, len(texts)) | |
| df: dict[str, int] = {} | |
| for t in texts: | |
| for w in {m.group(0) for m in _WORD_RE.finditer(t.lower())}: | |
| df[w] = df.get(w, 0) + 1 | |
| return {w: float(np.log((1 + n) / (1 + c)) + 1.0) for w, c in df.items()} | |
| # --------------------------------------------------------------------------- | |
| # Base vectorielle maison (numpy) | |
| # --------------------------------------------------------------------------- | |
| class VectorIndex: | |
| """Index vectoriel par similarité cosinus (numPy), écrit à la main.""" | |
| def __init__(self, dim: int = 256): | |
| self.dim = dim | |
| self._vectors: np.ndarray | None = None | |
| def add(self, vectors: np.ndarray) -> None: | |
| v = np.asarray(vectors, dtype=np.float32) | |
| if v.ndim == 1: | |
| v = v[None, :] | |
| self._vectors = v if self._vectors is None else np.vstack([self._vectors, v]) | |
| def search(self, query: np.ndarray, k: int = 3) -> list[tuple[int, float]]: | |
| """Retourne ``(index, score)`` des ``k`` passages les plus proches.""" | |
| if self._vectors is None or len(self._vectors) == 0: | |
| return [] | |
| q = np.asarray(query, dtype=np.float32) | |
| q = q / (np.linalg.norm(q) + 1e-9) | |
| mat = self._vectors / (np.linalg.norm(self._vectors, axis=1, keepdims=True) + 1e-9) | |
| scores = mat @ q | |
| k = min(k, len(scores)) | |
| top = np.argsort(-scores)[:k] | |
| return [(int(i), float(scores[i])) for i in top] | |
| def __len__(self) -> int: | |
| return 0 if self._vectors is None else len(self._vectors) | |
| # --------------------------------------------------------------------------- | |
| # Découpage en passages | |
| # --------------------------------------------------------------------------- | |
| class Passage: | |
| text: str | |
| source: str = "manual" | |
| start: float = 0.0 # position (s) dans la vidéo/audio source, si applicable | |
| def chunk_text(text: str, max_words: int = 40, source: str = "manual") -> list[Passage]: | |
| """Découpe un texte en passages (phrases regroupées jusqu'à ``max_words``).""" | |
| sentences = [s.strip() for s in _SENT_RE.split(text) if s.strip()] | |
| passages: list[Passage] = [] | |
| buf: list[str] = [] | |
| count = 0 | |
| for s in sentences: | |
| n = len(_WORD_RE.findall(s)) | |
| if buf and count + n > max_words: | |
| passages.append(Passage(" ".join(buf), source=source)) | |
| buf, count = [], 0 | |
| buf.append(s) | |
| count += n | |
| if buf: | |
| passages.append(Passage(" ".join(buf), source=source)) | |
| return passages | |
| # --------------------------------------------------------------------------- | |
| # Mémoire de cours (mode A — RAG) | |
| # --------------------------------------------------------------------------- | |
| class LessonMemory: | |
| """Base RAG maison : ingestion de cours -> passages -> réponses extractives.""" | |
| dim: int = 256 | |
| max_words: int = 40 | |
| passages: list[Passage] = field(default_factory=list) | |
| index: VectorIndex = field(default_factory=VectorIndex) | |
| _idf: dict[str, float] = field(default_factory=dict) | |
| def __post_init__(self) -> None: | |
| if self.index.dim != self.dim: | |
| self.index = VectorIndex(self.dim) | |
| # -- ingestion --------------------------------------------------------- | |
| def add_text(self, text: str, source: str = "manual") -> None: | |
| for p in chunk_text(text, max_words=self.max_words, source=source): | |
| self.passages.append(p) | |
| self._reindex() | |
| def add_transcript(self, segments: list[tuple[float, str]], source: str = "video") -> None: | |
| """Ajoute une transcription horodatée (vidéo/audio) : ``[(t, texte), ...]``.""" | |
| for t, seg in segments: | |
| for p in chunk_text(seg, max_words=self.max_words, source=source): | |
| p.start = float(t) | |
| self.passages.append(p) | |
| self._reindex() | |
| def add_video(self, transcript: list[tuple[float, str]], | |
| frame_captions: list[tuple[float, str]] | None = None, | |
| source: str = "video") -> None: | |
| """Un « cours vidéo » = transcription + légendes de frames clés.""" | |
| self.add_transcript(transcript, source=source) | |
| if frame_captions: | |
| self.add_transcript(frame_captions, source=f"{source}:frames") | |
| def _reindex(self) -> None: | |
| texts = [p.text for p in self.passages] | |
| self._idf = compute_idf(texts) | |
| self.index = VectorIndex(self.dim) | |
| if texts: | |
| self.index.add(np.stack([hashed_embed(t, self.dim, self._idf) for t in texts])) | |
| # -- requête ----------------------------------------------------------- | |
| def retrieve(self, query: str, k: int = 3) -> list[tuple[Passage, float]]: | |
| qv = hashed_embed(query, self.dim, self._idf) | |
| return [(self.passages[i], s) for i, s in self.index.search(qv, k)] | |
| def answer(self, query: str, k: int = 3) -> dict: | |
| """Réponse **extractive** : renvoie le meilleur passage + les sources. | |
| Mode A réaliste : pas d'hallucination, la réponse est un passage réel du cours. | |
| La confiance est le score cosinus du meilleur passage. | |
| """ | |
| hits = self.retrieve(query, k=k) | |
| if not hits: | |
| return {"answer": "", "sources": [], "confidence": 0.0, "retrieved": []} | |
| best, score = hits[0] | |
| return { | |
| "answer": best.text, | |
| "sources": sorted({p.source for p, _ in hits}), | |
| "confidence": round(score, 4), | |
| "retrieved": [{"text": p.text, "source": p.source, "start": p.start, | |
| "score": round(s, 4)} for p, s in hits], | |
| } | |
| def answer_with_model(self, query: str, model, tokenizer, k: int = 3, | |
| max_new_tokens: int = 60) -> dict: | |
| """Réponse **générative** : le contexte récupéré est mis dans le prompt. | |
| Le modèle maison génère la réponse à partir des passages retrouvés. On | |
| renvoie aussi les passages (traçabilité) : si la génération est faible, la | |
| réponse extractive reste disponible via :meth:`answer`. | |
| """ | |
| from src.generate import generate_text | |
| retrieved = self.retrieve(query, k=k) | |
| context = " ".join(p.text for p, _ in retrieved) | |
| prompt = f"Contexte : {context}\nQuestion : {query}\nRéponse :" | |
| text = generate_text(model, tokenizer, prompt, max_new_tokens=max_new_tokens, | |
| greedy=True) | |
| return { | |
| "answer": text.split("Réponse :")[-1].strip(), | |
| "sources": sorted({p.source for p, _ in retrieved}), | |
| "confidence": round(retrieved[0][1], 4) if retrieved else 0.0, | |
| "retrieved": [{"text": p.text, "source": p.source, "score": round(s, 4)} | |
| for p, s in retrieved], | |
| } | |