| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- """逐页提取:Python 文本层优先;GLM OCR 失败的扫描/混合页由 Qwen 补识别。"""
- from __future__ import annotations
- import asyncio
- import base64
- from dataclasses import dataclass
- import hashlib
- import json
- from pathlib import Path
- import re
- import threading
- import time
- from urllib.parse import urlsplit
- import httpx
- import pypdfium2 as pdfium
- import pypdfium2.raw as pdfium_raw
- import config
- from agents.model_factory import _is_qwen35_model, _qwen_thinking_payload, normalize_thinking
- from extensions.main_fan.exploration import _write
- from obs import get_logger
- from runtime.python_sandbox import digest, workspace_file
- logger = get_logger(__name__)
- VERSION = 'main-fan-workflow-pdf-v3'
- _PDFIUM_LOCK = threading.RLock()
- QWEN_OCR_PROMPT = (
- '请对这张报告页面进行完整OCR。按原页顺序逐字转录标题、报告编号、表头、单位和全部数据行;'
- '表格分别输出为Markdown表格,保留测点序号及工况行。数字保持原值,不计算、不纠错、不解释;'
- '图中只转录实际可读的标签,不从曲线猜数;看不清的单元格写[无法辨认]。只输出识别结果。')
- def service_url(value: str) -> str:
- parts = urlsplit(value)
- if parts.scheme not in {'http', 'https'} or not parts.netloc or parts.username or parts.password or parts.query or parts.fragment:
- raise ValueError('模型地址须为不含凭证、查询参数的 HTTP(S) API 基址')
- return value.rstrip('/')
- @dataclass(frozen=True)
- class OCRSettings:
- base_url: str = 'http://10.244.3.155:10803/v1'
- model: str = 'glm-ocr'
- api_key: str = ''
- timeout: int = 120
- max_tokens: int = 4096
- concurrency: int = 2
- dpi: int = 150
- max_side: int = 2200
- @classmethod
- def from_config(cls):
- value = cls(
- base_url=service_url(config.get_str('MAIN_FAN_OCR_BASE_URL', cls.base_url)),
- model=config.get_str('MAIN_FAN_OCR_MODEL', cls.model),
- api_key=config.get_str('MAIN_FAN_OCR_API_KEY'),
- timeout=config.get_int('MAIN_FAN_OCR_TIMEOUT_SECONDS', cls.timeout),
- max_tokens=config.get_int('MAIN_FAN_OCR_MAX_TOKENS', cls.max_tokens),
- concurrency=config.get_int('MAIN_FAN_OCR_CONCURRENCY', cls.concurrency),
- dpi=config.get_int('MAIN_FAN_PDF_DPI', cls.dpi),
- max_side=config.get_int('MAIN_FAN_PDF_MAX_SIDE', cls.max_side),
- )
- if (not value.model.strip() or value.timeout <= 0 or not 256 <= value.max_tokens <= 8192
- or not 1 <= value.concurrency <= 4 or not 100 <= value.dpi <= 300
- or not 1000 <= value.max_side <= 3000):
- raise ValueError('PDF/OCR 配置无效:检查模型名、超时、输出长度、并发及图片尺寸')
- return value
- def cache_signature(self):
- # 只缓存原件的原始提取文字,不缓存任何矿井归属或研判结论。
- return [VERSION, self.base_url, self.model, self.max_tokens, self.dpi, self.max_side,
- config.OPENAI_BASE_URL, config.MODEL, normalize_thinking(None),
- config.get_int('MAIN_FAN_QWEN_OCR_MAX_TOKENS', 8192)]
- def repeated_ocr(text):
- """仅拦截长串循环,避免把表内少量合法重复值误判为失败。"""
- previous, consecutive = '', 0
- for line in text.splitlines():
- line = re.sub(r'\s+', ' ', line).strip()
- if not line:
- continue
- consecutive = consecutive + 1 if line == previous else 1
- previous = line
- if consecutive >= 12 and sum(char.isalnum() for char in line) >= 4:
- return True
- tail = text[-2000:]
- for width in range(8, 81):
- unit = tail[-width:]
- if sum(char.isalnum() for char in unit) >= 4 and tail.endswith(unit * 12):
- return True
- return False
- def usable_text(text: str) -> bool:
- cleaned = re.sub(r'契约锁|扫描全能王|CamScanner', '', text, flags=re.I)
- meaningful = sum(char.isalnum() for char in cleaned)
- return meaningful >= 24 and text.count('\ufffd') <= max(1, len(text) // 100)
- def large_raster(page) -> bool:
- """有文字的页也可能嵌着整张扫描表;不能把页眉/水印当作完整内容。"""
- width, height = page.get_size()
- area = width * height
- image_area = 0
- for obj in page.get_objects(filter=[pdfium_raw.FPDF_PAGEOBJ_IMAGE]):
- left, bottom, right, top = obj.get_bounds()
- covered = max(0, min(width, right) - max(0, left)) * max(0, min(height, top) - max(0, bottom))
- image_area += covered
- if area and image_area / area >= .12:
- return True
- return False
- def prepare_pages(source: Path, directory: Path, settings: OCRSettings):
- """PDFium 不跨线程同时使用;渲染后立即释放页面和位图。"""
- pages = []
- directory.mkdir(parents=True, exist_ok=True)
- with _PDFIUM_LOCK, pdfium.PdfDocument(source) as pdf:
- for index in range(len(pdf)):
- page = pdf[index]
- stem = directory / f'page-{index + 1:04d}'
- row = {'page': index + 1, 'status': 'pending', 'method': 'python', 'text': ''}
- step = 'python_text'
- try:
- # PdfTextPage 只有 close(),不支持上下文管理器协议。
- textpage = page.get_textpage()
- try:
- text = textpage.get_text_range().replace('\r\n', '\n').strip()
- finally:
- textpage.close()
- step = 'classify_page'
- raster = large_raster(page)
- if usable_text(text) and not raster:
- row.update(status='completed', text=text)
- elif not text and not list(page.get_objects()):
- row.update(status='completed', blank=True)
- else:
- row['method'] = 'glm_ocr'
- step = 'render_for_ocr'
- # 保存原始文本层,扫描页 OCR 失败时也不能冒充完整提取。
- stem.with_suffix('.native.txt').write_text(text, encoding='utf-8')
- width, height = page.get_size()
- scale = min(settings.dpi / 72, settings.max_side / max(width, height))
- bitmap = page.render(scale=scale)
- try:
- image = bitmap.to_pil().convert('RGB')
- try:
- image.save(stem.with_suffix('.png'))
- finally:
- image.close()
- finally:
- bitmap.close()
- row['image_name'] = stem.with_suffix('.png').name
- pages.append(row)
- except Exception as exc:
- pages.append({**row, 'status': 'failed', 'error_stage': step,
- 'error': type(exc).__name__ + ': ' + str(exc)[:240]})
- finally:
- page.close()
- return pages
- def _pages_digest(pages):
- return hashlib.sha256(json.dumps(pages, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
- def load_cache(path, source_sha256, signature, page_count):
- try:
- value = json.loads(path.read_text(encoding='utf-8'))
- pages = value['pages']
- if (value['source_sha256'] == source_sha256 and value['signature'] == signature
- and value['pages_sha256'] == _pages_digest(pages)
- and [row['page'] for row in pages] == list(range(1, page_count + 1))
- and all(row['status'] == 'completed' and row.get('method') in {'python', 'glm_ocr', 'qwen_ocr'} and isinstance(row['text'], str)
- and (row['text'].strip() or row.get('blank')) for row in pages)):
- return pages
- except (OSError, ValueError, KeyError, TypeError):
- pass
- return None
- class PDFExtractor:
- def __init__(self, user, session, prefix, cache_root, *, settings=None, client=None,
- qwen_client=None, progress=None, on_usage=None, on_qwen_usage=None,
- on_qwen_call=None, cache_policy='auto'):
- self.user, self.session, self.prefix = user, session, prefix
- self.cache_root = Path(cache_root) / 'workflow_pdf'
- self.settings = settings or OCRSettings.from_config()
- self.client = client
- self.qwen_client = qwen_client
- self.progress = progress
- self.on_usage = on_usage
- self.on_qwen_usage = on_qwen_usage
- self.on_qwen_call = on_qwen_call
- if cache_policy not in {'auto', 'refresh', 'off'}:
- raise ValueError('PDF 缓存策略须为 auto / refresh / off')
- self.cache_policy = cache_policy
- self.files = {}
- def remember(self, path):
- virtual = self.prefix + '/' + path.name
- self.files[virtual] = digest(path)
- return virtual
- async def _ocr_attempt(self, row, directory, client, encoded, *, fallback=False):
- started = time.monotonic()
- image = directory / row['image_name']
- stem = directory / (f"page-{row['page']:04d}" + ('.qwen' if fallback else ''))
- result = {'method': 'qwen_ocr' if fallback else 'glm_ocr', 'status': 'failed'}
- try:
- if fallback:
- if not _is_qwen35_model(config.MODEL):
- raise ValueError('OCR 补识别需要配置 Qwen3.5 视觉模型')
- endpoint, model, api_key = service_url(config.OPENAI_BASE_URL), config.MODEL, config.OPENAI_API_KEY
- maximum = config.get_int('MAIN_FAN_QWEN_OCR_MAX_TOKENS', 8192)
- timeout = config.get_int('MAIN_FAN_QWEN_OCR_TIMEOUT_SECONDS', 240)
- if maximum <= 0 or timeout <= 0:
- raise ValueError('Qwen OCR 输出长度和超时必须大于 0')
- prompt = QWEN_OCR_PROMPT
- thinking = _qwen_thinking_payload(normalize_thinking(None))
- else:
- endpoint, model, api_key = self.settings.base_url, self.settings.model, self.settings.api_key
- maximum, timeout = self.settings.max_tokens, self.settings.timeout
- prompt, thinking = 'Text Recognition:', {}
- result['model'] = model
- _write(Path(str(stem) + '.request.json'), {'model': model, 'prompt': prompt,
- 'image': image.name, 'image_sha256': digest(image), 'max_tokens': maximum, **thinking})
- payload = {'model': model, 'messages': [{'role': 'user', 'content': [
- {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,' + encoded}},
- {'type': 'text', 'text': prompt}]}],
- 'temperature': 0, 'max_tokens': maximum, 'stream': False, **thinking}
- # 两个服务分别构造鉴权头,GLM 请求始终不带 Qwen 密钥。
- headers = {'Authorization': 'Bearer ' + api_key} if api_key else {}
- if fallback and self.on_qwen_call:
- self.on_qwen_call()
- response = await client.post(endpoint + '/chat/completions', json=payload,
- headers=headers, timeout=timeout)
- Path(str(stem) + '.response.json').write_text(response.text, encoding='utf-8')
- result['http_status'] = response.status_code
- response.raise_for_status()
- body = response.json()
- if not isinstance(body, dict):
- raise ValueError('OCR 服务返回格式无效')
- usage_callback = self.on_qwen_usage if fallback else self.on_usage
- if usage_callback:
- usage_callback(body.get('usage') or {})
- choice = body['choices'][0]
- if not isinstance(choice, dict):
- raise ValueError('OCR 服务未返回有效候选消息')
- message = choice['message']
- if not isinstance(message, dict):
- raise ValueError('OCR 服务未返回有效消息')
- text = message.get('content')
- result.update(finish_reason=choice.get('finish_reason'), response_id=body.get('id', ''))
- if choice.get('finish_reason') != 'stop' or not isinstance(text, str) or not text.strip() or message.get('tool_calls'):
- raise ValueError('OCR 未返回完整页面文字,finish_reason=' + str(choice.get('finish_reason')))
- if repeated_ocr(text):
- raise ValueError('OCR 输出出现长串循环重复,不能视为完整页面')
- result.update(status='completed', text=text.strip())
- except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError) as exc:
- result.update(text='', error=type(exc).__name__ + ': ' + str(exc)[:240])
- result['duration_ms'] = round((time.monotonic() - started) * 1000)
- logger.info('PDF 扫描页识别结束', extra={'kv': {'event': 'workflow.ocr.page',
- 'page': row['page'], **{key: value for key, value in result.items() if key != 'text'}}})
- return result
- async def _ocr(self, row, directory, client):
- started = time.monotonic()
- image = directory / row['image_name']
- encoded = base64.b64encode(await asyncio.to_thread(image.read_bytes)).decode('ascii')
- attempts = [await self._ocr_attempt(row, directory, client, encoded)]
- if attempts[0]['status'] != 'completed':
- logger.warning('GLM OCR 失败,改用 Qwen 识别该页', extra={'kv': {
- 'event': 'workflow.ocr.fallback', 'page': row['page'], 'error': attempts[0].get('error')}})
- attempts.append(await self._ocr_attempt(row, directory, self.qwen_client or client, encoded, fallback=True))
- final = attempts[-1]
- row.update(method=final['method'], status=final['status'], text=final.get('text', ''),
- response_id=final.get('response_id', ''), fallback_used=len(attempts) > 1,
- attempts=[{key: value for key, value in attempt.items() if key != 'text'} for attempt in attempts],
- duration_ms=round((time.monotonic() - started) * 1000))
- if final['status'] != 'completed':
- row.update(error_stage=final['method'], error=final.get('error', 'OCR 未完成'))
- else:
- row.pop('error_stage', None)
- row.pop('error', None)
- async def extract(self, materials):
- if self.client is not None:
- return await self._extract(materials, self.client)
- async with httpx.AsyncClient(trust_env=False) as client:
- return await self._extract(materials, client)
- async def _extract(self, materials, client):
- reports = []
- for number, material in enumerate(materials, 1):
- source = workspace_file(self.user, self.session, material['path'])
- sha = await asyncio.to_thread(digest, source)
- if sha != material['sha256']:
- raise ValueError('PDF 原件已变化:' + material['path'])
- signature = self.settings.cache_signature()
- key = hashlib.sha256(json.dumps([sha, signature], sort_keys=True).encode()).hexdigest()
- cache = self.cache_root / (key + '.json')
- old_prefix = self.prefix
- self.prefix = old_prefix + f'/pdf-{number}'
- directory = workspace_file(self.user, self.session, self.prefix + '/extraction.json').parent
- directory.mkdir(parents=True, exist_ok=True)
- pages = (await asyncio.to_thread(load_cache, cache, sha, signature, material['page_count'])
- if self.cache_policy == 'auto' else None)
- reused = pages is not None
- if pages is None:
- pages = await asyncio.to_thread(prepare_pages, source, directory, self.settings)
- if len(pages) != material['page_count']:
- raise ValueError('PDF 实际页数与下载记录不一致')
- semaphore = asyncio.Semaphore(self.settings.concurrency)
- async def recognize(row):
- async with semaphore:
- await self._ocr(row, directory, client)
- if self.progress:
- self.progress({'stage': 'pdf_ocr', 'report': number, 'page': row['page'],
- 'page_count': len(pages), 'status': row['status']})
- await asyncio.gather(*(recognize(row) for row in pages if row['status'] == 'pending'))
- if self.cache_policy != 'off' and all(row['status'] == 'completed' for row in pages):
- # 缓存仅存原始文字和页码;本轮绑定与正文来源总是重新附加。
- cached_pages = [{key: value for key, value in row.items() if key != 'image_name'} for row in pages]
- await asyncio.to_thread(_write, cache, {'source_sha256': sha, 'signature': signature,
- 'pages': cached_pages, 'pages_sha256': _pages_digest(cached_pages)})
- for row in pages:
- text_path = directory / f"page-{row['page']:04d}.txt"
- text_path.write_text(row['text'], encoding='utf-8')
- row['text_path'] = self.prefix + '/' + text_path.name
- row.pop('image_name', None)
- report = {key: material.get(key) for key in ('path', 'filename', 'sha256', 'is_mock', 'binding', 'note')}
- report.update(page_count=len(pages), complete=all(row['status'] == 'completed' for row in pages),
- reused=reused, pages=pages)
- _write(directory / 'extraction.json', report)
- for path in directory.iterdir():
- if path.is_file():
- self.remember(path)
- reports.append(report)
- self.prefix = old_prefix
- failures = [row for row in pages if row['status'] != 'completed']
- logger.info('PDF 全页提取结束', extra={'kv': {'event': 'workflow.pdf.completed',
- 'report': number, 'page_count': len(pages), 'complete': report['complete'], 'reused': reused,
- 'python_pages': sum(row['method'] == 'python' and row['status'] == 'completed' for row in pages),
- 'ocr_pages': sum(row['method'] in {'glm_ocr', 'qwen_ocr'} and row['status'] == 'completed' for row in pages),
- 'qwen_ocr_pages': sum(row['method'] == 'qwen_ocr' and row['status'] == 'completed' for row in pages),
- 'failed_pages': len(failures),
- **({'first_error': {key: failures[0].get(key) for key in ('page', 'error_stage', 'error')}}
- if failures else {})}})
- return reports
|