File size: 8,186 Bytes
6af9658
 
 
 
f3e893e
6af9658
 
f3e893e
6af9658
 
 
71d248c
f3e893e
6af9658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71d248c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1beda8
 
3bb03ce
71d248c
 
 
 
 
 
 
 
 
49cf547
71d248c
 
 
 
 
 
 
49cf547
71d248c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49cf547
71d248c
 
6af9658
 
 
 
 
 
 
 
 
dd91d53
6af9658
 
 
 
 
dd91d53
6af9658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3e893e
6af9658
 
 
 
 
 
 
 
 
 
 
 
 
f3e893e
6af9658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3e893e
6af9658
 
f3e893e
6af9658
 
 
f3e893e
6af9658
f3e893e
6af9658
 
f3e893e
6af9658
 
 
 
 
 
f3e893e
6af9658
 
 
 
f3e893e
6af9658
f3e893e
6af9658
 
 
f3e893e
6af9658
 
 
 
 
 
 
 
 
 
 
f3e893e
6af9658
f3e893e
6af9658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3e893e
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
"""Download and manage the FAISS index + SQLite chunk storage from HuggingFace Hub."""

import logging
import os
import shutil
import sqlite3
import threading
from datetime import UTC, datetime
from pathlib import Path

import faiss
import numpy as np
from huggingface_hub import HfApi, hf_hub_download

logger = logging.getLogger(__name__)

HF_USERNAME = os.environ.get("HF_USERNAME", "NedAktovOps")
HF_DATASET = os.environ.get("HF_DATASET", "eurlex-chat-data")
HF_TOKEN = os.environ.get("HF_TOKEN", None)
BACKUP_DATASET = f"{HF_USERNAME}/eurlex-chat-backups"

REPO_ID = f"{HF_USERNAME}/{HF_DATASET}"

BACKUP_FILES = ["index.faiss", "chunks.db", "build_meta.json", "last_updated.txt"]
DATA_DIR = Path(__file__).parent.parent / "data"

_index_data = {
    "index": None,
    "conn": None,
    "lock": threading.Lock(),
    "size": 0,
    "ntotal": 0,
    "last_updated": None,
    "loaded_at": None,
}


class EURLEXEmbedder:
    """768-dim legal embeddings via ONNX Runtime for EURLEX-BERT.

    Loads quantized ONNX model + tokenizer on first use.
    Used for query encoding at runtime (not for bulk index building).
    """

    def __init__(self, model_name: str = "nlpaueb/bert-base-uncased-eurlex"):
        self.model_name = model_name
        self._tokenizer = None
        self._session = None
        self._dim = 768

    def _load(self):
        """Lazy-load tokenizer and ONNX session."""
        if self._session is not None:
            return

        import onnxruntime as ort
        from transformers import AutoTokenizer

        # Try local ONNX model first, fall back to PyTorch
        local_path = os.path.join(os.path.dirname(__file__), "..", "data", "eurlex-bert-onnx", "model.quant.onnx")

        if os.path.exists(local_path):
            model_path = local_path
        else:
            from huggingface_hub import hf_hub_download
            model_path = hf_hub_download(
                repo_id="NedAktovOps/eurlex-chat-data",
                filename="onnx_models/eurlex-bert/model.quant.onnx",
                repo_type="dataset",
                token=HF_TOKEN,
            )

        self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
        self._session = ort.InferenceSession(
            model_path,
            providers=["CPUExecutionProvider"],
        )
        logger.info(f"EURLEXEmbedder loaded: {self.model_name} ({self._dim}-dim, model={model_path})")

    def encode(self, texts: list[str], batch_size: int = 32, **kwargs) -> np.ndarray:
        """Encode texts to 768-dim embeddings.

        Args:
            texts: List of text strings to encode
            batch_size: Inference batch size (default 32)

        Returns:
            Numpy array of embeddings, shape (len(texts), 768)
        """
        self._load()

        all_embeddings = []
        for i in range(0, len(texts), batch_size):
            batch = texts[i:i + batch_size]
            encoded = self._tokenizer(
                batch, padding=True, truncation=True,
                max_length=512, return_tensors="np",
            )

            feed = {
                "input_ids": encoded["input_ids"],
                "attention_mask": encoded["attention_mask"],
            }
            if "token_type_ids" in encoded:
                feed["token_type_ids"] = encoded["token_type_ids"]

            outputs = self._session.run(None, feed)[0]

            # Mean pooling
            mask = encoded["attention_mask"][:, :, None].astype(outputs.dtype)
            mask_sum = mask.sum(axis=1)
            embeddings = (outputs * mask).sum(axis=1) / np.maximum(mask_sum, 1e-9)

            # L2 normalize
            norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
            embeddings = embeddings / np.maximum(norms, 1e-9)

            all_embeddings.append(embeddings)

        return np.vstack(all_embeddings).astype(np.float32)


