Files changed (1) hide show
  1. app.py +224 -180
app.py CHANGED
@@ -19,9 +19,7 @@ from pymongo import MongoClient
19
  import pytz
20
  from datetime import datetime
21
 
22
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
  # A. Setup & Configuration
24
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
 
26
  load_dotenv()
27
 
@@ -68,9 +66,8 @@ except Exception as _e:
68
  _officers_csv_error = str(_e)
69
  print(f"โš ๏ธ Could not load officers.csv: {_e}")
70
 
71
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
72
  # B. Core Processing Logic
73
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
74
 
75
  SYSTEM_PROMPT = """You are an extremely precise and strict Indian legal document parser.
76
  Your task is to extract information from raw OCR text of a Punjab court warrant or summons.
@@ -125,14 +122,7 @@ _MONTH_MAP = {
125
  "oct": "10", "nov": "11", "dec": "12",
126
  }
127
 
128
- # โ”€โ”€ Detect available Tesseract languages โ”€โ”€โ”€โ”€โ”€โ”€
129
-
130
  def _get_available_tess_langs() -> str:
131
- """
132
- Query Tesseract for installed language packs and build the best
133
- available lang string for Punjab court documents (eng + hin + pan).
134
- Falls back gracefully if hin/pan are not installed.
135
- """
136
  try:
137
  import subprocess
138
  result = subprocess.run(
@@ -142,83 +132,43 @@ def _get_available_tess_langs() -> str:
142
  installed = set(result.stdout.lower().split() + result.stderr.lower().split())
143
  except Exception:
144
  installed = {"eng"}
145
-
146
  langs = ["eng"]
147
  if "hin" in installed:
148
  langs.append("hin")
149
  if "pan" in installed:
150
  langs.append("pan")
151
-
152
  lang_str = "+".join(langs)
153
  print(f"๐Ÿ”ค Tesseract languages available: {lang_str}")
154
  return lang_str
155
 
156
-
157
  _TESS_LANG = _get_available_tess_langs()
158
 
159
- # โ”€โ”€ OCR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
-
161
- _OCR_MAX_DIM = 1500 # px โ€” cap before handing to Tesseract (reduced to prevent HF timeout)
162
- _OCR_MIN_DIM = 1000 # px โ€” upscale target for very small images (optimized for speed)
163
- _OCR_TIMEOUT_S = 60 # seconds โ€” hard kill on the Tesseract subprocess
164
 
165
 
166
  def _preprocess_for_ocr(img: Image.Image) -> Image.Image:
167
- """
168
- Prepare image for Tesseract:
169
- 1. Convert to greyscale (L mode).
170
- 2. Upscale tiny images so Tesseract has enough pixel density (~300 DPI).
171
- 3. **Downscale large images** โ€” phone camera shots can be 4000ร—3000+ px,
172
- which causes Tesseract to hang for minutes. We cap the longest edge at
173
- _OCR_MAX_DIM (2000 px) which is more than enough for court-document text.
174
- 4. Sharpen after any resize to compensate for interpolation softness.
175
- """
176
  img = img.convert("L")
177
  max_dim = max(img.size)
178
-
179
  if max_dim < _OCR_MIN_DIM:
180
- # Too small โ€” upscale
181
  scale = _OCR_MIN_DIM / max_dim
182
- img = img.resize(
183
- (int(img.width * scale), int(img.height * scale)), Image.LANCZOS
184
- )
185
  elif max_dim > _OCR_MAX_DIM:
186
- # Too large โ€” downscale to prevent Tesseract timeout
187
  scale = _OCR_MAX_DIM / max_dim
188
- img = img.resize(
189
- (int(img.width * scale), int(img.height * scale)), Image.LANCZOS
190
- )
191
-
192
- # Sharpen after any resize
193
  img = img.filter(ImageFilter.SHARPEN)
194
  return img
195
 
196
 
197
  def _ocr_image(image_path: str) -> str:
198
- """
199
- Run Tesseract via subprocess with a hard OS-level kill on timeout.
200
-
201
- Key fixes vs original:
202
- โ€ข Images are now CAPPED at _OCR_MAX_DIM (2000 px longest edge).
203
- Phone camera images (4K+) were the primary cause of the hang.
204
- โ€ข Uses subprocess + os.killpg so the Tesseract process is truly killed
205
- on timeout (ThreadPoolExecutor.cancel() only abandons the Python
206
- thread โ€” the Tesseract child process kept running and blocked the next
207
- request).
208
- โ€ข PSM 6 (uniform block) instead of PSM 3 (full auto-layout) โ€” warrants
209
- are single-column documents; PSM 3's expensive layout analysis adds
210
- time without improving accuracy here.
211
- โ€ข Falls back to pytesseract if subprocess approach fails (e.g. Windows).
212
- """
213
  import subprocess, tempfile, os, signal, sys
214
-
215
  try:
216
  img = Image.open(image_path)
217
  processed = _preprocess_for_ocr(img)
218
  except Exception as exc:
219
  return f"[OCR Error] Could not open/preprocess image: {exc}"
220
 
221
- # Write the preprocessed image to a temp file for the subprocess approach
222
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
223
  tmp_path = tmp.name
224
  try:
@@ -229,25 +179,13 @@ def _ocr_image(image_path: str) -> str:
229
 
230
  tess_cmd = pytesseract.pytesseract.tesseract_cmd or "tesseract"
231
  out_base = tmp_path.replace(".png", "_out")
232
- cmd = [
233
- tess_cmd, tmp_path, out_base,
234
- "-l", _TESS_LANG,
235
- "--oem", "1",
236
- "--psm", "4", # single column of variable sizes โ€” matches Punjab warrants layout best
237
- ]
238
 
239
  try:
240
- # Use subprocess with a real timeout so we can kill the process tree
241
  kwargs = {}
242
  if sys.platform != "win32":
243
- kwargs["start_new_session"] = True # lets us killpg the whole group
244
-
245
- proc = subprocess.Popen(
246
- cmd,
247
- stdout=subprocess.PIPE,
248
- stderr=subprocess.PIPE,
249
- **kwargs,
250
- )
251
  try:
252
  proc.communicate(timeout=_OCR_TIMEOUT_S)
253
  except subprocess.TimeoutExpired:
@@ -267,7 +205,6 @@ def _ocr_image(image_path: str) -> str:
267
  result = f.read()
268
  os.unlink(out_txt)
269
  return result
270
- # Subprocess produced no output file โ€” fall through to pytesseract fallback
271
  except Exception:
272
  pass
273
  finally:
@@ -276,11 +213,8 @@ def _ocr_image(image_path: str) -> str:
276
  except Exception:
277
  pass
278
 
279
- # โ”€โ”€ Fallback: pytesseract (Windows or if subprocess path fails) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
280
  def _run() -> str:
281
- return pytesseract.image_to_string(
282
- processed, lang=_TESS_LANG, config="--oem 1 --psm 4"
283
- )
284
 
285
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
286
  future = executor.submit(_run)
@@ -292,7 +226,6 @@ def _ocr_image(image_path: str) -> str:
292
  except Exception as exc:
293
  return f"[OCR Error] {exc}"
294
 
295
- # โ”€โ”€ Post-processing helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
296
 
297
  def _extract_person_from_whereas(raw_ocr: str) -> str | None:
298
  m = re.search(
@@ -304,11 +237,6 @@ def _extract_person_from_whereas(raw_ocr: str) -> str | None:
304
 
305
 
306
  def _extract_next_date_from_ocr(raw_ocr: str) -> str | None:
307
- """
308
- Fallback: scan OCR text for a date near 'NEXT DATE' / 'Next Date' / 'Next Hearing' labels.
309
- Returns DD-MM-YYYY string if found, else None.
310
- """
311
- # Look for "NEXT DATE" or similar label followed by a date within ~60 characters
312
  m = re.search(
313
  r"(?:NEXT\s*DATE|Next\s*Date|Next\s*Hearing|Hearing\s*Date)\s*[:\-]?\s*"
314
  r"(\d{1,2}[-/.\s]\d{1,2}[-/.\s]\d{4}|\d{4}[-/.\s]\d{1,2}[-/.\s]\d{1,2})",
@@ -316,7 +244,6 @@ def _extract_next_date_from_ocr(raw_ocr: str) -> str | None:
316
  )
317
  if m:
318
  raw_date = re.sub(r"[\s/.]", "-", m.group(1).strip())
319
- # Normalise YYYY-MM-DD โ†’ DD-MM-YYYY
320
  yyyy_first = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", raw_date)
321
  if yyyy_first:
322
  raw_date = f"{yyyy_first.group(3).zfill(2)}-{yyyy_first.group(2).zfill(2)}-{yyyy_first.group(1)}"
@@ -325,9 +252,6 @@ def _extract_next_date_from_ocr(raw_ocr: str) -> str | None:
325
 
326
 
327
  def _post_validate(data: dict, raw_ocr: str) -> dict:
328
- """Correct Person_Name_To_Serve if LLM picked the accused instead of the witness.
329
- Also repair Hearing_Date if LLM returned only a year (e.g. '2026')."""
330
- # โ”€โ”€ Person fix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
331
  person = data.get("Person_Name_To_Serve") or ""
332
  if person and re.search(r"\bVs\s+" + re.escape(person), raw_ocr, re.IGNORECASE):
333
  fallback = _extract_person_from_whereas(raw_ocr)
@@ -338,15 +262,12 @@ def _post_validate(data: dict, raw_ocr: str) -> dict:
338
  if fallback:
339
  data["Person_Name_To_Serve"] = fallback
340
 
341
- # โ”€โ”€ Hearing_Date repair โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
342
  hdate = data.get("Hearing_Date") or ""
343
  if hdate and re.fullmatch(r"\d{4}", str(hdate).strip()):
344
- # LLM returned only a year โ€” try to recover the full date from OCR
345
  recovered = _extract_next_date_from_ocr(raw_ocr)
346
- data["Hearing_Date"] = recovered # may be None if not found
347
  hdate = recovered or ""
348
 
349
- # Correct OCR character-substitution errors in years (e.g. misreading '2026' as '2028')
350
  if hdate:
351
  m_date = re.fullmatch(r"(\d{2})-(\d{2})-(\d{4})", str(hdate).strip())
352
  if m_date:
@@ -363,30 +284,14 @@ def _post_validate(data: dict, raw_ocr: str) -> dict:
363
 
364
 
365
  def _is_date_grounded(val: str, raw_ocr_lower: str) -> bool:
366
- """
367
- Verify a date string against OCR output.
368
- Handles numeric dates (DD-MM-YYYY), written months, ordinal suffixes.
369
-
370
- FIX: Reject partial/year-only values like "2026".
371
- A valid Hearing_Date must contain at least a day, a month, and a year
372
- (i.e. at least 3 non-empty parts when split on separators).
373
- Pure 4-digit year strings are always rejected so that a year-only LLM
374
- output (e.g. "2026") is nulled out rather than passing the grounding check.
375
- """
376
  val_str = str(val).strip()
377
-
378
- # Reject bare year (4-digit number only, e.g. "2026")
379
  if re.fullmatch(r"\d{4}", val_str):
380
  return False
381
-
382
- # Split into parts and require at least 3 (day / month / year)
383
  parts = [p for p in re.split(r"[-/.\s]+", val_str) if p]
384
  if len(parts) < 3:
385
  return False
386
-
387
  if val_str.lower() in raw_ocr_lower:
388
  return True
389
-
390
  for part in parts:
391
  clean = re.sub(r"(?<=\d)(st|nd|rd|th)$", "", part.lower())
392
  alias = _MONTH_MAP.get(clean)
@@ -396,40 +301,26 @@ def _is_date_grounded(val: str, raw_ocr_lower: str) -> bool:
396
 
397
 
398
  def _strict_grounding_filter(data: dict, raw_ocr: str) -> dict:
399
- """
400
- Programmatic hallucination firewall.
401
- is_grounded() is nested here so it closes over raw_ocr_lower correctly.
402
- """
403
  if not isinstance(data, dict):
404
  return data
405
-
406
  raw_ocr_lower = raw_ocr.lower()
407
 
408
  def is_grounded(val) -> bool:
409
  if not val or str(val).strip().lower() in ("null", "none", "โ€”", ""):
410
  return False
411
  val_str = str(val).strip()
412
-
413
- # 1. Exact substring
414
  if val_str.lower() in raw_ocr_lower:
415
  return True
416
-
417
- # 2. Code-token check: "CHI/339/2021" โ†’ any segment present is enough
418
  code_tokens = [t.lower() for t in re.split(r"[/\-]", val_str) if len(t) > 2]
419
  if code_tokens and any(t in raw_ocr_lower for t in code_tokens):
420
  return True
421
-
422
- # 3. Prose token check: require 75% of meaningful words present
423
  words = [w.lower() for w in re.split(r"[^a-zA-Z0-9]", val_str) if len(w) > 2]
424
  if not words:
425
- # Pure numeric fallback (belt numbers, years)
426
  nums = [n for n in re.split(r"\D+", val_str) if n]
427
  return any(n in raw_ocr_lower for n in nums) if nums else False
428
-
429
  matched = sum(1 for w in words if w in raw_ocr_lower)
430
  return matched >= max(1, round(len(words) * 0.75))
431
 
432
- # โ€” Case/FIR deduplication & grounding โ€”
433
  case_fir = data.get("Case_FIR_Number")
434
  if case_fir:
435
  case_fir = str(case_fir).strip()
@@ -440,7 +331,6 @@ def _strict_grounding_filter(data: dict, raw_ocr: str) -> dict:
440
  elif not is_grounded(case_fir):
441
  data["Case_FIR_Number"] = None
442
 
443
- # โ€” All other fields โ€”
444
  for field in [
445
  "Act_and_Sections", "Type_of_Document", "Target_Police_Station",
446
  "IO_Name_and_Belt_No", "IO_Mobile_Number", "Person_Name_To_Serve",
@@ -480,7 +370,6 @@ def _clean_and_parse_json(raw_response: str, raw_ocr: str = "") -> dict:
480
  "_message": "Could not parse LLM response as JSON.",
481
  }
482
 
483
- # โ”€โ”€ Main pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
484
 
485
  _NVIDIA_MODELS = [
486
  "qwen/qwen3-coder-480b-a35b-instruct",
@@ -495,7 +384,6 @@ def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)):
495
  if image_path is None:
496
  raise gr.Error("Please upload an image first.")
497
 
498
- # Step 1 โ€” Upload
499
  progress(0, desc="โ˜๏ธ Uploading to Cloudinaryโ€ฆ")
500
  yield "โณ Uploading to Cloudinaryโ€ฆ", "", {}
501
 
@@ -507,7 +395,6 @@ def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)):
507
  except Exception as exc:
508
  raise gr.Error(f"Cloudinary upload failed: {exc}")
509
 
510
- # Step 2 โ€” OCR
511
  progress(0.25, desc="๐Ÿ” Running OCRโ€ฆ")
512
  yield cloudinary_url, "โณ Extracting text via OCRโ€ฆ", {}
513
 
@@ -519,7 +406,6 @@ def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)):
519
  if not raw_text or not raw_text.strip():
520
  raw_text = "[OCR returned empty text โ€” image may be blank or unreadable]"
521
 
522
- # Step 3 โ€” LLM
523
  progress(0.5, desc="๐Ÿค– Calling AI modelโ€ฆ")
524
  yield cloudinary_url, raw_text, {"status": "โณ Calling AI modelโ€ฆ"}
525
 
@@ -552,7 +438,6 @@ def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)):
552
  if llm_response is None:
553
  raise gr.Error(f"All NVIDIA models failed. Last error: {last_exception}")
554
 
555
- # Step 4 โ€” Parse + Store
556
  parsed_json = _clean_and_parse_json(llm_response, raw_text)
557
 
558
  if collection is not None and "_parse_error" not in parsed_json:
@@ -571,7 +456,7 @@ def process_document(image_path: str, progress=gr.Progress(track_tqdm=False)):
571
 
572
 
573
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
574
- # C. Dashboard โ€” inline styles so dark theme can't override
575
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
576
 
577
  C_LABEL = "#4f46e5"
@@ -763,7 +648,52 @@ def fetch_live_warrants(search_query: str = "") -> str:
763
 
764
  TAB_FIX_JS = """<script>
