feat: support image uploads in direct review flow

- accept png/jpg/jpeg in the uploaded-contract review path
- add local image OCR extraction and quality gating
- keep existing PDF/DOCX/TXT review behavior intact

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:42:15 +08:00
parent 1eae603c0f
commit 739d2f51f9
4 changed files with 176 additions and 33 deletions
+93 -6
View File
@@ -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:
+40 -10
View File
@@ -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(
+30 -4
View File
@@ -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):
+13 -13
View File
@@ -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() {
<div className="review-page-hero-copy">
<span className="landing-badge">Start Review</span>
<Title level={2}>上传客户已有合同,直接进入风险审查。</Title>
<Text type="secondary">无论合同是不是系统生成的,都可以直接上传原文 PDF / DOCX / TXT 或粘贴正文,再设置审查视角后进入审查链路。</Text>
<Text type="secondary">无论合同是不是系统生成的,都可以直接上传原文 PDF / DOCX / TXT / PNG / JPG / JPEG 或粘贴正文,再设置审查视角后进入审查链路。</Text>
<div className="review-page-highlight-list">
{reviewHighlights.map((item) => (
<span className="review-page-highlight-chip" key={item}>
@@ -245,10 +245,10 @@ export default function ReviewPage() {
/>
) : null}
<Dragger
accept=".pdf,.docx,.txt"
accept=".pdf,.docx,.txt,.png,.jpg,.jpeg"
beforeUpload={(file) => {
if (!isSupportedReviewFile(file.name)) {
messageApi.error("仅支持 PDF / DOCX / TXT 文件");
messageApi.error("仅支持 PDF / DOCX / TXT / PNG / JPG / JPEG 文件");
return Upload.LIST_IGNORE;
}
return false;
@@ -264,8 +264,8 @@ export default function ReviewPage() {
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p>点击或拖拽上传合同原文 PDF / DOCX / TXT</p>
<p className="upload-hint">支持客户已有合同原文直接审查;扫描型 PDF 会优先尝试 OCR 抽取后再进入风险识别。</p>
<p>点击或拖拽上传合同原文 PDF / DOCX / TXT / PNG / JPG / JPEG</p>
<p className="upload-hint">支持客户已有合同原文直接审查;扫描型 PDF 和图片合同会优先尝试 OCR 抽取后再进入风险识别。</p>
</Dragger>
{selectedFileName ? (
<div className="review-selected-file review-selected-file-rich">