chouchouvs commited on
Commit
8726e17
·
verified ·
1 Parent(s): c6a5264

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +144 -40
main.py CHANGED
@@ -12,9 +12,9 @@ from typing import Dict, Any, List, Tuple, Optional
12
 
13
  import numpy as np
14
  import faiss
15
- from fastapi import FastAPI, HTTPException, Body
16
  from fastapi.middleware.cors import CORSMiddleware
17
- from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
18
  from pydantic import BaseModel
19
 
20
  import gradio as gr
@@ -30,18 +30,33 @@ if not LOG.handlers:
30
  LOG.setLevel(logging.INFO)
31
 
32
  # =============================================================================
33
- # CONFIG
34
  # =============================================================================
35
  PORT = int(os.getenv("PORT", "7860"))
36
  DATA_ROOT = os.getenv("DATA_ROOT", "/tmp/data") # persistant dans le conteneur Space
37
  os.makedirs(DATA_ROOT, exist_ok=True)
38
 
39
- # Embeddings "provider"
40
- # - "dummy": hash vecteurs 128D (léger pour un Space Free)
41
- # (Tu peux plus tard brancher HF Transformers si tu veux.)
 
42
  EMB_PROVIDER = os.getenv("EMB_PROVIDER", "dummy").strip().lower()
 
 
 
 
 
 
 
 
 
43
  EMB_DIM = int(os.getenv("EMB_DIM", "128"))
44
 
 
 
 
 
 
45
  # =============================================================================
46
  # JOB STATE
47
  # =============================================================================
@@ -101,20 +116,93 @@ def _chunk_text(text: str, size: int = 200, overlap: int = 20) -> List[str]:
101
  i = j - overlap if (j - overlap) > i else j
102
  return chunks
103
 
 
 
 
 
 
104
  def _emb_dummy(texts: List[str], dim: int = EMB_DIM) -> np.ndarray:
105
- # vecteurs déterministes à partir d’un hash
106
  vecs = np.zeros((len(texts), dim), dtype="float32")
107
  for i, t in enumerate(texts):
108
  h = hashlib.sha1((t or "").encode("utf-8")).digest()
109
  rng = np.random.default_rng(int.from_bytes(h[:8], "little", signed=False))
110
  v = rng.standard_normal(dim).astype("float32")
111
- # normalisation
112
- n = np.linalg.norm(v) + 1e-9
113
- vecs[i] = v / n
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  return vecs
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def _save_dataset(ds_dir: str, rows: List[Dict[str, Any]]):
117
- # Format simple : on écrit un JSONL + un manifest JSON
118
  os.makedirs(ds_dir, exist_ok=True)
119
  data_path = os.path.join(ds_dir, "data.jsonl")
120
  with open(data_path, "w", encoding="utf-8") as f:
@@ -137,15 +225,14 @@ def _load_dataset(ds_dir: str) -> List[Dict[str, Any]]:
137
  continue
138
  return out
139
 
140
- def _save_faiss(fx_dir: str, xb: np.ndarray):
141
  os.makedirs(fx_dir, exist_ok=True)
142
  idx_path = os.path.join(fx_dir, "emb.faiss")
143
  index = faiss.IndexFlatIP(xb.shape[1]) # cosine ~ inner product si normalisé
144
- # les embeddings _emb_dummy sont déjà normalisés
145
  index.add(xb)
146
  faiss.write_index(index, idx_path)
147
  with open(os.path.join(fx_dir, "meta.json"), "w", encoding="utf-8") as f:
148
- json.dump({"dim": xb.shape[1], "count": int(index.ntotal), "provider": EMB_PROVIDER}, f)
149
 
150
  def _load_faiss(fx_dir: str) -> faiss.Index:
151
  idx_path = os.path.join(fx_dir, "emb.faiss")
@@ -163,7 +250,7 @@ def _tar_dir_to_bytes(dir_path: str) -> bytes:
163
  # =============================================================================
164
  # FASTAPI
165
  # =============================================================================
