Download apps/pdf_splitting/services/split_service.py from jun2024/fc: direct link, hf CLI and curl.
- Browser
- Download file 42.1 kB
-
https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/pdf_splitting/services/split_service.py
- Command line
-
hf download hf://spaces/jun2024/fc/apps/pdf_splitting/services/split_service.py
-
curl -L -o split_service.py https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/pdf_splitting/services/split_service.py
42.1 kB
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import zipfile | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| import fitz | |
| from django.conf import settings | |
| from django.utils import timezone | |
| from apps.automation.services.ocr.ocr_service import OCRService | |
| from apps.core.services.storage_service import sanitize_upload_filename | |
| from apps.pdf_splitting.models import ( | |
| PdfSplitJob, | |
| PdfSplitJobStatus, | |
| PdfSplitMode, | |
| PdfSplitOcrProfile, | |
| PdfSplitReviewFlag, | |
| PdfSplitSegment, | |
| PdfSplitSegmentType, | |
| ) | |
| from .storage import PdfSplitStorage | |
| from .template_registry import SegmentTemplateRule, get_default_filename, get_segment_label, get_template_definition | |
| logger = logging.getLogger("apps.pdf_splitting") | |
| _WINDOWS_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]") | |
| _NON_WORD_RE = re.compile(r"\s+") | |
| _TEXT_MIN_LENGTH = 12 | |
| def _levenshtein_distance(s1: str, s2: str) -> int: | |
| """计算两个字符串的 Levenshtein 编辑距离。""" | |
| if len(s1) < len(s2): | |
| return _levenshtein_distance(s2, s1) | |
| if not s2: | |
| return len(s1) | |
| prev_row = list(range(len(s2) + 1)) | |
| for i, c1 in enumerate(s1): | |
| curr_row = [i + 1] | |
| for j, c2 in enumerate(s2): | |
| cost = 0 if c1 == c2 else 1 | |
| curr_row.append(min(curr_row[j] + 1, prev_row[j + 1] + 1, prev_row[j] + cost)) | |
| prev_row = curr_row | |
| return prev_row[-1] | |
| class PageDescriptor: | |
| page_no: int | |
| text: str | |
| normalized_text: str | |
| head_text: str | |
| source_method: str | |
| ocr_failed: bool | |
| top_candidates: list[dict[str, Any]] | |
| class SegmentDraft: | |
| order: int | |
| page_start: int | |
| page_end: int | |
| segment_type: str | |
| filename: str | |
| confidence: float | |
| source_method: str | |
| review_flag: str | |
| class OCRRuntimeProfile: | |
| key: str | |
| use_v5: bool | |
| dpi: int | |
| workers: int | |
| class OCRPageResult: | |
| page_no: int | |
| text: str | |
| source_method: str | |
| ocr_failed: bool | |
| def _ocr_pages_worker( | |
| *, | |
| pdf_path: str, | |
| page_numbers: list[int], | |
| use_v5: bool, | |
| dpi: int, | |
| ) -> list[OCRPageResult]: | |
| results: list[OCRPageResult] = [] | |
| ocr_service = OCRService(use_v5=use_v5) | |
| try: | |
| with fitz.open(pdf_path) as doc: | |
| matrix = fitz.Matrix(dpi / 72, dpi / 72) | |
| for page_no in page_numbers: | |
| try: | |
| page = doc.load_page(page_no - 1) | |
| pix = page.get_pixmap(matrix=matrix) | |
| image_bytes = pix.tobytes("png") | |
| text = ocr_service.recognize_bytes(image_bytes) | |
| ocr_failed = not bool(text) | |
| except Exception: | |
| logger.exception("pdf_split_page_ocr_worker_failed", extra={"page_no": page_no}) | |
| text = "" | |
| ocr_failed = True | |
| results.append( | |
| OCRPageResult( | |
| page_no=page_no, | |
| text=text, | |
| source_method="ocr" if text else "ocr_failed", | |
| ocr_failed=ocr_failed, | |
| ) | |
| ) | |
| except Exception: | |
| logger.exception("pdf_split_ocr_worker_failed", extra={"pdf_path": pdf_path}) | |
| for page_no in page_numbers: | |
| results.append( | |
| OCRPageResult( | |
| page_no=page_no, | |
| text="", | |
| source_method="ocr_failed", | |
| ocr_failed=True, | |
| ) | |
| ) | |
| return results | |
| class PdfSplitService: | |
| MAX_PAGES = 300 | |
| PREVIEW_DPI = 200 | |
| COMPLAINT_MAX_SPAN = 20 | |
| PROGRESS_UPDATE_EVERY = 5 | |
| CANCEL_CHECK_EVERY = 5 | |
| COMPLAINT_TERMINAL_KEYWORDS = ("此致", "具状人", "起诉人", "起诉状具状人", "日期") | |
| COMPLAINT_ATTACHMENT_KEYWORDS = ( | |
| "证据", | |
| "借条", | |
| "转账", | |
| "聊天记录", | |
| "微信", | |
| "支付宝", | |
| "发票", | |
| "授权委托书", | |
| "居民身份证", | |
| "送达地址确认书", | |
| ) | |
| def __init__(self, *, ocr_service: OCRService | None = None) -> None: | |
| self._ocr_service = ocr_service | |
| def analyze_job(self, job: PdfSplitJob) -> None: | |
| storage = PdfSplitStorage(job.id) | |
| storage.ensure_dirs() | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.PROCESSING, | |
| progress=0, | |
| processed_pages=0, | |
| current_page=0, | |
| error_message="", | |
| ) | |
| job.refresh_from_db(fields=["cancel_requested", "split_mode", "ocr_profile", "template_key"]) | |
| if job.cancel_requested: | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.CANCELLED, | |
| finished_at=timezone.now(), | |
| ) | |
| return | |
| if job.split_mode == PdfSplitMode.PAGE_SPLIT: | |
| with fitz.open(storage.source_pdf_path) as doc: | |
| total_pages = int(doc.page_count) | |
| drafts = self._build_page_split_drafts(total_pages=total_pages, source_name=job.source_original_name) | |
| self._persist_page_split( | |
| job=job, | |
| drafts=drafts, | |
| total_pages=total_pages, | |
| storage=storage, | |
| ) | |
| return | |
| runtime_profile = self._resolve_ocr_runtime_profile(job.ocr_profile) | |
| pdf_hash = self._sha256_file(storage.source_pdf_path) | |
| resolved_pages = 0 | |
| cache_hit_count = 0 | |
| with fitz.open(storage.source_pdf_path) as doc: | |
| total_pages = int(doc.page_count) | |
| template = get_template_definition(job.template_key) | |
| descriptors: list[PageDescriptor | None] = [None] * total_pages | |
| pending_page_numbers: list[int] = [] | |
| for page_index in range(total_pages): | |
| if self._should_check_cancel(page_index): | |
| job.refresh_from_db(fields=["cancel_requested"]) | |
| if job.cancel_requested: | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.CANCELLED, | |
| finished_at=timezone.now(), | |
| ) | |
| return | |
| page_no = page_index + 1 | |
| page = doc.load_page(page_index) | |
| direct_text = page.get_text("text") or "" | |
| if self._is_effective_text(direct_text): | |
| descriptors[page_index] = self._build_descriptor_from_text( | |
| page_no=page_no, | |
| text=direct_text, | |
| source_method="text", | |
| ocr_failed=False, | |
| template_key=template.key, | |
| ) | |
| resolved_pages += 1 | |
| self._update_progress(job_id=job.id, resolved_pages=resolved_pages, total_pages=total_pages) | |
| continue | |
| cached = self._read_ocr_cache(pdf_hash=pdf_hash, profile_key=runtime_profile.key, page_no=page_no) | |
| if cached is not None: | |
| descriptors[page_index] = self._build_descriptor_from_text( | |
| page_no=page_no, | |
| text=cached.text, | |
| source_method="ocr_cache" if cached.text else "ocr_failed_cache", | |
| ocr_failed=cached.ocr_failed, | |
| template_key=template.key, | |
| ) | |
| resolved_pages += 1 | |
| cache_hit_count += 1 | |
| self._update_progress(job_id=job.id, resolved_pages=resolved_pages, total_pages=total_pages) | |
| continue | |
| pending_page_numbers.append(page_no) | |
| if pending_page_numbers: | |
| ocr_results = self._parallel_ocr_missing_pages( | |
| pdf_path=storage.source_pdf_path, | |
| page_numbers=pending_page_numbers, | |
| runtime_profile=runtime_profile, | |
| ) | |
| for page_no in pending_page_numbers: | |
| if self._should_check_cancel(page_no): | |
| job.refresh_from_db(fields=["cancel_requested"]) | |
| if job.cancel_requested: | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.CANCELLED, | |
| finished_at=timezone.now(), | |
| ) | |
| return | |
| result = ocr_results.get(page_no) | |
| if result is None: | |
| result = OCRPageResult( | |
| page_no=page_no, | |
| text="", | |
| source_method="ocr_failed", | |
| ocr_failed=True, | |
| ) | |
| self._write_ocr_cache( | |
| pdf_hash=pdf_hash, | |
| profile_key=runtime_profile.key, | |
| result=result, | |
| ) | |
| descriptors[page_no - 1] = self._build_descriptor_from_text( | |
| page_no=page_no, | |
| text=result.text, | |
| source_method=result.source_method, | |
| ocr_failed=result.ocr_failed, | |
| template_key=template.key, | |
| ) | |
| resolved_pages += 1 | |
| self._update_progress(job_id=job.id, resolved_pages=resolved_pages, total_pages=total_pages) | |
| final_descriptors = [item for item in descriptors if item is not None] | |
| drafts = self._detect_segments(final_descriptors, template_key=template.key) | |
| self._persist_analysis( | |
| job=job, | |
| descriptors=final_descriptors, | |
| drafts=drafts, | |
| storage=storage, | |
| runtime_profile=runtime_profile, | |
| cache_hit_count=cache_hit_count, | |
| pending_ocr_count=len(pending_page_numbers), | |
| ) | |
| def export_job(self, job: PdfSplitJob) -> None: | |
| storage = PdfSplitStorage(job.id) | |
| storage.ensure_dirs() | |
| segments = list(job.segments.order_by("order", "id")) | |
| if not segments: | |
| raise ValueError("没有可导出的片段") | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.EXPORTING, | |
| progress=0, | |
| error_message="", | |
| ) | |
| pdf_files: list[tuple[Path, str]] = [] | |
| with fitz.open(storage.source_pdf_path) as source_doc: | |
| total = len(segments) | |
| seen_names: set[str] = set() | |
| for index, segment in enumerate(segments, start=1): | |
| job.refresh_from_db(fields=["cancel_requested"]) | |
| if job.cancel_requested: | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.CANCELLED, | |
| finished_at=timezone.now(), | |
| ) | |
| return | |
| display_name = sanitize_upload_filename(segment.filename or get_default_filename(segment.segment_type)) | |
| if not display_name.lower().endswith(".pdf"): | |
| display_name = f"{display_name}.pdf" | |
| display_name = self._deduplicate_filename(display_name, seen_names) | |
| output_path = storage.export_pdf_path(display_name) | |
| self._export_segment_pdf(source_doc, segment.page_start, segment.page_end, output_path) | |
| pdf_files.append((output_path, display_name)) | |
| progress = int(index * 100 / total) | |
| PdfSplitJob.objects.filter(id=job.id).update(progress=progress) | |
| with zipfile.ZipFile(storage.export_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: | |
| for file_path, filename in pdf_files: | |
| zf.write(file_path, arcname=filename) | |
| def render_preview(self, job: PdfSplitJob, page_no: int) -> Path: | |
| storage = PdfSplitStorage(job.id) | |
| preview_path = storage.preview_path(page_no) | |
| if preview_path.exists(): | |
| return preview_path | |
| with fitz.open(storage.source_pdf_path) as doc: | |
| if page_no < 1 or page_no > doc.page_count: | |
| raise ValueError("页码超出范围") | |
| page = doc.load_page(page_no - 1) | |
| pix = page.get_pixmap(matrix=fitz.Matrix(self.PREVIEW_DPI / 72, self.PREVIEW_DPI / 72)) | |
| preview_path.parent.mkdir(parents=True, exist_ok=True) | |
| pix.save(preview_path.as_posix()) | |
| return preview_path | |
| def _persist_analysis( | |
| self, | |
| *, | |
| job: PdfSplitJob, | |
| descriptors: list[PageDescriptor], | |
| drafts: list[SegmentDraft], | |
| storage: PdfSplitStorage, | |
| runtime_profile: OCRRuntimeProfile, | |
| cache_hit_count: int, | |
| pending_ocr_count: int, | |
| ) -> None: | |
| storage.write_json(storage.pages_json_path, [asdict(item) for item in descriptors]) | |
| storage.write_json(storage.segments_json_path, [asdict(item) for item in drafts]) | |
| PdfSplitSegment.objects.filter(job=job).delete() | |
| PdfSplitSegment.objects.bulk_create( | |
| [ | |
| PdfSplitSegment( | |
| job=job, | |
| order=item.order, | |
| page_start=item.page_start, | |
| page_end=item.page_end, | |
| segment_type=item.segment_type, | |
| filename=item.filename, | |
| confidence=item.confidence, | |
| source_method=item.source_method, | |
| review_flag=item.review_flag, | |
| ) | |
| for item in drafts | |
| ] | |
| ) | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.REVIEW_REQUIRED, | |
| progress=100, | |
| total_pages=len(descriptors), | |
| processed_pages=len(descriptors), | |
| current_page=len(descriptors), | |
| summary_payload={ | |
| "split_mode": PdfSplitMode.CONTENT_ANALYSIS, | |
| "template_key": job.template_key, | |
| "template_version": job.template_version, | |
| "ocr_profile": runtime_profile.key, | |
| "ocr_dpi": runtime_profile.dpi, | |
| "ocr_model": "v5_server" if runtime_profile.use_v5 else "v4_default", | |
| "ocr_workers": runtime_profile.workers, | |
| "ocr_cache_hit_count": int(max(cache_hit_count, 0)), | |
| "ocr_miss_count": int(max(pending_ocr_count, 0)), | |
| "segment_count": len(drafts), | |
| "recognized_count": len( | |
| [item for item in drafts if item.segment_type != PdfSplitSegmentType.UNRECOGNIZED] | |
| ), | |
| "unrecognized_count": len( | |
| [item for item in drafts if item.segment_type == PdfSplitSegmentType.UNRECOGNIZED] | |
| ), | |
| }, | |
| ) | |
| def _persist_page_split( | |
| self, | |
| *, | |
| job: PdfSplitJob, | |
| drafts: list[SegmentDraft], | |
| total_pages: int, | |
| storage: PdfSplitStorage, | |
| ) -> None: | |
| storage.write_json(storage.pages_json_path, []) | |
| storage.write_json(storage.segments_json_path, [asdict(item) for item in drafts]) | |
| PdfSplitSegment.objects.filter(job=job).delete() | |
| PdfSplitSegment.objects.bulk_create( | |
| [ | |
| PdfSplitSegment( | |
| job=job, | |
| order=item.order, | |
| page_start=item.page_start, | |
| page_end=item.page_end, | |
| segment_type=item.segment_type, | |
| filename=item.filename, | |
| confidence=item.confidence, | |
| source_method=item.source_method, | |
| review_flag=item.review_flag, | |
| ) | |
| for item in drafts | |
| ] | |
| ) | |
| PdfSplitJob.objects.filter(id=job.id).update( | |
| status=PdfSplitJobStatus.REVIEW_REQUIRED, | |
| progress=100, | |
| total_pages=total_pages, | |
| processed_pages=total_pages, | |
| current_page=total_pages, | |
| summary_payload={ | |
| "split_mode": PdfSplitMode.PAGE_SPLIT, | |
| "template_key": job.template_key, | |
| "template_version": job.template_version, | |
| "segment_count": len(drafts), | |
| "recognized_count": 0, | |
| "unrecognized_count": len(drafts), | |
| "page_split": True, | |
| }, | |
| ) | |
| def _build_page_split_drafts(self, *, total_pages: int, source_name: str) -> list[SegmentDraft]: | |
| base_name = sanitize_upload_filename(Path(source_name or "document").stem) or "document" | |
| drafts: list[SegmentDraft] = [] | |
| for page_no in range(1, total_pages + 1): | |
| drafts.append( | |
| SegmentDraft( | |
| order=page_no, | |
| page_start=page_no, | |
| page_end=page_no, | |
| segment_type=PdfSplitSegmentType.UNRECOGNIZED, | |
| filename=f"{base_name}_第{page_no:03d}页.pdf", | |
| confidence=1.0, | |
| source_method="page_split", | |
| review_flag=PdfSplitReviewFlag.NORMAL, | |
| ) | |
| ) | |
| return drafts | |
| def _build_page_descriptor(self, *, doc: fitz.Document, page_index: int, template_key: str) -> PageDescriptor: | |
| runtime_profile = self._resolve_ocr_runtime_profile(PdfSplitOcrProfile.BALANCED) | |
| page = doc.load_page(page_index) | |
| raw_text = page.get_text("text") or "" | |
| source_method = "text" | |
| ocr_failed = False | |
| if not self._is_effective_text(raw_text): | |
| image_bytes = self._render_page_bytes(page, dpi=runtime_profile.dpi) | |
| try: | |
| ocr_service = self._ocr_service or OCRService(use_v5=runtime_profile.use_v5) | |
| raw_text = ocr_service.recognize_bytes(image_bytes) | |
| source_method = "ocr" if raw_text else "ocr_failed" | |
| ocr_failed = not bool(raw_text) | |
| except Exception: | |
| logger.exception("pdf_split_page_ocr_failed", extra={"page_no": page_index + 1}) | |
| raw_text = "" | |
| source_method = "ocr_failed" | |
| ocr_failed = True | |
| return self._build_descriptor_from_text( | |
| page_no=page_index + 1, | |
| text=raw_text, | |
| source_method=source_method, | |
| ocr_failed=ocr_failed, | |
| template_key=template_key, | |
| ) | |
| def _score_page(self, *, head_text: str, normalized_text: str, template_key: str) -> list[dict[str, Any]]: | |
| template = get_template_definition(template_key) | |
| candidates: list[dict[str, Any]] = [] | |
| for rule in template.rules: | |
| score = 0.0 | |
| matched_strong: list[str] = [] | |
| matched_weak: list[str] = [] | |
| # strong_keywords:全文搜索 + 模糊匹配,每个关键词独立计算贡献 | |
| strong_score = 0.0 | |
| for kw in rule.strong_keywords: | |
| hit, decay = self._fuzzy_contains_keyword(normalized_text, kw) | |
| if hit: | |
| matched_strong.append(kw) | |
| strong_score += (0.4 + 0.18) * decay | |
| score += min(0.75, strong_score) | |
| # weak_keywords:全文搜索 + 模糊匹配 | |
| for kw in rule.weak_keywords: | |
| hit, _decay = self._fuzzy_contains_keyword(normalized_text, kw) | |
| if hit: | |
| matched_weak.append(kw) | |
| if matched_weak: | |
| score += min(0.2, 0.08 * len(matched_weak)) | |
| # negative_keywords:仍用精确匹配 | |
| matched_negative = [kw for kw in rule.negative_keywords if self._contains_keyword(normalized_text, kw)] | |
| if matched_negative: | |
| score -= min(0.4, 0.12 * len(matched_negative)) | |
| score = round(max(score, 0.0), 3) | |
| if matched_strong and score >= 0.30: | |
| # strong 命中,阈值从 0.42 降至 0.30 | |
| candidates.append( | |
| { | |
| "segment_type": rule.segment_type, | |
| "label": rule.label, | |
| "score": score, | |
| "matched_strong": matched_strong, | |
| "matched_weak": matched_weak, | |
| } | |
| ) | |
| elif not matched_strong and len(matched_weak) >= 2: | |
| # 仅 weak 命中,生成低置信度候选 | |
| candidates.append( | |
| { | |
| "segment_type": rule.segment_type, | |
| "label": rule.label, | |
| "score": score, | |
| "matched_strong": [], | |
| "matched_weak": matched_weak, | |
| "weak_only": True, | |
| } | |
| ) | |
| candidates.sort(key=lambda item: item["score"], reverse=True) | |
| return candidates[:3] | |
| def _detect_segments(self, pages: list[PageDescriptor], *, template_key: str) -> list[SegmentDraft]: | |
| template = get_template_definition(template_key) | |
| start_candidates: list[dict[str, Any]] = [] | |
| for page in pages: | |
| if not page.top_candidates: | |
| continue | |
| top = page.top_candidates[0] | |
| start_candidates.append( | |
| { | |
| "page_no": page.page_no, | |
| "segment_type": top["segment_type"], | |
| "score": float(top["score"]), | |
| "weak_only": bool(top.get("weak_only", False)), | |
| } | |
| ) | |
| # 跨页上下文推理:将无候选但含 continuation_keywords 的页面归入前一段落 | |
| inferred = self._infer_continuation_pages(pages, start_candidates, template_key=template_key) | |
| candidate_page_set = {c["page_no"] for c in start_candidates} | |
| all_candidates = start_candidates + [c for c in inferred if c["page_no"] not in candidate_page_set] | |
| all_candidates.sort(key=lambda c: c["page_no"]) | |
| segments: list[SegmentDraft] = [] | |
| for index, start in enumerate(all_candidates): | |
| # 跨页推理出的候选不作为新段落起始点,跳过 | |
| if start.get("source") == "context_infer": | |
| continue | |
| next_start_page = len(pages) + 1 | |
| # 找下一个非推理候选的起始页作为真正的边界 | |
| for future in all_candidates[index + 1 :]: | |
| if future.get("source") != "context_infer": | |
| next_start_page = future["page_no"] | |
| break | |
| end_page = next_start_page - 1 | |
| if start["segment_type"] == PdfSplitSegmentType.COMPLAINT: | |
| end_page = self._find_complaint_end(pages, start["page_no"], next_start_page - 1, template.rules[0]) | |
| if ( | |
| segments | |
| and segments[-1].segment_type == PdfSplitSegmentType.COMPLAINT | |
| and start["page_no"] == segments[-1].page_end + 1 | |
| ): | |
| continue | |
| review_flag = PdfSplitReviewFlag.NORMAL | |
| source_method = "rule" | |
| confidence = start["score"] | |
| # weak_only 候选标记为低置信度 | |
| if start.get("weak_only"): | |
| review_flag = PdfSplitReviewFlag.LOW_CONFIDENCE | |
| source_method = "rule_weak_only" | |
| # 相邻同类型候选降低置信度 | |
| prev_real = [c for c in all_candidates[:index] if c.get("source") != "context_infer"] | |
| if prev_real and prev_real[-1]["page_no"] == start["page_no"] - 1: | |
| if prev_real[-1]["segment_type"] == start["segment_type"]: | |
| review_flag = PdfSplitReviewFlag.LOW_CONFIDENCE | |
| source_method = "rule_adjacent_start" | |
| confidence = min(confidence, 0.55) | |
| segments.append( | |
| SegmentDraft( | |
| order=len(segments) + 1, | |
| page_start=start["page_no"], | |
| page_end=max(start["page_no"], end_page), | |
| segment_type=start["segment_type"], | |
| filename=f"{get_default_filename(start['segment_type'])}.pdf", | |
| confidence=round(confidence, 3), | |
| source_method=source_method, | |
| review_flag=review_flag, | |
| ) | |
| ) | |
| merged_segments = self._merge_adjacent_pack_segments(segments) | |
| return self._fill_unrecognized_gaps(segments=merged_segments, total_pages=len(pages)) | |
| def _infer_continuation_pages( | |
| self, | |
| pages: list[PageDescriptor], | |
| start_candidates: list[dict[str, Any]], | |
| *, | |
| template_key: str, | |
| ) -> list[dict[str, Any]]: | |
| """ | |
| 跨页上下文推理:将无候选但包含 continuation_keywords 的页面归入前一高置信度段落。 | |
| 仅当前一候选 score >= 0.5 时触发,且段落页数不超过 30 页。 | |
| """ | |
| template = get_template_definition(template_key) | |
| candidate_page_set = {c["page_no"] for c in start_candidates} | |
| inferred: list[dict[str, Any]] = [] | |
| # 构建已知候选(含推理结果)的有序列表,用于查找前一候选 | |
| all_known: list[dict[str, Any]] = list(start_candidates) | |
| for page in pages: | |
| if page.page_no in candidate_page_set: | |
| continue | |
| # 找前一个有候选的页面(含已推理的) | |
| prev_candidate: dict[str, Any] | None = None | |
| for c in reversed(all_known): | |
| if c["page_no"] < page.page_no: | |
| prev_candidate = c | |
| break | |
| if prev_candidate is None or float(prev_candidate.get("score", 0.0)) < 0.5: | |
| continue | |
| # 检查段落页数限制(30页) | |
| span_start: int = int(prev_candidate.get("span_start", prev_candidate["page_no"])) | |
| if page.page_no - span_start >= 30: | |
| continue | |
| # 查找对应规则的 continuation_keywords | |
| rule = next( | |
| (r for r in template.rules if r.segment_type == prev_candidate["segment_type"]), | |
| None, | |
| ) | |
| if rule is None or not rule.continuation_keywords: | |
| continue | |
| has_continuation = any( | |
| self._contains_keyword(page.normalized_text, kw) for kw in rule.continuation_keywords | |
| ) | |
| if has_continuation: | |
| entry: dict[str, Any] = { | |
| "page_no": page.page_no, | |
| "segment_type": prev_candidate["segment_type"], | |
| "score": round(float(prev_candidate["score"]) * 0.9, 3), | |
| "source": "context_infer", | |
| "span_start": span_start, | |
| "weak_only": False, | |
| } | |
| inferred.append(entry) | |
| all_known.append(entry) | |
| all_known.sort(key=lambda c: c["page_no"]) | |
| return inferred | |
| def _find_complaint_end( | |
| self, | |
| pages: list[PageDescriptor], | |
| start_page: int, | |
| max_end_page: int, | |
| rule: SegmentTemplateRule, | |
| ) -> int: | |
| upper_bound = min(max_end_page, start_page + self.COMPLAINT_MAX_SPAN - 1) | |
| end_page = start_page | |
| terminal_seen = False | |
| for page_no in range(start_page, upper_bound + 1): | |
| page = pages[page_no - 1] | |
| if page_no == start_page: | |
| end_page = page_no | |
| continue | |
| has_continuation = any( | |
| self._contains_keyword(page.normalized_text, kw) for kw in rule.continuation_keywords | |
| ) | |
| has_attachment_signal = any( | |
| self._contains_keyword(page.normalized_text, kw) for kw in self.COMPLAINT_ATTACHMENT_KEYWORDS | |
| ) | |
| if not has_continuation and has_attachment_signal: | |
| break | |
| end_page = page_no | |
| if any(self._contains_keyword(page.normalized_text, kw) for kw in self.COMPLAINT_TERMINAL_KEYWORDS): | |
| terminal_seen = True | |
| next_page = pages[page_no] if page_no < len(pages) else None | |
| if next_page and not any( | |
| self._contains_keyword(next_page.normalized_text, kw) for kw in rule.continuation_keywords | |
| ): | |
| break | |
| if terminal_seen: | |
| return end_page | |
| return min(end_page, upper_bound) | |
| def _fill_unrecognized_gaps(self, *, segments: list[SegmentDraft], total_pages: int) -> list[SegmentDraft]: | |
| filled: list[SegmentDraft] = [] | |
| cursor = 1 | |
| ordered_segments = sorted(segments, key=lambda item: (item.page_start, item.page_end)) | |
| for item in ordered_segments: | |
| if item.page_start > cursor: | |
| filled.append( | |
| SegmentDraft( | |
| order=len(filled) + 1, | |
| page_start=cursor, | |
| page_end=item.page_start - 1, | |
| segment_type=PdfSplitSegmentType.UNRECOGNIZED, | |
| filename=f"未识别材料_{cursor}-{item.page_start - 1}.pdf", | |
| confidence=0.0, | |
| source_method="gap_fill", | |
| review_flag=PdfSplitReviewFlag.UNRECOGNIZED, | |
| ) | |
| ) | |
| item.order = len(filled) + 1 | |
| filled.append(item) | |
| cursor = item.page_end + 1 | |
| if cursor <= total_pages: | |
| filled.append( | |
| SegmentDraft( | |
| order=len(filled) + 1, | |
| page_start=cursor, | |
| page_end=total_pages, | |
| segment_type=PdfSplitSegmentType.UNRECOGNIZED, | |
| filename=f"未识别材料_{cursor}-{total_pages}.pdf", | |
| confidence=0.0, | |
| source_method="gap_fill", | |
| review_flag=PdfSplitReviewFlag.UNRECOGNIZED, | |
| ) | |
| ) | |
| return filled | |
| def _merge_adjacent_pack_segments(self, segments: list[SegmentDraft]) -> list[SegmentDraft]: | |
| if not segments: | |
| return [] | |
| merged: list[SegmentDraft] = [] | |
| mergeable_types = {PdfSplitSegmentType.PARTY_IDENTITY} | |
| for item in sorted(segments, key=lambda value: (value.page_start, value.page_end)): | |
| if ( | |
| merged | |
| and item.segment_type in mergeable_types | |
| and merged[-1].segment_type == item.segment_type | |
| and item.page_start == merged[-1].page_end + 1 | |
| ): | |
| previous = merged[-1] | |
| merged[-1] = SegmentDraft( | |
| order=previous.order, | |
| page_start=previous.page_start, | |
| page_end=item.page_end, | |
| segment_type=previous.segment_type, | |
| filename=previous.filename, | |
| confidence=max(previous.confidence, item.confidence), | |
| source_method="rule_merged_adjacent", | |
| review_flag=( | |
| PdfSplitReviewFlag.LOW_CONFIDENCE | |
| if PdfSplitReviewFlag.LOW_CONFIDENCE in {previous.review_flag, item.review_flag} | |
| else previous.review_flag | |
| ), | |
| ) | |
| continue | |
| item.order = len(merged) + 1 | |
| merged.append(item) | |
| return merged | |
| def _render_page_bytes(self, page: fitz.Page, *, dpi: int) -> bytes: | |
| pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72)) | |
| return pix.tobytes("png") # type: ignore[no-any-return] | |
| def _export_segment_pdf(self, source_doc: fitz.Document, page_start: int, page_end: int, output_path: Path) -> None: | |
| segment_doc = fitz.open() | |
| try: | |
| segment_doc.insert_pdf(source_doc, from_page=page_start - 1, to_page=page_end - 1) | |
| segment_doc.save(output_path.as_posix()) | |
| finally: | |
| segment_doc.close() | |
| def _deduplicate_filename(self, display_name: str, seen_names: set[str]) -> str: | |
| path = Path(display_name) | |
| stem = path.stem or "片段" | |
| suffix = path.suffix or ".pdf" | |
| candidate = f"{stem}{suffix}" | |
| counter = 2 | |
| while candidate in seen_names: | |
| candidate = f"{stem}_{counter}{suffix}" | |
| counter += 1 | |
| seen_names.add(candidate) | |
| return candidate | |
| def _normalize_text(self, value: str) -> str: | |
| return _NON_WORD_RE.sub("", value or "") | |
| def _contains_keyword(self, haystack: str, keyword: str) -> bool: | |
| return self._normalize_text(keyword) in haystack | |
| def _fuzzy_contains_keyword(self, haystack: str, keyword: str) -> tuple[bool, float]: | |
| """ | |
| 模糊匹配关键词。 | |
| 返回 (是否匹配, 衰减系数)。 | |
| - 精确匹配:系数 1.0 | |
| - 模糊匹配(编辑距离 > 0):系数 0.8 | |
| - 不匹配:(False, 0.0) | |
| 编辑距离阈值规则: | |
| - 关键词长度 ≤ 3:仅精确匹配 | |
| - 关键词长度 4-6:允许编辑距离 ≤ 1 | |
| - 关键词长度 > 6:允许编辑距离 ≤ 2 | |
| """ | |
| normalized_kw = self._normalize_text(keyword) | |
| kw_len = len(normalized_kw) | |
| if not normalized_kw: | |
| return False, 0.0 | |
| # 精确匹配优先 | |
| if normalized_kw in haystack: | |
| return True, 1.0 | |
| # 短关键词不做模糊匹配 | |
| if kw_len <= 3: | |
| return False, 0.0 | |
| # 确定允许的编辑距离 | |
| max_dist = 1 if kw_len <= 6 else 2 | |
| # 滑动窗口匹配 | |
| window_size = kw_len + max_dist | |
| for i in range(max(1, len(haystack) - window_size + 1)): | |
| fragment = haystack[i : i + window_size] | |
| if _levenshtein_distance(fragment, normalized_kw) <= max_dist: | |
| return True, 0.8 | |
| return False, 0.0 | |
| def _is_effective_text(self, value: str) -> bool: | |
| normalized = self._normalize_text(value) | |
| return len(normalized) >= _TEXT_MIN_LENGTH | |
| def _build_descriptor_from_text( | |
| self, | |
| *, | |
| page_no: int, | |
| text: str, | |
| source_method: str, | |
| ocr_failed: bool, | |
| template_key: str, | |
| ) -> PageDescriptor: | |
| normalized_text = self._normalize_text(text) | |
| head_text = normalized_text[:240] | |
| top_candidates = self._score_page( | |
| head_text=head_text, normalized_text=normalized_text, template_key=template_key | |
| ) | |
| return PageDescriptor( | |
| page_no=page_no, | |
| text=text, | |
| normalized_text=normalized_text, | |
| head_text=head_text, | |
| source_method=source_method, | |
| ocr_failed=ocr_failed, | |
| top_candidates=top_candidates, | |
| ) | |
| def _update_progress(self, *, job_id: Any, resolved_pages: int, total_pages: int) -> None: | |
| progress = int(resolved_pages * 100 / total_pages) if total_pages else 0 | |
| if resolved_pages % self.PROGRESS_UPDATE_EVERY == 0 or resolved_pages == total_pages: | |
| PdfSplitJob.objects.filter(id=job_id).update( | |
| total_pages=total_pages, | |
| processed_pages=resolved_pages, | |
| current_page=resolved_pages, | |
| progress=progress, | |
| ) | |
| def _should_check_cancel(self, marker: int) -> bool: | |
| return marker % self.CANCEL_CHECK_EVERY == 0 | |
| def _resolve_ocr_runtime_profile(self, profile_key: str) -> OCRRuntimeProfile: | |
| normalized = str(profile_key or "").strip().lower() | |
| cpu = max(1, os.cpu_count() or 1) | |
| profiles: dict[str, OCRRuntimeProfile] = { | |
| PdfSplitOcrProfile.FAST: OCRRuntimeProfile( | |
| key=PdfSplitOcrProfile.FAST, | |
| use_v5=False, | |
| dpi=140, | |
| workers=min(6, cpu), | |
| ), | |
| PdfSplitOcrProfile.BALANCED: OCRRuntimeProfile( | |
| key=PdfSplitOcrProfile.BALANCED, | |
| use_v5=True, | |
| dpi=200, | |
| workers=min(3, cpu), | |
| ), | |
| PdfSplitOcrProfile.ACCURATE: OCRRuntimeProfile( | |
| key=PdfSplitOcrProfile.ACCURATE, | |
| use_v5=True, | |
| dpi=220, | |
| workers=min(2, cpu), | |
| ), | |
| } | |
| return profiles.get(normalized, profiles[PdfSplitOcrProfile.BALANCED]) | |
| def _parallel_ocr_missing_pages( | |
| self, | |
| *, | |
| pdf_path: Path, | |
| page_numbers: list[int], | |
| runtime_profile: OCRRuntimeProfile, | |
| ) -> dict[int, OCRPageResult]: | |
| if not page_numbers: | |
| return {} | |
| if runtime_profile.workers <= 1 or len(page_numbers) <= 1: | |
| result_list = _ocr_pages_worker( | |
| pdf_path=pdf_path.as_posix(), | |
| page_numbers=page_numbers, | |
| use_v5=runtime_profile.use_v5, | |
| dpi=runtime_profile.dpi, | |
| ) | |
| return {item.page_no: item for item in result_list} | |
| results: dict[int, OCRPageResult] = {} | |
| chunks = self._chunk_pages(page_numbers=page_numbers, chunk_count=runtime_profile.workers) | |
| with ThreadPoolExecutor(max_workers=runtime_profile.workers) as executor: | |
| future_map = { | |
| executor.submit( | |
| _ocr_pages_worker, | |
| pdf_path=pdf_path.as_posix(), | |
| page_numbers=chunk, | |
| use_v5=runtime_profile.use_v5, | |
| dpi=runtime_profile.dpi, | |
| ): chunk | |
| for chunk in chunks | |
| if chunk | |
| } | |
| for future in as_completed(future_map): | |
| chunk = future_map[future] | |
| try: | |
| for item in future.result(): | |
| results[item.page_no] = item | |
| except Exception: | |
| logger.exception("pdf_split_parallel_ocr_failed_chunk", extra={"chunk_size": len(chunk)}) | |
| for item in _ocr_pages_worker( | |
| pdf_path=pdf_path.as_posix(), | |
| page_numbers=chunk, | |
| use_v5=runtime_profile.use_v5, | |
| dpi=runtime_profile.dpi, | |
| ): | |
| results[item.page_no] = item | |
| if len(results) < len(page_numbers): | |
| missing_pages = [page_no for page_no in page_numbers if page_no not in results] | |
| logger.warning( | |
| "pdf_split_parallel_ocr_missing_results", | |
| extra={"missing_pages": len(missing_pages), "total_pages": len(page_numbers)}, | |
| ) | |
| for item in _ocr_pages_worker( | |
| pdf_path=pdf_path.as_posix(), | |
| page_numbers=missing_pages, | |
| use_v5=runtime_profile.use_v5, | |
| dpi=runtime_profile.dpi, | |
| ): | |
| results[item.page_no] = item | |
| return results | |
| def _chunk_pages(self, *, page_numbers: list[int], chunk_count: int) -> list[list[int]]: | |
| if not page_numbers: | |
| return [] | |
| chunk_count = max(1, min(chunk_count, len(page_numbers))) | |
| buckets: list[list[int]] = [[] for _ in range(chunk_count)] | |
| for index, page_no in enumerate(page_numbers): | |
| buckets[index % chunk_count].append(page_no) | |
| return [bucket for bucket in buckets if bucket] | |
| def _sha256_file(self, file_path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with file_path.open("rb") as handle: | |
| while True: | |
| chunk = handle.read(1024 * 1024) | |
| if not chunk: | |
| break | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def _ocr_cache_file(self, *, pdf_hash: str, profile_key: str, page_no: int) -> Path: | |
| return ( | |
| Path(settings.MEDIA_ROOT) | |
| / "pdf_splitting" | |
| / "ocr_cache" | |
| / pdf_hash | |
| / profile_key | |
| / f"page_{page_no:04d}.json" | |
| ) | |
| def _read_ocr_cache(self, *, pdf_hash: str, profile_key: str, page_no: int) -> OCRPageResult | None: | |
| cache_file = self._ocr_cache_file(pdf_hash=pdf_hash, profile_key=profile_key, page_no=page_no) | |
| if not cache_file.exists(): | |
| return None | |
| try: | |
| payload = json.loads(cache_file.read_text(encoding="utf-8")) | |
| text = str(payload.get("text") or "") | |
| ocr_failed = bool(payload.get("ocr_failed")) | |
| return OCRPageResult( | |
| page_no=page_no, | |
| text=text, | |
| source_method="ocr_cache" if text else "ocr_failed_cache", | |
| ocr_failed=ocr_failed, | |
| ) | |
| except Exception: | |
| logger.exception("pdf_split_ocr_cache_read_failed", extra={"cache_file": cache_file.as_posix()}) | |
| return None | |
| def _write_ocr_cache(self, *, pdf_hash: str, profile_key: str, result: OCRPageResult) -> None: | |
| cache_file = self._ocr_cache_file(pdf_hash=pdf_hash, profile_key=profile_key, page_no=result.page_no) | |
| cache_file.parent.mkdir(parents=True, exist_ok=True) | |
| payload = { | |
| "text": result.text, | |
| "ocr_failed": result.ocr_failed, | |
| "source_method": result.source_method, | |
| } | |
| cache_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") | |