File size: 29,516 Bytes
fa15fa1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
"""
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
# ─────────────────────────────────────────────────────────────────────────────

@asynccontextmanager
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 ─
@app.exception_handler(HTTPException)
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
# ─────────────────────────────────────────────────────────────────────────────

@app.get("/", tags=["Health"])
async def health_check():
    """Health check endpoint."""
    return {"status": "ok", "message": "Document Analysis API is running."}


@app.post(
    "/api/document-analyze",
    response_model=DocumentResponse,
    tags=["Document Analysis"],
    summary="Analyse a base64-encoded PDF, DOCX, or image document",
)
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)