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],
        }