765
  (function(){
766
- function fix(){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  ['[role="tablist"]','.tab-nav','.tab-nav > div','.tab-nav > div > div'].forEach(function(s){
768
  document.querySelectorAll(s).forEach(function(el){
769
  el.style.cssText+=';display:flex!important;flex-direction:row!important;'+
@@ -782,69 +712,175 @@ TAB_FIX_JS = """<script>
782
  'pointer-events:auto!important;touch-action:manipulation!important;';
783
  });
784
  }
785
- fix();setTimeout(fix,300);setTimeout(fix,800);setTimeout(fix,2000);
786
- if(window.MutationObserver){new MutationObserver(fix).observe(document.body,{childList:true,subtree:true});}
 
 
 
 
 
787
  })();
788
  </script>"""
789
 
790
  CUSTOM_CSS = """
791
- *,*::before,*::after{box-sizing:border-box!important;}
792
- body,html{overflow-x:hidden!important;max-width:100vw!important;}
793
- .gradio-container{max-width:1280px!important;margin:auto!important;padding:0 12px!important;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
 
795
- .tabs,div[class*="tabs"],div[data-testid="tabs"],
796
- .tabitem>.block,.tabitem>div>.block{overflow:visible!important;}
 
 
 
 
797
 
798
  .tab-nav,.tab-nav>div,.tab-nav>div>div,
799
- [role="tablist"],div[class*="tab-nav"],div[data-testid="tab-nav"]{
800
- display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important;
801
- overflow-x:auto!important;overflow-y:visible!important;
802
- -webkit-overflow-scrolling:touch!important;gap:4px!important;
803
- scrollbar-width:none!important;-ms-overflow-style:none!important;
 
 
 
 
 
804
  }
805
- .tab-nav::-webkit-scrollbar,.tab-nav>div::-webkit-scrollbar,
806
- [role="tablist"]::-webkit-scrollbar{display:none!important;}
807
-
808
- [role="tab"],.tab-nav button{
809
- flex-shrink:0!important;white-space:nowrap!important;
810
- min-width:max-content!important;pointer-events:auto!important;
811
- touch-action:manipulation!important;cursor:pointer!important;
 
 
 
 
812
  }
813
 
814
- #process-btn{font-size:1rem;padding:12px 24px;width:100%;margin-top:8px;}
815
- #status-row{background:#f0fdf4;border-radius:8px;padding:8px 14px;font-size:0.85rem;}
816
- #status-row,#status-row *{color:#166534!important;}
817
- .upload-col{min-width:0!important;flex:1 1 280px!important;}
818
- .outputs-col{min-width:0!important;flex:2 1 380px!important;}
 
819
 
820
- .warrant-desktop{display:block;}
821
- .warrant-mobile{display:none;}
 
822
 
 
 
 
 
 
 
 
823
  @media screen and (max-width:768px){
824
- .gradio-container,.main,.wrap,.tabitem,footer{overflow-x:hidden!important;max-width:100%!important;}
825
 
826
- .gradio-container div.flex,.gradio-container div.gap,
827
- .gradio-container .gr-row,.gradio-container [class*="flex-row"],
828
- .gradio-container form>div{flex-direction:column!important;flex-wrap:nowrap!important;}
 
 
829
 
830
- .gradio-container div.flex>*,.gradio-container div.gap>*,
831
- .gradio-container .gr-row>*,.upload-col,.outputs-col,
832
- .gradio-container .block,.gradio-container .col,
833
- .gradio-container [data-testid="column"]{
834
- width:100%!important;max-width:100%!important;min-width:0!important;flex:none!important;
 
 
 
835
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  .gradio-container [data-testid="image"],
837
- .gradio-container .upload-container{width:100%!important;height:220px!important;}
 
 
 
 
 
838
  .gradio-container textarea,
839
- .gradio-container input[type="text"]{width:100%!important;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
 
841
- #search-refresh-row,#search-refresh-row>*{
842
- flex-direction:column!important;width:100%!important;min-width:0!important;flex:none!important;
 
 
 
 
 
 
 
843
  }
844
- #refresh-btn{width:100%!important;margin-top:6px;}
845
 
846
- .warrant-desktop{display:none!important;}
847
- .warrant-mobile{display:block!important;}
848
  }
849
  """
850
 
@@ -859,9 +895,6 @@ Upload a photo of a **bailable warrant**, **summon**, or similar legal document.
859
  | ๐Ÿ—„๏ธ 4 | Store the record securely in **MongoDB** |
860
  """
861
 
862
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
863
- # E. Gradio Interface
864
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
865
 
866
  def _status_html(icon: str, message: str, color: str, done: bool = False) -> str:
867
  if done:
@@ -885,7 +918,6 @@ def _status_html(icon: str, message: str, color: str, done: bool = False) -> str
885
 
886
  def _process_and_render(image_path):
887
  for url, ocr, data in process_document(image_path):
888
- # Determine status banner
889
  if not url.startswith("http"):
890
  s = _status_html("โ˜๏ธ", "Uploading image to Cloudinaryโ€ฆ", "#6366f1")
891
  elif ocr == "โณ Extracting text via OCRโ€ฆ":
@@ -927,24 +959,30 @@ _WA_JS = f"""
927
 
928
  _PHONE_JS = f"(name) => {{ const db = {json.dumps(officers_db)}; return db[name] || ''; }}"
929
 
 
 
 
 
930
  with gr.Blocks(
931
  title="โš–๏ธ Legal Document Digitization",
932
  theme=gr.themes.Soft(primary_hue="violet", secondary_hue="indigo", neutral_hue="slate"),
933
  css=CUSTOM_CSS,
934
  ) as demo:
935
 
936
- gr.HTML(TAB_FIX_JS) # must be first child โ€” patches tab bar before render
 
937
 
938
  gr.Markdown("# โš–๏ธ Automated Legal Document Digitization System")
939
  gr.Markdown("*Digitize warrants & summons in seconds โ€” OCR โ†’ AI parsing โ†’ secure storage*")
940
 
941
  with gr.Tabs():
942
 
943
- # โ”€โ”€ Tab 1: Pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
944
  with gr.Tab("๐Ÿ“ฅ Digitization Pipeline"):
945
  gr.Markdown(DESCRIPTION)
946
 
947
  with gr.Row():
 
948
  with gr.Column(elem_classes=["upload-col"]):
949
  image_input = gr.Image(
950
  type="filepath",
@@ -968,6 +1006,7 @@ with gr.Blocks(
968
  f"_(Error: `{_officers_csv_error}`)_"
969
  )
970
 
 
971
  with gr.Column(elem_classes=["outputs-col"]):
972
  status_out = gr.HTML(value="", elem_id="status-display")
973
  cloudinary_url_out = gr.Textbox(label="โ˜๏ธ Cloudinary URL", interactive=False)
@@ -976,7 +1015,8 @@ with gr.Blocks(
976
  json_out = gr.JSON(label="๐Ÿ“‹ Extracted Structured Data (JSON)")
977
 
978
  gr.Markdown("### ๐Ÿ“จ Notify Investigating Officer (WhatsApp)")
979
- with gr.Row():
 
980
  io_dropdown = gr.Dropdown(
981
  label="Select Officer (from CSV)",
982
  choices=list(officers_db.keys()),
@@ -987,10 +1027,14 @@ with gr.Blocks(
987
  placeholder="e.g. 919876543210",
988
  scale=2,
989
  )
990
- send_wa_btn = gr.Button("๐Ÿ’ฌ Send via WhatsApp", variant="secondary", scale=1)
 
 
 
 
991
  wa_status_out = gr.HTML()
992
 
993
- # Wire events โ€” defined after all components exist
994
  io_dropdown.change(
995
  fn=None, inputs=[io_dropdown], outputs=[manual_phone_in], js=_PHONE_JS
996
  )
@@ -1006,7 +1050,7 @@ with gr.Blocks(
1006
  js=_WA_JS,
1007
  )
1008
 
1009
- # โ”€โ”€ Tab 2: Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1010
  with gr.Tab("๐Ÿ‘ฎ Live Police Dashboard"):
1011
  gr.Markdown("## ๐Ÿ“‹ Real-Time Stored Warrants & Summons")
1012
  gr.Markdown(
 
19
  import pytz
20
  from datetime import datetime
21
 
 
22
  # A. Setup & Configuration
 
23
 
24
  load_dotenv()
25
 
 
66
  _officers_csv_error = str(_e)
67
  print(f"โš ๏ธ Could not load officers.csv: {_e}")
68
 
69
+
70
  # B. Core Processing Logic
 
71
 
72
  SYSTEM_PROMPT = """You are an extremely precise and strict Indian legal document parser.
73
  Your task is to extract information from raw OCR text of a Punjab court warrant or summons.
 
122
  "oct": "10", "nov": "11", "dec": "12",
123
  }
124
 
 
 
125
  def _get_available_tess_langs() -> str:
 
 
 
 
 
126
  try:
127
  import subprocess
128
  result = subprocess.run(
 
132
  installed = set(result.stdout.lower().split() + result.stderr.lower().split())
133
  except Exception:
134
  installed = {"eng"}
 
135
  langs = ["eng"]
136
  if "hin" in installed:
137
  langs.append("hin")
138
  if "pan" in installed:
139
  langs.append("pan")
 
140
  lang_str = "+".join(langs)
141
  print(f"๐Ÿ”ค Tesseract languages available: {lang_str}")
142
  return lang_str
143
 
 
144
  _TESS_LANG = _get_available_tess_langs()
145
 
146
+ _OCR_MAX_DIM = 1500
147
+ _OCR_MIN_DIM = 1000
148
+ _OCR_TIMEOUT_S = 60
 
 
149
 
150
 
151
  def _preprocess_for_ocr(img: Image.Image) -> Image.Image:
 
 
 
 
 
 
 
 
 
152
  img = img.convert("L")
153
  max_dim = max(img.size)
 
154
  if max_dim < _OCR_MIN_DIM:
 
155
  scale = _OCR_MIN_DIM / max_dim
156
+ img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
 
 
157
  elif max_dim > _OCR_MAX_DIM:
 
158
  scale = _OCR_MAX_DIM / max_dim
159
+ img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
 
 
 
 
160
  img = img.filter(ImageFilter.SHARPEN)
161
  return img
162
 
163
 
164
  def _ocr_image(image_path: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  import subprocess, tempfile, os, signal, sys
 
166
  try:
167
  img = Image.open(image_path)
168
  processed = _preprocess_for_ocr(img)
169
  except Exception as exc:
170
  return f"[OCR Error] Could not open/preprocess image: {exc}"
171
 
 
172
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
173
  tmp_path = tmp.name
174
  try:
 
179
 
180
  tess_cmd = pytesseract.pytesseract.tesseract_cmd or "tesseract"
181
  out_base = tmp_path.replace(".png", "_out")
182
+ cmd = [tess_cmd, tmp_path, out_base, "-l", _TESS_LANG, "--oem", "1", "--psm", "4"]
 
 
 
 
 
183
 
184
  try:
 
185
  kwargs = {}
186
  if sys.platform != "win32":
187
+ kwargs["start_new_session"] = True
188
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
 
 
 
 
 
 
189
  try:
190
  proc.communicate(timeout=_OCR_TIMEOUT_S)
191
  except subprocess.TimeoutExpired:
 
205
  result = f.read()
206
  os.unlink(out_txt)
207
  return result
 
208
  except Exception:
209
  pass
210
  finally:
 
213
  except Exception:
214
  pass
215
 
 
216
  def _run() -> str:
217
+ return pytesseract.image_to_string(processed, lang=_TESS_LANG, config="--oem 1 --psm 4")
 
 
218
 
219
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
220
  future = executor.submit(_run)
 
226
  except Exception as exc:
227
  return f"[OCR Error] {exc}"
228
 
 
229
 
230
  def _extract_person_from_whereas(raw_ocr: str) -> str | None:
231
  m = re.search(
 
237
 
238
 
239
  def _extract_next_date_from_ocr(raw_ocr: str) -> str | None:
 
 
 
 
 
240
  m = re.search(
241
  r"(?:NEXT\s*DATE|Next\s*Date|Next\s*Hearing|Hearing\s*Date)\s*[:\-]?\s*"
242
  r"(\d{1,2}[-/.\s]\d{1,2}[-/.\s]\d{4}|\d{4}[-/.\s]\d{1,2}[-/.\s]\d{1,2})",
 
244
  )
245
  if m:
246
  raw_date = re.sub(r"[\s/.]", "-", m.group(1).strip())
 
247
  yyyy_first = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", raw_date)
248
  if yyyy_first:
249
  raw_date = f"{yyyy_first.group(3).zfill(2)}-{yyyy_first.group(2).zfill(2)}-{yyyy_first.group(1)}"
 
252
 
253
 
254
  def _post_validate(data: dict, raw_ocr: str) -> dict:
 
 
 
255
  person = data.get("Person_Name_To_Serve") or ""
256
  if person and re.search(r"\bVs\s+" + re.escape(person), raw_ocr, re.IGNORECASE):
257
  fallback = _extract_person_from_whereas(raw_ocr)
 
262
  if fallback:
263
  data["Person_Name_To_Serve"] = fallback
264
 
 
265
  hdate = data.get("Hearing_Date") or ""
266
  if hdate and re.fullmatch(r"\d{4}", str(hdate).strip()):
 
267
  recovered = _extract_next_date_from_ocr(raw_ocr)
268
+ data["Hearing_Date"] = recovered
269
  hdate = recovered or ""
270
 
 
271
  if hdate:
272
  m_date = re.fullmatch(r"(\d{2})-(\d{2})-(\d{4})", str(hdate).strip())
273
  if m_date:
 
284
 
285
 
286
  def _is_date_grounded(val: str, raw_ocr_lower: str) -> bool:
 
 
 
 
 
 
 
 
 
 
287
  val_str = str(val).strip()
 
 
288
  if re.fullmatch(r"\d{4}", val_str):
289
  return False
 
 
290
  parts = [p for p in re.split(r"[-/.\s]+", val_str) if p]
291
  if len(parts) < 3:
292
  return False
 
293
  if val_str.lower() in raw_ocr_lower:
294
  return True
 
295
  for part in parts:
296
  clean = re.sub(r"(?<=\d)(st|nd|rd|th)$", "", part.lower())
297
  alias = _MONTH_MAP.get(clean)
 
301
 
302
 
303
  def _strict_grounding_filter(data: dict, raw_ocr: str) -> dict:
 
 
 
 
304
  if not isinstance(data, dict):
305
  return data
 
306
  raw_ocr_lower = raw_ocr.lower()
307
 
308
  def is_grounded(val) -> bool:
309
  if not val or str(val).strip().lower() in ("null", "none", "โ€”", ""):
310
  return False
311
  val_str = str(val).strip()
 
 
312
  if val_str.lower() in raw_ocr_lower:
313
  return True
 
 
314
  code_tokens = [t.lower() for t in re.split(r"[/\-]", val_str) if len(t) > 2]
315
  if code_tokens and any(t in raw_ocr_lower for t in code_tokens):
316
  return True
 
 
317
  words = [w.lower() for w in re.split(r"[^a-zA-Z0-9]", val_str) if len(w) > 2]
318
  if not words:
 
319
  nums = [n for n in re.split(r"\D+", val_str) if n]
320
  return any(n in raw_ocr_lower for n in nums) if nums else False
 
321
  matched = sum(1 for w in words if w in raw_ocr_lower)
322
  return matched >= max(1, round(len(words) * 0.75))
323
 
 
324
  case_fir = data.get("Case_FIR_Number")
325
  if case_fir:
326
  case_fir = str(case_fir).strip()
 
331
  elif not is_grounded(case_fir):
332
  data["Case_FIR_Number"] = None
333
 
 
334
  for field in [
335
  "Act_and_Sections", "Type_of_Document", "Target_Police_Station",
336
  "IO_Name_and_Belt_No", "IO_Mobile_Number", "Person_Name_To_Serve",
 
370
  "_message": "Could not parse LLM response as JSON.",
371
  }
372
 
 
373
 
374
  _NVIDIA_MODELS = [
375
  "qwen/qwen3-coder-480b-a35b-instruct",
 
384
  if image_path is None:
385
  raise gr.Error("Please upload an image first.")
386
 
 
387
  progress(0, desc="โ˜๏ธ Uploading to Cloudinaryโ€ฆ")
388
  yield "โณ Uploading to Cloudinaryโ€ฆ", "", {}
389
 
 
395
  except Exception as exc:
396
  raise gr.Error(f"Cloudinary upload failed: {exc}")
397
 
 
398
  progress(0.25, desc="๐Ÿ” Running OCRโ€ฆ")
399
  yield cloudinary_url, "โณ Extracting text via OCRโ€ฆ", {}
400
 
 
406
  if not raw_text or not raw_text.strip():
407
  raw_text = "[OCR returned empty text โ€” image may be blank or unreadable]"
408
 
 
409
  progress(0.5, desc="๐Ÿค– Calling AI modelโ€ฆ")
410
  yield cloudinary_url, raw_text, {"status": "โณ Calling AI modelโ€ฆ"}
411
 
 
438
  if llm_response is None:
439
  raise gr.Error(f"All NVIDIA models failed. Last error: {last_exception}")
440
 
 
441
  parsed_json = _clean_and_parse_json(llm_response, raw_text)
442
 
443
  if collection is not None and "_parse_error" not in parsed_json:
 
456
 
457
 
458
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
459
+ # C. Dashboard helpers
460
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
461
 
462
  C_LABEL = "#4f46e5"
 
648
 
649
  TAB_FIX_JS = """<script>
650
  (function(){
651
+ // โ”€โ”€ 1. Hide the Hugging Face floating repo/files button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
652
+ // HF injects a fixed-position pill (LitigationBrach / ALDDS) into the
653
+ // parent page. We can't remove it from Python, so we hide it with CSS
654
+ // injected into <head> and also poll+hide any matching DOM nodes.
655
+ var style = document.createElement('style');
656
+ style.textContent = [
657
+ /* The HF "Files" / repo pill โ€” fixed position, top-right corner */
658
+ 'div[class*="SpaceIframeBadge"],',
659
+ 'div[class*="space-badge"],',
660
+ 'div[class*="iframe-badge"],',
661
+ 'a[class*="SpaceIframeBadge"],',
662
+ 'a[class*="space-badge"],',
663
+ /* Catch-all: any fixed/absolute element in top-right with HF branding */
664
+ 'body > div[style*="position: fixed"][style*="top"][style*="right"],',
665
+ 'body > div[style*="position:fixed"][style*="top"][style*="right"]',
666
+ '{ display:none!important; visibility:hidden!important; pointer-events:none!important; }'
667
+ ].join('');
668
+ document.head.appendChild(style);
669
+
670
+ // Also poll the DOM and forcibly hide any element that looks like the badge
671
+ function hideBadge(){
672
+ document.querySelectorAll('body > div, body > a').forEach(function(el){
673
+ var cs = window.getComputedStyle(el);
674
+ if((cs.position==='fixed'||cs.position==='absolute') &&
675
+ parseInt(cs.top)<80 && parseInt(cs.right)<200){
676
+ var txt = el.innerText || el.textContent || '';
677
+ // Only hide if it contains HF-style content (heart/like icon or repo name)
678
+ if(txt.includes('โ™ฅ')||txt.includes('โค')||el.querySelector('svg')||
679
+ el.innerHTML.includes('hf.co')||el.innerHTML.includes('huggingface')||
680
+ el.innerHTML.includes('LitigationBrach')||el.innerHTML.includes('ALDDS')){
681
+ el.style.cssText += ';display:none!important;visibility:hidden!important;';
682
+ }
683
+ }
684
+ });
685
+ }
686
+ hideBadge();
687
+ setTimeout(hideBadge,300);
688
+ setTimeout(hideBadge,800);
689
+ setTimeout(hideBadge,2000);
690
+ setTimeout(hideBadge,5000);
691
+ if(window.MutationObserver){
692
+ new MutationObserver(hideBadge).observe(document.body,{childList:true,subtree:false});
693
+ }
694
+
695
+ // โ”€โ”€ 2. Fix tab bar horizontal layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
696
+ function fixTabs(){
697
  ['[role="tablist"]','.tab-nav','.tab-nav > div','.tab-nav > div > div'].forEach(function(s){
698
  document.querySelectorAll(s).forEach(function(el){
699
  el.style.cssText+=';display:flex!important;flex-direction:row!important;'+
 
712
  'pointer-events:auto!important;touch-action:manipulation!important;';
713
  });
714
  }
715
+ fixTabs();
716
+ setTimeout(fixTabs,300);
717
+ setTimeout(fixTabs,800);
718
+ setTimeout(fixTabs,2000);
719
+ if(window.MutationObserver){
720
+ new MutationObserver(fixTabs).observe(document.body,{childList:true,subtree:true});
721
+ }
722
  })();
723
  </script>"""
724
 
725
  CUSTOM_CSS = """
726
+ /* โ”€โ”€ Reset & base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
727
+ *,*::before,*::after { box-sizing:border-box!important; }
728
+ html,body { overflow-x:hidden!important; max-width:100vw!important; margin:0; padding:0; }
729
+
730
+ /* โ”€โ”€ Container โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
731
+ .gradio-container {
732
+ max-width:1280px!important;
733
+ margin:auto!important;
734
+ padding:0 12px!important;
735
+ }
736
+
737
+ /* โ”€โ”€ Hide HF badge via CSS (belt-and-suspenders with the JS above) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
738
+ div[class*="SpaceIframeBadge"],
739
+ div[class*="space-badge"],
740
+ div[class*="iframe-badge"],
741
+ a[class*="SpaceIframeBadge"],
742
+ a[class*="space-badge"] {
743
+ display:none!important;
744
+ visibility:hidden!important;
745
+ pointer-events:none!important;
746
+ }
747
 
748
+ /* โ”€โ”€ Tab bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
749
+ .tabs,
750
+ div[class*="tabs"],
751
+ div[data-testid="tabs"],
752
+ .tabitem>.block,
753
+ .tabitem>div>.block { overflow:visible!important; }
754
 
755
  .tab-nav,.tab-nav>div,.tab-nav>div>div,
756
+ [role="tablist"],div[class*="tab-nav"],div[data-testid="tab-nav"] {
757
+ display:flex!important;
758
+ flex-direction:row!important;
759
+ flex-wrap:nowrap!important;
760
+ overflow-x:auto!important;
761
+ overflow-y:visible!important;
762
+ -webkit-overflow-scrolling:touch!important;
763
+ gap:4px!important;
764
+ scrollbar-width:none!important;
765
+ -ms-overflow-style:none!important;
766
  }
767
+ .tab-nav::-webkit-scrollbar,
768
+ .tab-nav>div::-webkit-scrollbar,
769
+ [role="tablist"]::-webkit-scrollbar { display:none!important; }
770
+
771
+ [role="tab"],.tab-nav button {
772
+ flex-shrink:0!important;
773
+ white-space:nowrap!important;
774
+ min-width:max-content!important;
775
+ pointer-events:auto!important;
776
+ touch-action:manipulation!important;
777
+ cursor:pointer!important;
778
  }
779
 
780
+ /* โ”€โ”€ Process button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
781
+ #process-btn { font-size:1rem; padding:12px 24px; width:100%; margin-top:8px; }
782
+
783
+ /* โ”€โ”€ Status row hint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
784
+ #status-row { background:#f0fdf4; border-radius:8px; padding:8px 14px; font-size:0.85rem; }
785
+ #status-row, #status-row * { color:#166534!important; }
786
 
787
+ /* โ”€โ”€ Column sizing (desktop) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
788
+ .upload-col { min-width:0!important; flex:1 1 280px!important; }
789
+ .outputs-col { min-width:0!important; flex:2 1 380px!important; }
790
 
791
+ /* โ”€โ”€ Dashboard table / cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
792
+ .warrant-desktop { display:block; }
793
+ .warrant-mobile { display:none; }
794
+
795
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
796
+ MOBILE โ‰ค 768 px
797
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
798
  @media screen and (max-width:768px){
 
799
 
800
+ /* Prevent any horizontal scroll on the whole page */
801
+ .gradio-container,.main,.wrap,.tabitem,footer {
802
+ overflow-x:hidden!important;
803
+ max-width:100%!important;
804
+ }
805
 
806
+ /* Stack ALL flex rows into columns */
807
+ .gradio-container div.flex,
808
+ .gradio-container div.gap,
809
+ .gradio-container .gr-row,
810
+ .gradio-container [class*="flex-row"],
811
+ .gradio-container form>div {
812
+ flex-direction:column!important;
813
+ flex-wrap:nowrap!important;
814
  }
815
+
816
+ /* Full-width every child column/block */
817
+ .gradio-container div.flex>*,
818
+ .gradio-container div.gap>*,
819
+ .gradio-container .gr-row>*,
820
+ .upload-col,
821
+ .outputs-col,
822
+ .gradio-container .block,
823
+ .gradio-container .col,
824
+ .gradio-container [data-testid="column"] {
825
+ width:100%!important;
826
+ max-width:100%!important;
827
+ min-width:0!important;
828
+ flex:none!important;
829
+ }
830
+
831
+ /* Image uploader height */
832
  .gradio-container [data-testid="image"],
833
+ .gradio-container .upload-container {
834
+ width:100%!important;
835
+ height:220px!important;
836
+ }
837
+
838
+ /* Inputs full width */
839
  .gradio-container textarea,
840
+ .gradio-container input[type="text"] { width:100%!important; }
841
+
842
+ /* Search + refresh row */
843
+ #search-refresh-row,
844
+ #search-refresh-row>* {
845
+ flex-direction:column!important;
846
+ width:100%!important;
847
+ min-width:0!important;
848
+ flex:none!important;
849
+ }
850
+ #refresh-btn { width:100%!important; margin-top:6px; }
851
+
852
+ /* WhatsApp row โ€” stack dropdowns/inputs vertically */
853
+ .wa-notify-row,
854
+ .wa-notify-row>* {
855
+ flex-direction:column!important;
856
+ width:100%!important;
857
+ min-width:0!important;
858
+ flex:none!important;
859
+ }
860
+ .wa-notify-row .gradio-dropdown,
861
+ .wa-notify-row .gradio-textbox,
862
+ .wa-notify-row button {
863
+ width:100%!important;
864
+ min-width:0!important;
865
+ }
866
+
867
+ /* Switch dashboard to card layout on mobile */
868
+ .warrant-desktop { display:none!important; }
869
+ .warrant-mobile { display:block!important; }
870
 
871
+ /* Shrink heading text slightly so it doesn't overflow */
872
+ .gradio-container h1 { font-size:1.35rem!important; }
873
+ .gradio-container h2 { font-size:1.1rem!important; }
874
+
875
+ /* Make JSON output scrollable instead of overflowing */
876
+ .gradio-container .json-holder,
877
+ .gradio-container [data-testid="json"] {
878
+ overflow-x:auto!important;
879
+ max-width:100%!important;
880
  }
 
881
 
882
+ /* Process button โ€” comfortable tap target */
883
+ #process-btn { font-size:1rem!important; padding:14px 20px!important; }
884
  }
885
  """
886
 
 
895
  | ๐Ÿ—„๏ธ 4 | Store the record securely in **MongoDB** |
896
  """
897
 
 
 
 
898
 
899
  def _status_html(icon: str, message: str, color: str, done: bool = False) -> str:
900
  if done:
 
918
 
919
  def _process_and_render(image_path):
920
  for url, ocr, data in process_document(image_path):
 
921
  if not url.startswith("http"):
922
  s = _status_html("โ˜๏ธ", "Uploading image to Cloudinaryโ€ฆ", "#6366f1")
923
  elif ocr == "โณ Extracting text via OCRโ€ฆ":
 
959
 
960
  _PHONE_JS = f"(name) => {{ const db = {json.dumps(officers_db)}; return db[name] || ''; }}"
961
 
962
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
963
+ # E. Gradio Interface
964
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
965
+
966
  with gr.Blocks(
967
  title="โš–๏ธ Legal Document Digitization",
968
  theme=gr.themes.Soft(primary_hue="violet", secondary_hue="indigo", neutral_hue="slate"),
969
  css=CUSTOM_CSS,
970
  ) as demo:
971
 
972
+ # Must be first child โ€” patches badge + tab bar before first render
973
+ gr.HTML(TAB_FIX_JS)
974
 
975
  gr.Markdown("# โš–๏ธ Automated Legal Document Digitization System")
976
  gr.Markdown("*Digitize warrants & summons in seconds โ€” OCR โ†’ AI parsing โ†’ secure storage*")
977
 
978
  with gr.Tabs():
979
 
980
+ # โ”€โ”€ Tab 1: Pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
981
  with gr.Tab("๐Ÿ“ฅ Digitization Pipeline"):
982
  gr.Markdown(DESCRIPTION)
983
 
984
  with gr.Row():
985
+ # โ”€โ”€ Left column: upload + controls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
986
  with gr.Column(elem_classes=["upload-col"]):
987
  image_input = gr.Image(
988
  type="filepath",
 
1006
  f"_(Error: `{_officers_csv_error}`)_"
1007
  )
1008
 
1009
+ # โ”€โ”€ Right column: outputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1010
  with gr.Column(elem_classes=["outputs-col"]):
1011
  status_out = gr.HTML(value="", elem_id="status-display")
1012
  cloudinary_url_out = gr.Textbox(label="โ˜๏ธ Cloudinary URL", interactive=False)
 
1015
  json_out = gr.JSON(label="๐Ÿ“‹ Extracted Structured Data (JSON)")
1016
 
1017
  gr.Markdown("### ๐Ÿ“จ Notify Investigating Officer (WhatsApp)")
1018
+ # elem_classes used so our mobile CSS can target this row
1019
+ with gr.Row(elem_id="wa-notify-row", elem_classes=["wa-notify-row"]):
1020
  io_dropdown = gr.Dropdown(
1021
  label="Select Officer (from CSV)",
1022
  choices=list(officers_db.keys()),
 
1027
  placeholder="e.g. 919876543210",
1028
  scale=2,
1029
  )
1030
+ send_wa_btn = gr.Button(
1031
+ "๐Ÿ’ฌ Send via WhatsApp",
1032
+ variant="secondary",
1033
+ scale=1,
1034
+ )
1035
  wa_status_out = gr.HTML()
1036
 
1037
+ # Wire events
1038
  io_dropdown.change(
1039
  fn=None, inputs=[io_dropdown], outputs=[manual_phone_in], js=_PHONE_JS
1040
  )
 
1050
  js=_WA_JS,
1051
  )
1052
 
1053
+ # โ”€โ”€ Tab 2: Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1054
  with gr.Tab("๐Ÿ‘ฎ Live Police Dashboard"):
1055
  gr.Markdown("## ๐Ÿ“‹ Real-Time Stored Warrants & Summons")
1056
  gr.Markdown(