File size: 20,247 Bytes
5f5e6b2
50fe1f5
 
5f5e6b2
50fe1f5
 
 
 
5f5e6b2
 
50fe1f5
 
5f5e6b2
50fe1f5
 
 
 
5f5e6b2
 
50fe1f5
5f5e6b2
 
50fe1f5
bf61a7b
50fe1f5
5f5e6b2
50fe1f5
5f5e6b2
 
bf61a7b
 
5f5e6b2
 
 
 
 
 
 
 
 
 
 
50fe1f5
 
 
5f5e6b2
50fe1f5
bf61a7b
5f5e6b2
 
 
50fe1f5
 
bf61a7b
 
 
 
 
 
 
 
 
 
 
 
 
 
50fe1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
 
 
 
50fe1f5
5f5e6b2
 
 
 
 
50fe1f5
5f5e6b2
 
 
50fe1f5
5f5e6b2
 
50fe1f5
5f5e6b2
50fe1f5
5f5e6b2
 
 
 
 
 
 
 
 
 
 
 
bf61a7b
 
 
 
 
 
5f5e6b2
 
 
 
 
 
bf61a7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
 
 
50fe1f5
5f5e6b2
 
 
 
50fe1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
 
50fe1f5
5f5e6b2
50fe1f5
5f5e6b2
 
50fe1f5
 
 
 
 
 
 
5f5e6b2
50fe1f5
5f5e6b2
 
50fe1f5
5f5e6b2
 
 
 
50fe1f5
 
 
 
 
 
5f5e6b2
 
 
 
50fe1f5
 
 
 
 
 
 
 
 
5f5e6b2
50fe1f5
 
bf61a7b
 
 
 
 
 
 
 
50fe1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
50fe1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
50fe1f5
 
 
 
5f5e6b2
50fe1f5
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
 
 
 
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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
"""
Ingest Training Data Script โ€” Version parallรฉlisรฉe
=====================================================

Trois phases :
  1. Extraction + chunking de tous les fichiers  (ThreadPoolExecutor, I/O + CPU)
  2. Embedding via Azure OpenAI                  (ThreadPoolExecutor, I/O pur โ†’ gros gain)
  3. Insertion bulk dans ChromaDB                (1 seul appel par tranche de 5000)

Usage:
    python ingest_train_data.py              # ingestion normale (skip si dรฉjร  fait)
    python ingest_train_data.py --reset      # supprime la collection et recommence

Paramรจtres de parallรฉlisme (ajuster selon les limites de l'API Azure) :
    EXTRACTION_WORKERS  : workers pour la lecture/parsing des PDFs
    EMBEDDING_WORKERS   : workers pour les appels HTTP ร  l'API d'embedding
    EMBEDDING_BATCH_SIZE: chunks par requรชte (max ~2048 pour Azure OpenAI)
"""

import argparse
import json
import logging
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import numpy as np

os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")

import requests as http_requests
import chromadb
from chromadb.config import Settings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pypdf import PdfReader

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Configuration test de la configuration
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

PROJECT_ROOT   = Path(__file__).parent
DATA_DIR       = Path("/data_sementic")       if Path("/data_sementic").is_dir()       else PROJECT_ROOT / "data_sementic"
TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data"

CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
COLLECTION_NAME    = "rag_documents"

# โ”€โ”€ Mรฉthode de chunking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# "semantic"   : coupe lร  oรน le sens change (recommandรฉ, nรฉcessite des appels embedding)
# "recursive"  : coupe par taille fixe (fallback, sans appel API supplรฉmentaire)
CHUNKING_METHOD = "semantic"

# Paramรจtres du semantic chunking
SEMANTIC_BREAKPOINT_PERCENTILE = 95   # coupe oรน la distance dรฉpasse le 95e percentile
SEMANTIC_BUFFER_SIZE           = 1    # phrases de contexte de chaque cรดtรฉ pour l'embedding
SEMANTIC_MIN_CHUNK_CHARS       = 150  # fusionne les chunks trop petits
SEMANTIC_MAX_CHUNK_CHARS       = 2000 # re-dรฉcoupe les chunks trop grands

