Archie0099 commited on
Commit
74fe989
·
verified ·
1 Parent(s): 0f49634

Fix edge-case bugs found in a thorough code review

Browse files
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
- if not is_available():
 
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
- # Cap the scan to a small leading window so a 500-page born-digital PDF
155
- # (which never accumulates OCR pages) isn't fully scanned the first
156
- # few pages define the document's type for "recommend settings".
 
 
 
 
 
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 or i >= min(total - 1, scan_cap):
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"). We keep the only valid C0 controls
14
- # (tab, newline, carriage return) and strip the rest. A form-feed (U+000C) is the
15
- # common offender: PDF text layers emit it as a page/section separator, so it
16
- # reaches Word export organically and would otherwise 500 the whole export.
17
- _XML_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
 
 
 
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") or {}
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] or {}
204
  finish_reason = candidate.get("finishReason")
205
- content = candidate.get("content") or {}
206
- parts = content.get("parts") or []
 
 
 
 
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
- for k in {_key(lines[i]) for i in top}:
143
- if k:
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) {