Spaces:
Runtime error
Runtime error
| # api.py β Document Sentinel v2.0 | |
| # Routes: POST /classify, GET /download, GET /metrics, GET /feedback, POST /evaluate | |
| # Three-layer detection (Regex + NER + LLM) with verification and explainability | |
| import os | |
| import uuid | |
| import time | |
| from pathlib import Path | |
| from fastapi import FastAPI, UploadFile, File, HTTPException, Query | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from utils import ( | |
| extract_text_from_pdf, | |
| detect_and_mask, | |
| redact_pdf, | |
| get_pipeline_metrics, | |
| get_feedback, | |
| run_evaluation, | |
| ) | |
| app = FastAPI( | |
| title="Document Sentinel", | |
| description=( | |
| "Upload any PDF. " | |
| "Three-layer PII detection (Regex + NER + LLM) with verification loop, " | |
| "per-entity explainability, and performance metrics.\n\n" | |
| "**Layer 1 β Regex**: SSN, Email, Phone, Credit Card, IP, Dates, Financial Amounts\n\n" | |
| "**Layer 2 β NER (GLiNER)**: Names, Organizations, Addresses, Locations\n\n" | |
| "**Layer 3 β LLM**: Context-sensitive PII, NDA clauses, implicit identifiers\n\n" | |
| "**Verification**: Second-pass LLM confirms redaction completeness" | |
| ), | |
| version="2.0.0", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| UPLOAD_DIR = Path("temp_uploads") | |
| REDACTED_DIR = Path("temp_redacted") | |
| UPLOAD_DIR.mkdir(exist_ok=True) | |
| REDACTED_DIR.mkdir(exist_ok=True) | |
| # ββ POST /classify βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def classify(file: UploadFile = File(..., description="PDF document to analyse")): | |
| """ | |
| **Upload a PDF. Returns:** | |
| - `entities` β every sensitive entity found with: | |
| - `entity`: the original value | |
| - `label`: what type it is (EMAIL, PERSON_NAME, FINANCIAL_PENALTY, etc.) | |
| - `method`: how it was detected (`regex`, `ner`, or `llm`) | |
| - `confidence`: 0.0β1.0 | |
| - `reason`: context-specific AI explanation of WHY this is sensitive | |
| - `masked_text` β full document text with all entities replaced by `[LABEL]` | |
| - `redacted_pdf_url` β download link for the redacted PDF | |
| - `layer_breakdown` β per-layer detection statistics | |
| - `verification` β second-pass sanitization score and any missed entities | |
| - `processing_time_ms` β end-to-end latency | |
| """ | |
| if not file.filename.lower().endswith(".pdf"): | |
| raise HTTPException(status_code=400, detail="Only PDF files are accepted.") | |
| t_start = time.time() | |
| doc_id = str(uuid.uuid4()) | |
| in_path = UPLOAD_DIR / f"{doc_id}_original.pdf" | |
| out_path = REDACTED_DIR / f"{doc_id}_redacted.pdf" | |
| # Save upload | |
| contents = await file.read() | |
| with open(in_path, "wb") as f: | |
| f.write(contents) | |
| # Step 1 β Extract text | |
| raw_text = extract_text_from_pdf(str(in_path)) | |
| if not raw_text.strip(): | |
| raise HTTPException( | |
| status_code=422, | |
| detail="Could not extract text from this PDF. It may be corrupt or fully image-based without OCR support." | |
| ) | |
| # Step 2 β Detect + mask (Regex + NER + LLM + Verification) | |
| masked_text, entities, report = detect_and_mask(raw_text) | |
| # Step 3 β Redact original PDF | |
| redact_pdf(str(in_path), str(out_path), entities) | |
| elapsed_ms = round((time.time() - t_start) * 1000, 1) | |
| # Step 4 β Build response | |
| return JSONResponse({ | |
| "document_id": doc_id, | |
| "original_filename": file.filename, | |
| "processing_time_ms": elapsed_ms, | |
| "document_type": report.get("document_type", "unknown"), | |
| "sanitization_score": report.get("sanitization_score", 85.0), | |
| # Entity report β the "responsible AI" output | |
| "summary": { | |
| "total_entities": len(entities), | |
| "by_method": { | |
| "regex": sum(1 for e in entities if e["method"] == "regex"), | |
| "ner": sum(1 for e in entities if e["method"] == "ner"), | |
| "llm": sum(1 for e in entities if e["method"] == "llm"), | |
| "verification": sum(1 for e in entities if e["method"] == "verification"), | |
| }, | |
| "by_label": _count_by_label(entities), | |
| }, | |
| "layer_breakdown": report.get("layer_breakdown", {}), | |
| "verification": report.get("verification"), | |
| "entities": entities, | |
| "masked_text": masked_text, | |
| "redacted_pdf_url": f"/download/{doc_id}", | |
| }) | |
| # ββ GET /download/{doc_id} ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def download(doc_id: str): | |
| path = REDACTED_DIR / f"{doc_id}_redacted.pdf" | |
| if not path.exists(): | |
| raise HTTPException(status_code=404, detail="Redacted PDF not found or expired.") | |
| return FileResponse( | |
| path=str(path), | |
| media_type="application/pdf", | |
| filename=f"redacted_{doc_id[:8]}.pdf", | |
| ) | |
| # ββ GET /metrics ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def metrics(): | |
| """ | |
| Returns aggregated metrics across all processed documents: | |
| - Total documents processed | |
| - Average sanitization score | |
| - Layer contribution percentages (regex / ner / llm / verification) | |
| - Entity type distribution | |
| - Verification pass rate | |
| """ | |
| return JSONResponse(get_pipeline_metrics()) | |
| # ββ GET /feedback/{doc_id} ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def feedback(doc_id: str): | |
| """ | |
| Returns per-entity feedback for a processed document: | |
| - Each entity with its context-specific explanation | |
| - Detection method and confidence | |
| - Verification status | |
| - Layer breakdown | |
| """ | |
| result = get_feedback(doc_id) | |
| if "error" in result: | |
| raise HTTPException(status_code=404, detail=result["error"]) | |
| return JSONResponse(result) | |
| # ββ POST /evaluate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def evaluate(): | |
| """ | |
| Generates synthetic documents with known PII ground truth, | |
| runs the full pipeline, and computes precision/recall/F1: | |
| - Per entity type (SSN, EMAIL, PERSON_NAME, etc.) | |
| - Per detection layer (regex, ner, llm) | |
| - Overall pipeline metrics | |
| """ | |
| results = run_evaluation(n_docs=20, use_llm=True) | |
| return JSONResponse(results) | |
| # ββ GET /health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "service": "Document Sentinel v2.0", | |
| "provider": os.getenv("LLM_PROVIDER", "groq"), | |
| "layers": ["regex", "ner (GLiNER)", "llm", "verification"], | |
| } | |
| # ββ Util ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _count_by_label(entities): | |
| counts = {} | |
| for e in entities: | |
| counts[e["label"]] = counts.get(e["label"], 0) + 1 | |
| return counts |