# Paramรจtres du chunking rรฉcursif (fallback ou mรฉthode choisie)
CHUNK_SIZE    = 1000
CHUNK_OVERLAP = 150

# Parallรฉlisme
EMBEDDING_BATCH_SIZE = 64   # chunks par requรชte API (รฉtait 16)
EMBEDDING_WORKERS    = 6    # appels API simultanรฉs โ€” rรฉduire si rate-limit Azure
EXTRACTION_WORKERS   = 8    # workers pour l'extraction/parsing PDF

# Insertion ChromaDB par tranches pour รฉviter les problรจmes mรฉmoire
CHROMA_INSERT_BATCH  = 5_000

# Retry sur les appels embedding
EMBEDDING_MAX_RETRIES = 3
EMBEDDING_RETRY_DELAY = 2.0  # secondes entre deux tentatives

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Chargement de la config
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

_CONFIG_PATH = DATA_DIR / "config.json"
if not _CONFIG_PATH.exists():
    _CONFIG_PATH = PROJECT_ROOT / "config.json"
    logger.warning(f"Pas de config.json dans {DATA_DIR} โ€” utilisation de root config.json.")

with open(_CONFIG_PATH, encoding="utf-8") as _f:
    _config = json.load(_f)

EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"]
EMBEDDING_MODEL_NAME   = _config["embedding"]["model"]

AZURE_API_KEY = os.environ.get("AZURE_API_KEY")
if not AZURE_API_KEY:
    logger.error("AZURE_API_KEY n'est pas dรฉfini. Dรฉfinissez la variable d'environnement avant de lancer.")
    sys.exit(1)

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Helpers
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def extract_text_from_pdf(pdf_path: Path) -> str:
    reader = PdfReader(str(pdf_path))
    pages_text = []
    for page_num, page in enumerate(reader.pages, start=1):
        text = page.extract_text()
        if text and text.strip():
            pages_text.append(f"[Page {page_num}]\n{text.strip()}")
    return "\n\n".join(pages_text)


def chunk_text(text: str, source: str) -> list[dict]:
    if CHUNKING_METHOD == "semantic":
        return _semantic_chunk(text, source)
    return _recursive_chunk(text, source)


def _recursive_chunk(text: str, source: str) -> list[dict]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    chunks = splitter.split_text(text)
    return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(chunks)]


def _split_sentences(text: str) -> list[str]:
    """Dรฉcoupe le texte en phrases sur la ponctuation de fin."""
    parts = re.split(r'(?<=[.!?])\s+', text.strip())
    return [p.strip() for p in parts if len(p.strip()) > 10]


def _embed_in_batches(texts: list[str]) -> list[list[float]]:
    """Embedding par batches avec retry, compatible avec les gros documents."""
    all_embeddings: list = []
    for i in range(0, len(texts), EMBEDDING_BATCH_SIZE):
        batch = texts[i : i + EMBEDDING_BATCH_SIZE]
        for attempt in range(1, EMBEDDING_MAX_RETRIES + 1):
            try:
                all_embeddings.extend(generate_embeddings(batch))
                break
            except Exception as e:
                if attempt == EMBEDDING_MAX_RETRIES:
                    raise
                time.sleep(EMBEDDING_RETRY_DELAY)
    return all_embeddings


def _cosine_distance(a: list[float], b: list[float]) -> float:
    va, vb = np.array(a, dtype=np.float32), np.array(b, dtype=np.float32)
    return 1.0 - float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb) + 1e-10))


def _enforce_size_limits(chunks: list[str]) -> list[str]:
    """Fusionne les chunks trop courts, re-dรฉcoupe les trop longs."""
    # Fusion des chunks sous le minimum
    merged: list[str] = []
    buf = ""
    for chunk in chunks:
        if buf and len(buf) + len(chunk) < SEMANTIC_MIN_CHUNK_CHARS:
            buf += " " + chunk
        else:
            if buf:
                merged.append(buf)
            buf = chunk
    if buf:
        merged.append(buf)

    # Re-dรฉcoupage des chunks dรฉpassant le maximum
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=SEMANTIC_MAX_CHUNK_CHARS,
        chunk_overlap=50,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    result: list[str] = []
    for chunk in merged:
        if len(chunk) > SEMANTIC_MAX_CHUNK_CHARS:
            result.extend(splitter.split_text(chunk))
        else:
            result.append(chunk)
    return result


