main
py 321 lines 10.4 KB
Raw
1 """LiteParse-backed parser for fast local document parsing."""
2
3 from __future__ import annotations
4
5 import json
6 import os
7 import subprocess
8 import sys
9 from dataclasses import dataclass
10 from pathlib import Path
11
12 from plugins._document_query.helpers.fetch import FetchedDocument
13 from .base import BaseParser
14
15
16 DEFAULT_OCR_AUTO_DISABLE_PAGES = 30
17 DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5
18
19
20 @dataclass(frozen=True)
21 class _PdfTextProfile:
22 page_count: int
23 sampled_pages: int
24 text_chars: int
25
26
27 class LiteParseParser(BaseParser):
28 """Fast parser powered by run-llama/liteparse when available."""
29
30 DEFAULT_NUM_WORKERS = 2
31
32 mimetypes = [
33 "application/pdf",
34 "application/msword",
35 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
36 "application/vnd.ms-excel",
37 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
38 "application/vnd.ms-powerpoint",
39 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
40 "application/vnd.oasis.opendocument.text",
41 "application/vnd.oasis.opendocument.spreadsheet",
42 "application/vnd.oasis.opendocument.presentation",
43 "image/",
44 ]
45
46 def enabled(self, config: dict) -> bool:
47 return bool(config.get("liteparse_enabled", True))
48
49 def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
50 # Keep LiteParse native/OCR failures isolated from the Web UI process.
51 return self._parse_subprocess(document, config)
52
53 def _parse_in_process(self, document: FetchedDocument, config: dict) -> str:
54 try:
55 from liteparse import LiteParse
56 except Exception as e:
57 raise RuntimeError("LiteParse is not installed") from e
58
59 parser = LiteParse(**self._liteparse_kwargs(config))
60 with document.local_file() as file_path:
61 result = parser.parse(file_path)
62
63 text = getattr(result, "text", "") or ""
64 if not text.strip():
65 raise ValueError("LiteParse returned no text")
66 return text
67
68 def _parse_subprocess(self, document: FetchedDocument, config: dict) -> str:
69 with document.local_file() as file_path:
70 payload = {
71 "file_path": file_path,
72 "kwargs": self._liteparse_kwargs(config, document, file_path),
73 }
74 env = os.environ.copy()
75 project_root = str(Path(__file__).resolve().parents[4])
76 python_path = env.get("PYTHONPATH", "")
77 env["PYTHONPATH"] = (
78 f"{project_root}{os.pathsep}{python_path}"
79 if python_path
80 else project_root
81 )
82 timeout = float(config.get("per_document_timeout", 60))
83 result = subprocess.run(
84 [
85 sys.executable,
86 "-m",
87 "plugins._document_query.helpers.parsers.liteparse_worker",
88 ],
89 input=json.dumps(payload),
90 text=True,
91 capture_output=True,
92 cwd=project_root,
93 env=env,
94 timeout=timeout,
95 check=False,
96 )
97
98 if result.returncode != 0:
99 detail = (result.stderr or result.stdout or "").strip()
100 raise RuntimeError(
101 "LiteParse subprocess failed"
102 f" with exit code {result.returncode}: {detail[-2000:]}"
103 )
104
105 try:
106 response = json.loads(result.stdout)
107 except json.JSONDecodeError as e:
108 raise RuntimeError(
109 "LiteParse subprocess returned invalid output: "
110 f"{result.stdout[-2000:]}"
111 ) from e
112
113 text = response.get("text", "") or ""
114 if not text.strip():
115 raise ValueError("LiteParse returned no text")
116 return text
117
118 def _liteparse_kwargs(
119 self,
120 config: dict,
121 document: FetchedDocument | None = None,
122 file_path: str | None = None,
123 ) -> dict:
124 ocr_enabled = bool(config.get("liteparse_ocr_enabled", True))
125 if ocr_enabled and self._should_disable_ocr(config, document, file_path):
126 ocr_enabled = False
127
128 kwargs = {
129 "ocr_enabled": ocr_enabled,
130 "ocr_language": config.get("liteparse_ocr_language", "eng"),
131 "max_pages": int(config.get("liteparse_max_pages", 1000)),
132 "dpi": float(config.get("liteparse_dpi", 150)),
133 "preserve_very_small_text": bool(
134 config.get("liteparse_preserve_very_small_text", False)
135 ),
136 "quiet": True,
137 }
138
139 optional_keys = {
140 "ocr_server_url": "liteparse_ocr_server_url",
141 "target_pages": "liteparse_target_pages",
142 "output_format": "liteparse_output_format",
143 "password": "liteparse_password",
144 }
145 for liteparse_key, config_key in optional_keys.items():
146 value = config.get(config_key)
147 if value not in (None, ""):
148 kwargs[liteparse_key] = value
149
150 kwargs["num_workers"] = _positive_int(
151 config.get("liteparse_num_workers"),
152 self.DEFAULT_NUM_WORKERS,
153 )
154
155 tessdata_path = config.get("liteparse_tessdata_path") or _detect_tessdata_path()
156 if tessdata_path:
157 kwargs["tessdata_path"] = tessdata_path
158
159 return kwargs
160
161 def _should_disable_ocr(
162 self,
163 config: dict,
164 document: FetchedDocument | None,
165 file_path: str | None,
166 ) -> bool:
167 if not bool(config.get("liteparse_ocr_auto_disable", True)):
168 return False
169 if not document or document.mimetype != "application/pdf" or not file_path:
170 return False
171
172 profile = _pdf_text_profile(file_path, config)
173 if not profile or profile.sampled_pages <= 0:
174 return False
175
176 effective_pages = _effective_page_budget(config, profile.page_count)
177 auto_disable_pages = _positive_int(
178 config.get("liteparse_ocr_auto_disable_pages"),
179 DEFAULT_OCR_AUTO_DISABLE_PAGES,
180 )
181 if effective_pages < auto_disable_pages:
182 return False
183
184 return True
185
186
187 def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None:
188 try:
189 import fitz
190 except Exception:
191 return None
192
193 try:
194 with fitz.open(file_path) as doc:
195 page_count = doc.page_count
196 if page_count <= 0:
197 return _PdfTextProfile(0, 0, 0)
198 sample_indexes = _sample_page_indexes(
199 page_count=page_count,
200 sample_pages=_positive_int(
201 config.get("liteparse_ocr_auto_sample_pages"),
202 DEFAULT_OCR_AUTO_SAMPLE_PAGES,
203 ),
204 target_pages=config.get("liteparse_target_pages"),
205 )
206 text_chars = 0
207 for page_index in sample_indexes:
208 text = doc[page_index].get_text("text") or ""
209 text_chars += len("".join(text.split()))
210 return _PdfTextProfile(
211 page_count=page_count,
212 sampled_pages=len(sample_indexes),
213 text_chars=text_chars,
214 )
215 except Exception:
216 return None
217
218
219 def _effective_page_budget(config: dict, page_count: int) -> int:
220 target_pages = config.get("liteparse_target_pages")
221 if target_pages not in (None, ""):
222 parsed_target_count = _target_page_count(str(target_pages), page_count)
223 if parsed_target_count:
224 return parsed_target_count
225
226 max_pages = _positive_int(config.get("liteparse_max_pages"), 1000)
227 return min(max_pages, page_count)
228
229
230 def _target_page_count(value: str, page_count: int) -> int:
231 pages = _target_page_numbers(value, page_count)
232 if pages is None:
233 return 0
234 return len(pages)
235
236
237 def _target_page_numbers(value: str, page_count: int) -> set[int] | None:
238 pages: set[int] = set()
239 for raw_part in value.split(","):
240 part = raw_part.strip()
241 if not part:
242 continue
243 if "-" in part:
244 start_raw, end_raw = part.split("-", 1)
245 try:
246 start = int(start_raw.strip())
247 end = int(end_raw.strip())
248 except ValueError:
249 return None
250 if start <= 0 or end <= 0:
251 return None
252 if start > end:
253 start, end = end, start
254 pages.update(range(start, min(end, page_count) + 1))
255 else:
256 try:
257 page = int(part)
258 except ValueError:
259 return None
260 if page <= 0:
261 return None
262 if page <= page_count:
263 pages.add(page)
264 return pages
265
266
267 def _sample_page_indexes(
268 page_count: int,
269 sample_pages: int,
270 target_pages: str | None = None,
271 ) -> list[int]:
272 if page_count <= 0 or sample_pages <= 0:
273 return []
274
275 candidate_indexes: list[int]
276 if target_pages not in (None, ""):
277 target_page_numbers = _target_page_numbers(str(target_pages), page_count)
278 if target_page_numbers:
279 candidate_indexes = sorted(page - 1 for page in target_page_numbers)
280 else:
281 candidate_indexes = list(range(page_count))
282 else:
283 candidate_indexes = list(range(page_count))
284
285 if len(candidate_indexes) <= sample_pages:
286 return candidate_indexes
287
288 anchors = [0, 1, 2, len(candidate_indexes) // 2, len(candidate_indexes) - 1]
289 indexes = []
290 seen = set()
291 for anchor in anchors:
292 index = candidate_indexes[min(max(anchor, 0), len(candidate_indexes) - 1)]
293 if index not in seen:
294 seen.add(index)
295 indexes.append(index)
296 if len(indexes) >= sample_pages:
297 break
298 return indexes
299
300
301 def _detect_tessdata_path() -> str:
302 env_path = os.getenv("TESSDATA_PREFIX", "")
303 candidates = [
304 env_path,
305 "/usr/share/tesseract-ocr/5/tessdata",
306 "/usr/share/tesseract-ocr/4.00/tessdata",
307 "/usr/share/tessdata",
308 "/usr/local/share/tessdata",
309 ]
310 for candidate in candidates:
311 if candidate and (Path(candidate) / "eng.traineddata").is_file():
312 return candidate
313 return ""
314
315
316 def _positive_int(value, default: int) -> int:
317 try:
318 parsed = int(value)
319 except (TypeError, ValueError):
320 return default
321 return parsed if parsed > 0 else default