PRESUNI_BPOM / src /api.py
Expanic's picture
Deploy React + FastAPI to HuggingFace Spaces
03442f1
Raw
History Blame Contribute Delete
14.5 kB
"""
BPOM Compliance System โ€” FastAPI Backend
Menggantikan Gradio dengan REST API yang bisa dikonsumsi oleh React frontend.
Endpoints:
POST /api/analyze โ€” Upload file atau text, jalankan full pipeline
POST /api/report โ€” Generate PDF + markdown report
GET /api/download โ€” Download PDF yang sudah digenerate
GET /health โ€” Health check
"""
import os
import sys
import logging
import tempfile
import asyncio
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Optional
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from src.extractor import extract_and_parse
from src.classifier import classify_product
from src.rule_engine import run_full_compliance_check
from src.report_generator import generate_pdf_report, generate_markdown_report
# Thread pool untuk menjalankan blocking calls (embedding model, Gemini)
_executor = ThreadPoolExecutor(max_workers=2)
# RAG & LLM โ€” opsional, skip jika model/library bermasalah
try:
from src.rag_query import query_for_violations
from src.llm_narrator import narrate_violations
RAG_AVAILABLE = True
logging.info("โœ… RAG & LLM tersedia")
except Exception as _e:
logging.warning(f"โš ๏ธ RAG/LLM tidak tersedia (akan skip): {_e}")
RAG_AVAILABLE = False
query_for_violations = None
narrate_violations = None
# Timeout RAG+LLM dalam detik โ€” jika lebih dari ini, pakai fallback narasi
RAG_TIMEOUT_SECONDS = 45
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# โ”€โ”€โ”€ App Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app = FastAPI(title="BPOM Compliance AI API", version="2.4.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# โ”€โ”€โ”€ Pydantic Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ReportRequest(BaseModel):
category: str
narration: str
extracted: dict
compliance: dict
# โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _format_dasar_hukum(item: dict) -> str:
regulation = item.get("regulation", "")
pasal = item.get("pasal", "")
if regulation and pasal:
return f"{regulation}, {pasal}"
return regulation or pasal or "-"
def _build_compliance_rows(compliance: dict) -> list:
rows = []
for v in compliance.get("violations", []):
rows.append({
"param": v.get("param", ""),
"found": str(v.get("found", "")),
"threshold": str(v.get("threshold_max", v.get("required", ""))),
"status": "FAIL",
"dasarHukum": _format_dasar_hukum(v),
})
for p in compliance.get("passed", []):
rows.append({
"param": p.get("param", ""),
"found": str(p.get("found", "")),
"threshold": str(p.get("threshold_max", p.get("required", ""))),
"status": "PASS",
"dasarHukum": _format_dasar_hukum(p),
})
for m in compliance.get("missing", []):
rows.append({
"param": m.get("param", ""),
"found": "-",
"threshold": "-",
"status": "MISSING",
"dasarHukum": _format_dasar_hukum(m),
})
return rows
def _build_violation_cards(compliance: dict, rag_results: list) -> list:
cards = []
violations = compliance.get("violations", [])
rag_map = {}
for r in rag_results or []:
if isinstance(r, dict):
key = r.get("param", r.get("parameter", ""))
if key:
rag_map[key.lower()] = r
for i, v in enumerate(violations):
param = v.get("param", "")
found = str(v.get("found", ""))
threshold = str(v.get("threshold_max", v.get("required", "")))
regulation = _format_dasar_hukum(v)
# Severity by ratio
severity = "medium"
try:
fn = float(str(found).replace(",", ".").split()[0])
tn = float(str(threshold).replace(",", ".").split()[0])
ratio = fn / tn if tn > 0 else 2
severity = "high" if ratio >= 1.5 else "medium" if ratio >= 1.1 else "low"
except Exception:
severity = "high" if i == 0 else "medium"
rag_data = rag_map.get(param.lower(), {})
rekomendasi = rag_data.get("rekomendasi", rag_data.get("recommendation",
f"Nilai {param} ({found}) melebihi batas regulasi BPOM ({threshold}). "
f"Lakukan reformulasi produk untuk memenuhi persyaratan {regulation}."
))
cards.append({
"id": str(i + 1),
"namaParameter": param,
"nilaiTemuan": found,
"batasRegulasi": f"{threshold} ({regulation})",
"rekomendasi": rekomendasi,
"severity": severity,
})
return cards
def _simple_narration(compliance: dict) -> str:
"""Narasi sederhana tanpa LLM โ€” dari data violation langsung."""
violations = compliance.get("violations", [])
passed = compliance.get("passed", [])
missing = compliance.get("missing", [])
total = len(violations) + len(passed) + len(missing)
pct = round(len(passed) / total * 100, 1) if total > 0 else 0
if not violations:
return f"โœ… Semua {len(passed)} parameter memenuhi standar BPOM yang berlaku."
params_fail = ", ".join(v.get("param", "") for v in violations[:5])
narr = (
f"Berdasarkan hasil analisis rule engine terhadap {total} parameter uji, "
f"produk TIDAK MEMENUHI syarat BPOM dalam kondisi formulasi saat ini.\n\n"
f"โ€ข {len(passed)} parameter PASS ({pct}% kepatuhan)\n"
f"โ€ข {len(violations)} parameter FAIL: {params_fail}\n"
)
if missing:
narr += f"โ€ข {len(missing)} parameter data tidak tersedia (MISSING)\n"
narr += "\nProdusen disarankan melakukan reformulasi sebelum pengajuan registrasi ke BPOM."
return narr
def _determine_narration(extracted: dict, category: str, compliance: dict) -> str:
has_violations = bool(compliance.get("violations"))
has_passed = bool(compliance.get("passed"))
has_missing = bool(compliance.get("missing"))
if has_violations:
return _simple_narration(compliance)
elif has_passed and not has_missing:
return f"โœ… Semua {len(compliance.get('passed', []))} parameter memenuhi standar BPOM yang berlaku."
elif has_passed and has_missing:
return (
f"โœ… {len(compliance.get('passed', []))} parameter memenuhi standar BPOM.\n"
f"โš ๏ธ {len(compliance.get('missing', []))} parameter tidak memiliki data (MISSING). "
f"Lengkapi data lab untuk pengecekan lengkap."
)
else:
return "โš ๏ธ Tidak ada data parameter yang terdeteksi dari dokumen."
# โ”€โ”€โ”€ Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/api/analyze")
async def analyze(
file: Optional[UploadFile] = File(None),
text: Optional[str] = Form(None),
category_override: Optional[str] = Form(None),
):
logger.info("=" * 50)
logger.info("๐Ÿš€ NEW ANALYSIS REQUEST")
if not file and not text:
raise HTTPException(status_code=400, detail="Kirim file atau teks input.")
# Simpan file upload ke temp
file_path = None
tmp_path = None
if file and file.filename:
suffix = Path(file.filename).suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
file_path = tmp_path
logger.info(f"๐Ÿ“ File: {file.filename} โ†’ {file_path}")
try:
# 1. Extract
extracted = extract_and_parse(file_path=file_path, raw_text=text or "")
if not extracted:
raise HTTPException(status_code=422, detail="Tidak ada data yang berhasil diekstrak dari dokumen.")
# 2. Classify
if category_override:
category = category_override
else:
try:
class_result = classify_product(extracted)
category = class_result["kategori"]
except Exception as e:
logger.warning(f"Klasifikasi gagal: {e} โ†’ default SUPLEMEN")
category = "SUPLEMEN"
# 3. Rule Engine
compliance = run_full_compliance_check(extracted, category)
# 4. Narasi โ€” coba LLM dengan timeout, fallback ke simple narration
narration = _determine_narration(extracted, category, compliance)
rag_results = []
if compliance.get("violations") and RAG_AVAILABLE:
try:
loop = asyncio.get_event_loop()
def _run_rag():
results = query_for_violations(category, compliance["violations"])
llm_text = narrate_violations(
extracted, category, compliance["violations"], results
)
return results, llm_text
rag_future = loop.run_in_executor(_executor, _run_rag)
rag_results, llm_narration = await asyncio.wait_for(
rag_future, timeout=RAG_TIMEOUT_SECONDS
)
if llm_narration:
narration = llm_narration
except asyncio.TimeoutError:
logger.warning(f"โฑ๏ธ RAG/LLM timeout ({RAG_TIMEOUT_SECONDS}s) โ€” pakai fallback narasi")
except Exception as e:
logger.warning(f"RAG/LLM gagal (pakai fallback): {e}")
# 5. Build response
rows = _build_compliance_rows(compliance)
violation_cards = _build_violation_cards(compliance, rag_results)
pass_count = len(compliance.get("passed", []))
fail_count = len(compliance.get("violations", []))
miss_count = len(compliance.get("missing", []))
total = pass_count + fail_count + miss_count
compliance_pct = round(pass_count / total * 100, 1) if total > 0 else 0
logger.info(f"โœ… Selesai: {pass_count} PASS, {fail_count} FAIL, {miss_count} MISSING")
return JSONResponse({
"success": True,
"category": category,
"extracted": extracted,
"compliance": compliance,
"rows": rows,
"violationCards": violation_cards,
"narration": narration,
"summary": {
"pass": pass_count,
"fail": fail_count,
"missing": miss_count,
"total": total,
"compliancePct": compliance_pct,
},
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Analysis error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
@app.post("/api/report")
async def generate_report(body: ReportRequest):
logger.info("๐Ÿ“„ Generating Final Reports...")
if not body.extracted or not body.compliance:
raise HTTPException(status_code=400, detail="Data tidak lengkap.")
try:
md_report = generate_markdown_report(
body.extracted, body.category, body.compliance, body.narration
)
pdf_path = str(PROJECT_ROOT / "laporan_compliance_bpom.pdf")
generate_pdf_report(
body.extracted, body.category, body.compliance, body.narration, pdf_path
)
return JSONResponse({"success": True, "markdown": md_report, "pdfReady": True})
except Exception as e:
logger.error(f"Report error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/download")
async def download_report():
pdf_path = PROJECT_ROOT / "laporan_compliance_bpom.pdf"
if not pdf_path.exists():
raise HTTPException(status_code=404, detail="Laporan PDF belum dibuat.")
return FileResponse(
path=str(pdf_path),
filename="laporan_compliance_bpom.pdf",
media_type="application/pdf",
)
@app.get("/health")
async def health():
return {"status": "ok", "version": "2.4.0", "rag": RAG_AVAILABLE}
# Serve React Frontend (For Hugging Face Spaces / Docker deployment)
from fastapi.staticfiles import StaticFiles
dist_dir = PROJECT_ROOT / "frontend" / "dist"
if dist_dir.exists():
logger.info(f"Serving static frontend from {dist_dir}")
# Mount assets folder
assets_dir = dist_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
# Catch-all route to serve index.html for SPA routing (must be last)
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str):
# Don't intercept API routes if they return 404
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API route not found")
# Check if requested file exists (like favicon.ico)
file_path = dist_dir / full_path
if file_path.is_file():
return FileResponse(str(file_path))
# Fallback to index.html for React routing
return FileResponse(str(dist_dir / "index.html"))
if __name__ == "__main__":
import uvicorn
logger.info("๐Ÿš€ Starting BPOM Compliance AI API on :8000")
uvicorn.run("src.api:app", host="0.0.0.0", port=8000, reload=True)