def _semantic_chunk(text: str, source: str) -> list[dict]:
    """
    Semantic chunking :
    1. Dรฉcoupe en phrases
    2. Crรฉe des fenรชtres de contexte (buffer de phrases adjacentes)
    3. Embed les fenรชtres pour mesurer les ruptures sรฉmantiques
    4. Coupe aux distances > percentile de seuil
    """
    sentences = _split_sentences(text)
    if len(sentences) <= 2:
        return _recursive_chunk(text, source)

    # Fenรชtres : chaque phrase + BUFFER phrases de contexte de chaque cรดtรฉ
    windows = []
    for i in range(len(sentences)):
        start = max(0, i - SEMANTIC_BUFFER_SIZE)
        end   = min(len(sentences), i + SEMANTIC_BUFFER_SIZE + 1)
        windows.append(" ".join(sentences[start:end]))

    try:
        embeddings = _embed_in_batches(windows)
    except Exception as e:
        logger.warning(f"Semantic chunking รฉchouรฉ pour {source} โ†’ fallback rรฉcursif : {e}")
        return _recursive_chunk(text, source)

    # Distance cosine entre fenรชtres consรฉcutives
    distances = [
        _cosine_distance(embeddings[i], embeddings[i + 1])
        for i in range(len(embeddings) - 1)
    ]

    # Points de coupure au-dessus du seuil percentile
    threshold   = float(np.percentile(distances, SEMANTIC_BREAKPOINT_PERCENTILE))
    breakpoints = {i + 1 for i, d in enumerate(distances) if d > threshold}

    # Regroupement des phrases en chunks
    raw_chunks: list[str] = []
    current: list[str] = []
    for i, sentence in enumerate(sentences):
        if i in breakpoints and current:
            raw_chunks.append(" ".join(current))
            current = [sentence]
        else:
            current.append(sentence)
    if current:
        raw_chunks.append(" ".join(current))

    final_chunks = _enforce_size_limits(raw_chunks)
    return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(final_chunks)]


def generate_embeddings(texts: list[str]) -> list[list[float]]:
    """Un seul appel HTTP โ€” appelรฉ en parallรจle par plusieurs workers."""
    headers = {"api-key": AZURE_API_KEY, "Content-Type": "application/json"}
    payload = {"input": texts, "model": EMBEDDING_MODEL_NAME}
    resp = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120)
    resp.raise_for_status()
    return [item["embedding"] for item in resp.json()["data"]]


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Workers (appelรฉs depuis les ThreadPoolExecutors)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _process_file(file_path: Path) -> tuple[str, list[dict]]:
    """Phase 1 โ€” Extrait et chunke un fichier. Retourne (source, chunks)."""
    source = str(file_path.relative_to(TRAIN_DATA_DIR))
    try:
        text = extract_text_from_pdf(file_path) if file_path.suffix.lower() == ".pdf" \
               else file_path.read_text(encoding="utf-8")
    except Exception as e:
        logger.warning(f"  Ignorรฉ (erreur lecture) {source}: {e}")
        return source, []
    if not text.strip():
        logger.warning(f"  Ignorรฉ (pas de texte) {source}")
        return source, []
    chunks = chunk_text(text, source=source)
    return source, chunks


