Spaces:
Sleeping
Sleeping
| # app.py — Space API FastAPI (Docker) | |
| from __future__ import annotations | |
| from typing import Dict, Any | |
| import os, json, uuid, threading, time, traceback | |
| from pathlib import Path | |
| import logging, requests | |
| from fastapi import FastAPI, HTTPException, Query | |
| from fastapi.responses import JSONResponse, FileResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| # --- Remplace ces imports par ton vrai parseur/exports | |
| # from rfp_parser.prompting import build_chat_payload | |
| # from rfp_parser.exports_xls import build_xls_from_doc | |
| def build_chat_payload(text: str, model: str) -> Dict[str, Any]: | |
| # TODO: branche ton vrai payload | |
| return { | |
| "model": model, | |
| "max_tokens": 2048, | |
| "messages": [{"role":"user","content": text}], | |
| "temperature": 0.2, | |
| } | |
| def build_xls_from_doc(doc: Dict[str, Any], out_path: str, baseline_kg: float = 100.0): | |
| # TODO: branche ton vrai export XLSX | |
| # Ici on crée juste un xlsx vide pour la démo | |
| import pandas as pd | |
| df = pd.DataFrame([{"baseline_kg": baseline_kg, "ok": True}]) | |
| df.to_excel(out_path, index=False) | |
| # ---------------- 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 non défini.") | |
| payload = build_chat_payload(text, model=MODEL_NAME) | |
| headers = {"Authorization": f"Bearer {DEEPINFRA_API_KEY}", "Content-Type": "application/json"} | |
| logger.info("Appel DeepInfra 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("Échec json.loads(content); tentative strip. 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) | |
| xlsx_url = f"/results/{job_id}/feuille_de_charge.xlsx" # pas de /api en Docker (c’est la racine) | |
| 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 -------------- | |
| app = FastAPI(title="RFP_MASTER API", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| 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) | |
| logger.info("Submit reçu job_id=%s len(text)=%d", job_id, len(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", | |
| ) | |