Comfoa commited on
Commit
7927eff
·
1 Parent(s): e207bf6

fix: make OCR work always and consistently

Browse files
backend/api/server.py CHANGED
@@ -116,15 +116,17 @@ async def _auto_summarize_minutes(minutes: Minutes) -> Optional[SummaryResponse]
116
  try:
117
  print(f"[AUTO] Summarizing minutes {minutes.id}...")
118
 
119
- # If the document has page image URLs, provide an image fetcher
120
  # for OCR-based summarization (even if raw_text is a stub like
121
  # "[This document is a scanned image...]")
122
  image_fetcher = None
123
- if (
124
  minutes.page_image_urls
 
125
  and connector
126
  and minutes.document_url
127
- ):
 
128
  # Capture page_urls in a closure so the fallback in
129
  # fetch_page_images() can use them if PDF generation fails
130
  _page_urls = list(minutes.page_image_urls)
@@ -224,16 +226,23 @@ app.add_middleware(
224
  async def health():
225
  """Health check endpoint."""
226
  ocr_available = False
 
227
  if summarizer:
228
- ocr_available = (
229
- summarizer._pytesseract_available
230
- and summarizer._pillow_available
231
- )
 
 
 
 
 
232
  return {
233
  "status": "ok",
234
  "city": f"{PARIS_TX_CONFIG.name}, {PARIS_TX_CONFIG.state}",
235
  "llm_available": summarizer is not None,
236
  "ocr_available": ocr_available,
 
237
  "storage": "postgresql" if USE_POSTGRES else "sqlite",
238
  }
239
 
@@ -425,11 +434,13 @@ async def summarize_minutes_endpoint(request: SummaryRequest):
425
  # Provide image fetcher for OCR-based summarization
426
  # (even if raw_text is a stub like "[This document is a scanned image...]")
427
  image_fetcher = None
428
- if (
429
  minutes.page_image_urls
 
430
  and connector
431
  and minutes.document_url
432
- ):
 
433
  # Capture page_urls in a closure so the fallback in
434
  # fetch_page_images() can use them if PDF generation fails
435
  _page_urls = list(minutes.page_image_urls)
 
116
  try:
117
  print(f"[AUTO] Summarizing minutes {minutes.id}...")
118
 
119
+ # If the document has page image URLs (strings), provide an image fetcher
120
  # for OCR-based summarization (even if raw_text is a stub like
121
  # "[This document is a scanned image...]")
122
  image_fetcher = None
123
+ has_urls = (
124
  minutes.page_image_urls
125
+ and isinstance(minutes.page_image_urls[0], str)
126
  and connector
127
  and minutes.document_url
128
+ )
129
+ if has_urls:
130
  # Capture page_urls in a closure so the fallback in
131
  # fetch_page_images() can use them if PDF generation fails
132
  _page_urls = list(minutes.page_image_urls)
 
226
  async def health():
227
  """Health check endpoint."""
228
  ocr_available = False
229
+ ocr_engines = []
230
  if summarizer:
231
+ if summarizer._easyocr_available:
232
+ ocr_engines.append("easyocr")
233
+ if summarizer._pytesseract_available:
234
+ ocr_engines.append("tesseract")
235
+ if summarizer._pillow_available:
236
+ ocr_engines.append("pillow")
237
+ if summarizer._numpy_available:
238
+ ocr_engines.append("numpy")
239
+ ocr_available = bool(ocr_engines)
240
  return {
241
  "status": "ok",
242
  "city": f"{PARIS_TX_CONFIG.name}, {PARIS_TX_CONFIG.state}",
243
  "llm_available": summarizer is not None,
244
  "ocr_available": ocr_available,
245
+ "ocr_engines": ocr_engines,
246
  "storage": "postgresql" if USE_POSTGRES else "sqlite",
247
  }
248
 
 
434
  # Provide image fetcher for OCR-based summarization
435
  # (even if raw_text is a stub like "[This document is a scanned image...]")
436
  image_fetcher = None
437
+ has_urls = (
438
  minutes.page_image_urls
439
+ and isinstance(minutes.page_image_urls[0], str)
440
  and connector
441
  and minutes.document_url
442
+ )
443
+ if has_urls:
444
  # Capture page_urls in a closure so the fallback in
445
  # fetch_page_images() can use them if PDF generation fails
446
  _page_urls = list(minutes.page_image_urls)
backend/connectors/laserfiche.py CHANGED
@@ -64,9 +64,12 @@ class LaserficheConnector:
64
  cookies={},
65
  headers={
66
  "User-Agent": (
67
- "CivicCityHub/1.0 (civic research project; "
68
- "contact@civiccityhub.org)"
 
69
  ),
 
 
70
  },
71
  )
72
 
@@ -366,8 +369,9 @@ class LaserficheConnector:
366
 
367
  Primary strategy (PDF generation):
368
  1. Visit docview.aspx to establish session cookies
369
- 2. POST to GeneratePDF.aspx to generate a PDF of the document
370
- 3. Use PyMuPDF (fitz) to render each PDF page to a PIL Image
 
371
 
372
  Fallback strategy (direct page download):