166
- fastapi_app = FastAPI(title="remote-indexer-min", version="1.0.0")
167
  fastapi_app.add_middleware(
168
  CORSMiddleware,
169
  allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
@@ -183,12 +270,17 @@ class IndexRequest(BaseModel):
183
 
184
  @fastapi_app.get("/health")
185
  def health():
186
- return {"ok": True, "service": "remote-indexer-min", "emb_provider": EMB_PROVIDER}
 
 
 
 
 
 
187
 
188
  @fastapi_app.get("/")
189
  def root_redirect():
190
- # petit état + invite à utiliser /ui
191
- return {"ok": True, "service": "remote-indexer-min", "ui": "/ui"}
192
 
193
  @fastapi_app.post("/index")
194
  def index(req: IndexRequest):
@@ -196,7 +288,7 @@ def index(req: IndexRequest):
196
  st = JobState(job_id=job_id, project_id=req.project_id, stage="pending", messages=[])
197
  JOBS[job_id] = st
198
  _add_msg(st, f"Job {job_id} créé pour project {req.project_id}")
199
- _add_msg(st, 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}")
200
  try:
201
  base, ds_dir, fx_dir = _proj_dirs(req.project_id)
202
 
@@ -217,12 +309,17 @@ def index(req: IndexRequest):
217
  _set_stage(st, "embedding")
218
  if EMB_PROVIDER == "dummy":
219
  xb = _emb_dummy([r["text"] for r in rows], dim=EMB_DIM)
220
- else:
221
- # fallback sur dummy tant que pas d'autre provider
222
- xb = _emb_dummy([r["text"] for r in rows], dim=EMB_DIM)
 
 
 
 
 
223
  st.embedded = xb.shape[0]
224
  _add_msg(st, f"Embeddings {st.embedded}/{st.total_chunks}")
225
- _add_msg(st, f"Embeddings dim={xb.shape[1]}")
226
 
227
  # 3) Sauvegarde dataset (texte)
228
  _save_dataset(ds_dir, rows)
@@ -230,7 +327,13 @@ def index(req: IndexRequest):
230
 
231
  # 4) FAISS
232
  _set_stage(st, "indexing")
233
- _save_faiss(fx_dir, xb)
 
 
 
 
 
 
234
  st.indexed = int(xb.shape[0])
235
  _add_msg(st, f"FAISS écrit sur {os.path.join(fx_dir, 'emb.faiss')}")
236
  _add_msg(st, f"OK — dataset+index prêts (projet={req.project_id})")
@@ -265,10 +368,15 @@ def search(req: SearchRequest):
265
  if not rows:
266
  raise HTTPException(status_code=404, detail="dataset introuvable (index pas encore construit ?)")
267
 
268
- # embeddings du query (dummy)
269
- q = _emb_dummy([req.query], dim=EMB_DIM)[0:1, :] # shape (1, d)
 
 
 
 
 
270
 
271
- # faiss
272
  index = _load_faiss(fx_dir)
273
  if index.d != q.shape[1]:
274
  raise HTTPException(status_code=500, detail=f"dim incompatibles: index.d={index.d} vs query={q.shape[1]}")
@@ -276,7 +384,6 @@ def search(req: SearchRequest):
276
  ids = ids[0].tolist()
277
  scores = scores[0].tolist()
278
 
279
- # compose résultats
280
  out = []
281
  for idx, sc in zip(ids, scores):
282
  if idx < 0 or idx >= len(rows):
@@ -285,16 +392,14 @@ def search(req: SearchRequest):
285
  out.append({"path": r.get("path"), "text": r.get("text"), "score": float(sc)})
286
  return {"results": out}
287
 
288
- # ----------- ARTIFACTS EXPORT (ce qui manquait pour ton 404) -----------
289
  @fastapi_app.get("/artifacts/{project_id}/dataset")
290
  def download_dataset(project_id: str):
291
  base, ds_dir, _ = _proj_dirs(project_id)
292
  if not os.path.isdir(ds_dir):
293
  raise HTTPException(status_code=404, detail="Dataset introuvable")
294
  buf = _tar_dir_to_bytes(ds_dir)
295
- headers = {
296
- "Content-Disposition": f'attachment; filename="{project_id}_dataset.tgz"'
297
- }
298
  return StreamingResponse(io.BytesIO(buf), media_type="application/gzip", headers=headers)
299
 
300
  @fastapi_app.get("/artifacts/{project_id}/faiss")
@@ -303,13 +408,11 @@ def download_faiss(project_id: str):
303
  if not os.path.isdir(fx_dir):
304
  raise HTTPException(status_code=404, detail="FAISS introuvable")
305
  buf = _tar_dir_to_bytes(fx_dir)
306
- headers = {
307
- "Content-Disposition": f'attachment; filename="{project_id}_faiss.tgz"'
308
- }
309
  return StreamingResponse(io.BytesIO(buf), media_type="application/gzip", headers=headers)
310
 
311
  # =============================================================================
312
- # GRADIO (UI facultative pour déclencher / tester rapidement)
313
  # =============================================================================
314
  def _ui_index(project_id: str, sample_text: str):
315
  files = [{"path": "sample.txt", "text": sample_text}]
@@ -331,8 +434,9 @@ def _ui_search(project_id: str, query: str, k: int):
331
  except Exception as e:
332
  return f"Erreur search: {e}"
333
 
334
- with gr.Blocks(title="Remote Indexer (FAISS mini)", analytics_enabled=False) as ui:
335
- gr.Markdown("## Remote Indexer — demo UI (les vraies API sont sur `/index`, `/status/{job}`, `/search`, `/artifacts/...`).")
 
336
  with gr.Tab("Index"):
337
  pid = gr.Textbox(label="Project ID", value="DEEPWEB")
338
  sample = gr.Textbox(label="Texte d’exemple", value="Alpha bravo charlie delta echo foxtrot.", lines=4)
@@ -351,9 +455,9 @@ with gr.Blocks(title="Remote Indexer (FAISS mini)", analytics_enabled=False) as
351
  fastapi_app = gr.mount_gradio_app(fastapi_app, ui, path="/ui")
352
 
353
  # =============================================================================
354
- # MAIN (HF Space lancera ce module avec python -u main.py)
355
  # =============================================================================
356
  if __name__ == "__main__":
357
  import uvicorn
358
  LOG.info("Démarrage Uvicorn sur 0.0.0.0:%s (UI_PATH=/ui)", PORT)
359
- uvicorn.run(fastapi_app, host="0.0.0.0", port=PORT)
 
12
 
13
  import numpy as np
14
  import faiss
15
+ from fastapi import FastAPI, HTTPException
16
  from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import JSONResponse, StreamingResponse
18
  from pydantic import BaseModel
19
 
20
  import gradio as gr
 
30
  LOG.setLevel(logging.INFO)
31
 
32
  # =============================================================================
33
+ # CONFIG (via ENV)
34
  # =============================================================================
35
  PORT = int(os.getenv("PORT", "7860"))
36
  DATA_ROOT = os.getenv("DATA_ROOT", "/tmp/data") # persistant dans le conteneur Space
37
  os.makedirs(DATA_ROOT, exist_ok=True)
38
 
39
+ # Provider d'embeddings:
40
+ # - "dummy" : vecteurs aléatoires déterministes (très rapide)
41
+ # - "st" : Sentence-Transformers (CPU-friendly, simple)
42
+ # - "hf" : Transformers (AutoModel/AutoTokenizer, pooling manuel)
43
  EMB_PROVIDER = os.getenv("EMB_PROVIDER", "dummy").strip().lower()
44
+
45
+ # Modèle embeddings (utilisé si provider != "dummy")
46
+ # Reco rapide et multilingue (FR ok) : paraphrase-multilingual-MiniLM-L12-v2 (dim=384)
47
+ EMB_MODEL = os.getenv("EMB_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2").strip()
48
+
49
+ # Batch d'encodage
50
+ EMB_BATCH = int(os.getenv("EMB_BATCH", "32"))
51
+
52
+ # Dimension par défaut (dummy) — pour st/hf on lit depuis le modèle
53
  EMB_DIM = int(os.getenv("EMB_DIM", "128"))
54
 
55
+ # Cache global lazy
56
+ _ST_MODEL = None
57
+ _HF_TOKENIZER = None
58
+ _HF_MODEL = None
59
+
60
  # =============================================================================
61
  # JOB STATE
62
  # =============================================================================
 
116
  i = j - overlap if (j - overlap) > i else j
117
  return chunks
118
 
119
+ def _l2_normalize(x: np.ndarray) -> np.ndarray:
120
+ n = np.linalg.norm(x, axis=1, keepdims=True) + 1e-12
121
+ return x / n
122
+
123
+ # ----------------------- PROVIDER: DUMMY --------------------------------------
124
  def _emb_dummy(texts: List[str], dim: int = EMB_DIM) -> np.ndarray:
 
125
  vecs = np.zeros((len(texts), dim), dtype="float32")
126
  for i, t in enumerate(texts):
127
  h = hashlib.sha1((t or "").encode("utf-8")).digest()
128
  rng = np.random.default_rng(int.from_bytes(h[:8], "little", signed=False))
129
  v = rng.standard_normal(dim).astype("float32")
130
+ vecs[i] = v / (np.linalg.norm(v) + 1e-9)
131
+ return vecs
132
+
133
+ # ----------------- PROVIDER: Sentence-Transformers ----------------------------
134
+ def _get_st_model():
135
+ global _ST_MODEL
136
+ if _ST_MODEL is None:
137
+ from sentence_transformers import SentenceTransformer
138
+ _ST_MODEL = SentenceTransformer(EMB_MODEL)
139
+ LOG.info(f"[st] modèle chargé: {EMB_MODEL}")
140
+ return _ST_MODEL
141
+
142
+ def _emb_st(texts: List[str]) -> np.ndarray:
143
+ model = _get_st_model()
144
+ vecs = model.encode(
145
+ texts,
146
+ batch_size=max(1, EMB_BATCH),
147
+ convert_to_numpy=True,
148
+ normalize_embeddings=True,
149
+ show_progress_bar=False,
150
+ ).astype("float32")
151
  return vecs
152
 
153
+ def _st_dim() -> int:
154
+ model = _get_st_model()
155
+ try:
156
+ return int(model.get_sentence_embedding_dimension())
157
+ except Exception:
158
+ # fallback : encode une phrase et lit la shape
159
+ v = model.encode(["dimension probe"], convert_to_numpy=True)
160
+ return int(v.shape[1])
161
+
162
+ # ----------------------- PROVIDER: Transformers (HF) --------------------------
163
+ def _get_hf_model():
164
+ global _HF_TOKENIZER, _HF_MODEL
165
+ if _HF_MODEL is None or _HF_TOKENIZER is None:
166
+ from transformers import AutoTokenizer, AutoModel
167
+ _HF_TOKENIZER = AutoTokenizer.from_pretrained(EMB_MODEL)
168
+ _HF_MODEL = AutoModel.from_pretrained(EMB_MODEL)
169
+ _HF_MODEL.eval()
170
+ LOG.info(f"[hf] modèle chargé: {EMB_MODEL}")
171
+ return _HF_TOKENIZER, _HF_MODEL
172
+
173
+ def _mean_pool(last_hidden_state: "np.ndarray", attention_mask: "np.ndarray") -> "np.ndarray":
174
+ # mean pooling masquée
175
+ mask = attention_mask[..., None].astype(last_hidden_state.dtype) # (b, t, 1)
176
+ summed = (last_hidden_state * mask).sum(axis=1) # (b, h)
177
+ counts = mask.sum(axis=1).clip(min=1e-9) # (b, 1)
178
+ return summed / counts
179
+
180
+ def _emb_hf(texts: List[str]) -> np.ndarray:
181
+ import torch
182
+ tok, mod = _get_hf_model()
183
+ all_vecs = []
184
+ bs = max(1, EMB_BATCH)
185
+ with torch.no_grad():
186
+ for i in range(0, len(texts), bs):
187
+ batch = texts[i:i+bs]
188
+ enc = tok(batch, padding=True, truncation=True, return_tensors="pt")
189
+ out = mod(**enc)
190
+ last = out.last_hidden_state # (b, t, h)
191
+ pooled = _mean_pool(last.numpy(), enc["attention_mask"].numpy()) # numpy
192
+ all_vecs.append(pooled.astype("float32"))
193
+ vecs = np.concatenate(all_vecs, axis=0)
194
+ return _l2_normalize(vecs)
195
+
196
+ def _hf_dim() -> int:
197
+ # essaie de lire hidden_size
198
+ try:
199
+ _, mod = _get_hf_model()
200
+ return int(getattr(mod.config, "hidden_size", 768))
201
+ except Exception:
202
+ return 768
203
+
204
+ # ---------------------------- DATASET / FAISS ---------------------------------
205
  def _save_dataset(ds_dir: str, rows: List[Dict[str, Any]]):
 
206
  os.makedirs(ds_dir, exist_ok=True)
207
  data_path = os.path.join(ds_dir, "data.jsonl")
208
  with open(data_path, "w", encoding="utf-8") as f:
 
225
  continue
226
  return out
227
 
228
+ def _save_faiss(fx_dir: str, xb: np.ndarray, meta: Dict[str, Any]):
229
  os.makedirs(fx_dir, exist_ok=True)
230
  idx_path = os.path.join(fx_dir, "emb.faiss")
231
  index = faiss.IndexFlatIP(xb.shape[1]) # cosine ~ inner product si normalisé
 
232
  index.add(xb)
233
  faiss.write_index(index, idx_path)
234
  with open(os.path.join(fx_dir, "meta.json"), "w", encoding="utf-8") as f:
235
+ json.dump(meta, f, ensure_ascii=False, indent=2)
236
 
237
  def _load_faiss(fx_dir: str) -> faiss.Index:
238
  idx_path = os.path.join(fx_dir, "emb.faiss")
 
250
  # =============================================================================
251
  # FASTAPI
252
  # =============================================================================
253
+ fastapi_app = FastAPI(title="remote-indexer", version="2.0.0")
254
  fastapi_app.add_middleware(
255
  CORSMiddleware,
256
  allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
 
270
 
271
  @fastapi_app.get("/health")
272
  def health():
273
+ info = {
274
+ "ok": True,
275
+ "service": "remote-indexer",
276
+ "provider": EMB_PROVIDER,
277
+ "model": EMB_MODEL if EMB_PROVIDER != "dummy" else None
278
+ }
279
+ return info
280
 
281
  @fastapi_app.get("/")
282
  def root_redirect():
283
+ return {"ok": True, "service": "remote-indexer", "ui": "/ui"}
 
284
 
285
  @fastapi_app.post("/index")
286
  def index(req: IndexRequest):
 
288
  st = JobState(job_id=job_id, project_id=req.project_id, stage="pending", messages=[])
289
  JOBS[job_id] = st
290
  _add_msg(st, f"Job {job_id} créé pour project {req.project_id}")
291
+ _add_msg(st, 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} provider={EMB_PROVIDER} model={EMB_MODEL if EMB_PROVIDER!='dummy' else '-'}")
292
  try:
293
  base, ds_dir, fx_dir = _proj_dirs(req.project_id)
294
 
 
309
  _set_stage(st, "embedding")
310
  if EMB_PROVIDER == "dummy":
311
  xb = _emb_dummy([r["text"] for r in rows], dim=EMB_DIM)
312
+ dim = xb.shape[1]
313
+ elif EMB_PROVIDER == "st":
314
+ xb = _emb_st([r["text"] for r in rows])
315
+ dim = xb.shape[1]
316
+ else: # "hf"
317
+ xb = _emb_hf([r["text"] for r in rows])
318
+ dim = xb.shape[1]
319
+
320
  st.embedded = xb.shape[0]
321
  _add_msg(st, f"Embeddings {st.embedded}/{st.total_chunks}")
322
+ _add_msg(st, f"Embeddings dim={dim}")
323
 
324
  # 3) Sauvegarde dataset (texte)
325
  _save_dataset(ds_dir, rows)
 
327
 
328
  # 4) FAISS
329
  _set_stage(st, "indexing")
330
+ faiss_meta = {
331
+ "dim": int(dim),
332
+ "count": int(xb.shape[0]),
333
+ "provider": EMB_PROVIDER,
334
+ "model": EMB_MODEL if EMB_PROVIDER != "dummy" else None
335
+ }
336
+ _save_faiss(fx_dir, xb, meta=faiss_meta)
337
  st.indexed = int(xb.shape[0])
338
  _add_msg(st, f"FAISS écrit sur {os.path.join(fx_dir, 'emb.faiss')}")
339
  _add_msg(st, f"OK — dataset+index prêts (projet={req.project_id})")
 
368
  if not rows:
369
  raise HTTPException(status_code=404, detail="dataset introuvable (index pas encore construit ?)")
370
 
371
+ # Embedding de la requête avec le MÊME provider
372
+ if EMB_PROVIDER == "dummy":
373
+ q = _emb_dummy([req.query], dim=EMB_DIM)[0:1, :]
374
+ elif EMB_PROVIDER == "st":
375
+ q = _emb_st([req.query])[0:1, :]
376
+ else:
377
+ q = _emb_hf([req.query])[0:1, :]
378
 
379
+ # FAISS
380
  index = _load_faiss(fx_dir)
381
  if index.d != q.shape[1]:
382
  raise HTTPException(status_code=500, detail=f"dim incompatibles: index.d={index.d} vs query={q.shape[1]}")
 
384
  ids = ids[0].tolist()
385
  scores = scores[0].tolist()
386
 
 
387
  out = []
388
  for idx, sc in zip(ids, scores):
389
  if idx < 0 or idx >= len(rows):
 
392
  out.append({"path": r.get("path"), "text": r.get("text"), "score": float(sc)})
393
  return {"results": out}
394
 
395
+ # ----------- ARTIFACTS EXPORT -----------
396
  @fastapi_app.get("/artifacts/{project_id}/dataset")
397
  def download_dataset(project_id: str):
398
  base, ds_dir, _ = _proj_dirs(project_id)
399
  if not os.path.isdir(ds_dir):
400
  raise HTTPException(status_code=404, detail="Dataset introuvable")
401
  buf = _tar_dir_to_bytes(ds_dir)
402
+ headers = {"Content-Disposition": f'attachment; filename="{project_id}_dataset.tgz"'}
 
 
403
  return StreamingResponse(io.BytesIO(buf), media_type="application/gzip", headers=headers)
404
 
405
  @fastapi_app.get("/artifacts/{project_id}/faiss")
 
408
  if not os.path.isdir(fx_dir):
409
  raise HTTPException(status_code=404, detail="FAISS introuvable")
410
  buf = _tar_dir_to_bytes(fx_dir)
411
+ headers = {"Content-Disposition": f'attachment; filename="{project_id}_faiss.tgz"'}
 
 
412
  return StreamingResponse(io.BytesIO(buf), media_type="application/gzip", headers=headers)
413
 
414
  # =============================================================================
415
+ # GRADIO UI (facultatif)
416
  # =============================================================================
417
  def _ui_index(project_id: str, sample_text: str):
418
  files = [{"path": "sample.txt", "text": sample_text}]
 
434
  except Exception as e:
435
  return f"Erreur search: {e}"
436
 
437
+ with gr.Blocks(title="Remote Indexer (FAISS)", analytics_enabled=False) as ui:
438
+ gr.Markdown("## Remote Indexer — demo UI (API: `/index`, `/status/{job}`, `/search`, `/artifacts/...`).")
439
+ gr.Markdown(f"**Provider**: `{EMB_PROVIDER}` — **Model**: `{EMB_MODEL if EMB_PROVIDER!='dummy' else '-'}'")
440
  with gr.Tab("Index"):
441
  pid = gr.Textbox(label="Project ID", value="DEEPWEB")
442
  sample = gr.Textbox(label="Texte d’exemple", value="Alpha bravo charlie delta echo foxtrot.", lines=4)
 
455
  fastapi_app = gr.mount_gradio_app(fastapi_app, ui, path="/ui")
456
 
457
  # =============================================================================
458
+ # MAIN
459
  # =============================================================================
460
  if __name__ == "__main__":
461
  import uvicorn
462
  LOG.info("Démarrage Uvicorn sur 0.0.0.0:%s (UI_PATH=/ui)", PORT)
463
+ uvicorn.run(fastapi_app, host="0.0.0.0", port=PORT)