Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -4,6 +4,9 @@ import glob
|
|
| 4 |
import io
|
| 5 |
import os
|
| 6 |
import re
|
|
|
|
|
|
|
|
|
|
| 7 |
import unicodedata
|
| 8 |
from collections import defaultdict
|
| 9 |
|
|
@@ -14,7 +17,7 @@ import pandas as pd
|
|
| 14 |
import streamlit as st
|
| 15 |
from docx import Document
|
| 16 |
from langchain_groq import ChatGroq
|
| 17 |
-
from PIL import Image,
|
| 18 |
from pptx import Presentation
|
| 19 |
from rank_bm25 import BM25Okapi
|
| 20 |
from sentence_transformers import SentenceTransformer
|
|
@@ -26,7 +29,9 @@ CHUNK_WORDS = 80 # stays under the embedding model's 128-token limit
|
|
| 26 |
CHUNK_OVERLAP = 15
|
| 27 |
TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
|
| 28 |
RRF_K = 60
|
| 29 |
-
OCR_DPI =
|
|
|
|
|
|
|
| 30 |
OCR_LANGS = "ara+eng"
|
| 31 |
OCR_FALLBACK_LANGS = "eng" # tried first (Arabic data can hurt English-only pages); also the fallback if Arabic data is missing
|
| 32 |
OCR_CONTENT_THRESHOLD = 200 # gray level below which a pixel counts as "content" (0-255)
|
|
@@ -99,22 +104,26 @@ def _content_clip(page):
|
|
| 99 |
except Exception:
|
| 100 |
return page.rect
|
| 101 |
|
| 102 |
-
def
|
| 103 |
-
"""Render the page, flatten uneven background, stretch contrast, and also return an
|
| 104 |
-
inverted copy (for white-on-dark text such as banners). Returns [normal, inverted]."""
|
| 105 |
pix = page.get_pixmap(dpi=OCR_DPI, clip=clip, colorspace=fitz.csGRAY)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
pix.set_dpi(OCR_DPI, OCR_DPI)
|
| 119 |
ocr_doc = fitz.open("pdf", pix.pdfocr_tobytes(language=lang))
|
| 120 |
try:
|
|
@@ -124,39 +133,43 @@ def _ocr_array(arr, lang):
|
|
| 124 |
|
| 125 |
def ocr_page(page):
|
| 126 |
"""OCR a scanned page. Returns (text, error).
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
_set_tessdata()
|
| 131 |
clip = _content_clip(page)
|
| 132 |
if clip is None: # truly blank page: nothing to OCR, and not an error
|
| 133 |
-
return "", None
|
| 134 |
-
errors, lines, seen = [], [], set()
|
| 135 |
try:
|
| 136 |
-
|
| 137 |
except Exception as e:
|
| 138 |
-
return "", f"
|
|
|
|
|
|
|
|
|
|
| 139 |
for lang in dict.fromkeys((OCR_FALLBACK_LANGS, OCR_LANGS)): # de-duplicated, order kept
|
| 140 |
-
for
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
| 151 |
if lines:
|
| 152 |
-
return "\n".join(lines), None
|
| 153 |
-
return "", ("; ".join(errors) if errors else None)
|
| 154 |
|
| 155 |
def extract_pdf(name, data):
|
| 156 |
blocks = []
|
| 157 |
stats = {"pages": 0, "tables": 0, "notes": []}
|
| 158 |
doc = fitz.open(stream=data, filetype="pdf")
|
| 159 |
-
empty_pages, ocr_pages, ocr_errors = [], [], []
|
| 160 |
for i, page in enumerate(doc, 1):
|
| 161 |
stats["pages"] += 1
|
| 162 |
loc = f"p.{i}"
|
|
@@ -165,7 +178,8 @@ def extract_pdf(name, data):
|
|
| 165 |
blocks.append(make_block(tbl, name, loc, "table"))
|
| 166 |
stats["tables"] += 1
|
| 167 |
if len(text) < 50:
|
| 168 |
-
ocr_text, ocr_err = ocr_page(page)
|
|
|
|
| 169 |
if ocr_err:
|
| 170 |
ocr_errors.append((i, ocr_err))
|
| 171 |
if len(ocr_text) > len(text):
|
|
@@ -186,6 +200,9 @@ def extract_pdf(name, data):
|
|
| 186 |
"Check that Tesseract and its language data (eng, ara) are installed."
|
| 187 |
)
|
| 188 |
if empty_pages:
|
|
|
|
|
|
|
|
|
|
| 189 |
shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
|
| 190 |
stats["notes"].append(
|
| 191 |
f"Little or no readable text found on page(s) {shown}"
|
|
@@ -271,7 +288,7 @@ def extract_text_file(name, data):
|
|
| 271 |
return blocks, {"pages": 1, "tables": 0, "notes": []}
|
| 272 |
|
| 273 |
@st.cache_data(show_spinner=False)
|
| 274 |
-
def extract_file(name, data):
|
| 275 |
lower = name.lower()
|
| 276 |
if lower.endswith(".pdf"):
|
| 277 |
return extract_pdf(name, data)
|
|
@@ -386,6 +403,23 @@ with st.sidebar:
|
|
| 386 |
min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
|
| 387 |
help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
embedder = load_embedder()
|
| 390 |
|
| 391 |
groq_key = None
|
|
@@ -413,7 +447,7 @@ if uploaded_files:
|
|
| 413 |
with st.spinner("Reading documents (scanned pages are OCR'd and may take a while)..."):
|
| 414 |
for f in uploaded_files:
|
| 415 |
try:
|
| 416 |
-
blocks, stats = extract_file(f.name, f.getvalue())
|
| 417 |
except Exception as e:
|
| 418 |
blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
|
| 419 |
all_blocks.extend(blocks)
|
|
|
|
| 4 |
import io
|
| 5 |
import os
|
| 6 |
import re
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
import unicodedata
|
| 11 |
from collections import defaultdict
|
| 12 |
|
|
|
|
| 17 |
import streamlit as st
|
| 18 |
from docx import Document
|
| 19 |
from langchain_groq import ChatGroq
|
| 20 |
+
from PIL import Image, ImageOps
|
| 21 |
from pptx import Presentation
|
| 22 |
from rank_bm25 import BM25Okapi
|
| 23 |
from sentence_transformers import SentenceTransformer
|
|
|
|
| 29 |
CHUNK_OVERLAP = 15
|
| 30 |
TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
|
| 31 |
RRF_K = 60
|
| 32 |
+
OCR_DPI = 200 # Tesseract works best when text is ~30px tall; 400 hurt large poster text. Raise for tiny fax text.
|
| 33 |
+
OCR_PSMS = (3, 11) # 3 = auto page layout, 11 = sparse text (finds scattered words on posters)
|
| 34 |
+
EXTRACT_VERSION = "ocr-cli-v3" # bump to invalidate st.cache_data (it does not track helper functions)
|
| 35 |
OCR_LANGS = "ara+eng"
|
| 36 |
OCR_FALLBACK_LANGS = "eng" # tried first (Arabic data can hurt English-only pages); also the fallback if Arabic data is missing
|
| 37 |
OCR_CONTENT_THRESHOLD = 200 # gray level below which a pixel counts as "content" (0-255)
|
|
|
|
| 104 |
except Exception:
|
| 105 |
return page.rect
|
| 106 |
|
| 107 |
+
def _render_gray(page, clip):
|
|
|
|
|
|
|
| 108 |
pix = page.get_pixmap(dpi=OCR_DPI, clip=clip, colorspace=fitz.csGRAY)
|
| 109 |
+
return Image.frombytes("L", (pix.width, pix.height), pix.samples)
|
| 110 |
+
|
| 111 |
+
def _tesseract_cli(img, lang, psm):
|
| 112 |
+
"""Run the tesseract binary directly on a PIL image. Raises if it is missing or fails."""
|
| 113 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 114 |
+
path = os.path.join(tmp, "page.png")
|
| 115 |
+
img.save(path)
|
| 116 |
+
env = dict(os.environ)
|
| 117 |
+
r = subprocess.run(["tesseract", path, "stdout", "-l", lang, "--psm", str(psm)],
|
| 118 |
+
capture_output=True, text=True, timeout=180, env=env)
|
| 119 |
+
if r.returncode != 0:
|
| 120 |
+
raise RuntimeError((r.stderr or "tesseract failed").strip()[:300])
|
| 121 |
+
return unicodedata.normalize("NFKC", r.stdout).strip()
|
| 122 |
+
|
| 123 |
+
def _tesseract_pymupdf(img, lang):
|
| 124 |
+
"""Fallback when the tesseract binary is not on PATH: PyMuPDF's built-in OCR."""
|
| 125 |
+
w, h = img.size
|
| 126 |
+
pix = fitz.Pixmap(fitz.csGRAY, w, h, img.tobytes(), False)
|
| 127 |
pix.set_dpi(OCR_DPI, OCR_DPI)
|
| 128 |
ocr_doc = fitz.open("pdf", pix.pdfocr_tobytes(language=lang))
|
| 129 |
try:
|
|
|
|
| 133 |
|
| 134 |
def ocr_page(page):
|
| 135 |
"""OCR a scanned page. Returns (text, error).
|
| 136 |
+
Tries English first, then Arabic+English, each on the raw render and a contrast-stretched
|
| 137 |
+
inverted copy (for white-on-dark banners), with two page-segmentation modes, and merges the
|
| 138 |
+
unique lines. error is None unless every attempt raised."""
|
| 139 |
_set_tessdata()
|
| 140 |
clip = _content_clip(page)
|
| 141 |
if clip is None: # truly blank page: nothing to OCR, and not an error
|
| 142 |
+
return "", None, "page looked blank (no pixels darker than the content threshold)"
|
|
|
|
| 143 |
try:
|
| 144 |
+
raw = _render_gray(page, clip)
|
| 145 |
except Exception as e:
|
| 146 |
+
return "", f"could not render page: {e}", ""
|
| 147 |
+
inv = ImageOps.invert(ImageOps.autocontrast(raw, cutoff=2))
|
| 148 |
+
use_cli = shutil.which("tesseract") is not None
|
| 149 |
+
errors, lines, seen = [], [], set()
|
| 150 |
for lang in dict.fromkeys((OCR_FALLBACK_LANGS, OCR_LANGS)): # de-duplicated, order kept
|
| 151 |
+
for img in (raw, inv):
|
| 152 |
+
for psm in (OCR_PSMS if use_cli else (None,)):
|
| 153 |
+
try:
|
| 154 |
+
txt = _tesseract_cli(img, lang, psm) if use_cli else _tesseract_pymupdf(img, lang)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
errors.append(f"[{lang}] {e}")
|
| 157 |
+
continue
|
| 158 |
+
for ln in txt.splitlines():
|
| 159 |
+
key = re.sub(r"\W+", "", ln.lower())
|
| 160 |
+
if len(key) >= 3 and key not in seen:
|
| 161 |
+
seen.add(key)
|
| 162 |
+
lines.append(ln.strip())
|
| 163 |
+
info = f"engine={'tesseract CLI' if use_cli else 'PyMuPDF OCR'}, image={raw.size[0]}x{raw.size[1]}px, unique lines={len(lines)}"
|
| 164 |
if lines:
|
| 165 |
+
return "\n".join(lines), None, info
|
| 166 |
+
return "", ("; ".join(errors) if errors else None), info
|
| 167 |
|
| 168 |
def extract_pdf(name, data):
|
| 169 |
blocks = []
|
| 170 |
stats = {"pages": 0, "tables": 0, "notes": []}
|
| 171 |
doc = fitz.open(stream=data, filetype="pdf")
|
| 172 |
+
empty_pages, ocr_pages, ocr_errors, ocr_infos = [], [], [], []
|
| 173 |
for i, page in enumerate(doc, 1):
|
| 174 |
stats["pages"] += 1
|
| 175 |
loc = f"p.{i}"
|
|
|
|
| 178 |
blocks.append(make_block(tbl, name, loc, "table"))
|
| 179 |
stats["tables"] += 1
|
| 180 |
if len(text) < 50:
|
| 181 |
+
ocr_text, ocr_err, ocr_info = ocr_page(page)
|
| 182 |
+
ocr_infos.append((i, len(ocr_text), ocr_info))
|
| 183 |
if ocr_err:
|
| 184 |
ocr_errors.append((i, ocr_err))
|
| 185 |
if len(ocr_text) > len(text):
|
|
|
|
| 200 |
"Check that Tesseract and its language data (eng, ara) are installed."
|
| 201 |
)
|
| 202 |
if empty_pages:
|
| 203 |
+
dbg = "; ".join(f"p.{p}: {n} chars, {inf}" for p, n, inf in ocr_infos if p in empty_pages[:3])
|
| 204 |
+
if dbg:
|
| 205 |
+
stats["notes"].append(f"OCR details: {dbg}")
|
| 206 |
shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
|
| 207 |
stats["notes"].append(
|
| 208 |
f"Little or no readable text found on page(s) {shown}"
|
|
|
|
| 288 |
return blocks, {"pages": 1, "tables": 0, "notes": []}
|
| 289 |
|
| 290 |
@st.cache_data(show_spinner=False)
|
| 291 |
+
def extract_file(name, data, version=EXTRACT_VERSION):
|
| 292 |
lower = name.lower()
|
| 293 |
if lower.endswith(".pdf"):
|
| 294 |
return extract_pdf(name, data)
|
|
|
|
| 403 |
min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
|
| 404 |
help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
|
| 405 |
|
| 406 |
+
with st.expander("OCR diagnostics"):
|
| 407 |
+
tbin = shutil.which("tesseract")
|
| 408 |
+
st.write(f"tesseract binary: `{tbin or 'NOT FOUND (using PyMuPDF OCR fallback)'}`")
|
| 409 |
+
if tbin:
|
| 410 |
+
try:
|
| 411 |
+
_set_tessdata()
|
| 412 |
+
out = subprocess.run(["tesseract", "--list-langs"], capture_output=True, text=True, timeout=20)
|
| 413 |
+
st.code((out.stdout or out.stderr).strip())
|
| 414 |
+
except Exception as e:
|
| 415 |
+
st.write(f"Could not query Tesseract: {e}")
|
| 416 |
+
st.write(f"TESSDATA_PREFIX: `{os.environ.get('TESSDATA_PREFIX', 'unset')}`")
|
| 417 |
+
st.caption(f"Extraction version: {EXTRACT_VERSION}")
|
| 418 |
+
if st.button("Clear extraction cache"):
|
| 419 |
+
st.cache_data.clear()
|
| 420 |
+
st.session_state.pop("signature", None)
|
| 421 |
+
st.rerun()
|
| 422 |
+
|
| 423 |
embedder = load_embedder()
|
| 424 |
|
| 425 |
groq_key = None
|
|
|
|
| 447 |
with st.spinner("Reading documents (scanned pages are OCR'd and may take a while)..."):
|
| 448 |
for f in uploaded_files:
|
| 449 |
try:
|
| 450 |
+
blocks, stats = extract_file(f.name, f.getvalue(), EXTRACT_VERSION)
|
| 451 |
except Exception as e:
|
| 452 |
blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
|
| 453 |
all_blocks.extend(blocks)
|