373
  If PDF generation fails (e.g. on HF Spaces where the Laserfiche
@@ -390,8 +394,41 @@ class LaserficheConnector:
390
  return []
391
 
392
  # Step 1: Visit docview.aspx to establish session cookies
 
 
 
 
393
  try:
394
- await self.client.get(document_url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  except Exception as e:
396
  print(f"[WARN] Error accessing document viewer: {e}")
397
  # Continue anyway — cookies might not be needed for GeneratePDF
@@ -400,16 +437,27 @@ class LaserficheConnector:
400
  gen_url = f"{self.base_url}/GeneratePDF.aspx"
401
  pdf_bytes = None
402
  try:
 
 
 
 
 
 
 
 
 
 
 
 
403
  pdf_response = await self.client.post(
404
  gen_url,
405
- data={
406
- "id": doc_id,
407
- "dbid": DBID,
408
- "pageFrom": "1",
409
- "pageTo": "999", # Request all pages
410
- },
411
  headers={
412
  "Content-Type": "application/x-www-form-urlencoded",
 
 
 
 
413
  },
414
  timeout=60.0, # PDF generation can be slow
415
  )
@@ -421,8 +469,8 @@ class LaserficheConnector:
421
  if not pdf_bytes or len(pdf_bytes) < 100:
422
  print(f"[WARN] GeneratePDF returned empty or too small response ({len(pdf_bytes)} bytes)")
423
  pdf_bytes = None
424
- elif b"<html" in pdf_bytes[:100].lower() or b"<!DOCTYPE" in pdf_bytes[:100]:
425
- print(f"[WARN] GeneratePDF returned HTML instead of PDF (likely an error page)")
426
  pdf_bytes = None
427
  else:
428
  print(f"[PDF] Generated PDF: {len(pdf_bytes)} bytes, Content-Type: {content_type}")
@@ -441,7 +489,57 @@ class LaserficheConnector:
441
  # Step 4: Fallback — download individual page images directly
442
  # This handles cases where GeneratePDF.aspx is blocked (e.g. HF Spaces)
443
  print("[FALLBACK] Trying direct page image download...")
444
- return await self._fetch_page_images_fallback(doc_id, page_urls)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
446
  def _render_pdf_to_images(self, pdf_bytes: bytes) -> list[bytes]:
447
  """Render PDF pages to PNG images using PyMuPDF."""
@@ -476,25 +574,34 @@ class LaserficheConnector:
476
  return images
477
 
478
  async def _fetch_page_images_fallback(
479
- self, doc_id: str, page_urls: Optional[list[str]] = None
480
  ) -> list[bytes]:
481
  """Fallback: download individual page images directly from Laserfiche.
482
 
483
  Tries multiple strategies:
484
  1. Download each page URL directly (Page1.aspx, Page2.aspx, etc.)
485
- and extract the embedded image reference from the HTML.
486
  2. Try the GetImage.aspx endpoint with PageIds from docInfo JSON.
 
487
 
488
  Returns a list of PNG image bytes.
489
  """
490
  images: list[bytes] = []
491
 
492
- # If we have explicit page URLs, try downloading them
493
  if page_urls:
494
  for i, page_url in enumerate(page_urls):
495
  try:
496
  print(f"[FALLBACK] Downloading page {i + 1}: {page_url}")
497
- response = await self.client.get(page_url, timeout=30.0)
 
 
 
 
 
 
 
 
498
  response.raise_for_status()
499
 
500
  content = response.content
@@ -502,7 +609,6 @@ class LaserficheConnector:
502
 
503
  # Check if we got an image directly
504
  if content_type.startswith("image/"):
505
- # Convert to PNG if needed
506
  img_bytes = self._convert_to_png_bytes(content)
507
  if img_bytes:
508
  images.append(img_bytes)
@@ -526,9 +632,10 @@ class LaserficheConnector:
526
  if images:
527
  return images
528
 
529
- # Last resort: try GetImage.aspx with page IDs from docInfo
 
530
  print("[FALLBACK] Trying GetImage.aspx endpoint...")
531
- return await self._fetch_via_getimage(doc_id)
532
 
533
  async def _extract_image_from_page_html(
534
  self, html: str, doc_id: str, page_num: int
@@ -536,10 +643,28 @@ class LaserficheConnector:
536
  """Extract a page image from the Laserfiche page HTML.
537
 
538
  The page HTML contains a docInfo JSON with PageIds that can be
539
- used to construct GetImage.aspx URLs.
 
540
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  # Try to extract docInfo JSON from the HTML
542
- # Pattern: var docInfo = {"NumPages":...,"PageIds":[...],...};
543
  doc_info_match = re.search(
544
  r'var\s+docInfo\s*=\s*(\{.*?"PageIds"\s*:\s*\[.*?\]\s*.*?\});',
545
  html,
@@ -563,12 +688,24 @@ class LaserficheConnector:
563
  )
564
  print(f"[FALLBACK] Trying GetImage.aspx: {img_url}")
565
  try:
566
- img_response = await self.client.get(img_url, timeout=30.0)
 
 
 
 
 
 
 
 
 
567
  img_response.raise_for_status()
568
  img_content = img_response.content
569
  content_type = img_response.headers.get("content-type", "")
570
  if content_type.startswith("image/") and len(img_content) > 1000:
571
  return self._convert_to_png_bytes(img_content)
 
 
 
572
  except Exception as e:
573
  print(f"[FALLBACK] GetImage.aspx failed: {e}")
574
 
@@ -580,10 +717,18 @@ class LaserficheConnector:
580
  f"?pageid={page_id}&dbid={dbid}&docid={doc_id_from_info}"
581
  f"&format={fmt}"
582
  )
583
- img_response = await self.client.get(img_url_fmt, timeout=30.0)
 
 
 
 
 
 
 
 
584
  img_response.raise_for_status()
585
  img_content = img_response.content
586
- if len(img_content) > 1000:
587
  return self._convert_to_png_bytes(img_content)
588
  except Exception:
589
  continue
@@ -592,19 +737,103 @@ class LaserficheConnector:
592
 
593
  return None
594
 
595
- async def _fetch_via_getimage(self, doc_id: str) -> list[bytes]:
596
- """Last resort: try to enumerate page IDs via GetImage.aspx.
 
 
 
597
 
598
- Laserfiche assigns sequential page IDs. We try a range of
599
- possible page IDs and see which ones return valid images.
 
600
  """
601
  images: list[bytes] = []
602
- # Try page IDs in a reasonable range (typically sequential starting from a base)
603
- # We don't know the exact page IDs, so we try a heuristic approach
604
- # Page IDs are usually large numbers (e.g. 5580932, 5580933, 5580934)
605
- # We can't enumerate them, so this is a best-effort attempt.
606
- # Instead, try the page URLs with image-specific headers.
607
- print("[FALLBACK] GetImage.aspx enumeration not feasible without page IDs")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  return images
609
 
610
  def _convert_to_png_bytes(self, image_data: bytes) -> Optional[bytes]:
 
64
  cookies={},
65
  headers={
66
  "User-Agent": (
67
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
68
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
69
+ "Chrome/120.0.0.0 Safari/537.36"
70
  ),
71
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
72
+ "Accept-Language": "en-US,en;q=0.5",
73
  },
74
  )
75
 
 
369
 
370
  Primary strategy (PDF generation):
371
  1. Visit docview.aspx to establish session cookies
372
+ 2. Extract ASP.NET __VIEWSTATE and __EVENTVALIDATION tokens from the HTML
373
+ 3. POST to GeneratePDF.aspx with ViewState tokens to generate a PDF
374
+ 4. Use PyMuPDF (fitz) to render each PDF page to a PIL Image
375
 
376
  Fallback strategy (direct page download):
377
  If PDF generation fails (e.g. on HF Spaces where the Laserfiche
 
394
  return []
395
 
396
  # Step 1: Visit docview.aspx to establish session cookies
397
+ # and extract ASP.NET ViewState tokens needed for GeneratePDF POST
398
+ viewstate = None
399
+ eventvalidation = None
400
+ docview_html = None
401
  try:
402
+ docview_response = await self.client.get(
403
+ document_url,
404
+ headers={
405
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
406
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
407
+ "Accept-Language": "en-US,en;q=0.5",
408
+ },
409
+ timeout=30.0,
410
+ )
411
+ docview_response.raise_for_status()
412
+ docview_html = docview_response.text
413
+
414
+ # Extract __VIEWSTATE from the docview.aspx HTML
415
+ vs_match = re.search(
416
+ r'<input[^>]*name="__VIEWSTATE"[^>]*value="([^"]*)"',
417
+ docview_html,
418
+ )
419
+ if vs_match:
420
+ viewstate = vs_match.group(1)
421
+ print(f"[PDF] Extracted __VIEWSTATE ({len(viewstate)} chars)")
422
+
423
+ # Extract __EVENTVALIDATION from the docview.aspx HTML
424
+ ev_match = re.search(
425
+ r'<input[^>]*name="__EVENTVALIDATION"[^>]*value="([^"]*)"',
426
+ docview_html,
427
+ )
428
+ if ev_match:
429
+ eventvalidation = ev_match.group(1)
430
+ print(f"[PDF] Extracted __EVENTVALIDATION ({len(eventvalidation)} chars)")
431
+
432
  except Exception as e:
433
  print(f"[WARN] Error accessing document viewer: {e}")
434
  # Continue anyway — cookies might not be needed for GeneratePDF
 
437
  gen_url = f"{self.base_url}/GeneratePDF.aspx"
438
  pdf_bytes = None
439
  try:
440
+ # Build POST data with ViewState tokens if available
441
+ post_data: dict[str, str] = {
442
+ "id": doc_id,
443
+ "dbid": DBID,
444
+ "pageFrom": "1",
445
+ "pageTo": "999", # Request all pages
446
+ }
447
+ if viewstate:
448
+ post_data["__VIEWSTATE"] = viewstate
449
+ if eventvalidation:
450
+ post_data["__EVENTVALIDATION"] = eventvalidation
451
+
452
  pdf_response = await self.client.post(
453
  gen_url,
454
+ data=post_data,
 
 
 
 
 
455
  headers={
456
  "Content-Type": "application/x-www-form-urlencoded",
457
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
458
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
459
+ "Accept-Language": "en-US,en;q=0.5",
460
+ "Referer": document_url,
461
  },
462
  timeout=60.0, # PDF generation can be slow
463
  )
 
469
  if not pdf_bytes or len(pdf_bytes) < 100:
470
  print(f"[WARN] GeneratePDF returned empty or too small response ({len(pdf_bytes)} bytes)")
471
  pdf_bytes = None
472
+ elif self._is_error_response(pdf_bytes):
473
+ print(f"[WARN] GeneratePDF returned error page instead of PDF ({len(pdf_bytes)} bytes, Content-Type: {content_type})")
474
  pdf_bytes = None
475
  else:
476
  print(f"[PDF] Generated PDF: {len(pdf_bytes)} bytes, Content-Type: {content_type}")
 
489
  # Step 4: Fallback — download individual page images directly
490
  # This handles cases where GeneratePDF.aspx is blocked (e.g. HF Spaces)
491
  print("[FALLBACK] Trying direct page image download...")
492
+ return await self._fetch_page_images_fallback(doc_id, page_urls, docview_html)
493
+
494
+ def _is_error_response(self, content: bytes) -> bool:
495
+ """Check if the response content is an error page rather than a valid PDF.
496
+
497
+ Detects:
498
+ - HTML pages (<html, <!DOCTYPE)
499
+ - ASP.NET error pages (JavaScript alerts, "Object reference" errors)
500
+ - XML error responses
501
+ """
502
+ if not content:
503
+ return True
504
+
505
+ head = content[:500].lower()
506
+
507
+ # Check for HTML tags
508
+ if b"<html" in head or b"<!DOCTYPE" in head:
509
+ return True
510
+
511
+ # Check for ASP.NET error patterns
512
+ error_patterns = [
513
+ b"object reference not set",
514
+ b"nullreferenceexception",
515
+ b"exception of type",
516
+ b"error has occurred",
517
+ b"could not complete your request",
518
+ b"the system has encountered an error",
519
+ b"alert(",
520
+ b"stack trace:",
521
+ ]
522
+ for pattern in error_patterns:
523
+ if pattern in head:
524
+ return True
525
+
526
+ # Check for XML with error indicators
527
+ if b"<?xml" in head:
528
+ # XML responses from GeneratePDF are usually errors
529
+ # A valid PDF starts with %PDF
530
+ if b"<html" in content[:2000].lower() or b"error" in content[:2000].lower():
531
+ return True
532
+
533
+ # Check if it starts with PDF magic bytes
534
+ if content[:4] == b"%PDF":
535
+ return False
536
+
537
+ # If it doesn't start with PDF magic bytes and isn't clearly an error,
538
+ # it's likely not a valid PDF
539
+ if len(content) < 1000:
540
+ return True
541
+
542
+ return False
543
 
544
  def _render_pdf_to_images(self, pdf_bytes: bytes) -> list[bytes]:
545
  """Render PDF pages to PNG images using PyMuPDF."""
 
574
  return images
575
 
576
  async def _fetch_page_images_fallback(
577
+ self, doc_id: str, page_urls: Optional[list[str]] = None, docview_html: Optional[str] = None
578
  ) -> list[bytes]:
579
  """Fallback: download individual page images directly from Laserfiche.
580
 
581
  Tries multiple strategies:
582
  1. Download each page URL directly (Page1.aspx, Page2.aspx, etc.)
583
+ and extract docInfo JSON with PageIds from the HTML.
584
  2. Try the GetImage.aspx endpoint with PageIds from docInfo JSON.
585
+ 3. Try GetImage.aspx with sequential page ID enumeration.
586
 
587
  Returns a list of PNG image bytes.
588
  """
589
  images: list[bytes] = []
590
 
591
+ # Strategy 1: Try downloading each page URL and extracting docInfo
592
  if page_urls:
593
  for i, page_url in enumerate(page_urls):
594
  try:
595
  print(f"[FALLBACK] Downloading page {i + 1}: {page_url}")
596
+ response = await self.client.get(
597
+ page_url,
598
+ headers={
599
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
600
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
601
+ "Accept-Language": "en-US,en;q=0.5",
602
+ },
603
+ timeout=30.0,
604
+ )
605
  response.raise_for_status()
606
 
607
  content = response.content
 
609
 
610
  # Check if we got an image directly
611
  if content_type.startswith("image/"):
 
612
  img_bytes = self._convert_to_png_bytes(content)
613
  if img_bytes:
614
  images.append(img_bytes)
 
632
  if images:
633
  return images
634
 
635
+ # Strategy 2: Try GetImage.aspx with page IDs from docInfo
636
+ # Pass the already-fetched docview_html to avoid a redundant HTTP request
637
  print("[FALLBACK] Trying GetImage.aspx endpoint...")
638
+ return await self._fetch_via_getimage(doc_id, docview_html)
639
 
640
  async def _extract_image_from_page_html(
641
  self, html: str, doc_id: str, page_num: int
 
643
  """Extract a page image from the Laserfiche page HTML.
644
 
645
  The page HTML contains a docInfo JSON with PageIds that can be
646
+ used to construct GetImage.aspx URLs. Also tries to extract
647
+ ViewState tokens for authenticated requests.
648
  """
649
+ # Extract __VIEWSTATE from the page HTML for authenticated requests
650
+ viewstate = None
651
+ vs_match = re.search(
652
+ r'<input[^>]*name="__VIEWSTATE"[^>]*value="([^"]*)"',
653
+ html,
654
+ )
655
+ if vs_match:
656
+ viewstate = vs_match.group(1)
657
+
658
+ # Extract __EVENTVALIDATION
659
+ eventvalidation = None
660
+ ev_match = re.search(
661
+ r'<input[^>]*name="__EVENTVALIDATION"[^>]*value="([^"]*)"',
662
+ html,
663
+ )
664
+ if ev_match:
665
+ eventvalidation = ev_match.group(1)
666
+
667
  # Try to extract docInfo JSON from the HTML
 
668
  doc_info_match = re.search(
669
  r'var\s+docInfo\s*=\s*(\{.*?"PageIds"\s*:\s*\[.*?\]\s*.*?\});',
670
  html,
 
688
  )
689
  print(f"[FALLBACK] Trying GetImage.aspx: {img_url}")
690
  try:
691
+ img_response = await self.client.get(
692
+ img_url,
693
+ headers={
694
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
695
+ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
696
+ "Accept-Language": "en-US,en;q=0.5",
697
+ "Referer": f"{self.base_url}/docview.aspx?id={doc_id_from_info}&dbid={dbid}",
698
+ },
699
+ timeout=30.0,
700
+ )
701
  img_response.raise_for_status()
702
  img_content = img_response.content
703
  content_type = img_response.headers.get("content-type", "")
704
  if content_type.startswith("image/") and len(img_content) > 1000:
705
  return self._convert_to_png_bytes(img_content)
706
+ elif len(img_content) > 1000 and not self._is_error_response(img_content):
707
+ # Might be an image with wrong content-type
708
+ return self._convert_to_png_bytes(img_content)
709
  except Exception as e:
710
  print(f"[FALLBACK] GetImage.aspx failed: {e}")
711
 
 
717
  f"?pageid={page_id}&dbid={dbid}&docid={doc_id_from_info}"
718
  f"&format={fmt}"
719
  )
720
+ img_response = await self.client.get(
721
+ img_url_fmt,
722
+ headers={
723
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
724
+ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
725
+ "Referer": f"{self.base_url}/docview.aspx?id={doc_id_from_info}&dbid={dbid}",
726
+ },
727
+ timeout=30.0,
728
+ )
729
  img_response.raise_for_status()
730
  img_content = img_response.content
731
+ if len(img_content) > 1000 and not self._is_error_response(img_content):
732
  return self._convert_to_png_bytes(img_content)
733
  except Exception:
734
  continue
 
737
 
738
  return None
739
 
740
+ async def _fetch_via_getimage(self, doc_id: str, existing_html: Optional[str] = None) -> list[bytes]:
741
+ """Last resort: try to get page images via GetImage.aspx.
742
+
743
+ Uses the docInfo JSON from the docview.aspx page to get PageIds,
744
+ then tries GetImage.aspx with each page ID.
745
 
746
+ Args:
747
+ doc_id: The Laserfiche document ID.
748
+ existing_html: Previously fetched docview.aspx HTML to avoid redundant requests.
749
  """
750
  images: list[bytes] = []
751
+
752
+ # Use existing HTML if provided (avoids redundant HTTP request)
753
+ html = existing_html
754
+ docview_url = f"{self.base_url}/docview.aspx?id={doc_id}&dbid={DBID}"
755
+
756
+ # Fetch docview.aspx if we don't already have the HTML
757
+ if not html:
758
+ try:
759
+ response = await self.client.get(
760
+ docview_url,
761
+ headers={
762
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
763
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
764
+ },
765
+ timeout=30.0,
766
+ )
767
+ response.raise_for_status()
768
+ html = response.text
769
+ except Exception as e:
770
+ print(f"[FALLBACK] Failed to get docInfo from docview.aspx: {e}")
771
+ return images
772
+
773
+ # Extract docInfo JSON
774
+ try:
775
+ doc_info_match = re.search(
776
+ r'var\s+docInfo\s*=\s*(\{.*?"PageIds"\s*:\s*\[.*?\]\s*.*?\});',
777
+ html,
778
+ re.DOTALL,
779
+ )
780
+ if doc_info_match:
781
+ doc_info = json.loads(doc_info_match.group(1))
782
+ page_ids = doc_info.get("PageIds", [])
783
+ dbid = doc_info.get("DBID", DBID)
784
+ doc_id_from_info = str(doc_info.get("Id", doc_id))
785
+
786
+ print(f"[FALLBACK] Got docInfo: {len(page_ids) - 1} pages, PageIds={page_ids}")
787
+
788
+ # Try each page ID with GetImage.aspx
789
+ for page_num in range(1, len(page_ids)):
790
+ page_id = page_ids[page_num]
791
+ if page_id and page_id > 0:
792
+ img_url = (
793
+ f"{self.base_url}/GetImage.aspx"
794
+ f"?pageid={page_id}&dbid={dbid}&docid={doc_id_from_info}"
795
+ )
796
+ try:
797
+ img_response = await self.client.get(
798
+ img_url,
799
+ headers={
800
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
801
+ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
802
+ "Referer": docview_url,
803
+ },
804
+ timeout=30.0,
805
+ )
806
+ img_response.raise_for_status()
807
+ img_content = img_response.content
808
+ content_type = img_response.headers.get("content-type", "")
809
+
810
+ if content_type.startswith("image/") and len(img_content) > 1000:
811
+ img_bytes = self._convert_to_png_bytes(img_content)
812
+ if img_bytes:
813
+ images.append(img_bytes)
814
+ print(f"[FALLBACK] GetImage.aspx page {page_num}: Got image ({len(img_bytes)} bytes)")
815
+ continue
816
+
817
+ # Check if it's an error page
818
+ if self._is_error_response(img_content):
819
+ print(f"[FALLBACK] GetImage.aspx page {page_num}: Got error page ({len(img_content)} bytes, {content_type})")
820
+ else:
821
+ # Try to convert anyway
822
+ img_bytes = self._convert_to_png_bytes(img_content)
823
+ if img_bytes:
824
+ images.append(img_bytes)
825
+ print(f"[FALLBACK] GetImage.aspx page {page_num}: Got image ({len(img_bytes)} bytes)")
826
+
827
+ except Exception as e:
828
+ print(f"[FALLBACK] GetImage.aspx page {page_num}: Failed: {e}")
829
+
830
+ except (json.JSONDecodeError, KeyError, IndexError) as e:
831
+ print(f"[FALLBACK] Failed to parse docInfo JSON: {e}")
832
+
833
+ if images:
834
+ return images
835
+
836
+ print("[FALLBACK] All GetImage.aspx attempts failed")
837
  return images
838
 
839
  def _convert_to_png_bytes(self, image_data: bytes) -> Optional[bytes]:
backend/parsers/llm_summarizer.py CHANGED
@@ -134,6 +134,10 @@ class LLMSummarizer:
134
  # This prevents LLM hallucination on completely unreadable text
135
  UNREADABLE_THRESHOLD = 0.2
136
 
 
 
 
 
137
  def __init__(
138
  self,
139
  deepseek_key: Optional[str] = None,
@@ -229,7 +233,7 @@ class LLMSummarizer:
229
  """Cleanup resources (no-op for now, kept for API compatibility)."""
230
  pass
231
 
232
- def _preprocess_image_for_ocr(self, image) -> object:
233
  """Preprocess a PIL image to improve OCR accuracy.
234
 
235
  Applies:
@@ -239,9 +243,17 @@ class LLMSummarizer:
239
  4. Denoise with median filter
240
  5. Deskew (straighten) the image
241
 
242
- Returns the preprocessed PIL Image.
 
 
 
 
 
 
 
 
243
  """
244
- # Convert to grayscale
245
  if image.mode != "L":
246
  image = image.convert("L")
247
 
@@ -271,6 +283,10 @@ class LLMSummarizer:
271
  # Simple threshold without numpy
272
  image = image.point(lambda x: 255 if x > 128 else 0)
273
 
 
 
 
 
274
  return image
275
 
276
  def _get_easyocr_reader(self):
@@ -294,11 +310,14 @@ class LLMSummarizer:
294
  return self._easyocr_reader
295
 
296
  def _ocr_with_easyocr(self, image_bytes: bytes) -> str:
297
- """Run EasyOCR on a single page image.
298
 
299
  EasyOCR is a deep-learning based OCR engine (MIT license) that
300
  significantly outperforms Tesseract on scanned documents.
301
 
 
 
 
302
  Args:
303
  image_bytes: PNG/JPEG bytes of the page image.
304
 
@@ -310,12 +329,15 @@ class LLMSummarizer:
310
  return ""
311
 
312
  try:
313
- # EasyOCR expects a numpy array (H, W, 3) in RGB format
314
  image = self._PIL_Image.open(io.BytesIO(image_bytes))
315
- # Convert to RGB if necessary (EasyOCR expects 3-channel)
316
- if image.mode != "RGB":
317
- image = image.convert("RGB")
318
- img_array = self._np.array(image)
 
 
 
319
 
320
  # Run EasyOCR
321
  results = reader.readtext(img_array, paragraph=True)
@@ -327,7 +349,26 @@ class LLMSummarizer:
327
  if confidence > 0.3: # Filter low-confidence detections
328
  lines.append(text.strip())
329
 
330
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  except Exception as e:
332
  print(f"[EASYOCR] Error during OCR: {e}")
333
  return ""
@@ -447,10 +488,12 @@ class LLMSummarizer:
447
  """Summarize meeting minutes using the best available method.
448
 
449
  Priority:
450
- 1. EasyOCR mode (primary) — deep-learning OCR, MIT license.
451
- Significantly better accuracy than Tesseract on scanned documents.
452
- 2. Tesseract OCR mode (fallback) — traditional OCR, free, open-source.
453
- 3. Text mode (DeepSeek) — if raw_text is available.
 
 
454
 
455
  Args:
456
  minutes: The minutes document to summarize.
@@ -458,8 +501,15 @@ class LLMSummarizer:
458
  for OCR. Used when page_image_urls contains URLs (strings)
459
  instead of actual image data.
460
  """
 
 
 
 
 
 
 
461
  # Prefer OCR mode for scanned document images
462
- if minutes.page_image_urls and self._pillow_available:
463
  return await self._summarize_with_ocr(minutes, image_fetcher)
464
 
465
  # Fall back to text mode
@@ -521,51 +571,65 @@ class LLMSummarizer:
521
  )
522
 
523
  # Run OCR on each page image
524
- # Priority: EasyOCR (primary) -> Tesseract (fallback)
525
  extracted_pages = []
526
  total_raw_chars = 0
527
- easyocr_available = self._easyocr_available
528
- tesseract_available = self._pytesseract_available
529
 
530
  for i, img_bytes in enumerate(image_bytes_list):
531
  page_text = ""
532
  ocr_engine = "none"
533
 
534
  try:
 
 
 
535
  # --- Try EasyOCR first (deep-learning, better accuracy) ---
536
- if easyocr_available:
537
- page_text = self._ocr_with_easyocr(img_bytes)
538
- if page_text and page_text.strip():
539
- ocr_engine = "EasyOCR"
540
- print(f"[OCR] Page {i + 1}: EasyOCR extracted {len(page_text.strip())} chars")
541
-
542
- # --- Fall back to Tesseract if EasyOCR failed or produced nothing ---
543
- if (not page_text or len(page_text.strip()) < 20) and tesseract_available:
544
- image = self._PIL_Image.open(io.BytesIO(img_bytes))
545
-
546
- # Try with preprocessing first
547
- processed = self._preprocess_image_for_ocr(image)
548
- text_tess = self._pytesseract.image_to_string(
549
- processed,
550
- lang=self.TESSERACT_LANGUAGES,
551
- config="--psm 6 --oem 3",
552
- )
 
 
 
 
 
553
 
554
- # Try without preprocessing as fallback
555
- if not text_tess or len(text_tess.strip()) < 20:
556
- text_raw = self._pytesseract.image_to_string(
557
  image,
558
  lang=self.TESSERACT_LANGUAGES,
559
  config="--psm 6 --oem 3",
560
- )
561
- if len(text_raw.strip()) > len(text_tess.strip()):
562
- text_tess = text_raw
563
- print(f"[OCR] Page {i + 1}: Tesseract using raw image")
564
-
565
- if text_tess and len(text_tess.strip()) > len(page_text.strip()):
566
- page_text = text_tess
567
- ocr_engine = "Tesseract"
568
- print(f"[OCR] Page {i + 1}: Tesseract extracted {len(text_tess.strip())} chars")
 
 
 
 
 
 
 
 
 
569
 
570
  # --- Log results ---
571
  if page_text and page_text.strip():
 
134
  # This prevents LLM hallucination on completely unreadable text
135
  UNREADABLE_THRESHOLD = 0.2
136
 
137
+ # Minimum character threshold for considering OCR successful.
138
+ # If an engine produces fewer chars than this, other engines will still be tried.
139
+ MIN_OCR_CHARS = 50
140
+
141
  def __init__(
142
  self,
143
  deepseek_key: Optional[str] = None,
 
233
  """Cleanup resources (no-op for now, kept for API compatibility)."""
234
  pass
235
 
236
+ def _preprocess_image_for_ocr(self, image, for_easyocr: bool = False):
237
  """Preprocess a PIL image to improve OCR accuracy.
238
 
239
  Applies:
 
243
  4. Denoise with median filter
244
  5. Deskew (straighten) the image
245
 
246
+ For EasyOCR, returns the preprocessed image in RGB mode (EasyOCR needs 3-channel).
247
+ For Tesseract, returns the preprocessed image in grayscale (L) mode.
248
+
249
+ Args:
250
+ image: PIL Image to preprocess.
251
+ for_easyocr: If True, returns RGB image; if False, returns grayscale.
252
+
253
+ Returns:
254
+ Preprocessed PIL Image.
255
  """
256
+ # Convert to grayscale for processing
257
  if image.mode != "L":
258
  image = image.convert("L")
259
 
 
283
  # Simple threshold without numpy
284
  image = image.point(lambda x: 255 if x > 128 else 0)
285
 
286
+ # EasyOCR expects 3-channel RGB
287
+ if for_easyocr:
288
+ image = image.convert("RGB")
289
+
290
  return image
291
 
292
  def _get_easyocr_reader(self):
 
310
  return self._easyocr_reader
311
 
312
  def _ocr_with_easyocr(self, image_bytes: bytes) -> str:
313
+ """Run EasyOCR on a single page image with preprocessing.
314
 
315
  EasyOCR is a deep-learning based OCR engine (MIT license) that
316
  significantly outperforms Tesseract on scanned documents.
317
 
318
+ Applies image preprocessing (contrast enhancement, binarization)
319
+ before passing to EasyOCR for better results on scanned documents.
320
+
321
  Args:
322
  image_bytes: PNG/JPEG bytes of the page image.
323
 
 
329
  return ""
330
 
331
  try:
332
+ # Open the image
333
  image = self._PIL_Image.open(io.BytesIO(image_bytes))
334
+
335
+ # Preprocess the image for better OCR accuracy
336
+ # EasyOCR gets the same preprocessing as Tesseract now
337
+ processed = self._preprocess_image_for_ocr(image, for_easyocr=True)
338
+
339
+ # EasyOCR expects a numpy array (H, W, 3) in RGB format
340
+ img_array = self._np.array(processed)
341
 
342
  # Run EasyOCR
343
  results = reader.readtext(img_array, paragraph=True)
 
349
  if confidence > 0.3: # Filter low-confidence detections
350
  lines.append(text.strip())
351
 
352
+ easyocr_text = "\n".join(lines)
353
+
354
+ # If EasyOCR produced very little text, try without preprocessing too
355
+ # (some documents OCR better with original image)
356
+ if len(easyocr_text.strip()) < self.MIN_OCR_CHARS:
357
+ raw_image = self._PIL_Image.open(io.BytesIO(image_bytes))
358
+ if raw_image.mode != "RGB":
359
+ raw_image = raw_image.convert("RGB")
360
+ raw_array = self._np.array(raw_image)
361
+ raw_results = reader.readtext(raw_array, paragraph=True)
362
+ raw_lines = []
363
+ for bbox, text, confidence in raw_results:
364
+ if confidence > 0.3:
365
+ raw_lines.append(text.strip())
366
+ raw_text = "\n".join(raw_lines)
367
+ if len(raw_text.strip()) > len(easyocr_text.strip()):
368
+ print(f"[EASYOCR] Raw image produced more text ({len(raw_text.strip())} chars) than preprocessed ({len(easyocr_text.strip())} chars)")
369
+ easyocr_text = raw_text
370
+
371
+ return easyocr_text
372
  except Exception as e:
373
  print(f"[EASYOCR] Error during OCR: {e}")
374
  return ""
 
488
  """Summarize meeting minutes using the best available method.
489
 
490
  Priority:
491
+ 1. OCR mode (EasyOCR -> Tesseract) — for scanned document images.
492
+ 2. Text mode (DeepSeek) — if raw_text is available.
493
+
494
+ OCR mode is entered when:
495
+ - page_image_urls is non-empty, AND
496
+ - At least one OCR engine is available (EasyOCR, Tesseract, or Pillow for preprocessing)
497
 
498
  Args:
499
  minutes: The minutes document to summarize.
 
501
  for OCR. Used when page_image_urls contains URLs (strings)
502
  instead of actual image data.
503
  """
504
+ # Check if any OCR capability is available
505
+ ocr_capable = (
506
+ self._easyocr_available
507
+ or self._pytesseract_available
508
+ or self._pillow_available
509
+ )
510
+
511
  # Prefer OCR mode for scanned document images
512
+ if minutes.page_image_urls and ocr_capable:
513
  return await self._summarize_with_ocr(minutes, image_fetcher)
514
 
515
  # Fall back to text mode
 
571
  )
572
 
573
  # Run OCR on each page image
574
+ # Strategy: Try ALL available engines and take the BEST result per page
575
  extracted_pages = []
576
  total_raw_chars = 0
 
 
577
 
578
  for i, img_bytes in enumerate(image_bytes_list):
579
  page_text = ""
580
  ocr_engine = "none"
581
 
582
  try:
583
+ best_text = ""
584
+ best_engine = "none"
585
+
586
  # --- Try EasyOCR first (deep-learning, better accuracy) ---
587
+ if self._easyocr_available:
588
+ try:
589
+ text_easy = self._ocr_with_easyocr(img_bytes)
590
+ if text_easy and len(text_easy.strip()) > len(best_text.strip()):
591
+ best_text = text_easy.strip()
592
+ best_engine = "EasyOCR"
593
+ print(f"[OCR] Page {i + 1}: EasyOCR extracted {len(best_text)} chars")
594
+ except Exception as e:
595
+ print(f"[WARN] EasyOCR failed for page {i + 1}: {e}")
596
+
597
+ # --- Always try Tesseract too (it may extract different content) ---
598
+ if self._pytesseract_available:
599
+ try:
600
+ image = self._PIL_Image.open(io.BytesIO(img_bytes))
601
+
602
+ # Try with preprocessing first
603
+ processed = self._preprocess_image_for_ocr(image, for_easyocr=False)
604
+ text_tess_processed = self._pytesseract.image_to_string(
605
+ processed,
606
+ lang=self.TESSERACT_LANGUAGES,
607
+ config="--psm 6 --oem 3",
608
+ ).strip()
609
 
610
+ # Try without preprocessing as fallback
611
+ text_tess_raw = self._pytesseract.image_to_string(
 
612
  image,
613
  lang=self.TESSERACT_LANGUAGES,
614
  config="--psm 6 --oem 3",
615
+ ).strip()
616
+
617
+ # Take the longer result from Tesseract
618
+ text_tess = text_tess_processed if len(text_tess_processed) >= len(text_tess_raw) else text_tess_raw
619
+
620
+ if text_tess and len(text_tess) > len(best_text):
621
+ best_text = text_tess
622
+ best_engine = "Tesseract"
623
+ print(f"[OCR] Page {i + 1}: Tesseract extracted {len(best_text)} chars")
624
+ if text_tess == text_tess_raw and text_tess != text_tess_processed:
625
+ print(f"[OCR] Page {i + 1}: Tesseract using raw image (preprocessed gave {len(text_tess_processed)} chars)")
626
+ except Exception as e:
627
+ print(f"[WARN] Tesseract failed for page {i + 1}: {e}")
628
+
629
+ # --- Use the best result ---
630
+ if best_text:
631
+ page_text = best_text
632
+ ocr_engine = best_engine
633
 
634
  # --- Log results ---
635
  if page_text and page_text.strip():
backend/run.py CHANGED
@@ -22,13 +22,29 @@ if __name__ == "__main__":
22
  providers.append("DeepSeek (text + OCR)")
23
  llm_status = ", ".join(providers) if providers else "Not configured"
24
 
25
- ocr_available = False
 
 
 
 
 
26
  try:
27
  import pytesseract
 
 
 
 
28
  import PIL
29
- ocr_available = True
30
  except ImportError:
31
  pass
 
 
 
 
 
 
 
32
 
33
  print("=" * 60)
34
  print(" Civic City Hub API Server")
@@ -37,7 +53,7 @@ if __name__ == "__main__":
37
  print(f" City: Paris, TX")
38
  print(f" Website: https://www.paristexas.gov")
39
  print(f" LLM: {llm_status}")
40
- print(f" OCR: {'Tesseract (free)' if ocr_available else 'Not installed (pip install pytesseract Pillow)'}")
41
  print()
42
  print(" Endpoints:")
43
  print(f" Health: http://localhost:{port}/health")
 
22
  providers.append("DeepSeek (text + OCR)")
23
  llm_status = ", ".join(providers) if providers else "Not configured"
24
 
25
+ ocr_engines = []
26
+ try:
27
+ import easyocr
28
+ ocr_engines.append("EasyOCR")
29
+ except ImportError:
30
+ pass
31
  try:
32
  import pytesseract
33
+ ocr_engines.append("Tesseract")
34
+ except ImportError:
35
+ pass
36
+ try:
37
  import PIL
38
+ ocr_engines.append("Pillow")
39
  except ImportError:
40
  pass
41
+ try:
42
+ import numpy
43
+ ocr_engines.append("NumPy")
44
+ except ImportError:
45
+ pass
46
+
47
+ ocr_status = ", ".join(ocr_engines) if ocr_engines else "Not installed"
48
 
49
  print("=" * 60)
50
  print(" Civic City Hub API Server")
 
53
  print(f" City: Paris, TX")
54
  print(f" Website: https://www.paristexas.gov")
55
  print(f" LLM: {llm_status}")
56
+ print(f" OCR: {ocr_status}")
57
  print()
58
  print(" Endpoints:")
59
  print(f" Health: http://localhost:{port}/health")
frontend/.gitignore CHANGED
@@ -39,3 +39,5 @@ yarn-error.log*
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
 
 
 
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
42
+
43
+ .vercel