chouchouvs commited on
Commit
ace5bcd
·
verified ·
1 Parent(s): ac071f7

Create rfp_api_app.py

Browse files
Files changed (1) hide show
  1. rfp_api_app.py +164 -0
rfp_api_app.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rfp_api_app.py
2
+ # -*- coding: utf-8 -*-
3
+ from __future__ import annotations
4
+ from typing import Dict, Any
5
+ import os, json, uuid, threading, time, traceback
6
+ from pathlib import Path
7
+ import logging
8
+ import requests
9
+
10
+ from fastapi import FastAPI, HTTPException, Query
11
+ from fastapi.responses import JSONResponse, FileResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ # === Imports depuis ton repo cloné RFPmaster ===
15
+ from rfp_parser.prompting import build_chat_payload
16
+ from rfp_parser.exports_xls import build_xls_from_doc
17
+
18
+ # --------- Config ---------
19
+ DEEPINFRA_API_KEY = os.environ.get("DEEPINFRA_API_KEY", "")
20
+ MODEL_NAME = os.environ.get("RFP_MODEL", "meta-llama/Meta-Llama-3.1-70B-Instruct")
21
+ DEEPINFRA_URL = os.environ.get("DEEPINFRA_URL", "https://api.deepinfra.com/v1/openai/chat/completions")
22
+ RFP_DEBUG = str(os.environ.get("RFP_DEBUG", "0")).lower() in {"1", "true", "yes"}
23
+ BASE_TMP = Path("/tmp/rfp_jobs"); BASE_TMP.mkdir(parents=True, exist_ok=True)
24
+
25
+ logger = logging.getLogger("RFP_API")
26
+ if not logger.handlers:
27
+ h = logging.StreamHandler()
28
+ h.setFormatter(logging.Formatter("[API] %(levelname)s: %(message)s"))
29
+ logger.addHandler(h)
30
+ logger.setLevel(logging.DEBUG if RFP_DEBUG else logging.INFO)
31
+
32
+ # --------- Jobs en mémoire ---------
33
+ JOBS: Dict[str, Dict[str, Any]] = {}
34
+ JOBS_LOCK = threading.Lock()
35
+
36
+ def new_job(text: str) -> str:
37
+ job_id = uuid.uuid4().hex[:12]
38
+ with JOBS_LOCK:
39
+ JOBS[job_id] = {
40
+ "status": "queued",
41
+ "error": None,
42
+ "xlsx_path": None,
43
+ "xlsx_url": None,
44
+ "started_at": time.time(),
45
+ "done_at": None,
46
+ "meta": {"model": MODEL_NAME, "length": len(text or "")},
47
+ }
48
+ return job_id
49
+
50
+ def set_job_status(job_id: str, **updates):
51
+ with JOBS_LOCK:
52
+ if job_id in JOBS:
53
+ JOBS[job_id].update(**updates)
54
+
55
+ # --------- Cœur pipeline ---------
56
+ def parse_with_deepinfra(text: str) -> Dict[str, Any]:
57
+ if not DEEPINFRA_API_KEY:
58
+ raise RuntimeError("DEEPINFRA_API_KEY manquant (Settings → Secrets du Space).")
59
+ payload = build_chat_payload(text, model=MODEL_NAME)
60
+ headers = {"Authorization": f"Bearer {DEEPINFRA_API_KEY}", "Content-Type": "application/json"}
61
+ logger.info("DeepInfra call model=%s max_tokens=%s", payload.get("model"), payload.get("max_tokens"))
62
+ r = requests.post(DEEPINFRA_URL, headers=headers, json=payload, timeout=120)
63
+ if r.status_code // 100 != 2:
64
+ raise RuntimeError(f"DeepInfra HTTP {r.status_code}: {r.text}")
65
+ data = r.json()
66
+ try:
67
+ content = data["choices"][0]["message"]["content"]
68
+ except Exception:
69
+ raise RuntimeError(f"Réponse inattendue DeepInfra: {json.dumps(data)[:400]}")
70
+ try:
71
+ doc = json.loads(content)
72
+ except Exception as e:
73
+ logger.warning("json.loads(content) a échoué; strip fallback. Err=%s", e)
74
+ doc = json.loads(content.strip().strip('`').strip())
75
+ if not isinstance(doc, dict):
76
+ raise RuntimeError("Le contenu renvoyé n'est pas un objet JSON.")
77
+ return doc
78
+
79
+ def build_xlsx(doc: Dict[str, Any], job_dir: Path) -> str:
80
+ job_dir.mkdir(parents=True, exist_ok=True)
81
+ out_path = str(job_dir / "feuille_de_charge.xlsx")
82
+ baseline = (doc.get("assumptions") or {}).get("baseline_uop_kg") or 100.0
83
+ try:
84
+ baseline = float(baseline)
85
+ except Exception:
86
+ baseline = 100.0
87
+ build_xls_from_doc(doc, out_path, baseline_kg=baseline)
88
+ return out_path
89
+
90
+ def run_job(job_id: str, text: str) -> None:
91
+ set_job_status(job_id, status="running")
92
+ job_dir = BASE_TMP / job_id
93
+ try:
94
+ doc = parse_with_deepinfra(text)
95
+ xlsx_path = build_xlsx(doc, job_dir)
96
+ # Ici, comme on n’est PAS monté sous /api : le chemin est direct
97
+ xlsx_url = f"/results/{job_id}/feuille_de_charge.xlsx"
98
+ set_job_status(
99
+ job_id,
100
+ status="done",
101
+ xlsx_path=xlsx_path,
102
+ xlsx_url=xlsx_url,
103
+ done_at=time.time(),
104
+ meta={**JOBS[job_id]["meta"], "assumptions": doc.get("assumptions")},
105
+ )
106
+ logger.info("Job %s terminé -> %s", job_id, xlsx_path)
107
+ except Exception as e:
108
+ logger.error("Job %s échoué: %s\n%s", job_id, e, traceback.format_exc())
109
+ set_job_status(job_id, status="error", error=str(e), done_at=time.time())
110
+
111
+ # --------- FastAPI ---------
112
+ app = FastAPI(title="RFP_MASTER API", version="1.0.0")
113
+ app.add_middleware(
114
+ CORSMiddleware,
115
+ allow_origins=["*"], # serre si tu veux limiter à ton Space Gradio
116
+ allow_credentials=True,
117
+ allow_methods=["*"],
118
+ allow_headers=["*"],
119
+ )
120
+
121
+ @app.get("/health")
122
+ def health():
123
+ return {"ok": True, "ts": time.time(), "model": MODEL_NAME}
124
+
125
+ @app.post("/submit")
126
+ def submit(payload: Dict[str, Any]):
127
+ text = (payload or {}).get("text", "")
128
+ if not isinstance(text, str) or not text.strip():
129
+ raise HTTPException(400, "Champ 'text' manquant ou vide.")
130
+ job_id = new_job(text)
131
+ t = threading.Thread(target=run_job, args=(job_id, text), daemon=True)
132
+ t.start()
133
+ return JSONResponse({"job_id": job_id, "status": "queued"})
134
+
135
+ @app.get("/status")
136
+ def status(job_id: str = Query(..., description="Identifiant renvoyé par /submit")):
137
+ with JOBS_LOCK:
138
+ info = JOBS.get(job_id)
139
+ if not info:
140
+ raise HTTPException(404, f"job_id inconnu: {job_id}")
141
+ return JSONResponse({
142
+ "job_id": job_id,
143
+ "status": info.get("status"),
144
+ "xlsx_url": info.get("xlsx_url"),
145
+ "error": info.get("error"),
146
+ "meta": info.get("meta"),
147
+ })
148
+
149
+ @app.get("/results/{job_id}/feuille_de_charge.xlsx")
150
+ def download(job_id: str):
151
+ with JOBS_LOCK:
152
+ info = JOBS.get(job_id)
153
+ if not info:
154
+ raise HTTPException(404, f"job_id inconnu: {job_id}")
155
+ if info.get("status") != "done":
156
+ raise HTTPException(409, f"job {job_id} non prêt (status={info.get('status')})")
157
+ xlsx_path = info.get("xlsx_path")
158
+ if not xlsx_path or not Path(xlsx_path).exists():
159
+ raise HTTPException(404, "Fichier indisponible.")
160
+ return FileResponse(
161
+ xlsx_path,
162
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
163
+ filename="feuille_de_charge.xlsx",
164
+ )