Spaces:
Running
Running
| """ | |
| 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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 | |
| 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)) | |
| 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", | |
| ) | |
| 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) | |
| 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) | |