File size: 9,374 Bytes
850e745 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """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
# ---------------------------------------------------------------------------
@dataclass
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)
# ---------------------------------------------------------------------------
@dataclass
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],
}
|