Spaces:
Sleeping
Sleeping
| """ | |
| GUVI Hackathon Track 2 - AI-Powered Document Analysis & Extraction | |
| ================================================================== | |
| Endpoint : POST /api/document-analyze | |
| Auth : x-api-key header (401 if missing/invalid) | |
| Formats : PDF, DOCX, Image (OCR) | |
| AI Stack : Gemini 2.5 Flash (primary) β spaCy + DistilBERT + sumy (fallback) | |
| """ | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 1 β Imports & Configuration | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import base64 | |
| import io | |
| import os | |
| import re | |
| import sys | |
| import logging | |
| import tempfile | |
| from contextlib import asynccontextmanager | |
| from typing import Any | |
| import nltk | |
| from dotenv import load_dotenv | |
| from fastapi import Depends, FastAPI, Header, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| from pathlib import Path | |
| # Load .env β try multiple locations to be robust regardless of cwd | |
| for _candidate in [ | |
| Path(__file__).resolve().parent.parent / ".env", # hcl-docu/.env (when running from src/) | |
| Path(__file__).resolve().parent / ".env", # src/.env (if copied there) | |
| Path.cwd() / ".env", # cwd | |
| Path.cwd().parent / ".env", # cwd parent | |
| ]: | |
| if _candidate.exists(): | |
| load_dotenv(_candidate, override=True) | |
| break | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # ββ API Keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| API_KEY: str = os.getenv("API_KEY", "") | |
| GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "") | |
| # Startup debug β confirm key is loaded (prints only first 10 chars) | |
| print(f"DEBUG | GEMINI_API_KEY loaded: {GEMINI_API_KEY[:10]}... (len={len(GEMINI_API_KEY)})" if GEMINI_API_KEY else "DEBUG | GEMINI_API_KEY is EMPTY!") | |
| # ββ Tesseract path: .env override β auto-detect common install locations ββββββ | |
| import pytesseract | |
| TESSERACT_PATH: str = os.getenv("TESSERACT_PATH", "") | |
| if not TESSERACT_PATH: | |
| # Auto-detect on Windows / Linux / macOS | |
| _candidates = [ | |
| r"C:\Program Files\Tesseract-OCR\tesseract.exe", | |
| r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe", | |
| os.path.expanduser("~/tesseract/tesseract.exe"), | |
| "/usr/bin/tesseract", | |
| "/usr/local/bin/tesseract", | |
| "/opt/homebrew/bin/tesseract", | |
| ] | |
| for _c in _candidates: | |
| if os.path.isfile(_c): | |
| TESSERACT_PATH = _c | |
| break | |
| if TESSERACT_PATH: | |
| pytesseract.pytesseract.tesseract_cmd = TESSERACT_PATH | |
| print(f"DEBUG | Tesseract found: {TESSERACT_PATH}") | |
| else: | |
| print("DEBUG | Tesseract not found β OCR for images will fail. Set TESSERACT_PATH in .env") | |
| # ββ Global model containers (loaded once at startup) ββββββββββββββββββββββββββ | |
| _nlp = None # spaCy model | |
| _sentiment_pipe = None # HuggingFace DistilBERT pipeline | |
| _gemini_model = None # Gemini generative model | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 2 β Pydantic Request / Response Models | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class DocumentRequest(BaseModel): | |
| """Incoming request body: base64-encoded document.""" | |
| fileName: str | |
| fileType: str # pdf | docx | image | |
| fileBase64: str | |
| class EntitiesModel(BaseModel): | |
| names: list[str] = [] | |
| dates: list[str] = [] | |
| organizations: list[str] = [] | |
| amounts: list[str] = [] | |
| class DocumentResponse(BaseModel): | |
| """Exact response shape required by the problem statement.""" | |
| status: str # "success" | "error" | |
| fileName: str | |
| summary: str = "" | |
| entities: EntitiesModel = EntitiesModel() | |
| sentiment: str = "Neutral" # Positive | Neutral | Negative | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 3 β Authentication | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def verify_api_key(x_api_key: str | None = Header(default=None, alias="x-api-key")) -> str: | |
| """ | |
| FastAPI dependency that validates the x-api-key header. | |
| Returns 401 for missing OR invalid key (PS requirement). | |
| """ | |
| if not API_KEY: | |
| raise HTTPException(status_code=500, detail="Server API_KEY not configured.") | |
| if not x_api_key or x_api_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Unauthorized: invalid or missing API key.") | |
| return x_api_key | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 4 β Text Extraction Layer | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _ocr_bytes(image_bytes: bytes) -> str: | |
| """Run Tesseract OCR on raw image bytes with preprocessing for better accuracy.""" | |
| import pytesseract | |
| from PIL import Image, ImageFilter, ImageOps | |
| img = Image.open(io.BytesIO(image_bytes)) | |
| # Upscale small images (Tesseract works best at 300 DPI / large text) | |
| w, h = img.size | |
| if w < 1000 or h < 1000: | |
| scale = max(2, 1500 // min(w, h)) | |
| img = img.resize((w * scale, h * scale), Image.LANCZOS) | |
| # Preprocessing: grayscale β sharpen β contrast β binarize | |
| img = img.convert("L") | |
| img = img.filter(ImageFilter.SHARPEN) | |
| img = ImageOps.autocontrast(img) | |
| img = img.point(lambda x: 0 if x < 140 else 255) # binarize | |
| text = pytesseract.image_to_string(img, config="--psm 6") | |
| return text.strip() | |
| def extract_text_from_pdf(file_bytes: bytes) -> str: | |
| """ | |
| Extract text from PDF bytes using pdfplumber. | |
| Falls back to OCR (via pdf2image + pytesseract) for scanned/image-only pages. | |
| """ | |
| import pdfplumber | |
| full_text: list[str] = [] | |
| scanned_page_indices: list[int] = [] | |
| with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: | |
| for i, page in enumerate(pdf.pages): | |
| page_text = page.extract_text() | |
| if page_text and page_text.strip(): | |
| full_text.append(page_text.strip()) | |
| else: | |
| scanned_page_indices.append(i) # mark for OCR | |
| # OCR fallback for pages that yielded no text | |
| if scanned_page_indices: | |
| try: | |
| from pdf2image import convert_from_bytes | |
| images = convert_from_bytes(file_bytes, dpi=200) | |
| for idx in scanned_page_indices: | |
| if idx < len(images): | |
| ocr_text = _ocr_bytes( | |
| _pil_to_bytes(images[idx]) | |
| ) | |
| if ocr_text: | |
| full_text.append(ocr_text) | |
| except Exception as e: | |
| logger.warning(f"PDF OCR fallback failed: {e}") | |
| return "\n\n".join(full_text) | |
| def extract_text_from_docx(file_bytes: bytes) -> str: | |
| """ | |
| Extract text from DOCX bytes using python-docx. | |
| Includes paragraphs, headings, and table cell content. | |
| """ | |
| from docx import Document | |
| doc = Document(io.BytesIO(file_bytes)) | |
| parts: list[str] = [] | |
| # Paragraphs & headings | |
| for para in doc.paragraphs: | |
| if para.text.strip(): | |
| parts.append(para.text.strip()) | |
| # Table content | |
| for table in doc.tables: | |
| for row in table.rows: | |
| row_texts = [cell.text.strip() for cell in row.cells if cell.text.strip()] | |
| if row_texts: | |
| parts.append(" | ".join(row_texts)) | |
| return "\n".join(parts) | |
| def extract_text_from_image(file_bytes: bytes) -> str: | |
| """Extract text from image bytes using Tesseract OCR.""" | |
| return _ocr_bytes(file_bytes) | |
| def _pil_to_bytes(pil_image) -> bytes: | |
| """Convert a PIL Image to PNG bytes.""" | |
| buf = io.BytesIO() | |
| pil_image.save(buf, format="PNG") | |
| return buf.getvalue() | |
| def extract_text(file_bytes: bytes, file_type: str) -> str: | |
| """ | |
| Router: dispatch to the correct extraction function based on file type. | |
| Returns the extracted plain-text string. | |
| """ | |
| ft = file_type.lower().strip() | |
| if ft == "pdf": | |
| return extract_text_from_pdf(file_bytes) | |
| elif ft == "docx": | |
| return extract_text_from_docx(file_bytes) | |
| elif ft in ("image", "img", "png", "jpg", "jpeg", "tiff", "bmp", "gif"): | |
| return extract_text_from_image(file_bytes) | |
| else: | |
| raise ValueError(f"Unsupported fileType: '{file_type}'. Use pdf, docx, or image.") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 5 β AI Analysis: Gemini 2.5 Flash (Primary) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _GEMINI_PROMPT_TEMPLATE = """You are a precise document analysis expert. Analyze the document text below and return ONLY valid JSON β no markdown fences, no explanation, nothing else. | |
| Document filename: {file_name} | |
| Document text: | |
| \"\"\" | |
| {text} | |
| \"\"\" | |
| Return this exact JSON structure: | |
| {{ | |
| "summary": "<concise 1β2 sentence summary capturing the main purpose and key facts of the document>", | |
| "entities": {{ | |
| "names": ["<person names found in the document>"], | |
| "dates": ["<dates found, preserve the original format exactly as it appears>"], | |
| "organizations": ["<company or organization names found>"], | |
| "amounts": ["<monetary amounts found, include currency symbol>"] | |
| }}, | |
| "sentiment": "<exactly one of: Positive, Neutral, Negative>" | |
| }} | |
| Rules: | |
| - summary: capture the who/what/when/how-much of the document in 1-2 sentences | |
| - names: only real person names (not job titles, not places) | |
| - dates: preserve original format (e.g. "10 March 2026", "2026-03-10", "10/03/2026") | |
| - organizations: company names, institutions, brands | |
| - amounts: include currency symbol and formatting (e.g. "βΉ10,000", "$500.00", "Rs. 2,50,000") | |
| - sentiment: Positive for praise/good news/approvals, Negative for complaints/rejections/problems, Neutral for factual/informational content | |
| - Return empty arrays [] for entity types not found | |
| - Return ONLY the JSON object, nothing else""" | |
| def analyze_with_gemini(text: str, file_name: str) -> dict[str, Any]: | |
| """ | |
| Call Gemini 2.5 Flash via direct HTTP API (no SDK dependency issues). | |
| Returns a dict with keys: summary, entities (dict), sentiment. | |
| Raises RuntimeError if Gemini is unavailable or returns invalid JSON. | |
| """ | |
| import json | |
| import requests as _requests | |
| if not GEMINI_API_KEY: | |
| raise RuntimeError("GEMINI_API_KEY not set.") | |
| # Truncate to avoid token limits (keep first 6000 chars β plenty for context) | |
| truncated_text = text[:6000] if len(text) > 6000 else text | |
| prompt = _GEMINI_PROMPT_TEMPLATE.format( | |
| file_name=file_name, | |
| text=truncated_text, | |
| ) | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={GEMINI_API_KEY}" | |
| payload = { | |
| "contents": [{"parts": [{"text": prompt}]}], | |
| "generationConfig": { | |
| "temperature": 0.1, | |
| "maxOutputTokens": 1024, | |
| }, | |
| } | |
| resp = _requests.post(url, json=payload, timeout=60) | |
| if resp.status_code != 200: | |
| raise RuntimeError(f"Gemini HTTP {resp.status_code}: {resp.text[:300]}") | |
| data = resp.json() | |
| raw = data["candidates"][0]["content"]["parts"][0]["text"].strip() | |
| # Strip any accidental markdown fences | |
| if raw.startswith("```"): | |
| raw = re.sub(r"^```[a-z]*\n?", "", raw) | |
| raw = re.sub(r"\n?```$", "", raw) | |
| raw = raw.strip() | |
| result = json.loads(raw) | |
| # Normalise entity keys to match response schema | |
| entities = result.get("entities", {}) | |
| return { | |
| "summary": str(result.get("summary", "")).strip(), | |
| "entities": { | |
| "names": _dedup(entities.get("names", [])), | |
| "dates": _dedup(entities.get("dates", [])), | |
| "organizations": _dedup(entities.get("organizations", [])), | |
| "amounts": _dedup(entities.get("amounts", [])), | |
| }, | |
| "sentiment": _normalize_sentiment(result.get("sentiment", "Neutral")), | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 6 β AI Analysis: Offline Fallback | |
| # (spaCy NER + regex + DistilBERT + sumy TextRank) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββ Regex patterns ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _DATE_PATTERNS = [ | |
| # "10 March 2026", "10th March 2026" | |
| r"\b\d{1,2}(?:st|nd|rd|th)?\s+(?:January|February|March|April|May|June|" | |
| r"July|August|September|October|November|December)\s+\d{4}\b", | |
| # "March 10, 2026" / "March 10 2026" | |
| r"\b(?:January|February|March|April|May|June|July|August|September|" | |
| r"October|November|December)\s+\d{1,2},?\s+\d{4}\b", | |
| # "Jan 10, 2026" abbreviated | |
| r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+\d{1,2},?\s+\d{4}\b", | |
| # "10/03/2026", "2026-03-10", "10-03-2026" | |
| r"\b\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}\b", | |
| r"\b\d{4}[/\-]\d{1,2}[/\-]\d{1,2}\b", | |
| ] | |
| _AMOUNT_PATTERNS = [ | |
| # βΉ10,000 / βΉ10000 / βΉ 10,000.50 | |
| r"βΉ\s?[\d,]+(?:\.\d{1,2})?", | |
| # Rs. 10,000 / Rs 10000 | |
| r"Rs\.?\s?[\d,]+(?:\.\d{1,2})?", | |
| # INR 10,000 | |
| r"INR\s?[\d,]+(?:\.\d{1,2})?", | |
| # $500 / $500.00 | |
| r"\$\s?[\d,]+(?:\.\d{1,2})?", | |
| # USD 500 | |
| r"USD\s?[\d,]+(?:\.\d{1,2})?", | |
| # β¬ 100 | |
| r"β¬\s?[\d,]+(?:\.\d{1,2})?", | |
| # Β£ 100 | |
| r"Β£\s?[\d,]+(?:\.\d{1,2})?", | |
| ] | |
| def _regex_extract_dates(text: str) -> list[str]: | |
| found = [] | |
| for pattern in _DATE_PATTERNS: | |
| found.extend(re.findall(pattern, text, re.IGNORECASE)) | |
| return _dedup(found) | |
| def _regex_extract_amounts(text: str) -> list[str]: | |
| found = [] | |
| for pattern in _AMOUNT_PATTERNS: | |
| found.extend(re.findall(pattern, text)) | |
| return _dedup(found) | |
| def generate_summary_fallback(text: str) -> str: | |
| """Summarise text using sumy TextRank algorithm (100% offline).""" | |
| try: | |
| from sumy.parsers.plaintext import PlaintextParser | |
| from sumy.nlp.tokenizers import Tokenizer | |
| from sumy.summarizers.text_rank import TextRankSummarizer | |
| # Use first 4000 chars to keep sumy fast | |
| snippet = text[:4000] | |
| parser = PlaintextParser.from_string(snippet, Tokenizer("english")) | |
| summarizer = TextRankSummarizer() | |
| sentences = summarizer(parser.document, sentences_count=2) | |
| summary = " ".join(str(s) for s in sentences).strip() | |
| return summary if summary else text[:250].strip() + "..." | |
| except Exception as e: | |
| logger.warning(f"sumy summarisation failed: {e}") | |
| # Last-resort: first 250 characters as stub summary | |
| return text[:250].strip() + ("..." if len(text) > 250 else "") | |
| def extract_entities_fallback(text: str) -> dict[str, list[str]]: | |
| """ | |
| Extract named entities using spaCy NER + regex. | |
| spaCy handles PERSON / ORG; regex handles dates and monetary amounts. | |
| """ | |
| global _nlp | |
| names: list[str] = [] | |
| organizations: list[str] = [] | |
| if _nlp is not None: | |
| try: | |
| # Truncate for speed (spaCy can be slow on very long docs) | |
| doc = _nlp(text[:5000]) | |
| for ent in doc.ents: | |
| if ent.label_ == "PERSON": | |
| names.append(ent.text.strip()) | |
| elif ent.label_ in ("ORG", "GPE"): | |
| organizations.append(ent.text.strip()) | |
| except Exception as e: | |
| logger.warning(f"spaCy NER failed: {e}") | |
| dates = _regex_extract_dates(text) | |
| amounts = _regex_extract_amounts(text) | |
| return { | |
| "names": _dedup(names), | |
| "dates": _dedup(dates), | |
| "organizations": _dedup(organizations), | |
| "amounts": _dedup(amounts), | |
| } | |
| def analyze_sentiment_fallback(text: str) -> str: | |
| """ | |
| Classify sentiment using DistilBERT SST-2 (offline HuggingFace model). | |
| Maps POSITIVE β "Positive", NEGATIVE β "Negative". | |
| Score < 0.65 maps to "Neutral" to avoid overconfident labelling. | |
| """ | |
| global _sentiment_pipe | |
| if _sentiment_pipe is None: | |
| return "Neutral" | |
| try: | |
| # Use first 512 tokens worth of text | |
| snippet = text[:1500] | |
| result = _sentiment_pipe(snippet, truncation=True, max_length=512)[0] | |
| label: str = result["label"] # "POSITIVE" or "NEGATIVE" | |
| score: float = result["score"] | |
| if score < 0.65: | |
| return "Neutral" | |
| return "Positive" if label == "POSITIVE" else "Negative" | |
| except Exception as e: | |
| logger.warning(f"Sentiment analysis failed: {e}") | |
| return "Neutral" | |
| def analyze_with_fallback(text: str, file_name: str) -> dict[str, Any]: | |
| """Orchestrate offline analysis: summary + entities + sentiment.""" | |
| summary = generate_summary_fallback(text) | |
| entities = extract_entities_fallback(text) | |
| sentiment = analyze_sentiment_fallback(text) | |
| return { | |
| "summary": summary, | |
| "entities": entities, | |
| "sentiment": sentiment, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 7 β Main Analysis Dispatcher | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def analyze_document(text: str, file_name: str) -> dict[str, Any]: | |
| """ | |
| Try Gemini first; if it fails (network error, quota, bad JSON) fall back | |
| to the fully-offline pipeline so the API never returns an empty response. | |
| """ | |
| if GEMINI_API_KEY: | |
| try: | |
| result = analyze_with_gemini(text, file_name) | |
| logger.info("Analysis completed via Gemini.") | |
| return result | |
| except Exception as e: | |
| logger.warning(f"Gemini failed ({e}), switching to offline fallback.") | |
| result = analyze_with_fallback(text, file_name) | |
| logger.info("Analysis completed via offline fallback.") | |
| return result | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 8 β Startup: preload heavy models | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def lifespan(app: FastAPI): | |
| """ | |
| Preload spaCy and DistilBERT at startup so the first request isn't slow. | |
| NLTK punkt tokenizer (required by sumy) is also downloaded if missing. | |
| """ | |
| global _nlp, _sentiment_pipe | |
| # spaCy | |
| try: | |
| import spacy | |
| _nlp = spacy.load("en_core_web_sm") | |
| logger.info("spaCy model loaded.") | |
| except Exception as e: | |
| logger.warning(f"spaCy load failed (entity extraction will be regex-only): {e}") | |
| # DistilBERT sentiment | |
| try: | |
| from transformers import pipeline as hf_pipeline | |
| _sentiment_pipe = hf_pipeline( | |
| "sentiment-analysis", | |
| model="distilbert-base-uncased-finetuned-sst-2-english", | |
| device=-1, # CPU | |
| ) | |
| logger.info("DistilBERT sentiment model loaded.") | |
| except Exception as e: | |
| logger.warning(f"DistilBERT load failed (sentiment will default to Neutral): {e}") | |
| # NLTK punkt (required by sumy) | |
| try: | |
| nltk.download("punkt", quiet=True) | |
| nltk.download("punkt_tab", quiet=True) | |
| nltk.download("stopwords", quiet=True) | |
| logger.info("NLTK data ready.") | |
| except Exception as e: | |
| logger.warning(f"NLTK download failed: {e}") | |
| yield # application runs | |
| logger.info("Shutting down.") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 9 β FastAPI Application | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="GUVI Track 2 β Document Analysis API", | |
| description="Multi-format document analysis: PDF, DOCX, Image (OCR). AI-powered summary, entity extraction, and sentiment analysis.", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["POST", "GET"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Global exception handler: ensure ALL errors return the required JSON shape β | |
| async def http_exception_handler(request: Request, exc: HTTPException): | |
| """Return errors in the same JSON shape the PS requires (except 401 which is standard).""" | |
| if exc.status_code == 401: | |
| return JSONResponse(status_code=401, content={"detail": exc.detail}) | |
| return JSONResponse( | |
| status_code=exc.status_code, | |
| content={ | |
| "status": "error", | |
| "fileName": "", | |
| "summary": str(exc.detail), | |
| "entities": {"names": [], "dates": [], "organizations": [], "amounts": []}, | |
| "sentiment": "Neutral", | |
| }, | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 10 β Endpoints | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health_check(): | |
| """Health check endpoint.""" | |
| return {"status": "ok", "message": "Document Analysis API is running."} | |
| async def document_analyze( | |
| body: DocumentRequest, | |
| _key: str = Depends(verify_api_key), | |
| ): | |
| """ | |
| Main analysis endpoint. | |
| - **fileName**: original file name (echoed back in response) | |
| - **fileType**: `pdf` | `docx` | `image` | |
| - **fileBase64**: base64-encoded file content | |
| Returns summary, named entities, and sentiment. | |
| """ | |
| try: | |
| # ββ 1. Decode base64 ββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| file_bytes = base64.b64decode(body.fileBase64) | |
| except Exception: | |
| raise HTTPException(status_code=400, detail="Invalid base64 encoding in fileBase64.") | |
| if not file_bytes: | |
| raise HTTPException(status_code=400, detail="fileBase64 decoded to empty bytes.") | |
| # ββ 2. Extract text βββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| text = extract_text(file_bytes, body.fileType) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Text extraction failed: {e}") | |
| raise HTTPException(status_code=422, detail=f"Text extraction error: {e}") | |
| if not text or not text.strip(): | |
| # Return a graceful error rather than crashing | |
| return DocumentResponse( | |
| status="error", | |
| fileName=body.fileName, | |
| summary="Could not extract any text from the document.", | |
| entities=EntitiesModel(), | |
| sentiment="Neutral", | |
| ) | |
| # ββ 3. AI Analysis ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| analysis = analyze_document(text.strip(), body.fileName) | |
| # ββ 4. Build response βββββββββββββββββββββββββββββββββββββββββββββ | |
| ents = analysis.get("entities", {}) | |
| return DocumentResponse( | |
| status="success", | |
| fileName=body.fileName, | |
| summary=analysis.get("summary", ""), | |
| entities=EntitiesModel( | |
| names=ents.get("names", []), | |
| dates=ents.get("dates", []), | |
| organizations=ents.get("organizations", []), | |
| amounts=ents.get("amounts", []), | |
| ), | |
| sentiment=analysis.get("sentiment", "Neutral"), | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Unexpected error: {e}", exc_info=True) | |
| return DocumentResponse( | |
| status="error", | |
| fileName=body.fileName, | |
| summary=f"Processing failed: {str(e)}", | |
| entities=EntitiesModel(), | |
| sentiment="Neutral", | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 11 β Helpers | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _dedup(items: list) -> list[str]: | |
| """Remove duplicates while preserving order; cast all items to str.""" | |
| seen: set = set() | |
| result: list[str] = [] | |
| for item in items: | |
| s = str(item).strip() | |
| if s and s.lower() not in seen: | |
| seen.add(s.lower()) | |
| result.append(s) | |
| return result | |
| def _normalize_sentiment(raw: str) -> str: | |
| """Normalise any sentiment string to exactly Positive / Neutral / Negative.""" | |
| mapping = { | |
| "positive": "Positive", | |
| "negative": "Negative", | |
| "neutral": "Neutral", | |
| } | |
| return mapping.get(raw.strip().lower(), "Neutral") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 12 β Entry point | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) | |