Spaces:
Sleeping
Sleeping
| # rfp_api_app.py | |
| # -*- coding: utf-8 -*- | |
| from __future__ import annotations | |
| from typing import Dict, Any | |
| import os, json, uuid, threading, time, traceback | |
| from pathlib import Path | |
| import logging | |
| import requests | |
| from fastapi import FastAPI, HTTPException, Query | |
| from fastapi.responses import JSONResponse, FileResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| # === Imports depuis ton repo cloné RFPmaster === | |
| from rfp_parser.prompting import build_chat_payload | |
| from rfp_parser.exports_xls import build_xls_from_doc | |
| # --------- Config --------- | |
| DEEPINFRA_API_KEY = os.environ.get("DEEPINFRA_API_KEY", "") | |
| MODEL_NAME = os.environ.get("RFP_MODEL", "meta-llama/Meta-Llama-3.1-70B-Instruct") | |
| DEEPINFRA_URL = os.environ.get("DEEPINFRA_URL", "https://api.deepinfra.com/v1/openai/chat/completions") | |
| RFP_DEBUG = str(os.environ.get("RFP_DEBUG", "0")).lower() in {"1", "true", "yes"} | |
| BASE_TMP = Path("/tmp/rfp_jobs"); BASE_TMP.mkdir(parents=True, exist_ok=True) | |
| logger = logging.getLogger("RFP_API") | |
| if not logger.handlers: | |
| h = logging.StreamHandler() | |
| h.setFormatter(logging.Formatter("[API] %(levelname)s: %(message)s")) | |
| logger.addHandler(h) | |
| logger.setLevel(logging.DEBUG if RFP_DEBUG else logging.INFO) | |
| # --------- Jobs en mémoire --------- | |
| JOBS: Dict[str, Dict[str, Any]] = {} | |
| JOBS_LOCK = threading.Lock() | |
| def new_job(text: str) -> str: | |
| job_id = uuid.uuid4().hex[:12] | |
| with JOBS_LOCK: | |
| JOBS[job_id] = { | |
| "status": "queued", | |
| "error": None, | |
| "xlsx_path": None, | |
| "xlsx_url": None, | |
| "started_at": time.time(), | |
| "done_at": None, | |
| "meta": {"model": MODEL_NAME, "length": len(text or "")}, | |
| } | |
| return job_id | |
| def set_job_status(job_id: str, **updates): | |
| with JOBS_LOCK: | |
| if job_id in JOBS: | |
| JOBS[job_id].update(**updates) | |
| # --------- Cœur pipeline --------- | |
| def parse_with_deepinfra(text: str) -> Dict[str, Any]: | |
| if not DEEPINFRA_API_KEY: | |
| raise RuntimeError("DEEPINFRA_API_KEY manquant (Settings → Secrets du Space).") | |
| payload = build_chat_payload(text, model=MODEL_NAME) | |
| headers = {"Authorization": f"Bearer {DEEPINFRA_API_KEY}", "Content-Type": "application/json"} | |
| logger.info("DeepInfra call model=%s max_tokens=%s", payload.get("model"), payload.get("max_tokens")) | |
| r = requests.post(DEEPINFRA_URL, headers=headers, json=payload, timeout=120) | |
| if r.status_code // 100 != 2: | |
| raise RuntimeError(f"DeepInfra HTTP {r.status_code}: {r.text}") | |
| data = r.json() | |
| try: | |
| content = data["choices"][0]["message"]["content"] | |
| except Exception: | |
| raise RuntimeError(f"Réponse inattendue DeepInfra: {json.dumps(data)[:400]}") | |
| try: | |
| doc = json.loads(content) | |
| except Exception as e: | |
| logger.warning("json.loads(content) a échoué; strip fallback. Err=%s", e) | |
| doc = json.loads(content.strip().strip('`').strip()) | |
| if not isinstance(doc, dict): | |
| raise RuntimeError("Le contenu renvoyé n'est pas un objet JSON.") | |
| return doc | |
| def build_xlsx(doc: Dict[str, Any], job_dir: Path) -> str: | |
| job_dir.mkdir(parents=True, exist_ok=True) | |
| out_path = str(job_dir / "feuille_de_charge.xlsx") | |
| baseline = (doc.get("assumptions") or {}).get("baseline_uop_kg") or 100.0 | |
| try: | |
| baseline = float(baseline) | |
| except Exception: | |
| baseline = 100.0 | |
| build_xls_from_doc(doc, out_path, baseline_kg=baseline) | |
| return out_path | |
| def run_job(job_id: str, text: str) -> None: | |
| set_job_status(job_id, status="running") | |
| job_dir = BASE_TMP / job_id | |
| try: | |
| doc = parse_with_deepinfra(text) | |
| xlsx_path = build_xlsx(doc, job_dir) | |
| # Ici, comme on n’est PAS monté sous /api : le chemin est direct | |
| xlsx_url = f"/results/{job_id}/feuille_de_charge.xlsx" | |
| set_job_status( | |
| job_id, | |
| status="done", | |
| xlsx_path=xlsx_path, | |
| xlsx_url=xlsx_url, | |
| done_at=time.time(), | |
| meta={**JOBS[job_id]["meta"], "assumptions": doc.get("assumptions")}, | |
| ) | |
| logger.info("Job %s terminé -> %s", job_id, xlsx_path) | |
| except Exception as e: | |
| logger.error("Job %s échoué: %s\n%s", job_id, e, traceback.format_exc()) | |
| set_job_status(job_id, status="error", error=str(e), done_at=time.time()) | |
| # --------- FastAPI --------- | |
| app = FastAPI(title="RFP_MASTER API", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], # serre si tu veux limiter à ton Space Gradio | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def health(): | |
| return {"ok": True, "ts": time.time(), "model": MODEL_NAME} | |
| def submit(payload: Dict[str, Any]): | |
| text = (payload or {}).get("text", "") | |
| if not isinstance(text, str) or not text.strip(): | |
| raise HTTPException(400, "Champ 'text' manquant ou vide.") | |
| job_id = new_job(text) | |
| t = threading.Thread(target=run_job, args=(job_id, text), daemon=True) | |
| t.start() | |
| return JSONResponse({"job_id": job_id, "status": "queued"}) | |
| def status(job_id: str = Query(..., description="Identifiant renvoyé par /submit")): | |
| with JOBS_LOCK: | |
| info = JOBS.get(job_id) | |
| if not info: | |
| raise HTTPException(404, f"job_id inconnu: {job_id}") | |
| return JSONResponse({ | |
| "job_id": job_id, | |
| "status": info.get("status"), | |
| "xlsx_url": info.get("xlsx_url"), | |
| "error": info.get("error"), | |
| "meta": info.get("meta"), | |
| }) | |
| def download(job_id: str): | |
| with JOBS_LOCK: | |
| info = JOBS.get(job_id) | |
| if not info: | |
| raise HTTPException(404, f"job_id inconnu: {job_id}") | |
| if info.get("status") != "done": | |
| raise HTTPException(409, f"job {job_id} non prêt (status={info.get('status')})") | |
| xlsx_path = info.get("xlsx_path") | |
| if not xlsx_path or not Path(xlsx_path).exists(): | |
| raise HTTPException(404, "Fichier indisponible.") | |
| return FileResponse( | |
| xlsx_path, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| filename="feuille_de_charge.xlsx", | |
| ) | |