def _embed_batch(batch_idx: int, texts: list[str]) -> tuple[int, list[list[float]]]:
    """Phase 2 โ€” Embedde un batch avec retry. Retourne (batch_idx, embeddings)."""
    for attempt in range(1, EMBEDDING_MAX_RETRIES + 1):
        try:
            return batch_idx, generate_embeddings(texts)
        except Exception as e:
            if attempt == EMBEDDING_MAX_RETRIES:
                logger.error(f"  Batch {batch_idx} รฉchouรฉ aprรจs {EMBEDDING_MAX_RETRIES} tentatives : {e}")
                raise
            logger.warning(f"  Batch {batch_idx} โ€” tentative {attempt} รฉchouรฉe ({e}), retry dans {EMBEDDING_RETRY_DELAY}s...")
            time.sleep(EMBEDDING_RETRY_DELAY)


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Main
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def main():
    parser = argparse.ArgumentParser(description="Ingestion parallรฉlisรฉe des documents d'entraรฎnement")
    parser.add_argument(
        "--reset", action="store_true",
        help="Supprime la collection ChromaDB existante avant de rรฉingรฉrer (nรฉcessaire aprรจs changement de chunking)",
    )
    args = parser.parse_args()

    if not TRAIN_DATA_DIR.exists():
        logger.error(f"Dossier train_data introuvable : {TRAIN_DATA_DIR}")
        sys.exit(1)

    # โ”€โ”€ ChromaDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    chroma_client = chromadb.PersistentClient(
        path=CHROMA_PERSIST_DIR,
        settings=Settings(anonymized_telemetry=False),
    )
    if args.reset:
        try:
            chroma_client.delete_collection(COLLECTION_NAME)
            logger.info("Collection supprimรฉe (--reset).")
        except Exception:
            pass
    collection = chroma_client.get_or_create_collection(
        name=COLLECTION_NAME,
        metadata={"hnsw:space": "cosine"},
    )
    if collection.count() > 0 and not args.reset:
        logger.info(f"Collection dรฉjร  peuplรฉe ({collection.count()} chunks). Utilisez --reset pour rรฉingรฉrer.")
        sys.exit(0)

    # โ”€โ”€ Dรฉcouverte des fichiers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    files = sorted(list(TRAIN_DATA_DIR.rglob("*.pdf")) + list(TRAIN_DATA_DIR.rglob("*.txt")))
    if not files:
        logger.error(f"Aucun fichier PDF/TXT trouvรฉ dans {TRAIN_DATA_DIR}")
        sys.exit(1)

    t_start = time.perf_counter()
    logger.info(f"{'='*60}")
    logger.info(f"Fichiers trouvรฉs    : {len(files)}")
    logger.info(f"Mรฉthode de chunking : {CHUNKING_METHOD.upper()}")
    if CHUNKING_METHOD == "semantic":
        logger.info(f"  Seuil percentile  : {SEMANTIC_BREAKPOINT_PERCENTILE}")
        logger.info(f"  Buffer phrases    : {SEMANTIC_BUFFER_SIZE}")
        logger.info(f"  Taille min/max    : {SEMANTIC_MIN_CHUNK_CHARS} / {SEMANTIC_MAX_CHUNK_CHARS} chars")
    else:
        logger.info(f"  Chunk size/overlap: {CHUNK_SIZE} / {CHUNK_OVERLAP}")
    logger.info(f"Batch size embedding: {EMBEDDING_BATCH_SIZE}")
    logger.info(f"Workers extraction  : {EXTRACTION_WORKERS}")
    logger.info(f"Workers embedding   : {EMBEDDING_WORKERS}")
    logger.info(f"{'='*60}")

    # โ”€โ”€ Phase 1 : Extraction + chunking en parallรจle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    logger.info(f"\nPhase 1/3 โ€” Extraction et chunking ({EXTRACTION_WORKERS} workers)...")
    all_chunks: list[dict] = []
    errors_extraction = 0

    with ThreadPoolExecutor(max_workers=EXTRACTION_WORKERS) as executor:
        futures = {executor.submit(_process_file, f): f for f in files}
        done = 0
        for future in as_completed(futures):
            done += 1
            try:
                source, chunks = future.result()
                all_chunks.extend(chunks)
                logger.info(f"  [{done}/{len(files)}] {source} โ†’ {len(chunks)} chunks")
            except Exception as e:
                errors_extraction += 1
                logger.error(f"  [{done}/{len(files)}] Erreur inattendue : {e}")

    t_phase1 = time.perf_counter() - t_start
    logger.info(f"Phase 1 terminรฉe en {t_phase1:.1f}s โ€” {len(all_chunks)} chunks ({errors_extraction} erreurs)")

    if not all_chunks:
        logger.error("Aucun chunk produit. Vรฉrifiez les fichiers dans train_data/.")
        sys.exit(1)

    # โ”€โ”€ Phase 2 : Embedding en parallรจle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    logger.info(f"\nPhase 2/3 โ€” Embedding ({len(all_chunks)} chunks, batch={EMBEDDING_BATCH_SIZE}, workers={EMBEDDING_WORKERS})...")

    texts  = [c["text"] for c in all_chunks]
    batches = [(i, texts[i : i + EMBEDDING_BATCH_SIZE]) for i in range(0, len(texts), EMBEDDING_BATCH_SIZE)]
    embeddings_map: dict[int, list] = {}
    errors_embedding = 0

    t_phase2_start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=EMBEDDING_WORKERS) as executor:
        futures = {executor.submit(_embed_batch, idx, batch): idx for idx, batch in batches}
        done = 0
        for future in as_completed(futures):
            done += 1
            try:
                batch_idx, embeddings = future.result()
                embeddings_map[batch_idx] = embeddings
            except Exception as e:
                errors_embedding += 1
                logger.error(f"  Batch รฉchouรฉ, {errors_embedding} erreurs totales")
            if done % 20 == 0 or done == len(batches):
                elapsed = time.perf_counter() - t_phase2_start
                rate = done / elapsed if elapsed > 0 else 0
                eta = (len(batches) - done) / rate if rate > 0 else 0
                logger.info(f"  {done}/{len(batches)} batches โ€” {elapsed:.0f}s รฉcoulรฉs, ETA ~{eta:.0f}s")

    t_phase2 = time.perf_counter() - t_phase2_start
    logger.info(f"Phase 2 terminรฉe en {t_phase2:.1f}s ({errors_embedding} erreurs)")

    if errors_embedding > 0:
        logger.warning(f"{errors_embedding} batches ont รฉchouรฉ โ€” les chunks correspondants seront absents de la base.")

    # Reconstruction dans l'ordre original
    flat_embeddings: list = []
    failed_indices: set[int] = set()
    valid_chunks: list[dict] = []

    for batch_start in range(0, len(texts), EMBEDDING_BATCH_SIZE):
        if batch_start in embeddings_map:
            flat_embeddings.extend(embeddings_map[batch_start])
            batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, len(all_chunks))
            valid_chunks.extend(all_chunks[batch_start:batch_end])
        else:
            failed_indices.add(batch_start)

    # โ”€โ”€ Phase 3 : Insertion bulk dans ChromaDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    logger.info(f"\nPhase 3/3 โ€” Insertion dans ChromaDB ({len(valid_chunks)} chunks)...")
    t_phase3_start = time.perf_counter()

    for i in range(0, len(valid_chunks), CHROMA_INSERT_BATCH):
        batch_chunks     = valid_chunks[i : i + CHROMA_INSERT_BATCH]
        batch_embeddings = flat_embeddings[i : i + CHROMA_INSERT_BATCH]
        collection.add(
            ids        = [f"doc_{i + j}" for j in range(len(batch_chunks))],
            embeddings = batch_embeddings,
            documents  = [c["text"]         for c in batch_chunks],
            metadatas  = [{"source": c["source"], "chunk_index": c["chunk_index"]} for c in batch_chunks],
        )
        logger.info(f"  Insรฉrรฉ {min(i + CHROMA_INSERT_BATCH, len(valid_chunks))}/{len(valid_chunks)} chunks")

    t_phase3 = time.perf_counter() - t_phase3_start
    t_total   = time.perf_counter() - t_start

    logger.info(f"\n{'='*60}")
    logger.info(f"Ingestion terminรฉe en {t_total:.1f}s ({t_total/60:.1f} min)")
    logger.info(f"  Phase 1 (extraction) : {t_phase1:.1f}s")
    logger.info(f"  Phase 2 (embedding)  : {t_phase2:.1f}s")
    logger.info(f"  Phase 3 (ChromaDB)   : {t_phase3:.1f}s")
    logger.info(f"Chunks dans la base    : {collection.count()}")
    logger.info(f"{'='*60}")


if __name__ == "__main__":
    main()