Spaces:
Runtime error
Runtime error
Fix edge-case bugs found in a thorough code review
Browse files- app.py +34 -2
- pipeline/analyze.py +9 -5
- pipeline/export_docx.py +8 -5
- pipeline/jobs.py +10 -0
- pipeline/kg.py +35 -0
- pipeline/online_ocr.py +16 -4
- pipeline/postprocess.py +65 -6
- static/app.js +4 -0
app.py
CHANGED
|
@@ -15,7 +15,7 @@ import asyncio
|
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Body
|
| 18 |
-
from fastapi.responses import FileResponse, StreamingResponse
|
| 19 |
from fastapi.staticfiles import StaticFiles
|
| 20 |
|
| 21 |
from pipeline.jobs import manager
|
|
@@ -32,6 +32,31 @@ app = FastAPI(title="Local PDF Text Extractor")
|
|
| 32 |
# Serve static assets (CSS/JS) locally — no external CDN/network dependency.
|
| 33 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# Hold strong references to fire-and-forget background tasks: asyncio keeps only
|
| 36 |
# a weak reference to a Task, so a bare create_task() result can in principle be
|
| 37 |
# GC'd mid-flight ("Task was destroyed but it is pending"). Keeping them here and
|
|
@@ -157,8 +182,15 @@ async def upload(
|
|
| 157 |
)
|
| 158 |
|
| 159 |
if handw:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
from pipeline.handwriting import is_available
|
| 161 |
-
|
|
|
|
| 162 |
raise HTTPException(
|
| 163 |
status_code=400,
|
| 164 |
detail=(
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Body
|
| 18 |
+
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
|
| 19 |
from fastapi.staticfiles import StaticFiles
|
| 20 |
|
| 21 |
from pipeline.jobs import manager
|
|
|
|
| 32 |
# Serve static assets (CSS/JS) locally — no external CDN/network dependency.
|
| 33 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 34 |
|
| 35 |
+
# Reject oversized request bodies early — before FastAPI buffers/spools the
|
| 36 |
+
# upload — so a huge (or many concurrent) POST can't exhaust memory. Generous
|
| 37 |
+
# enough to never affect normal local use (the primary, trusted single-user
|
| 38 |
+
# case); it mainly caps the DoS surface of the public demo Space, where body
|
| 39 |
+
# size is attacker-controlled. Checked by Content-Length, so an honest client
|
| 40 |
+
# (browser / curl) is rejected before the body is read.
|
| 41 |
+
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.middleware("http")
|
| 45 |
+
async def _limit_request_body(request, call_next):
|
| 46 |
+
cl = request.headers.get("content-length")
|
| 47 |
+
if cl:
|
| 48 |
+
try:
|
| 49 |
+
oversized = int(cl) > MAX_UPLOAD_BYTES
|
| 50 |
+
except ValueError:
|
| 51 |
+
oversized = False
|
| 52 |
+
if oversized:
|
| 53 |
+
return JSONResponse(
|
| 54 |
+
{"detail": "Request body exceeds the {} MB limit.".format(
|
| 55 |
+
MAX_UPLOAD_BYTES // (1024 * 1024))},
|
| 56 |
+
status_code=413,
|
| 57 |
+
)
|
| 58 |
+
return await call_next(request)
|
| 59 |
+
|
| 60 |
# Hold strong references to fire-and-forget background tasks: asyncio keeps only
|
| 61 |
# a weak reference to a Task, so a bare create_task() result can in principle be
|
| 62 |
# GC'd mid-flight ("Task was destroyed but it is pending"). Keeping them here and
|
|
|
|
| 182 |
)
|
| 183 |
|
| 184 |
if handw:
|
| 185 |
+
# is_available() imports torch/transformers on first call (multi-second
|
| 186 |
+
# on the CPU build). Offload it to the OCR worker thread — like
|
| 187 |
+
# capabilities() does — so it never stalls the event loop (which would
|
| 188 |
+
# freeze other requests' SSE progress). create_job below already awaits
|
| 189 |
+
# the same executor, so this adds no serialization the upload didn't have.
|
| 190 |
+
from pipeline.jobs import _EXECUTOR
|
| 191 |
from pipeline.handwriting import is_available
|
| 192 |
+
loop = asyncio.get_running_loop()
|
| 193 |
+
if not await loop.run_in_executor(_EXECUTOR, is_available):
|
| 194 |
raise HTTPException(
|
| 195 |
status_code=400,
|
| 196 |
detail=(
|
pipeline/analyze.py
CHANGED
|
@@ -151,18 +151,22 @@ def suggest_settings(pdf_bytes: bytes, *, lang: str = "en",
|
|
| 151 |
return _fallback("PDF has no pages; using defaults.", lang)
|
| 152 |
|
| 153 |
# --- Pass 1: classify pages cheaply (text-layer vs needs-OCR). -------
|
| 154 |
-
#
|
| 155 |
-
#
|
| 156 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
text_pages, ocr_page_indices = 0, []
|
| 158 |
-
scan_cap = max_pages * 4
|
| 159 |
for i in range(total):
|
| 160 |
page = doc.load_page(i)
|
| 161 |
if has_text_layer(page):
|
| 162 |
text_pages += 1
|
| 163 |
else:
|
| 164 |
ocr_page_indices.append(i)
|
| 165 |
-
if len(ocr_page_indices) >= max_pages
|
| 166 |
break
|
| 167 |
|
| 168 |
# --- Born-digital short-circuit: no OCR needed. ----------------------
|
|
|
|
| 151 |
return _fallback("PDF has no pages; using defaults.", lang)
|
| 152 |
|
| 153 |
# --- Pass 1: classify pages cheaply (text-layer vs needs-OCR). -------
|
| 154 |
+
# Stop as soon as we have enough OCR-eligible pages to SAMPLE for Pass 2.
|
| 155 |
+
# We deliberately do NOT stop on a leading-window cap: has_text_layer()
|
| 156 |
+
# is a cheap get_text char-count (no rendering, no OCR), so scanning the
|
| 157 |
+
# whole doc to confirm it's born-digital is fast — and it avoids the
|
| 158 |
+
# false "no OCR required" verdict for a typed report with a scanned
|
| 159 |
+
# appendix / signature page (an OCR page beyond a leading window). A
|
| 160 |
+
# scanned or mixed doc still short-circuits fast once max_pages OCR pages
|
| 161 |
+
# are found; only a pure born-digital doc is fully (cheaply) swept.
|
| 162 |
text_pages, ocr_page_indices = 0, []
|
|
|
|
| 163 |
for i in range(total):
|
| 164 |
page = doc.load_page(i)
|
| 165 |
if has_text_layer(page):
|
| 166 |
text_pages += 1
|
| 167 |
else:
|
| 168 |
ocr_page_indices.append(i)
|
| 169 |
+
if len(ocr_page_indices) >= max_pages:
|
| 170 |
break
|
| 171 |
|
| 172 |
# --- Born-digital short-circuit: no OCR needed. ----------------------
|
pipeline/export_docx.py
CHANGED
|
@@ -10,11 +10,14 @@ import math
|
|
| 10 |
import re
|
| 11 |
|
| 12 |
# Characters that are illegal in XML 1.0 (and so rejected by python-docx/lxml
|
| 13 |
-
# with "All strings must be XML compatible"
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _xml_safe(value) -> str:
|
|
|
|
| 10 |
import re
|
| 11 |
|
| 12 |
# Characters that are illegal in XML 1.0 (and so rejected by python-docx/lxml
|
| 13 |
+
# with "All strings must be XML compatible", or by the final UTF-8 save with
|
| 14 |
+
# "surrogates not allowed"). We keep the only valid C0 controls (tab, newline,
|
| 15 |
+
# carriage return) and strip the rest. A form-feed (U+000C) is the common
|
| 16 |
+
# offender: PDF text layers emit it as a page/section separator. Lone surrogates
|
| 17 |
+
# (U+D800-U+DFFF) and the non-characters U+FFFE/U+FFFF are the others — PyMuPDF
|
| 18 |
+
# can emit U+FFFE from a broken/byte-swapped ToUnicode CMap, so it reaches Word
|
| 19 |
+
# export organically and would otherwise abort the ENTIRE multi-document export.
|
| 20 |
+
_XML_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
|
| 21 |
|
| 22 |
|
| 23 |
def _xml_safe(value) -> str:
|
pipeline/jobs.py
CHANGED
|
@@ -441,6 +441,16 @@ class JobManager:
|
|
| 441 |
job.status = "error"
|
| 442 |
job.error = str(exc)
|
| 443 |
self._broadcast(job, {"type": "error", "error": job.error})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
def cancel(self, job_id: str) -> None:
|
| 446 |
job = self.get(job_id)
|
|
|
|
| 441 |
job.status = "error"
|
| 442 |
job.error = str(exc)
|
| 443 |
self._broadcast(job, {"type": "error", "error": job.error})
|
| 444 |
+
finally:
|
| 445 |
+
# The TTL is an IDLE timer, but last_access is otherwise only set at
|
| 446 |
+
# creation/stream-start. A job that spent longer than JOB_TTL_SECONDS
|
| 447 |
+
# actually PROCESSING (e.g. a 1000-page OCR, or a slow online-OCR run)
|
| 448 |
+
# would otherwise be instantly evictable the moment it finishes — the
|
| 449 |
+
# next sweep (triggered by any other upload) could drop a result the
|
| 450 |
+
# user just received, 404-ing a page-image click or KG build. Start
|
| 451 |
+
# the idle clock at completion instead. Covers every terminal path
|
| 452 |
+
# (done / cancelled / error / corrupt-open early return).
|
| 453 |
+
job.last_access = time.monotonic()
|
| 454 |
|
| 455 |
def cancel(self, job_id: str) -> None:
|
| 456 |
job = self.get(job_id)
|
pipeline/kg.py
CHANGED
|
@@ -38,6 +38,7 @@ Public API
|
|
| 38 |
"""
|
| 39 |
|
| 40 |
import hashlib
|
|
|
|
| 41 |
import json
|
| 42 |
import re
|
| 43 |
import time
|
|
@@ -216,6 +217,18 @@ def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
|
|
| 216 |
raise RuntimeError(
|
| 217 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 218 |
) from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
else: # pragma: no cover - loop always breaks or raises
|
| 220 |
raise GeminiHTTPError(
|
| 221 |
str(_friendly_http_error(last_http_err)),
|
|
@@ -280,6 +293,14 @@ def _list_embedding_models(api_key, *, timeout=30) -> list:
|
|
| 280 |
raise RuntimeError(
|
| 281 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 282 |
) from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
try:
|
| 284 |
payload = json.loads(raw.decode("utf-8", "replace"))
|
| 285 |
except (ValueError, AttributeError):
|
|
@@ -1006,5 +1027,19 @@ def build_graph(
|
|
| 1006 |
graph.set_vectors(vectors, model=resolved)
|
| 1007 |
except RuntimeError as exc:
|
| 1008 |
graph.embed_error = str(exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
|
| 1010 |
return graph
|
|
|
|
| 38 |
"""
|
| 39 |
|
| 40 |
import hashlib
|
| 41 |
+
import http.client
|
| 42 |
import json
|
| 43 |
import re
|
| 44 |
import time
|
|
|
|
| 217 |
raise RuntimeError(
|
| 218 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 219 |
) from None
|
| 220 |
+
except (OSError, http.client.HTTPException) as err:
|
| 221 |
+
# The connection dropped mid-body: urlopen() returned headers but
|
| 222 |
+
# response.read() failed (e.g. ConnectionResetError — an OSError not
|
| 223 |
+
# wrapped in URLError — or http.client.IncompleteRead, which isn't
|
| 224 |
+
# even an OSError). This clause MUST sit after URLError/TimeoutError
|
| 225 |
+
# (both OSError subclasses). Mapping it to a single-line RuntimeError
|
| 226 |
+
# keeps the "_post_json only ever raises RuntimeError" contract that
|
| 227 |
+
# build_graph's per-page isolation and non-fatal embedding rely on.
|
| 228 |
+
raise RuntimeError(
|
| 229 |
+
"The Gemini connection dropped while reading the response ({}); "
|
| 230 |
+
"retry shortly.".format(err)
|
| 231 |
+
) from None
|
| 232 |
else: # pragma: no cover - loop always breaks or raises
|
| 233 |
raise GeminiHTTPError(
|
| 234 |
str(_friendly_http_error(last_http_err)),
|
|
|
|
| 293 |
raise RuntimeError(
|
| 294 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 295 |
) from None
|
| 296 |
+
except (OSError, http.client.HTTPException) as err:
|
| 297 |
+
# Connection dropped while reading the ListModels body (see _post_json).
|
| 298 |
+
# Keep it a single-line RuntimeError so _pick_embedding_model's
|
| 299 |
+
# `except RuntimeError` catches it and falls back to the default model.
|
| 300 |
+
raise RuntimeError(
|
| 301 |
+
"The Gemini connection dropped while reading the response ({}); "
|
| 302 |
+
"retry shortly.".format(err)
|
| 303 |
+
) from None
|
| 304 |
try:
|
| 305 |
payload = json.loads(raw.decode("utf-8", "replace"))
|
| 306 |
except (ValueError, AttributeError):
|
|
|
|
| 1027 |
graph.set_vectors(vectors, model=resolved)
|
| 1028 |
except RuntimeError as exc:
|
| 1029 |
graph.embed_error = str(exc)
|
| 1030 |
+
# If the DISCOVERED model 404'd (Google retired the id mid-process),
|
| 1031 |
+
# drop its per-key cache entry so the NEXT build re-discovers a
|
| 1032 |
+
# working model instead of pinning the dead one for the whole process
|
| 1033 |
+
# lifetime. Only for discovery (no explicit embed_model) and only on a
|
| 1034 |
+
# model-not-found 404 — a transient 429/quota must NOT evict a good id.
|
| 1035 |
+
if (
|
| 1036 |
+
isinstance(exc, GeminiHTTPError)
|
| 1037 |
+
and exc.code == 404
|
| 1038 |
+
and not embed_model
|
| 1039 |
+
):
|
| 1040 |
+
key_hash = hashlib.sha256(
|
| 1041 |
+
_clean_key(api_key).encode("utf-8")
|
| 1042 |
+
).hexdigest()
|
| 1043 |
+
_EMBED_MODEL_RESOLVED.pop(key_hash, None)
|
| 1044 |
|
| 1045 |
return graph
|
pipeline/online_ocr.py
CHANGED
|
@@ -185,9 +185,17 @@ def _extract_text(payload: dict) -> str:
|
|
| 185 |
SAFETY / MAX_TOKENS / RECITATION.
|
| 186 |
"""
|
| 187 |
# Prompt blocked before any candidate was produced.
|
| 188 |
-
feedback = payload.get("promptFeedback")
|
| 189 |
block_reason = feedback.get("blockReason")
|
| 190 |
candidates = payload.get("candidates") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
if not candidates:
|
| 192 |
if block_reason:
|
| 193 |
raise RuntimeError(
|
|
@@ -200,10 +208,14 @@ def _extract_text(payload: dict) -> str:
|
|
| 200 |
"empty; try again or switch models."
|
| 201 |
)
|
| 202 |
|
| 203 |
-
candidate = candidates[0]
|
| 204 |
finish_reason = candidate.get("finishReason")
|
| 205 |
-
content = candidate.get("content")
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
# ``part.get("text", "")`` only defaults when the key is ABSENT; an explicit
|
| 208 |
# ``{"text": null}`` would yield None and break the join. Coerce defensively.
|
| 209 |
text = "".join(
|
|
|
|
| 185 |
SAFETY / MAX_TOKENS / RECITATION.
|
| 186 |
"""
|
| 187 |
# Prompt blocked before any candidate was produced.
|
| 188 |
+
feedback = payload.get("promptFeedback") if isinstance(payload.get("promptFeedback"), dict) else {}
|
| 189 |
block_reason = feedback.get("blockReason")
|
| 190 |
candidates = payload.get("candidates") or []
|
| 191 |
+
if not isinstance(candidates, list):
|
| 192 |
+
# A non-list "candidates" (proxy corruption / future API change) would
|
| 193 |
+
# otherwise index/attribute-error below and escape the module's
|
| 194 |
+
# single-line-RuntimeError contract. Degrade to a clean message.
|
| 195 |
+
raise RuntimeError(
|
| 196 |
+
"Gemini returned an unexpected response shape (candidates was not a "
|
| 197 |
+
"list); retry shortly."
|
| 198 |
+
)
|
| 199 |
if not candidates:
|
| 200 |
if block_reason:
|
| 201 |
raise RuntimeError(
|
|
|
|
| 208 |
"empty; try again or switch models."
|
| 209 |
)
|
| 210 |
|
| 211 |
+
candidate = candidates[0] if isinstance(candidates[0], dict) else {}
|
| 212 |
finish_reason = candidate.get("finishReason")
|
| 213 |
+
content = candidate.get("content")
|
| 214 |
+
if not isinstance(content, dict):
|
| 215 |
+
content = {}
|
| 216 |
+
parts = content.get("parts")
|
| 217 |
+
if not isinstance(parts, list):
|
| 218 |
+
parts = []
|
| 219 |
# ``part.get("text", "")`` only defaults when the key is ABSENT; an explicit
|
| 220 |
# ``{"text": null}`` would yield None and break the join. Coerce defensively.
|
| 221 |
text = "".join(
|
pipeline/postprocess.py
CHANGED
|
@@ -54,6 +54,49 @@ def _key(line: str) -> str:
|
|
| 54 |
return base
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def realign_line_confidences(cleaned_text, orig_lines):
|
| 58 |
"""Map a stripped page's surviving lines back to their confidences.
|
| 59 |
|
|
@@ -137,18 +180,34 @@ def strip_running_headers_footers(
|
|
| 137 |
pages_lines.append((lines, nonblank))
|
| 138 |
|
| 139 |
top_counter, bot_counter = Counter(), Counter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
for lines, nonblank in pages_lines:
|
| 141 |
top, bot = _bands(nonblank, band)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
top_counter[k] += 1
|
| 145 |
-
for k in {_key(lines[i]) for i in bot}:
|
| 146 |
-
if k:
|
| 147 |
-
bot_counter[k] += 1
|
| 148 |
|
| 149 |
thresh = max(2, math.ceil(min_frac * n))
|
| 150 |
running_top = {k for k, c in top_counter.items() if c >= thresh}
|
| 151 |
running_bot = {k for k, c in bot_counter.items() if c >= thresh}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if not running_top and not running_bot:
|
| 153 |
cleaned = list(page_texts)
|
| 154 |
return (cleaned, _all_indices()) if return_kept else cleaned
|
|
|
|
| 54 |
return base
|
| 55 |
|
| 56 |
|
| 57 |
+
def _bare_int(line: str):
|
| 58 |
+
"""Return the integer value if ``line`` is a BARE page-number-like integer.
|
| 59 |
+
|
| 60 |
+
"Bare" = a single digit group, NO page-word, and nothing but that integer
|
| 61 |
+
plus surrounding punctuation/whitespace — so "5", "4521", "- 12 -" qualify,
|
| 62 |
+
but "Page 5" (page-word), "Section 3 intro" (letters) and "1,234.56"
|
| 63 |
+
(multi-group) do not. Returns ``None`` otherwise. These bare lines are the
|
| 64 |
+
only ones whose page-number collapse can erase real per-page DATA, so they
|
| 65 |
+
get the sequentiality guard below; page-word page numbers are always trusted.
|
| 66 |
+
"""
|
| 67 |
+
base = _WS.sub(" ", line.strip().lower()).strip()
|
| 68 |
+
if _PAGEWORDS.search(base):
|
| 69 |
+
return None
|
| 70 |
+
groups = _DIGITS.findall(base)
|
| 71 |
+
if len(groups) != 1:
|
| 72 |
+
return None
|
| 73 |
+
probe = re.sub(r"[^\w\s]", "", base)
|
| 74 |
+
if _DIGITS.sub("", probe).strip() != "": # has letters -> not a bare integer
|
| 75 |
+
return None
|
| 76 |
+
try:
|
| 77 |
+
return int(groups[0])
|
| 78 |
+
except ValueError:
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _looks_like_page_numbers(values) -> bool:
|
| 83 |
+
"""Do these per-page integer values look like running page numbers?
|
| 84 |
+
|
| 85 |
+
``values`` is ``None`` for a non-bare-integer key (a page-word page number or
|
| 86 |
+
a verbatim text header) — those are trusted and kept. For a BARE-integer band
|
| 87 |
+
the values are treated as page numbers ONLY when they are non-decreasing
|
| 88 |
+
across pages and span a small range (~ the page count). Arbitrary per-page
|
| 89 |
+
data (IDs, measurements, amounts) is neither, so it is preserved rather than
|
| 90 |
+
stripped from every page.
|
| 91 |
+
"""
|
| 92 |
+
if not values: # None (not a bare-int key) or empty -> keep as-is
|
| 93 |
+
return True
|
| 94 |
+
if len(values) < 2: # too little signal -> fall back to collapsing
|
| 95 |
+
return True
|
| 96 |
+
non_decreasing = all(values[i] <= values[i + 1] for i in range(len(values) - 1))
|
| 97 |
+
return non_decreasing and (max(values) - min(values)) <= 2 * len(values)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def realign_line_confidences(cleaned_text, orig_lines):
|
| 101 |
"""Map a stripped page's surviving lines back to their confidences.
|
| 102 |
|
|
|
|
| 180 |
pages_lines.append((lines, nonblank))
|
| 181 |
|
| 182 |
top_counter, bot_counter = Counter(), Counter()
|
| 183 |
+
top_vals, bot_vals = {}, {} # bare-integer key -> per-page int values (page order)
|
| 184 |
+
|
| 185 |
+
def _tally(indices, lines, counter, vals):
|
| 186 |
+
seen = {}
|
| 187 |
+
for i in indices:
|
| 188 |
+
k = _key(lines[i])
|
| 189 |
+
if k and k not in seen:
|
| 190 |
+
seen[k] = _bare_int(lines[i])
|
| 191 |
+
for k, v in seen.items():
|
| 192 |
+
counter[k] += 1
|
| 193 |
+
if v is not None:
|
| 194 |
+
vals.setdefault(k, []).append(v)
|
| 195 |
+
|
| 196 |
for lines, nonblank in pages_lines:
|
| 197 |
top, bot = _bands(nonblank, band)
|
| 198 |
+
_tally(top, lines, top_counter, top_vals)
|
| 199 |
+
_tally(bot, lines, bot_counter, bot_vals)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
thresh = max(2, math.ceil(min_frac * n))
|
| 202 |
running_top = {k for k, c in top_counter.items() if c >= thresh}
|
| 203 |
running_bot = {k for k, c in bot_counter.items() if c >= thresh}
|
| 204 |
+
# Value-aware page-number guard: a running key formed from BARE integers is
|
| 205 |
+
# only treated as a running page number when its per-page values look like
|
| 206 |
+
# page numbers (see _looks_like_page_numbers). This keeps genuine page
|
| 207 |
+
# numbers (1,2,3,…) stripped while PRESERVING distinct per-page numeric DATA
|
| 208 |
+
# (IDs, measurements, amounts) that merely recurs in a top/bottom band.
|
| 209 |
+
running_top = {k for k in running_top if _looks_like_page_numbers(top_vals.get(k))}
|
| 210 |
+
running_bot = {k for k in running_bot if _looks_like_page_numbers(bot_vals.get(k))}
|
| 211 |
if not running_top and not running_bot:
|
| 212 |
cleaned = list(page_texts)
|
| 213 |
return (cleaned, _all_indices()) if return_kept else cleaned
|
static/app.js
CHANGED
|
@@ -786,6 +786,10 @@
|
|
| 786 |
toolbar.hidden = true;
|
| 787 |
}
|
| 788 |
refreshExportState();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
}
|
| 790 |
|
| 791 |
function setFileStatus(job, status, label) {
|
|
|
|
| 786 |
toolbar.hidden = true;
|
| 787 |
}
|
| 788 |
refreshExportState();
|
| 789 |
+
// Removing a card changes the total match set. Re-run the active search so
|
| 790 |
+
// the "N matches" chip doesn't stay stale (over-counted) — same as the
|
| 791 |
+
// applyPage and edit-save paths already do.
|
| 792 |
+
if (jobs.size > 0 && searchInput.value.trim()) runSearch();
|
| 793 |
}
|
| 794 |
|
| 795 |
function setFileStatus(job, status, label) {
|