workflow_documents.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. """逐页提取:Python 文本层优先;GLM OCR 失败的扫描/混合页由 Qwen 补识别。"""
  2. from __future__ import annotations
  3. import asyncio
  4. import base64
  5. from dataclasses import dataclass
  6. import hashlib
  7. import json
  8. from pathlib import Path
  9. import re
  10. import threading
  11. import time
  12. from urllib.parse import urlsplit
  13. import httpx
  14. import pypdfium2 as pdfium
  15. import pypdfium2.raw as pdfium_raw
  16. import config
  17. from agents.model_factory import _is_qwen35_model, _qwen_thinking_payload, normalize_thinking
  18. from extensions.main_fan.exploration import _write
  19. from obs import get_logger
  20. from runtime.python_sandbox import digest, workspace_file
  21. logger = get_logger(__name__)
  22. VERSION = 'main-fan-workflow-pdf-v3'
  23. _PDFIUM_LOCK = threading.RLock()
  24. QWEN_OCR_PROMPT = (
  25. '请对这张报告页面进行完整OCR。按原页顺序逐字转录标题、报告编号、表头、单位和全部数据行;'
  26. '表格分别输出为Markdown表格,保留测点序号及工况行。数字保持原值,不计算、不纠错、不解释;'
  27. '图中只转录实际可读的标签,不从曲线猜数;看不清的单元格写[无法辨认]。只输出识别结果。')
  28. def service_url(value: str) -> str:
  29. parts = urlsplit(value)
  30. if parts.scheme not in {'http', 'https'} or not parts.netloc or parts.username or parts.password or parts.query or parts.fragment:
  31. raise ValueError('模型地址须为不含凭证、查询参数的 HTTP(S) API 基址')
  32. return value.rstrip('/')
  33. @dataclass(frozen=True)
  34. class OCRSettings:
  35. base_url: str = 'http://10.244.3.155:10803/v1'
  36. model: str = 'glm-ocr'
  37. api_key: str = ''
  38. timeout: int = 120
  39. max_tokens: int = 4096
  40. concurrency: int = 2
  41. dpi: int = 150
  42. max_side: int = 2200
  43. @classmethod
  44. def from_config(cls):
  45. value = cls(
  46. base_url=service_url(config.get_str('MAIN_FAN_OCR_BASE_URL', cls.base_url)),
  47. model=config.get_str('MAIN_FAN_OCR_MODEL', cls.model),
  48. api_key=config.get_str('MAIN_FAN_OCR_API_KEY'),
  49. timeout=config.get_int('MAIN_FAN_OCR_TIMEOUT_SECONDS', cls.timeout),
  50. max_tokens=config.get_int('MAIN_FAN_OCR_MAX_TOKENS', cls.max_tokens),
  51. concurrency=config.get_int('MAIN_FAN_OCR_CONCURRENCY', cls.concurrency),
  52. dpi=config.get_int('MAIN_FAN_PDF_DPI', cls.dpi),
  53. max_side=config.get_int('MAIN_FAN_PDF_MAX_SIDE', cls.max_side),
  54. )
  55. if (not value.model.strip() or value.timeout <= 0 or not 256 <= value.max_tokens <= 8192
  56. or not 1 <= value.concurrency <= 4 or not 100 <= value.dpi <= 300
  57. or not 1000 <= value.max_side <= 3000):
  58. raise ValueError('PDF/OCR 配置无效:检查模型名、超时、输出长度、并发及图片尺寸')
  59. return value
  60. def cache_signature(self):
  61. # 只缓存原件的原始提取文字,不缓存任何矿井归属或研判结论。
  62. return [VERSION, self.base_url, self.model, self.max_tokens, self.dpi, self.max_side,
  63. config.OPENAI_BASE_URL, config.MODEL, normalize_thinking(None),
  64. config.get_int('MAIN_FAN_QWEN_OCR_MAX_TOKENS', 8192)]
  65. def repeated_ocr(text):
  66. """仅拦截长串循环,避免把表内少量合法重复值误判为失败。"""
  67. previous, consecutive = '', 0
  68. for line in text.splitlines():
  69. line = re.sub(r'\s+', ' ', line).strip()
  70. if not line:
  71. continue
  72. consecutive = consecutive + 1 if line == previous else 1
  73. previous = line
  74. if consecutive >= 12 and sum(char.isalnum() for char in line) >= 4:
  75. return True
  76. tail = text[-2000:]
  77. for width in range(8, 81):
  78. unit = tail[-width:]
  79. if sum(char.isalnum() for char in unit) >= 4 and tail.endswith(unit * 12):
  80. return True
  81. return False
  82. def usable_text(text: str) -> bool:
  83. cleaned = re.sub(r'契约锁|扫描全能王|CamScanner', '', text, flags=re.I)
  84. meaningful = sum(char.isalnum() for char in cleaned)
  85. return meaningful >= 24 and text.count('\ufffd') <= max(1, len(text) // 100)
  86. def large_raster(page) -> bool:
  87. """有文字的页也可能嵌着整张扫描表;不能把页眉/水印当作完整内容。"""
  88. width, height = page.get_size()
  89. area = width * height
  90. image_area = 0
  91. for obj in page.get_objects(filter=[pdfium_raw.FPDF_PAGEOBJ_IMAGE]):
  92. left, bottom, right, top = obj.get_bounds()
  93. covered = max(0, min(width, right) - max(0, left)) * max(0, min(height, top) - max(0, bottom))
  94. image_area += covered
  95. if area and image_area / area >= .12:
  96. return True
  97. return False
  98. def prepare_pages(source: Path, directory: Path, settings: OCRSettings):
  99. """PDFium 不跨线程同时使用;渲染后立即释放页面和位图。"""
  100. pages = []
  101. directory.mkdir(parents=True, exist_ok=True)
  102. with _PDFIUM_LOCK, pdfium.PdfDocument(source) as pdf:
  103. for index in range(len(pdf)):
  104. page = pdf[index]
  105. stem = directory / f'page-{index + 1:04d}'
  106. row = {'page': index + 1, 'status': 'pending', 'method': 'python', 'text': ''}
  107. step = 'python_text'
  108. try:
  109. # PdfTextPage 只有 close(),不支持上下文管理器协议。
  110. textpage = page.get_textpage()
  111. try:
  112. text = textpage.get_text_range().replace('\r\n', '\n').strip()
  113. finally:
  114. textpage.close()
  115. step = 'classify_page'
  116. raster = large_raster(page)
  117. if usable_text(text) and not raster:
  118. row.update(status='completed', text=text)
  119. elif not text and not list(page.get_objects()):
  120. row.update(status='completed', blank=True)
  121. else:
  122. row['method'] = 'glm_ocr'
  123. step = 'render_for_ocr'
  124. # 保存原始文本层,扫描页 OCR 失败时也不能冒充完整提取。
  125. stem.with_suffix('.native.txt').write_text(text, encoding='utf-8')
  126. width, height = page.get_size()
  127. scale = min(settings.dpi / 72, settings.max_side / max(width, height))
  128. bitmap = page.render(scale=scale)
  129. try:
  130. image = bitmap.to_pil().convert('RGB')
  131. try:
  132. image.save(stem.with_suffix('.png'))
  133. finally:
  134. image.close()
  135. finally:
  136. bitmap.close()
  137. row['image_name'] = stem.with_suffix('.png').name
  138. pages.append(row)
  139. except Exception as exc:
  140. pages.append({**row, 'status': 'failed', 'error_stage': step,
  141. 'error': type(exc).__name__ + ': ' + str(exc)[:240]})
  142. finally:
  143. page.close()
  144. return pages
  145. def _pages_digest(pages):
  146. return hashlib.sha256(json.dumps(pages, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
  147. def load_cache(path, source_sha256, signature, page_count):
  148. try:
  149. value = json.loads(path.read_text(encoding='utf-8'))
  150. pages = value['pages']
  151. if (value['source_sha256'] == source_sha256 and value['signature'] == signature
  152. and value['pages_sha256'] == _pages_digest(pages)
  153. and [row['page'] for row in pages] == list(range(1, page_count + 1))
  154. and all(row['status'] == 'completed' and row.get('method') in {'python', 'glm_ocr', 'qwen_ocr'} and isinstance(row['text'], str)
  155. and (row['text'].strip() or row.get('blank')) for row in pages)):
  156. return pages
  157. except (OSError, ValueError, KeyError, TypeError):
  158. pass
  159. return None
  160. class PDFExtractor:
  161. def __init__(self, user, session, prefix, cache_root, *, settings=None, client=None,
  162. qwen_client=None, progress=None, on_usage=None, on_qwen_usage=None,
  163. on_qwen_call=None, cache_policy='auto'):
  164. self.user, self.session, self.prefix = user, session, prefix
  165. self.cache_root = Path(cache_root) / 'workflow_pdf'
  166. self.settings = settings or OCRSettings.from_config()
  167. self.client = client
  168. self.qwen_client = qwen_client
  169. self.progress = progress
  170. self.on_usage = on_usage
  171. self.on_qwen_usage = on_qwen_usage
  172. self.on_qwen_call = on_qwen_call
  173. if cache_policy not in {'auto', 'refresh', 'off'}:
  174. raise ValueError('PDF 缓存策略须为 auto / refresh / off')
  175. self.cache_policy = cache_policy
  176. self.files = {}
  177. def remember(self, path):
  178. virtual = self.prefix + '/' + path.name
  179. self.files[virtual] = digest(path)
  180. return virtual
  181. async def _ocr_attempt(self, row, directory, client, encoded, *, fallback=False):
  182. started = time.monotonic()
  183. image = directory / row['image_name']
  184. stem = directory / (f"page-{row['page']:04d}" + ('.qwen' if fallback else ''))
  185. result = {'method': 'qwen_ocr' if fallback else 'glm_ocr', 'status': 'failed'}
  186. try:
  187. if fallback:
  188. if not _is_qwen35_model(config.MODEL):
  189. raise ValueError('OCR 补识别需要配置 Qwen3.5 视觉模型')
  190. endpoint, model, api_key = service_url(config.OPENAI_BASE_URL), config.MODEL, config.OPENAI_API_KEY
  191. maximum = config.get_int('MAIN_FAN_QWEN_OCR_MAX_TOKENS', 8192)
  192. timeout = config.get_int('MAIN_FAN_QWEN_OCR_TIMEOUT_SECONDS', 240)
  193. if maximum <= 0 or timeout <= 0:
  194. raise ValueError('Qwen OCR 输出长度和超时必须大于 0')
  195. prompt = QWEN_OCR_PROMPT
  196. thinking = _qwen_thinking_payload(normalize_thinking(None))
  197. else:
  198. endpoint, model, api_key = self.settings.base_url, self.settings.model, self.settings.api_key
  199. maximum, timeout = self.settings.max_tokens, self.settings.timeout
  200. prompt, thinking = 'Text Recognition:', {}
  201. result['model'] = model
  202. _write(Path(str(stem) + '.request.json'), {'model': model, 'prompt': prompt,
  203. 'image': image.name, 'image_sha256': digest(image), 'max_tokens': maximum, **thinking})
  204. payload = {'model': model, 'messages': [{'role': 'user', 'content': [
  205. {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,' + encoded}},
  206. {'type': 'text', 'text': prompt}]}],
  207. 'temperature': 0, 'max_tokens': maximum, 'stream': False, **thinking}
  208. # 两个服务分别构造鉴权头,GLM 请求始终不带 Qwen 密钥。
  209. headers = {'Authorization': 'Bearer ' + api_key} if api_key else {}
  210. if fallback and self.on_qwen_call:
  211. self.on_qwen_call()
  212. response = await client.post(endpoint + '/chat/completions', json=payload,
  213. headers=headers, timeout=timeout)
  214. Path(str(stem) + '.response.json').write_text(response.text, encoding='utf-8')
  215. result['http_status'] = response.status_code
  216. response.raise_for_status()
  217. body = response.json()
  218. if not isinstance(body, dict):
  219. raise ValueError('OCR 服务返回格式无效')
  220. usage_callback = self.on_qwen_usage if fallback else self.on_usage
  221. if usage_callback:
  222. usage_callback(body.get('usage') or {})
  223. choice = body['choices'][0]
  224. if not isinstance(choice, dict):
  225. raise ValueError('OCR 服务未返回有效候选消息')
  226. message = choice['message']
  227. if not isinstance(message, dict):
  228. raise ValueError('OCR 服务未返回有效消息')
  229. text = message.get('content')
  230. result.update(finish_reason=choice.get('finish_reason'), response_id=body.get('id', ''))
  231. if choice.get('finish_reason') != 'stop' or not isinstance(text, str) or not text.strip() or message.get('tool_calls'):
  232. raise ValueError('OCR 未返回完整页面文字,finish_reason=' + str(choice.get('finish_reason')))
  233. if repeated_ocr(text):
  234. raise ValueError('OCR 输出出现长串循环重复,不能视为完整页面')
  235. result.update(status='completed', text=text.strip())
  236. except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError) as exc:
  237. result.update(text='', error=type(exc).__name__ + ': ' + str(exc)[:240])
  238. result['duration_ms'] = round((time.monotonic() - started) * 1000)
  239. logger.info('PDF 扫描页识别结束', extra={'kv': {'event': 'workflow.ocr.page',
  240. 'page': row['page'], **{key: value for key, value in result.items() if key != 'text'}}})
  241. return result
  242. async def _ocr(self, row, directory, client):
  243. started = time.monotonic()
  244. image = directory / row['image_name']
  245. encoded = base64.b64encode(await asyncio.to_thread(image.read_bytes)).decode('ascii')
  246. attempts = [await self._ocr_attempt(row, directory, client, encoded)]
  247. if attempts[0]['status'] != 'completed':
  248. logger.warning('GLM OCR 失败,改用 Qwen 识别该页', extra={'kv': {
  249. 'event': 'workflow.ocr.fallback', 'page': row['page'], 'error': attempts[0].get('error')}})
  250. attempts.append(await self._ocr_attempt(row, directory, self.qwen_client or client, encoded, fallback=True))
  251. final = attempts[-1]
  252. row.update(method=final['method'], status=final['status'], text=final.get('text', ''),
  253. response_id=final.get('response_id', ''), fallback_used=len(attempts) > 1,
  254. attempts=[{key: value for key, value in attempt.items() if key != 'text'} for attempt in attempts],
  255. duration_ms=round((time.monotonic() - started) * 1000))
  256. if final['status'] != 'completed':
  257. row.update(error_stage=final['method'], error=final.get('error', 'OCR 未完成'))
  258. else:
  259. row.pop('error_stage', None)
  260. row.pop('error', None)
  261. async def extract(self, materials):
  262. if self.client is not None:
  263. return await self._extract(materials, self.client)
  264. async with httpx.AsyncClient(trust_env=False) as client:
  265. return await self._extract(materials, client)
  266. async def _extract(self, materials, client):
  267. reports = []
  268. for number, material in enumerate(materials, 1):
  269. source = workspace_file(self.user, self.session, material['path'])
  270. sha = await asyncio.to_thread(digest, source)
  271. if sha != material['sha256']:
  272. raise ValueError('PDF 原件已变化:' + material['path'])
  273. signature = self.settings.cache_signature()
  274. key = hashlib.sha256(json.dumps([sha, signature], sort_keys=True).encode()).hexdigest()
  275. cache = self.cache_root / (key + '.json')
  276. old_prefix = self.prefix
  277. self.prefix = old_prefix + f'/pdf-{number}'
  278. directory = workspace_file(self.user, self.session, self.prefix + '/extraction.json').parent
  279. directory.mkdir(parents=True, exist_ok=True)
  280. pages = (await asyncio.to_thread(load_cache, cache, sha, signature, material['page_count'])
  281. if self.cache_policy == 'auto' else None)
  282. reused = pages is not None
  283. if pages is None:
  284. pages = await asyncio.to_thread(prepare_pages, source, directory, self.settings)
  285. if len(pages) != material['page_count']:
  286. raise ValueError('PDF 实际页数与下载记录不一致')
  287. semaphore = asyncio.Semaphore(self.settings.concurrency)
  288. async def recognize(row):
  289. async with semaphore:
  290. await self._ocr(row, directory, client)
  291. if self.progress:
  292. self.progress({'stage': 'pdf_ocr', 'report': number, 'page': row['page'],
  293. 'page_count': len(pages), 'status': row['status']})
  294. await asyncio.gather(*(recognize(row) for row in pages if row['status'] == 'pending'))
  295. if self.cache_policy != 'off' and all(row['status'] == 'completed' for row in pages):
  296. # 缓存仅存原始文字和页码;本轮绑定与正文来源总是重新附加。
  297. cached_pages = [{key: value for key, value in row.items() if key != 'image_name'} for row in pages]
  298. await asyncio.to_thread(_write, cache, {'source_sha256': sha, 'signature': signature,
  299. 'pages': cached_pages, 'pages_sha256': _pages_digest(cached_pages)})
  300. for row in pages:
  301. text_path = directory / f"page-{row['page']:04d}.txt"
  302. text_path.write_text(row['text'], encoding='utf-8')
  303. row['text_path'] = self.prefix + '/' + text_path.name
  304. row.pop('image_name', None)
  305. report = {key: material.get(key) for key in ('path', 'filename', 'sha256', 'is_mock', 'binding', 'note')}
  306. report.update(page_count=len(pages), complete=all(row['status'] == 'completed' for row in pages),
  307. reused=reused, pages=pages)
  308. _write(directory / 'extraction.json', report)
  309. for path in directory.iterdir():
  310. if path.is_file():
  311. self.remember(path)
  312. reports.append(report)
  313. self.prefix = old_prefix
  314. failures = [row for row in pages if row['status'] != 'completed']
  315. logger.info('PDF 全页提取结束', extra={'kv': {'event': 'workflow.pdf.completed',
  316. 'report': number, 'page_count': len(pages), 'complete': report['complete'], 'reused': reused,
  317. 'python_pages': sum(row['method'] == 'python' and row['status'] == 'completed' for row in pages),
  318. 'ocr_pages': sum(row['method'] in {'glm_ocr', 'qwen_ocr'} and row['status'] == 'completed' for row in pages),
  319. 'qwen_ocr_pages': sum(row['method'] == 'qwen_ocr' and row['status'] == 'completed' for row in pages),
  320. 'failed_pages': len(failures),
  321. **({'first_error': {key: failures[0].get(key) for key in ('page', 'error_stage', 'error')}}
  322. if failures else {})}})
  323. return reports