File size: 7,427 Bytes
7e2d97f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Ingestion OCR d'UN seul PDF (vision, GPU)
=========================================
Pour les PDF dont le texte est en vecteurs/images (non extractible) : on rend
chaque page en image, on lit le texte par OCR vision (EasyOCR, GPU si dispo),
puis on chunk + embed (Azure) + ajoute à la base ChromaDB.

Cible par défaut : le zonier MRH. Modifiable via argument :
    ./.venv/Scripts/python.exe ingest_ocr_single.py "MRH Train/xxx.pdf"

Prérequis :
    export AZURE_API_KEY="..."     # pour l'embedding
    pip install easyocr pymupdf    # déjà installé

Étapes :
  1) OCR -> écrit le texte dans _ocr_<nom>.txt (pour relecture)
  2) chunk + embed + ajout base (skip si déjà présent)
"""

import os
import sys
import json
import time
import logging
from pathlib import Path

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

import requests as http_requests
import fitz  # pymupdf
import chromadb
from chromadb.config import Settings
from langchain_text_splitters import RecursiveCharacterTextSplitter

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

# --- Config (identique au reste) ---
PROJECT_ROOT = Path(__file__).parent
DATA_DIR = Path("/data") if Path("/data").is_dir() else PROJECT_ROOT / "data"
TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data"
COLLECTION_NAME = "rag_documents"
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50
EMBEDDING_BATCH_SIZE = 16
REQUEST_TIMEOUT = 180
MAX_RETRIES = 5
OCR_DPI = 200  # qualité de rendu pour l'OCR

DEFAULT_TARGET = "MRH Train/Elaboration de zoniers en assurance MRH à partir de l'open data.pdf"

_CONFIG_PATH = DATA_DIR / "config.json"
if not _CONFIG_PATH.exists():
    _CONFIG_PATH = PROJECT_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"]


def ocr_pdf(pdf_path: Path) -> str:
    """Rend chaque page en image et lit le texte par OCR vision (EasyOCR)."""
    import easyocr
    import numpy as np

    # gpu=True -> utilise CUDA si dispo, sinon retombe sur CPU automatiquement
    try:
        import torch
        use_gpu = torch.cuda.is_available()
        logger.info(f"GPU CUDA disponible : {use_gpu}"
                    + (f" ({torch.cuda.get_device_name(0)})" if use_gpu else " -> OCR sur CPU"))
    except Exception:
        use_gpu = False
        logger.info("torch non détecté pour la vérif GPU -> EasyOCR choisira.")

    reader = easyocr.Reader(["fr"], gpu=use_gpu)
    doc = fitz.open(str(pdf_path))
    n = len(doc)
    pages_text = []
    for i in range(n):
        pix = doc[i].get_pixmap(dpi=OCR_DPI)
        img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
        if pix.n == 4:  # RGBA -> RGB
            img = img[:, :, :3]
        lines = reader.readtext(img, detail=0, paragraph=True)
        txt = "\n".join(lines).strip()
        if txt:
            pages_text.append(f"[Page {i+1}]\n{txt}")
        if (i + 1) % 10 == 0 or i + 1 == n:
            logger.info(f"  OCR page {i+1}/{n}")
    return "\n\n".join(pages_text)


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


def generate_embeddings(texts, api_key):
    headers = {"api-key": api_key, "Content-Type": "application/json"}
    payload = {"input": texts, "model": EMBEDDING_MODEL_NAME}
    last = None
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            r = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
            r.raise_for_status()
            return [d["embedding"] for d in r.json()["data"]]
        except Exception as e:
            last = e
            w = 2 ** attempt
            logger.warning(f"    embedding tentative {attempt}/{MAX_RETRIES} KO ({type(e).__name__}); retry {w}s")
            time.sleep(w)
    raise RuntimeError(f"Embedding échoué: {last}")


def main():
    rel = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_TARGET
    rel = rel.replace("/", os.sep)
    pdf_path = TRAIN_DATA_DIR / rel
    if not pdf_path.exists():
        logger.error(f"Introuvable: {pdf_path}")
        sys.exit(1)

    source = str(pdf_path.relative_to(TRAIN_DATA_DIR))
    logger.info(f"Cible: {source}")

    client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma_db"), settings=Settings(anonymized_telemetry=False))
    col = client.get_or_create_collection(name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"})

    # déjà présent ? (évite les doublons)
    existing = col.get(where={"source": source}, limit=1)
    if existing["ids"]:
        logger.warning(f"'{source}' est DÉJÀ dans la base. Rien à faire (relance après suppression si tu veux le refaire).")
        return

    # 1) OCR — réutilise le texte déjà OCRisé si présent (évite de refaire ~20 min d'OCR)
    out_txt = PROJECT_ROOT / ("_ocr_" + pdf_path.stem[:40] + ".txt")
    if out_txt.exists() and len(out_txt.read_text(encoding="utf-8").strip()) > 100:
        text = out_txt.read_text(encoding="utf-8")
        logger.info(f"=== ÉTAPE 1 : OCR déjà fait -> rechargé depuis {out_txt.name} ({len(text)} caractères) ===")
    else:
        logger.info("=== ÉTAPE 1 : OCR (vision) ===")
        text = ocr_pdf(pdf_path)
        out_txt.write_text(text, encoding="utf-8")
        logger.info(f"Texte OCR: {len(text)} caractères -> sauvegardé dans {out_txt.name} (relis-le si tu veux)")
    if len(text.strip()) < 100:
        logger.error("Très peu de texte OCR récupéré — on s'arrête. Vérifie le PDF / le moteur OCR.")
        return

    # 2) chunk + embed + add (besoin de la clé Azure ici)
    api_key = os.environ.get("AZURE_API_KEY")
    if not api_key:
        logger.error("AZURE_API_KEY non défini -> OCR fait (texte sauvé) mais embedding impossible. "
                     "Exporte la clé et relance pour finir l'ajout.")
        return

    logger.info("=== ÉTAPE 2 : chunk + embedding + ajout base ===")
    chunks = chunk_text(text, source=source)
    texts = [c["text"] for c in chunks]
    logger.info(f"{len(chunks)} chunks à embedder")

    embeddings = []
    for i in range(0, len(texts), EMBEDDING_BATCH_SIZE):
        batch = texts[i:i + EMBEDDING_BATCH_SIZE]
        logger.info(f"  batch {i // EMBEDDING_BATCH_SIZE + 1}/{(len(texts)-1)//EMBEDDING_BATCH_SIZE + 1}")
        embeddings.extend(generate_embeddings(batch, api_key))
        time.sleep(0.3)

    # IDs déterministes ET anti-collision : count() n'est PAS fiable après des suppressions
    # (le plus grand doc_N peut dépasser count()). On préfixe par la source.
    ids = [f"{source}#ocr#{i}" for i in range(len(chunks))]
    metas = [{"source": c["source"], "chunk_index": c["chunk_index"]} for c in chunks]
    col.add(ids=ids, embeddings=embeddings, documents=texts, metadatas=metas)
    logger.info("=" * 60)
    logger.info(f"AJOUTÉ : {len(chunks)} chunks pour '{source}'")
    logger.info(f"Total base : {col.count()} chunks")


if __name__ == "__main__":
    main()