chouchouvs commited on
Commit
80f110c
·
verified ·
1 Parent(s): 96a2024

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +558 -0
main.py ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ HF Space - main.py de substitution pour tests Qdrant / indexation minimale
4
+
5
+ Fonctions clés :
6
+ - POST /wipe?project_id=XXX : supprime la collection Qdrant
7
+ - POST /index : lance un job d'indexation (JSON files=[{path,text},...])
8
+ - GET /status/{job_id} : état du job + logs
9
+ - GET /collections/{proj}/count : retourne le nombre de points dans Qdrant
10
+ - POST /query : recherche sémantique (top_k, text, project_id)
11
+
12
+ Une UI Gradio minimale est montée sur "/" pour déclencher les tests sans console.
13
+
14
+ ENV attendues :
15
+ - QDRANT_URL : ex. https://xxxxx.eu-central-1-0.aws.cloud.qdrant.io:6333
16
+ - QDRANT_API_KEY : clé Qdrant Cloud
17
+ - COLLECTION_PREFIX : défaut "proj_"
18
+ - EMB_PROVIDER : "hf" (défaut) ou "dummy"
19
+ - HF_EMBED_MODEL : défaut "BAAI/bge-m3"
20
+ - HUGGINGFACEHUB_API_TOKEN : token HF Inference (si EMB_PROVIDER=hf)
21
+ - LOG_LEVEL : DEBUG (défaut), INFO...
22
+
23
+ Dépendances (requirements) suggérées :
24
+ fastapi>=0.111
25
+ uvicorn>=0.30
26
+ httpx>=0.27
27
+ pydantic>=2.7
28
+ gradio>=4.43
29
+ numpy>=2.0
30
+ """
31
+
32
+ from __future__ import annotations
33
+ import os
34
+ import time
35
+ import uuid
36
+ import math
37
+ import json
38
+ import hashlib
39
+ import logging
40
+ import asyncio
41
+ from typing import List, Dict, Any, Optional, Tuple
42
+
43
+ import numpy as np
44
+ import httpx
45
+ from pydantic import BaseModel, Field, ValidationError
46
+ from fastapi import FastAPI, HTTPException, Query
47
+ from fastapi.middleware.cors import CORSMiddleware
48
+
49
+ import gradio as gr
50
+
51
+ # ------------------------------------------------------------------------------
52
+ # Configuration & logs
53
+ # ------------------------------------------------------------------------------
54
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
55
+ logging.basicConfig(
56
+ level=getattr(logging, LOG_LEVEL, logging.DEBUG),
57
+ format="%(asctime)s - %(levelname)s - %(message)s",
58
+ )
59
+ LOG = logging.getLogger("remote_indexer_min")
60
+
61
+ QDRANT_URL = os.getenv("QDRANT_URL", "").rstrip("/")
62
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", "")
63
+ COLLECTION_PREFIX = os.getenv("COLLECTION_PREFIX", "proj_").strip() or "proj_"
64
+
65
+ EMB_PROVIDER = os.getenv("EMB_PROVIDER", "hf").lower() # "hf" | "dummy"
66
+ HF_EMBED_MODEL = os.getenv("HF_EMBED_MODEL", "BAAI/bge-m3")
67
+ HF_TOKEN = os.getenv("HUGGINGFACEHUB_API_TOKEN", "")
68
+
69
+ if not QDRANT_URL or not QDRANT_API_KEY:
70
+ LOG.warning("QDRANT_URL / QDRANT_API_KEY non fournis : l'upsert échouera. Fournis-les dans les Secrets du Space.")
71
+
72
+ if EMB_PROVIDER == "hf" and not HF_TOKEN:
73
+ LOG.warning("EMB_PROVIDER=hf mais HUGGINGFACEHUB_API_TOKEN absent. Tu peux basculer EMB_PROVIDER=dummy pour tester sans token.")
74
+
75
+ # ------------------------------------------------------------------------------
76
+ # Schémas Pydantic
77
+ # ------------------------------------------------------------------------------
78
+ class FileItem(BaseModel):
79
+ path: str
80
+ text: str
81
+
82
+ class IndexRequest(BaseModel):
83
+ project_id: str = Field(..., min_length=1)
84
+ files: List<FileItem] = Field(default_factory=list)
85
+ chunk_size: int = Field(200, ge=64, le=4096)
86
+ overlap: int = Field(20, ge=0, le=512)
87
+ batch_size: int = Field(32, ge=1, le=1024)
88
+ store_text: bool = True
89
+
90
+ class QueryRequest(BaseModel):
91
+ project_id: str
92
+ text: str
93
+ top_k: int = Field(5, ge=1, le=100)
94
+
95
+ # ------------------------------------------------------------------------------
96
+ # Job store (en mémoire)
97
+ # ------------------------------------------------------------------------------
98
+ class JobState(BaseModel):
99
+ job_id: str
100
+ project_id: str
101
+ stage: str = "pending" # pending -> embedding -> upserting -> done/failed
102
+ total_files: int = 0
103
+ total_chunks: int = 0
104
+ embedded: int = 0
105
+ upserted: int = 0
106
+ errors: List[str] = Field(default_factory=list)
107
+ messages: List[str] = Field(default_factory=list)
108
+ started_at: float = Field(default_factory=time.time)
109
+ finished_at: Optional[float] = None
110
+
111
+ def log(self, msg: str) -> None:
112
+ stamp = time.strftime("%H:%M:%S")
113
+ line = f"[{stamp}] {msg}"
114
+ self.messages.append(line)
115
+ LOG.debug(f"[{self.job_id}] {msg}")
116
+
117
+ JOBS: Dict[str, JobState] = {}
118
+
119
+ # ------------------------------------------------------------------------------
120
+ # Utilitaires
121
+ # ------------------------------------------------------------------------------
122
+ def hash8(s: str) -> str:
123
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
124
+
125
+ def l2_normalize(vec: List[float]) -> List[float]:
126
+ arr = np.array(vec, dtype=np.float32)
127
+ n = float(np.linalg.norm(arr))
128
+ if n > 0:
129
+ arr = arr / n
130
+ return arr.astype(np.float32).tolist()
131
+
132
+ def flatten_any(x: Any) -> List[float]:
133
+ """
134
+ Certaines APIs renvoient [[...]] ou [[[...]]]; on aplanit en 1D.
135
+ """
136
+ if isinstance(x, (list, tuple)):
137
+ if len(x) > 0 and isinstance(x[0], (list, tuple)):
138
+ # Aplanit récursif
139
+ return flatten_any(x[0])
140
+ return list(map(float, x))
141
+ raise ValueError("Embedding vector mal formé")
142
+
143
+ def chunk_text(text: str, chunk_size: int, overlap: int) -> List[Tuple[int, int, str]]:
144
+ """
145
+ Retourne une liste de (start, end, chunk_text)
146
+ Ignore les petits fragments (< 30 chars) pour éviter le bruit.
147
+ """
148
+ text = text or ""
149
+ if not text.strip():
150
+ return []
151
+ res = []
152
+ n = len(text)
153
+ i = 0
154
+ while i < n:
155
+ j = min(i + chunk_size, n)
156
+ chunk = text[i:j]
157
+ if len(chunk.strip()) >= 30:
158
+ res.append((i, j, chunk))
159
+ i = j - overlap
160
+ if i <= 0:
161
+ i = j
162
+ return res
163
+
164
+ async def ensure_collection(client: httpx.AsyncClient, coll: str, vector_size: int) -> None:
165
+ """
166
+ Crée ou ajuste la collection Qdrant (distance = Cosine).
167
+ """
168
+ url = f"{QDRANT_URL}/collections/{coll}"
169
+ # Vérifie l'existence
170
+ r = await client.get(url, headers={"api-key": QDRANT_API_KEY}, timeout=20)
171
+ if r.status_code == 200:
172
+ # Optionnel: vérifier la taille du vecteur ; si mismatch, on peut supprimer/recréer
173
+ data = r.json()
174
+ existing_size = data.get("result", {}).get("vectors", {}).get("size")
175
+ if existing_size and int(existing_size) != int(vector_size):
176
+ LOG.warning(f"Collection {coll} dim={existing_size} ≠ attendu {vector_size} → recréation")
177
+ await client.delete(url, headers={"api-key": QDRANT_API_KEY}, timeout=20)
178
+ else:
179
+ LOG.debug(f"Collection {coll} déjà prête (dim={existing_size})")
180
+ # (Re)création
181
+ body = {
182
+ "vectors": {"size": vector_size, "distance": "Cosine"}
183
+ }
184
+ r2 = await client.put(url, headers={"api-key": QDRANT_API_KEY}, json=body, timeout=30)
185
+ if r2.status_code not in (200, 201):
186
+ raise HTTPException(status_code=500, detail=f"Qdrant PUT collection a échoué: {r2.text}")
187
+
188
+ async def qdrant_upsert(
189
+ client: httpx.AsyncClient,
190
+ coll: str,
191
+ points: List[Dict[str, Any]],
192
+ ) -> int:
193
+ if not points:
194
+ return 0
195
+ url = f"{QDRANT_URL}/collections/{coll}/points?wait=true"
196
+ body = {"points": points}
197
+ r = await client.put(url, headers={"api-key": QDRANT_API_KEY}, json=body, timeout=60)
198
+ if r.status_code not in (200, 202):
199
+ raise HTTPException(status_code=500, detail=f"Qdrant upsert échoué: {r.text}")
200
+ return len(points)
201
+
202
+ async def qdrant_count(client: httpx.AsyncClient, coll: str) -> int:
203
+ url = f"{QDRANT_URL}/collections/{coll}/points/count"
204
+ r = await client.post(
205
+ url,
206
+ headers={"api-key": QDRANT_API_KEY},
207
+ json={"exact": True},
208
+ timeout=20,
209
+ )
210
+ if r.status_code != 200:
211
+ raise HTTPException(status_code=500, detail=f"Qdrant count échoué: {r.text}")
212
+ return int(r.json().get("result", {}).get("count", 0))
213
+
214
+ async def qdrant_search(
215
+ client: httpx.AsyncClient,
216
+ coll: str,
217
+ vector: List[float],
218
+ limit: int = 5,
219
+ ) -> Dict[str, Any]:
220
+ url = f"{QDRANT_URL}/collections/{coll}/points/search"
221
+ r = await client.post(
222
+ url,
223
+ headers={"api-key": QDRANT_API_KEY},
224
+ json={"vector": vector, "limit": limit, "with_payload": True},
225
+ timeout=30,
226
+ )
227
+ if r.status_code != 200:
228
+ raise HTTPException(status_code=500, detail=f"Qdrant search échoué: {r.text}")
229
+ return r.json()
230
+
231
+ # ------------------------------------------------------------------------------
232
+ # Embeddings (HF Inference ou dummy)
233
+ # ------------------------------------------------------------------------------
234
+ async def embed_hf(
235
+ client: httpx.AsyncClient,
236
+ texts: List[str],
237
+ model: str = HF_EMBED_MODEL,
238
+ token: str = HF_TOKEN,
239
+ ) -> List[List[float]]:
240
+ """
241
+ Appel HuggingFace Inference (feature extraction) - batch.
242
+ Normalise L2 les vecteurs.
243
+ """
244
+ if not token:
245
+ raise HTTPException(status_code=400, detail="HUGGINGFACEHUB_API_TOKEN manquant pour EMB_PROVIDER=hf")
246
+ url = f"https://api-inference.huggingface.co/models/{model}"
247
+ headers = {"Authorization": f"Bearer {token}"}
248
+ # HF accepte une liste de textes directement
249
+ payload = {"inputs": texts, "options": {"wait_for_model": True}}
250
+ r = await client.post(url, headers=headers, json=payload, timeout=120)
251
+ if r.status_code != 200:
252
+ raise HTTPException(status_code=502, detail=f"HF Inference error: {r.text}")
253
+ data = r.json()
254
+ # data peut être une liste de listes (ou de listes de listes...)
255
+ embeddings: List[List[float]] = []
256
+ if isinstance(data, list):
257
+ for row in data:
258
+ vec = flatten_any(row)
259
+ embeddings.append(l2_normalize(vec))
260
+ else:
261
+ vec = flatten_any(data)
262
+ embeddings.append(l2_normalize(vec))
263
+ return embeddings
264
+
265
+ def embed_dummy(texts: List[str], dim: int = 128) -> List[List[float]]:
266
+ """
267
+ Embedding déterministe basé sur un hash -> vecteur pseudo-aléatoire stable.
268
+ Suffisant pour tester le pipeline Qdrant (dimensions cohérentes, upsert, count, search).
269
+ """
270
+ out: List[List[float]] = []
271
+ for t in texts:
272
+ h = hashlib.sha256(t.encode("utf-8")).digest()
273
+ # Étale sur dim floats
274
+ arr = np.frombuffer((h * ((dim // len(h)) + 1))[:dim], dtype=np.uint8).astype(np.float32)
275
+ # Centrage et normalisation
276
+ arr = (arr - 127.5) / 127.5
277
+ arr = arr / (np.linalg.norm(arr) + 1e-9)
278
+ out.append(arr.astype(np.float32).tolist())
279
+ return out
280
+
281
+ async def embed_texts(client: httpx.AsyncClient, texts: List[str]) -> List[List[float]]:
282
+ if EMB_PROVIDER == "hf":
283
+ return await embed_hf(client, texts)
284
+ return embed_dummy(texts, dim=128)
285
+
286
+ # ------------------------------------------------------------------------------
287
+ # Pipeline d'indexation
288
+ # ------------------------------------------------------------------------------
289
+ async def run_index_job(job: JobState, req: IndexRequest) -> None:
290
+ job.stage = "embedding"
291
+ job.total_files = len(req.files)
292
+ job.log(f"Index start project={req.project_id} files={len(req.files)} chunk_size={req.chunk_size} overlap={req.overlap} batch_size={req.batch_size} store_text={req.store_text}")
293
+
294
+ # Dédup global par hash du texte brut des fichiers
295
+ file_hashes = [hash8(f.text) for f in req.files]
296
+ uniq = len(set(file_hashes))
297
+ if uniq != len(file_hashes):
298
+ job.log(f"Attention: {len(file_hashes)-uniq} fichiers ont un texte identique (hash dupliqué).")
299
+
300
+ # Chunking
301
+ records: List[Dict[str, Any]] = []
302
+ for f in req.files:
303
+ chunks = chunk_text(f.text, req.chunk_size, req.overlap)
304
+ if not chunks:
305
+ job.log(f"{f.path}: 0 chunk (trop court ou vide)")
306
+ for idx, (start, end, ch) in enumerate(chunks):
307
+ payload = {
308
+ "path": f.path,
309
+ "chunk": idx,
310
+ "start": start,
311
+ "end": end,
312
+ }
313
+ if req.store_text:
314
+ payload["text"] = ch
315
+ records.append({"payload": payload, "raw": ch})
316
+ job.total_chunks = len(records)
317
+ job.log(f"Total chunks = {job.total_chunks}")
318
+
319
+ if job.total_chunks == 0:
320
+ job.stage = "failed"
321
+ job.errors.append("Aucun chunk à indexer.")
322
+ job.finished_at = time.time()
323
+ return
324
+
325
+ # Embedding + Upsert (en batches)
326
+ async with httpx.AsyncClient(timeout=120) as client:
327
+ # Dimension à partir du 1er embedding (warmup)
328
+ warmup_vec = (await embed_texts(client, [records[0]["raw"]]))[0]
329
+ vec_dim = len(warmup_vec)
330
+ job.log(f"Warmup embeddings dim={vec_dim} provider={EMB_PROVIDER}")
331
+
332
+ # Qdrant collection
333
+ coll = f"{COLLECTION_PREFIX}{req.project_id}"
334
+ await ensure_collection(client, coll, vector_size=vec_dim)
335
+
336
+ job.stage = "upserting"
337
+ batch_vectors: List[List[float]] = []
338
+ batch_points: List[Dict[str, Any]] = []
339
+
340
+ async def flush_batch():
341
+ nonlocal batch_vectors, batch_points
342
+ if not batch_points:
343
+ return 0
344
+ added = await qdrant_upsert(client, coll, batch_points)
345
+ job.upserted += added
346
+ job.log(f"+{added} points upsert (total={job.upserted})")
347
+ batch_vectors = []
348
+ batch_points = []
349
+ return added
350
+
351
+ # Traite par lot d'embeddings (embedding_batch_size indépendant de l'upsert batch_size)
352
+ EMB_BATCH = max(8, min(64, req.batch_size * 2))
353
+ i = 0
354
+ while i < len(records):
355
+ sub = records[i : i + EMB_BATCH]
356
+ texts = [r["raw"] for r in sub]
357
+ vecs = await embed_texts(client, texts)
358
+ if len(vecs) != len(sub):
359
+ raise HTTPException(status_code=500, detail="Embedding batch size mismatch")
360
+ job.embedded += len(vecs)
361
+
362
+ for r, v in zip(sub, vecs):
363
+ payload = r["payload"]
364
+ point = {
365
+ "id": str(uuid.uuid4()),
366
+ "vector": v,
367
+ "payload": payload,
368
+ }
369
+ batch_points.append(point)
370
+ if len(batch_points) >= req.batch_size:
371
+ await flush_batch()
372
+ i += EMB_BATCH
373
+
374
+ # Flush final
375
+ await flush_batch()
376
+
377
+ job.stage = "done"
378
+ job.finished_at = time.time()
379
+ job.log("Index job terminé.")
380
+
381
+ # ------------------------------------------------------------------------------
382
+ # FastAPI app + endpoints
383
+ # ------------------------------------------------------------------------------
384
+ fastapi_app = FastAPI(title="Remote Indexer - Minimal Test Space")
385
+ fastapi_app.add_middleware(
386
+ CORSMiddleware,
387
+ allow_origins=["*"],
388
+ allow_methods=["*"],
389
+ allow_headers=["*"],
390
+ )
391
+
392
+ @fastapi_app.get("/")
393
+ async def root():
394
+ return {"ok": True, "service": "remote-indexer-min", "qdrant": bool(QDRANT_URL), "emb_provider": EMB_PROVIDER}
395
+
396
+ @fastapi_app.post("/wipe")
397
+ async def wipe(project_id: str = Query(..., min_length=1)):
398
+ if not QDRANT_URL or not QDRANT_API_KEY:
399
+ raise HTTPException(status_code=400, detail="QDRANT_URL / QDRANT_API_KEY requis")
400
+ coll = f"{COLLECTION_PREFIX}{project_id}"
401
+ async with httpx.AsyncClient() as client:
402
+ r = await client.delete(f"{QDRANT_URL}/collections/{coll}", headers={"api-key": QDRANT_API_KEY}, timeout=30)
403
+ if r.status_code not in (200, 202, 404):
404
+ raise HTTPException(status_code=500, detail=f"Echec wipe: {r.text}")
405
+ return {"ok": True, "collection": coll, "wiped": True}
406
+
407
+ @fastapi_app.post("/index")
408
+ async def index(req: IndexRequest):
409
+ if not QDRANT_URL or not QDRANT_API_KEY:
410
+ raise HTTPException(status_code=400, detail="QDRANT_URL / QDRANT_API_KEY requis")
411
+ job_id = uuid.uuid4().hex[:12]
412
+ job = JobState(job_id=job_id, project_id=req.project_id)
413
+ JOBS[job_id] = job
414
+ # Lance en tâche de fond
415
+ asyncio.create_task(run_index_job(job, req))
416
+ job.log(f"Job {job_id} créé pour project {req.project_id}")
417
+ return {"job_id": job_id, "project_id": req.project_id}
418
+
419
+ @fastapi_app.get("/status/{job_id}")
420
+ async def status(job_id: str):
421
+ job = JOBS.get(job_id)
422
+ if not job:
423
+ raise HTTPException(status_code=404, detail="job_id inconnu")
424
+ return job.model_dump()
425
+
426
+ @fastapi_app.get("/collections/{project_id}/count")
427
+ async def coll_count(project_id: str):
428
+ if not QDRANT_URL or not QDRANT_API_KEY:
429
+ raise HTTPException(status_code=400, detail="QDRANT_URL / QDRANT_API_KEY requis")
430
+ coll = f"{COLLECTION_PREFIX}{project_id}"
431
+ async with httpx.AsyncClient() as client:
432
+ cnt = await qdrant_count(client, coll)
433
+ return {"project_id": project_id, "collection": coll, "count": cnt}
434
+
435
+ @fastapi_app.post("/query")
436
+ async def query(req: QueryRequest):
437
+ if not QDRANT_URL or not QDRANT_API_KEY:
438
+ raise HTTPException(status_code=400, detail="QDRANT_URL / QDRANT_API_KEY requis")
439
+ coll = f"{COLLECTION_PREFIX}{req.project_id}"
440
+ async with httpx.AsyncClient() as client:
441
+ vec = (await embed_texts(client, [req.text]))[0]
442
+ data = await qdrant_search(client, coll, vec, limit=req.top_k)
443
+ return data
444
+
445
+ # ------------------------------------------------------------------------------
446
+ # Gradio UI (montée sur "/")
447
+ # ------------------------------------------------------------------------------
448
+ def _default_two_docs() -> List[Dict[str, str]]:
449
+ a = "Alpha bravo charlie delta echo foxtrot golf hotel india. " * 3
450
+ b = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy." * 3
451
+ return [
452
+ {"path": "a.txt", "text": a},
453
+ {"path": "b.txt", "text": b},
454
+ ]
455
+
456
+ async def ui_wipe(project: str):
457
+ try:
458
+ resp = await wipe(project) # appelle la route interne
459
+ return f"✅ Wipe ok — collection {resp['collection']} supprimée."
460
+ except Exception as e:
461
+ LOG.exception("wipe UI error")
462
+ return f"❌ Wipe erreur: {e}"
463
+
464
+ async def ui_index_sample(project: str, chunk_size: int, overlap: int, batch_size: int, store_text: bool):
465
+ files = _default_two_docs()
466
+ req = IndexRequest(
467
+ project_id=project,
468
+ files=[FileItem(**f) for f in files],
469
+ chunk_size=chunk_size,
470
+ overlap=overlap,
471
+ batch_size=batch_size,
472
+ store_text=store_text,
473
+ )
474
+ try:
475
+ data = await index(req)
476
+ job_id = data["job_id"]
477
+ return f"🚀 Job lancé: {job_id}"
478
+ except ValidationError as ve:
479
+ return f"❌ Payload invalide: {ve}"
480
+ except Exception as e:
481
+ LOG.exception("index UI error")
482
+ return f"❌ Index erreur: {e}"
483
+
484
+ async def ui_status(job_id: str):
485
+ if not job_id.strip():
486
+ return "⚠️ Renseigne un job_id"
487
+ try:
488
+ st = await status(job_id)
489
+ # Formatage
490
+ lines = [f"Job {st['job_id']} — stage={st['stage']} files={st['total_files']} chunks={st['total_chunks']} embedded={st['embedded']} upserted={st['upserted']}"]
491
+ lines += st.get("messages", [])[-50:] # dernières lignes
492
+ if st.get("errors"):
493
+ lines.append("Erreurs:")
494
+ lines += [f" - {e}" for e in st["errors"]]
495
+ return "\n".join(lines)
496
+ except Exception as e:
497
+ return f"❌ Status erreur: {e}"
498
+
499
+ async def ui_count(project: str):
500
+ try:
501
+ resp = await coll_count(project)
502
+ return f"📊 Count — collection={resp['collection']} → {resp['count']} points"
503
+ except Exception as e:
504
+ LOG.exception("count UI error")
505
+ return f"❌ Count erreur: {e}"
506
+
507
+ async def ui_query(project: str, text: str, topk: int):
508
+ try:
509
+ data = await query(QueryRequest(project_id=project, text=text, top_k=topk))
510
+ hits = data.get("result", [])
511
+ if not hits:
512
+ return "Aucun résultat."
513
+ out = []
514
+ for h in hits:
515
+ score = h.get("score")
516
+ payload = h.get("payload", {})
517
+ path = payload.get("path")
518
+ chunk = payload.get("chunk")
519
+ preview = (payload.get("text") or "")[:120].replace("\n", " ")
520
+ out.append(f"{score:.4f} — {path} [chunk {chunk}] �� {preview}…")
521
+ return "\n".join(out)
522
+ except Exception as e:
523
+ LOG.exception("query UI error")
524
+ return f"❌ Query erreur: {e}"
525
+
526
+ with gr.Blocks(title="Remote Indexer - Minimal Test", analytics_enabled=False) as ui:
527
+ gr.Markdown("## 🔬 Remote Indexer — Tests sans console\n"
528
+ "Wipe → Index 2 docs → Status → Count → Query\n"
529
+ f"- **Embeddings**: `{EMB_PROVIDER}` (model: `{HF_EMBED_MODEL}`)\n"
530
+ f"- **Qdrant**: `{'OK' if QDRANT_URL else 'ABSENT'}`\n"
531
+ "Conseil: si tu n'as pas de token HF, mets `EMB_PROVIDER=dummy` dans les Secrets du Space.")
532
+ with gr.Row():
533
+ project_tb = gr.Textbox(label="Project ID", value="DEEPWEB")
534
+ jobid_tb = gr.Textbox(label="Job ID (pour Status)", value="", interactive=True)
535
+ with gr.Row():
536
+ wipe_btn = gr.Button("🧨 Wipe collection", variant="stop")
537
+ index_btn = gr.Button("🚀 Indexer 2 documents", variant="primary")
538
+ count_btn = gr.Button("📊 Count points", variant="secondary")
539
+ with gr.Row():
540
+ chunk_size = gr.Slider(64, 1024, value=200, step=8, label="chunk_size")
541
+ overlap = gr.Slider(0, 256, value=20, step=2, label="overlap")
542
+ batch_size = gr.Slider(1, 128, value=32, step=1, label="batch_size")
543
+ store_text = gr.Checkbox(value=True, label="store_text (payload)")
544
+ out_log = gr.Textbox(lines=18, label="Logs / Résultats", interactive=False)
545
+ with gr.Row():
546
+ query_tb = gr.Textbox(label="Query text", value="alpha bravo")
547
+ topk = gr.Slider(1, 20, value=5, step=1, label="top_k")
548
+ query_btn = gr.Button("🔎 Query")
549
+ query_out = gr.Textbox(lines=10, label="Résultats Query", interactive=False)
550
+
551
+ wipe_btn.click(ui_wipe, inputs=[project_tb], outputs=[out_log])
552
+ index_btn.click(ui_index_sample, inputs=[project_tb, chunk_size, overlap, batch_size, store_text], outputs=[out_log])
553
+ # Petit auto-poll status: on relance ui_status à la main en collant le job_id
554
+ count_btn.click(ui_count, inputs=[project_tb], outputs=[out_log])
555
+ query_btn.click(ui_query, inputs=[project_tb, query_tb, topk], outputs=[query_out])
556
+
557
+ # Monte l'UI Gradio sur la FastAPI
558
+ app = gr.mount_gradio_app(fastapi_app, ui, path="/")