def download_index(index_suffix=""):
    """Download index files from HF Hub. Supports suffix for EURLEX-BERT (set INDEX_SUFFIX env var)."""
    suffix = index_suffix or os.environ.get("INDEX_SUFFIX", "")
    index_file = f"index{suffix}.faiss"
    db_file = f"chunks{suffix}.db"
    logger.info(f"Downloading index from {REPO_ID} (files: {index_file}, {db_file})...")
    try:
        index_path = hf_hub_download(
            repo_id=REPO_ID,
            filename=index_file,
            repo_type="dataset",
            token=HF_TOKEN,
        )
        db_path = hf_hub_download(
            repo_id=REPO_ID,
            filename=db_file,
            repo_type="dataset",
            token=HF_TOKEN,
        )
    except Exception as e:
        logger.error(f"Failed to download from HF Hub: {e}")
        raise

    index = faiss.read_index(index_path)
    conn = sqlite3.connect(db_path, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA query_only = 1")
    conn.execute("PRAGMA temp_store = MEMORY")

    cursor = conn.execute("SELECT COUNT(*) AS cnt FROM chunks")
    size = cursor.fetchone()["cnt"]

    _index_data["index"] = index
    _index_data["conn"] = conn
    _index_data["lock"] = threading.Lock()
    _index_data["size"] = size
    _index_data["ntotal"] = index.ntotal
    _index_data["last_updated"] = _get_last_updated()
    _index_data["loaded_at"] = datetime.now(UTC).isoformat()

    logger.info(f"Index loaded: {index.ntotal} vectors, {size} chunks")
    return _index_data


def _get_last_updated():
    try:
        ts_path = hf_hub_download(
            repo_id=REPO_ID,
            filename="last_updated.txt",
            repo_type="dataset",
            token=HF_TOKEN,
        )
        with open(ts_path) as f:
            return f.read().strip()
    except Exception:
        return None


def check_for_updates():
    current_remote = _get_last_updated()
    if current_remote and current_remote != _index_data["last_updated"]:
        logger.info(f"Remote index updated: {current_remote}")
        return True
    return False


def create_backup():
    """Create a local backup of current index data before refreshing.

    Copies current data files to a timestamped backup directory.
    If HF_TOKEN is set, also uploads to HuggingFace Hub backup dataset.

    Returns:
        Path to the backup directory, or None if backup failed.
    """
    timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
    backup_dir = DATA_DIR / f"backup-{timestamp}"

    try:
        os.makedirs(backup_dir, exist_ok=True)

        copied = []
        for f in BACKUP_FILES:
            src = DATA_DIR / f
            if src.exists():
                shutil.copy2(src, backup_dir / f)
                copied.append(f)

        if not copied:
            logger.warning("No data files found to backup")
            shutil.rmtree(backup_dir, ignore_errors=True)
            return None

        logger.info(f"Local backup created at {backup_dir}: {', '.join(copied)}")

        # Attempt to upload to HuggingFace Hub if token is available
        if HF_TOKEN:
            try:
                branch = f"backup-{datetime.now(UTC).strftime('%Y%m%d')}"
                api = HfApi(token=HF_TOKEN)
                api.upload_folder(
                    folder_path=str(backup_dir),
                    repo_id=BACKUP_DATASET,
                    repo_type="dataset",
                    revision=branch,
                    create_pr=False,
                )
                logger.info(f"Remote backup saved to {BACKUP_DATASET}@{branch}")
            except Exception as e:
                logger.warning(f"Remote backup failed (local backup still exists): {e}")

        return backup_dir

    except Exception as e:
        logger.error(f"Backup failed: {e}")
        # Clean up partial backup
        shutil.rmtree(backup_dir, ignore_errors=True)
        return None


def reload_index():
    conn = _index_data.get("conn")
    if conn:
        conn.close()
    return download_index()


def get_index():
    return _index_data


def get_stats():
    data = get_index()
    return {
        "vectors": data["ntotal"],
        "size": data["size"],
        "last_updated": data["last_updated"],
        "loaded_at": data["loaded_at"],
    }