import os import sys import subprocess # ────────────────────────────────────────────── # 0. Auto-fix PaddlePaddle 3.0+ PIR Crash # ────────────────────────────────────────────── if os.environ.get("_PADDLE_VER_FIXED") != "1": try: import paddleocr if paddleocr.__version__.startswith(("3.", "2.10")): print("⏳ Auto-fixing PaddleOCR (downgrading to stable v2.9.1 to fix CPU crash)...") subprocess.check_call([sys.executable, "-m", "pip", "install", "paddleocr==2.9.1", "paddlepaddle==2.6.2", "-q"]) os.environ["_PADDLE_VER_FIXED"] = "1" print("🔄 Restarting process with fixed versions...") os.execv(sys.executable, [sys.executable] + sys.argv) except Exception as e: print(f"⚠️ PaddleOCR auto-fix skipped: {e}") # Thread limits for CPU stability os.environ["OMP_THREAD_LIMIT"] = "1" os.environ["OMP_NUM_THREADS"] = "1" os.environ["MKL_NUM_THREADS"] = "1" os.environ["OPENBLAS_NUM_THREADS"] = "1" import re import json import csv import gradio as gr import cloudinary import cloudinary.uploader import numpy as np from PIL import Image, ImageFilter from paddleocr import PaddleOCR from dotenv import load_dotenv from pymongo import MongoClient import pytz from datetime import datetime # Local LLM dependencies from huggingface_hub import hf_hub_download from llama_cpp import Llama # ────────────────────────────────────────────── # A. Setup & Configuration # ────────────────────────────────────────────── load_dotenv() cloudinary.config( cloud_name=os.environ.get("CLOUDINARY_CLOUD_NAME"), api_key=os.environ.get("CLOUDINARY_API_KEY"), api_secret=os.environ.get("CLOUDINARY_API_SECRET"), ) # Initialize PaddleOCR (v2.9.1 stable syntax) paddle_ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False) # Initialize Local LLM (Qwen2.5-1.5B - Ultra-fast for HF Free Tier 2 vCPUs) print("📥 Loading Ultra-Fast Local LLM (Qwen2.5-1.5B-Instruct)...") model_path = hf_hub_download( repo_id="bartowski/Qwen2.5-1.5B-Instruct-GGUF", filename="Qwen2.5-1.5B-Instruct-Q4_K_M.gguf" ) has_gpu = bool(os.environ.get("CUDA_VISIBLE_DEVICES", "").strip()) local_llm = Llama( model_path=model_path, n_ctx=2048, # Increased to fit prompt + full JSON without truncating n_threads=min(4, os.cpu_count() or 2), # Capped specifically for HF Free Tier (2 vCPUs) n_gpu_layers=-1 if has_gpu else 0, n_batch=512, # Safe batch size for 2 CPUs use_mlock=True, # Prevents swapping to disk verbose=False ) print("✅ Local LLM Loaded Successfully!") mongo_uri = os.environ.get("MONGODB_URI") mongo_client = None db = None collection = None if mongo_uri: try: mongo_client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000) mongo_client.admin.command("ping") db = mongo_client["police_db"] collection = db["warrant"] print("✅ Connected successfully to MongoDB!") except Exception as exc: print(f"❌ MongoDB connection failed: {exc}") collection = None officers_db: dict = {} _officers_csv_error: str | None = None try: with open("officers.csv", "r", encoding="utf-8") as _f: for row in csv.DictReader(_f): officers_db[row["Officer_Name"]] = row["Phone_Number"] except Exception as _e: _officers_csv_error = str(_e) print(f"⚠️ Could not load officers.csv: {_e}") # ────────────────────────────────────────────── # B. Core Processing Logic # ────────────────────────────────────────────── SYSTEM_PROMPT = """You are an extremely precise and strict Indian legal document parser. Your task is to extract information from raw OCR text of a Punjab court warrant or summons. CRITICAL RULES TO PREVENT HALLUCINATION & FABRICATION: 1. NEVER assume, guess, or fabricate any field. If a field is not explicitly and clearly mentioned in the provided text, you MUST return null for that field. 2. DO NOT use placeholder values unless they are literally printed in the text. 3. Case_FIR_Number: Extract the court case number AND/OR the FIR number. - Use " | " separator only if two DISTINCT numbers exist. - Ignore or filter out barcode metadata, serial numbers, or form numbers. 4. Act_and_Sections: Extract only explicitly mentioned sections (e.g. "IPC 302"). null if absent. 5. Person_Name_To_Serve: The person to be served/arrested — found after "Whereas [NAME] has been duly served ... has failed to attend". NEVER use the accused from "Vs [name]" headers. 6. Hearing_Date: The NEXT hearing date only. Format DD-MM-YYYY (e.g. "05-05-2026"). - You MUST extract the full day, month, AND year. A year-only value like "2026" is WRONG. - Look for labels like "NEXT DATE", "Next Date", "Next Hearing", "Date of Hearing". 7. Court_Name: Extract the specific court designation/level and location. 8. Ground every value in the OCR text. Prefer null over a guess. Return ONLY valid JSON, no markdown fences, no explanation: { "Case_FIR_Number": "...", "Act_and_Sections": null, "Type_of_Document": "...", "Target_Police_Station": "...", "IO_Name_and_Belt_No": "...", "IO_Mobile_Number": null, "Person_Name_To_Serve": "...", "Person_Address": "...", "Court_Name": "...", "Hearing_Date": "..." } """ REQUIRED_KEYS = [ "Case_FIR_Number", "Act_and_Sections", "Type_of_Document", "Target_Police_Station", "IO_Name_and_Belt_No", "IO_Mobile_Number", "Person_Name_To_Serve", "Person_Address", "Court_Name", "Hearing_Date", ] _MONTH_MAP = { "january": "01", "february": "02", "march": "03", "april": "04", "may": "05", "june": "06", "july": "07", "august": "08", "september": "09", "october": "10", "november": "11", "december": "12", "jan": "01", "feb": "02", "mar": "03", "apr": "04", "jun": "06", "jul": "07", "aug": "08", "sep": "09", "oct": "10", "nov": "11", "dec": "12", } # ── OCR ─────────────────────────────────────── _OCR_MAX_DIM = 1500 # px — cap before handing to PaddleOCR _OCR_MIN_DIM = 1000 # px — upscale target for very small images def _preprocess_for_ocr(img: Image.Image) -> Image.Image: img = img.convert("RGB") max_dim = max(img.size) if max_dim < _OCR_MIN_DIM: scale = _OCR_MIN_DIM / max_dim img = img.resize( (int(img.width * scale), int(img.height * scale)), Image.LANCZOS ) elif max_dim > _OCR_MAX_DIM: scale = _OCR_MAX_DIM / max_dim img = img.resize( (int(img.width * scale), int(img.height * scale)), Image.LANCZOS ) img = img.filter(ImageFilter.SHARPEN) return img def _ocr_image(image_path: str) -> str: try: img = Image.open(image_path) processed = _preprocess_for_ocr(img) img_array = np.array(processed) # v2.9.1 stable syntax result = paddle_ocr.ocr(img_array, cls=True) if not result or not result[0]: return "[OCR returned empty text — image may be blank or unreadable]" lines = [] for line in result[0]: text = line[1][0] lines.append(text) return "\n".join(lines) except Exception as exc: return f"[OCR Error] {exc}" # ── Post-processing helpers ─────────────────── def _extract_person_from_whereas(raw_ocr: str) -> str | None: m = re.search( r"[Ww]hereas\s+([A-Za-z\s]+(?:,\s*no\.?\s*\d+)?)[,\s]+" r"(?:\([^)]*\)[,\s]+)?(?:R/O[^,]*,)?\s*has been duly served", raw_ocr, ) return m.group(1).strip() if m else None def _extract_next_date_from_ocr(raw_ocr: str) -> str | None: m = re.search( r"(?:NEXT\s*DATE|Next\s*Date|Next\s*Hearing|Hearing\s*Date)\s*[:\-]?\s*" r"(\d{1,2}[-/.\s]\d{1,2}[-/.\s]\d{4}|\d{4}[-/.\s]\d{1,2}[-/.\s]\d{1,2})", raw_ocr, re.IGNORECASE ) if m: raw_date = re.sub(r"[\s/.]", "-", m.group(1).strip()) yyyy_first = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", raw_date) if yyyy_first: raw_date = f"{yyyy_first.group(3).zfill(2)}-{yyyy_first.group(2).zfill(2)}-{yyyy_first.group(1)}" return raw_date return None def _post_validate(data: dict, raw_ocr: str) -> dict: person = data.get("Person_Name_To_Serve") or "" if person and re.search(r"\bVs\s+" + re.escape(person), raw_ocr, re.IGNORECASE): fallback = _extract_person_from_whereas(raw_ocr) if fallback: data["Person_Name_To_Serve"] = fallback if not person: fallback = _extract_person_from_whereas(raw_ocr) if fallback: data["Person_Name_To_Serve"] = fallback hdate = data.get("Hearing_Date") or "" if hdate and re.fullmatch(r"\d{4}", str(hdate).strip()): recovered = _extract_next_date_from_ocr(raw_ocr) data["Hearing_Date"] = recovered hdate = recovered or "" if hdate: m_date = re.fullmatch(r"(\d{2})-(\d{2})-(\d{4})", str(hdate).strip()) if m_date: day, month, year = m_date.groups() ocr_years = [y for y in re.findall(r"\b(202\d|203\d)\b", raw_ocr)] if ocr_years: from collections import Counter year_counts = Counter(ocr_years) most_common_year, count = year_counts.most_common(1)[0] if year != most_common_year and year_counts[most_common_year] >= 2 and year_counts[year] <= 1: data["Hearing_Date"] = f"{day}-{month}-{most_common_year}" return data def _is_date_grounded(val: str, raw_ocr_lower: str) -> bool: val_str = str(val).strip() if re.fullmatch(r"\d{4}", val_str): return False parts = [p for p in re.split(r"[-/.\s]+", val_str) if p] if len(parts) < 3: return False if val_str.lower() in raw_ocr_lower: return True for part in parts: clean = re.sub(r"(?<=\d)(st|nd|rd|th)$", "", part.lower()) alias = _MONTH_MAP.get(clean) if clean not in raw_ocr_lower and (alias is None or alias not in raw_ocr_lower): return False return True def _strict_grounding_filter(data: dict, raw_ocr: str) -> dict: if not isinstance(data, dict): return data raw_ocr_lower = raw_ocr.lower() def is_grounded(val) -> bool: if not val or str(val).strip().lower() in ("null", "none", "—", ""): return False val_str = str(val).strip() if val_str.lower() in raw_ocr_lower: return True code_tokens = [t.lower() for t in re.split(r"[/\-]", val_str) if len(t) > 2] if code_tokens and any(t in raw_ocr_lower for t in code_tokens): return True words = [w.lower() for w in re.split(r"[^a-zA-Z0-9]", val_str) if len(w) > 2] if not words: nums = [n for n in re.split(r"\D+", val_str) if n] return any(n in raw_ocr_lower for n in nums) if nums else False matched = sum(1 for w in words if w in raw_ocr_lower) return matched >= max(1, round(len(words) * 0.75)) case_fir = data.get("Case_FIR_Number") if case_fir: case_fir = str(case_fir).strip() if " | " in case_fir: parts = list(dict.fromkeys(p.strip() for p in case_fir.split("|") if p.strip())) grounded = [p for p in parts if is_grounded(p)] data["Case_FIR_Number"] = " | ".join(grounded) if grounded else None elif not is_grounded(case_fir): data["Case_FIR_Number"] = None for field in [ "Act_and_Sections", "Type_of_Document", "Target_Police_Station", "IO_Name_and_Belt_No", "IO_Mobile_Number", "Person_Name_To_Serve", "Person_Address", "Court_Name", "Hearing_Date", ]: val = data.get(field) if val: if field == "Hearing_Date": if not _is_date_grounded(val, raw_ocr_lower): data[field] = None elif not is_grounded(val): data[field] = None return data def _clean_and_parse_json(raw_response: str, raw_ocr: str = "") -> dict: # Used \x60 to prevent markdown parser truncation bug cleaned = re.sub(r"^\x60\x60\x60(?:json)?\s*", "", raw_response.strip()) cleaned = re.sub(r"\s*\x60\x60\x60$", "", cleaned).strip() try: data = json.loads(cleaned) if isinstance(data, dict): normalized = {k.replace("__", "_").strip(): v for k, v in data.items()} final_data = { req: next((v for k, v in normalized.items() if k.lower() == req.lower()), None) for req in REQUIRED_KEYS } if raw_ocr: final_data = _post_validate(final_data, raw_ocr) final_data = _strict_grounding_filter(final_data, raw_ocr) return final_data return data except json.JSONDecodeError: return { "_parse_error": True, "_raw_llm_response": raw_response, "_message": "Could not parse LLM response as JSON.", } # ── Main pipeline ───────────────────────────── def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)): if image_path is None: raise gr.Error("Please upload an image first.") progress(0, desc="☁️ Uploading to Cloudinary…") yield "⏳ Uploading to Cloudinary…", "", {} try: upload_result = cloudinary.uploader.upload( image_path, folder="warrants", resource_type="image" ) cloudinary_url = upload_result.get("secure_url", "") except Exception as exc: raise gr.Error(f"Cloudinary upload failed: {exc}") progress(0.25, desc="🔍 Running OCR…") yield cloudinary_url, "⏳ Extracting text via OCR…", {} try: raw_text = _ocr_image(image_path) except Exception as exc: raw_text = f"[OCR Error] {exc}" if not raw_text or not raw_text.strip(): raw_text = "[OCR returned empty text — image may be blank or unreadable]" progress(0.5, desc="🤖 Calling Local AI model…") yield cloudinary_url, raw_text, {"status": "⏳ Calling Local AI model…"} prompt = f"--- RAW OCR TEXT ---\n{raw_text}\n--- END ---" try: # stream=False runs significantly faster on limited CPUs (HF Free Tier) response = local_llm.create_chat_completion( messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt} ], temperature=0.1, top_p=0.1, max_tokens=1024, # Plenty of room to finish the JSON stop=["\n\n", "```"], stream=False, ) llm_response = response["choices"][0]["message"]["content"] yield cloudinary_url, raw_text, {"streaming_raw_response": llm_response} except Exception as exc: raise gr.Error(f"Local LLM processing failed: {exc}") parsed_json = _clean_and_parse_json(llm_response, raw_text) if collection is not None and "_parse_error" not in parsed_json: try: collection.insert_one({ **parsed_json, "cloudinary_url": cloudinary_url, "raw_ocr_text": raw_text, "uploaded_at": datetime.now(pytz.timezone("Asia/Kolkata")), }) except Exception as exc: print(f"❌ MongoDB insert failed: {exc}") progress(1.0, desc="✅ Done!") yield cloudinary_url, raw_text, parsed_json # ────────────────────────────────────────────── # C. Dashboard # ────────────────────────────────────────────── C_LABEL = "#4f46e5" C_VALUE = "#1a1a2e" C_CARD_BG = "#ffffff" C_CARD_BG2 = "#f8f7ff" C_CARD_BORDER = "#e0e7ff" C_PILL_BG = "#ede9fe" C_PILL_TEXT = "#3730a3" C_SEPARATOR = "#ede9fe" C_MUTED = "#6b7280" C_BTN_BG = "#4f46e5" C_BTN_TEXT = "#ffffff" C_TH_BG1 = "#4f46e5" C_TH_BG2 = "#7c3aed" C_TH_TEXT = "#ffffff" def _card_row(label: str, value: str, is_pill: bool = False, is_btn: bool = False) -> str: label_html = ( f'' ) if is_btn: value_html = ( f'🖼 View' ) elif is_pill: value_html = ( f'' f'' f'{value}' ) elif value and value != "—": value_html = ( f'{value}' ) else: value_html = ( f'—' ) return ( f'