diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index 7a6e2aec..3fa8de01 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -19,6 +19,10 @@ try: from pytesseract import Output as TesseractOutput except ImportError: # pragma: no cover - optional local dependency path pytesseract = None +try: + from PIL import Image +except ImportError: # pragma: no cover - optional local dependency path + Image = None from fastapi import HTTPException, status from reportlab.lib.pagesizes import A4 from reportlab.pdfbase import pdfmetrics @@ -312,11 +316,16 @@ PDF_SOURCE_KIND_UNKNOWN = "unknown_pdf" SUPPORTED_TEXT_SUFFIXES = (".txt", ".md", ".csv", ".json") DOCX_SUFFIX = ".docx" PDF_SUFFIX = ".pdf" -SUPPORTED_REVIEW_UPLOAD_SUFFIXES = (PDF_SUFFIX, DOCX_SUFFIX, ".txt") +IMAGE_PNG_SUFFIX = ".png" +IMAGE_JPG_SUFFIXES = (".jpg", ".jpeg") +SUPPORTED_REVIEW_IMAGE_SUFFIXES = (IMAGE_PNG_SUFFIX, *IMAGE_JPG_SUFFIXES) +SUPPORTED_REVIEW_UPLOAD_SUFFIXES = (PDF_SUFFIX, DOCX_SUFFIX, ".txt", *SUPPORTED_REVIEW_IMAGE_SUFFIXES) SUPPORTED_REVIEW_UPLOAD_MIME_TYPES = ( "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain", + "image/png", + "image/jpeg", ) PDF_FONT_NAME = "STSong-Light" pdfmetrics.registerFont(UnicodeCIDFont(PDF_FONT_NAME)) @@ -584,6 +593,68 @@ class ReviewService: }, } + def _extract_text_from_image_with_ocr(self, file_bytes: bytes) -> Dict[str, Any]: + image_source = { + "kind": "image_upload", + "page_count": 1, + "text_pages": 0, + "image_pages": 1, + } + if Image is None or pytesseract is None: + return { + "text": None, + "failure_message": "检测到图片合同,但 OCR 运行时不可用,请改用更清晰的图片、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", + "metadata": { + "document_kind": image_source["kind"], + "extraction_mode": "ocr_runtime_missing", + "ocr_gate_status": OCR_GATE_BLOCK, + "ocr_quality": {"status": OCR_GATE_BLOCK, "char_count": 0, "failure_message": "ocr runtime missing"}, + }, + } + try: + image = Image.open(io.BytesIO(file_bytes)) + image = image.convert("RGB") + data = pytesseract.image_to_data(image, lang="chi_sim+eng", output_type=TesseractOutput.DICT) + words = [str(item).strip() for item in data.get("text", []) if str(item).strip()] + confidences: List[float] = [] + for conf in data.get("conf", []): + try: + conf_value = float(conf) + except (TypeError, ValueError): + continue + if conf_value >= 0: + confidences.append(conf_value) + text = self._normalize_extracted_text(" ".join(words)) + quality = self._evaluate_ocr_quality(text, confidences, image_source) + if quality["status"] == OCR_GATE_BLOCK: + failure_message = "检测到图片合同,但 OCR 识别质量不足,请上传更清晰的图片、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。" + elif quality["status"] == OCR_GATE_WARN: + failure_message = "已通过 OCR 提取图片文本,但识别质量一般,建议人工复核原图后再使用审查结果。" + else: + failure_message = None + quality["failure_message"] = failure_message + return { + "text": text if quality["status"] != OCR_GATE_BLOCK else None, + "failure_message": failure_message, + "metadata": { + "document_kind": image_source["kind"], + "extraction_mode": "ocr", + "ocr_gate_status": quality["status"], + "ocr_quality": quality, + }, + } + except Exception: + return { + "text": None, + "failure_message": "检测到图片合同,但 OCR 解析失败,请改用更清晰的图片、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", + "metadata": { + "document_kind": image_source["kind"], + "extraction_mode": "ocr_parse_failed", + "ocr_gate_status": OCR_GATE_BLOCK, + "ocr_quality": {"status": OCR_GATE_BLOCK, "char_count": 0, "failure_message": "ocr parse failed"}, + }, + } + def _looks_like_platform_review_report_file_name(self, normalized_name: str) -> bool: return "review-report" in (normalized_name or "") or "审查报告" in (normalized_name or "") @@ -795,8 +866,8 @@ class ReviewService: return self._extract_text_from_file(file_bytes, file_name) def extract_review_upload_text(self, file_bytes: bytes, file_name: Optional[str]) -> Dict[str, Optional[str]]: - text = self.extract_text_from_file(file_bytes=file_bytes, file_name=file_name) normalized_name = (file_name or "").lower() + text = None if normalized_name.endswith(SUPPORTED_REVIEW_IMAGE_SUFFIXES) else self.extract_text_from_file(file_bytes=file_bytes, file_name=file_name) extraction_metadata: Dict[str, Any] = { "document_kind": None, "extraction_mode": "text", @@ -837,9 +908,20 @@ class ReviewService: }, } + if normalized_name.endswith(SUPPORTED_REVIEW_IMAGE_SUFFIXES): + ocr_result = self._extract_text_from_image_with_ocr(file_bytes) + return { + "text": ocr_result["text"], + "failure_message": ocr_result["failure_message"], + "metadata": { + **extraction_metadata, + **ocr_result["metadata"], + }, + } + return { "text": text, - "failure_message": "无法从上传文件中提取可审查文本,请改为可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", + "failure_message": "无法从上传文件中提取可审查文本,请改为可复制文本 PDF / DOCX / TXT / PNG / JPG / JPEG,或直接粘贴合同正文。", "metadata": extraction_metadata, } @@ -849,20 +931,23 @@ class ReviewService: suffix = Path(file_name).suffix.lower() if suffix not in SUPPORTED_REVIEW_UPLOAD_SUFFIXES: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT 文件") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") normalized_mime = (mime_type or "").strip().lower() if normalized_mime and not any(normalized_mime.startswith(allowed) for allowed in SUPPORTED_REVIEW_UPLOAD_MIME_TYPES): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT 文件") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") detected_type = self._detect_review_upload_type(file_bytes) expected_type = { PDF_SUFFIX: "pdf", DOCX_SUFFIX: "docx", ".txt": "txt", + IMAGE_PNG_SUFFIX: "png", + ".jpg": "jpg", + ".jpeg": "jpg", }[suffix] if detected_type != expected_type: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT 文件") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") def _detect_review_upload_type(self, file_bytes: bytes) -> str: if file_bytes.startswith(b"%PDF"): @@ -877,6 +962,8 @@ class ReviewService: return "unknown" if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"): return "png" + if file_bytes.startswith(b"\xff\xd8\xff"): + return "jpg" if file_bytes.startswith(b"MZ"): return "exe" if not file_bytes: diff --git a/backend/tests/test_review_service.py b/backend/tests/test_review_service.py index 198c7101..4b50ca71 100644 --- a/backend/tests/test_review_service.py +++ b/backend/tests/test_review_service.py @@ -780,6 +780,43 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertEqual(result["metadata"]["ocr_gate_status"], "BLOCK") self.assertIn("OCR 识别质量不足", result["failure_message"]) + def test_extract_review_upload_text_uses_ocr_for_image_upload(self): + self.service._extract_text_from_image_with_ocr = lambda file_bytes: { + "text": "图片合同正文\n付款安排", + "failure_message": "已通过 OCR 提取图片文本,但识别质量一般,建议人工复核原图后再使用审查结果。", + "metadata": { + "document_kind": "image_upload", + "extraction_mode": "ocr", + "ocr_gate_status": "WARN", + "ocr_quality": {"status": "WARN", "char_count": 12}, + }, + } + + result = self.service.extract_review_upload_text(b"\x89PNG\r\n\x1a\nmock-image", "contract.png") + + self.assertEqual(result["text"], "图片合同正文\n付款安排") + self.assertEqual(result["metadata"]["document_kind"], "image_upload") + self.assertEqual(result["metadata"]["ocr_gate_status"], "WARN") + + def test_validate_review_upload_accepts_supported_image_suffix(self): + self.service.validate_review_upload("contract.png", "image/png", b"\x89PNG\r\n\x1a\nmock-image") + self.service.validate_review_upload("contract.jpg", "image/jpeg", b"\xff\xd8\xffmock-jpeg") + self.service.validate_review_upload("contract.jpeg", "image/jpeg", b"\xff\xd8\xffmock-jpeg") + + def test_validate_review_upload_rejects_png_renamed_as_txt(self): + with self.assertRaises(HTTPException) as context: + self.service.validate_review_upload("contract.txt", "text/plain", b"\x89PNG\r\n\x1a\n") + + self.assertEqual(context.exception.status_code, 400) + self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") + + def test_validate_review_upload_rejects_jpg_renamed_as_txt(self): + with self.assertRaises(HTTPException) as context: + self.service.validate_review_upload("contract.txt", "text/plain", b"\xff\xd8\xfffake-jpeg") + + self.assertEqual(context.exception.status_code, 400) + self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") + def test_build_clauses_from_text_groups_multiline_sections_and_skips_metadata(self): clauses = self.service._build_clauses_from_text( "\n".join( @@ -940,17 +977,10 @@ class ReviewServiceTestCase(unittest.TestCase): def test_validate_review_upload_rejects_unsupported_suffix(self): with self.assertRaises(HTTPException) as context: - self.service.validate_review_upload("contract.png", "image/png", b"\x89PNG\r\n\x1a\n") + self.service.validate_review_upload("contract.exe", "application/octet-stream", b"MZ") self.assertEqual(context.exception.status_code, 400) - self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT 文件") - - def test_validate_review_upload_rejects_png_renamed_as_txt(self): - with self.assertRaises(HTTPException) as context: - self.service.validate_review_upload("contract.txt", "text/plain", b"\x89PNG\r\n\x1a\n") - - self.assertEqual(context.exception.status_code, 400) - self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT 文件") + self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") def test_validate_review_upload_rejects_exe_renamed_as_docx(self): with self.assertRaises(HTTPException) as context: @@ -961,7 +991,7 @@ class ReviewServiceTestCase(unittest.TestCase): ) self.assertEqual(context.exception.status_code, 400) - self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT 文件") + self.assertEqual(context.exception.detail, "仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") def test_evaluate_ocr_quality_returns_pass_for_clean_text(self): quality = self.service._evaluate_ocr_quality( diff --git a/backend/tests/test_reviews_api.py b/backend/tests/test_reviews_api.py index a149ec15..a04f5722 100644 --- a/backend/tests/test_reviews_api.py +++ b/backend/tests/test_reviews_api.py @@ -38,6 +38,17 @@ class StubReviewService: "ocr_quality": {"status": "WARN", "char_count": 12}, }, } + if file_name.endswith(".png") or file_name.endswith(".jpg") or file_name.endswith(".jpeg"): + return { + "text": "图片合同正文\n付款安排", + "failure_message": "已通过 OCR 提取图片文本,但识别质量一般,建议人工复核原图后再使用审查结果。", + "metadata": { + "document_kind": "image_upload", + "extraction_mode": "ocr", + "ocr_gate_status": "WARN", + "ocr_quality": {"status": "WARN", "char_count": 12}, + }, + } text = self.extract_text_from_file(file_bytes, file_name) return { "text": text, @@ -50,8 +61,8 @@ class StubReviewService: } def validate_review_upload(self, file_name, mime_type, file_bytes): - if file_name.endswith(".png") or file_name.endswith(".exe") or file_bytes.startswith(b"\x89PNG") or file_bytes.startswith(b"MZ"): - raise HTTPException(status_code=400, detail="仅支持 PDF / DOCX / TXT 文件") + if file_name.endswith(".exe") or file_bytes.startswith(b"MZ"): + raise HTTPException(status_code=400, detail="仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") def create_review_task(self, tenant_id, user_id, review_as, contract_type, text, file_name, extracted_text=None, extraction_metadata=None): return { @@ -283,15 +294,30 @@ class ReviewsApiTestCase(unittest.TestCase): body = response.json() self.assertEqual(body["code"], 40001) + def test_create_review_accepts_image_file_for_ocr_review(self): + response = self.client.post( + "/api/v1/reviews", + data={"review_as": "party_a"}, + files={"file": ("contract.png", b"\x89PNG\r\n\x1a\nmock-image", "image/png")}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["file_name"], "contract.png") + self.assertEqual(body["data"]["merged_text"], "图片合同正文\n付款安排") + self.assertEqual(body["data"]["extraction_metadata"]["document_kind"], "image_upload") + self.assertEqual(body["data"]["extraction_metadata"]["ocr_gate_status"], "WARN") + def test_create_review_rejects_unsupported_file_type(self): response = self.client.post( "/api/v1/reviews", data={"review_as": "party_a"}, - files={"file": ("contract.png", b"png", "image/png")}, + files={"file": ("contract.exe", b"MZfake-executable", "application/octet-stream")}, ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["message"], "仅支持 PDF / DOCX / TXT 文件") + self.assertEqual(response.json()["message"], "仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件") def test_create_review_rejects_unextractable_supported_file_without_text_fallback(self): class UnextractableReviewService(StubReviewService): diff --git a/frontend/src/app/review/page.tsx b/frontend/src/app/review/page.tsx index a8288201..7e4d63f2 100644 --- a/frontend/src/app/review/page.tsx +++ b/frontend/src/app/review/page.tsx @@ -12,7 +12,7 @@ import { createReview } from "@/services/review"; const { Dragger } = Upload; const { Title, Text } = Typography; -const SUPPORTED_REVIEW_FILE_EXTENSIONS = [".pdf", ".docx", ".txt"]; +const SUPPORTED_REVIEW_FILE_EXTENSIONS = [".pdf", ".docx", ".txt", ".png", ".jpg", ".jpeg"]; function isSupportedReviewFile(fileName: string) { const normalized = fileName.toLowerCase(); @@ -25,7 +25,7 @@ type ReviewFormValues = { text?: string; }; -const reviewHighlights = ["直接审核客户现有合同", "支持 PDF / DOCX / TXT 上传", "扫描 PDF 可先走 OCR", "可叠加补充文本说明", "提交后自动进入结果详情页"]; +const reviewHighlights = ["直接审核客户现有合同", "支持 PDF / DOCX / TXT / PNG / JPG / JPEG 上传", "扫描 PDF 和图片可先走 OCR", "可叠加补充文本说明", "提交后自动进入结果详情页"]; type SubmissionGuidance = { type: "info" | "warning" | "error"; @@ -57,35 +57,35 @@ function buildSubmissionGuidance(message: string): SubmissionGuidance | null { return { type: "warning", title: "当前上传的是平台审查报告,不是合同原文", - description: "请改为上传合同原文 PDF / DOCX / TXT,或直接粘贴合同正文后再审查。", + description: "请改为上传合同原文 PDF / DOCX / TXT / PNG / JPG / JPEG,或直接粘贴合同正文后再审查。", }; } if (message.includes("OCR 运行时缺失") || message.includes("OCR 未启用") || message.includes("OCR 能力未启用")) { return { type: "warning", title: "当前环境未启用 OCR", - description: "扫描件暂时无法直接进入完整审查,请改传可复制文本 PDF / DOCX / TXT,或先补充合同正文文本后再提交。", + description: "扫描件或图片暂时无法直接进入完整审查,请改传更清晰的图片、可复制文本 PDF / DOCX / TXT,或先补充合同正文文本后再提交。", }; } if (message.includes("OCR 识别质量不足")) { return { type: "warning", title: "扫描件识别质量不足", - description: "建议上传更清晰的扫描件、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。清晰扫描件已支持 OCR,但低质量扫描件仍会被门禁拦截。", + description: "建议上传更清晰的扫描件或图片、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。清晰扫描件和图片已支持 OCR,但低质量输入仍会被门禁拦截。", }; } if (message.includes("扫描件/图片型 PDF") || message.includes("OCR")) { return { type: "info", title: "检测到扫描件或图片型 PDF", - description: "系统会先尝试 OCR;若识别质量不足,会建议你改传更清晰扫描件、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", + description: "系统会先尝试 OCR;若识别质量不足,会建议你改传更清晰扫描件或图片、可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", }; } if (message.includes("无法从上传文件中提取可审查文本")) { return { type: "warning", title: "当前文件未形成可审查正文", - description: "请确认文件是可复制文本 PDF / DOCX / TXT;如果是扫描件,请提高扫描清晰度或直接粘贴合同正文。", + description: "请确认文件是可复制文本 PDF / DOCX / TXT,或更清晰的图片合同;如果是扫描件或图片,请提高识别清晰度或直接粘贴合同正文。", }; } return null; @@ -147,7 +147,7 @@ export default function ReviewPage() { setSubmissionGuidance(null); setSubmitting(false); submitLockRef.current = false; - messageApi.error("仅支持 PDF / DOCX / TXT 文件"); + messageApi.error("仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件"); return; } @@ -195,7 +195,7 @@ export default function ReviewPage() {
点击或拖拽上传合同原文 PDF / DOCX / TXT
-支持客户已有合同原文直接审查;扫描型 PDF 会优先尝试 OCR 抽取后再进入风险识别。
+点击或拖拽上传合同原文 PDF / DOCX / TXT / PNG / JPG / JPEG
+支持客户已有合同原文直接审查;扫描型 PDF 和图片合同会优先尝试 OCR 抽取后再进入风险识别。