Files
ai-contract-platform/backend/tools/pdf_acceptance_gate.py
T

714 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import io
import json
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Dict, List, Optional
import httpx
from PIL import Image, ImageDraw, ImageFont
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfgen import canvas
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
@dataclass
class PdfCase:
case_id: str
category: str
label: str
expectation: str
file_name: str
build_bytes: Optional[Callable[[], bytes]] = None
file_path: Optional[str] = None
require_preview: Optional[List[str]] = None
forbid_preview: Optional[List[str]] = None
require_clause_absent: Optional[List[str]] = None
require_message_contains: Optional[str] = None
allow_ocr_statuses: Optional[List[str]] = None
notes: Optional[str] = None
def make_text_pdf(lines: List[str], font_name: str = "Helvetica", font_size: int = 12) -> bytes:
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer)
pdf.setFont(font_name, font_size)
y = 780
for line in lines:
pdf.drawString(72, y, line)
y -= 20
if y < 72:
pdf.showPage()
pdf.setFont(font_name, font_size)
y = 780
pdf.save()
return buffer.getvalue()
def make_report_like_pdf() -> bytes:
return make_text_pdf(
[
"审查任务:文本输入合同",
"任务编号:review-123",
"风险汇总:",
"审查意见:建议复核",
"原始文本摘要:合同文本",
],
font_name="STSong-Light",
)
def make_clean_short_pdf() -> bytes:
return make_text_pdf(
[
"Service Agreement",
"Party A shall pay within 30 days after acceptance.",
"If Party B delays delivery, liquidated damages apply.",
]
)
def make_clean_long_pdf() -> bytes:
return make_text_pdf(
[
"Technology Development Agreement",
"1. Scope: Party B shall develop and deploy the requested system and deliver source code.",
"2. Payment: Party A shall pay milestone one within 15 days after written acceptance.",
"3. The balance shall be paid within 45 days after stable production operation.",
"4. Confidentiality obligations shall survive termination for three years.",
"5. Liability shall be capped except for wilful misconduct and confidentiality breaches.",
]
)
def make_header_footer_pdf() -> bytes:
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer)
pdf.setFont("Helvetica", 12)
pdf.drawString(72, 780, "Page 1 / 2")
pdf.drawString(72, 760, "PDF extraction boundary sample")
pdf.drawString(72, 720, "Procurement Agreement")
pdf.drawString(72, 700, "Party A shall pay within 15 business days after invoice receipt and acceptance.")
pdf.showPage()
pdf.setFont("Helvetica", 12)
pdf.drawString(72, 780, "Page 2 / 2")
pdf.drawString(72, 760, "PDF extraction boundary sample")
pdf.drawString(72, 720, "Both parties shall define confidentiality obligations clearly.")
pdf.save()
return buffer.getvalue()
def make_broken_minimal_pdf() -> bytes:
return b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"
def _make_image_contract(low_res: bool) -> bytes:
size = (420, 240)
image = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(image)
lines = [
"SCANNED CONTRACT SAMPLE",
"Party A shall pay within 15 days after acceptance.",
"Confidential information shall remain protected.",
]
y = 20
for line in lines:
draw.text((20, y), line, fill="black")
y += 28
if low_res:
image = image.resize((180, 100)).resize(size)
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
pdf.drawImage(ImageReader(image), 36, 400, width=520, height=300)
pdf.save()
return pdf_buffer.getvalue()
def _load_cjk_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
font_candidates = [
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simsun.ttc"),
Path("C:/Windows/Fonts/simhei.ttf"),
]
for font_path in font_candidates:
if font_path.exists():
try:
return ImageFont.truetype(str(font_path), size=size)
except OSError:
continue
return ImageFont.load_default()
def make_cn_image_contract() -> bytes:
size = (900, 560)
image = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(image)
font = _load_cjk_font(30)
lines = [
"采购合同扫描样本",
"甲方应在验收合格并收到发票后十五个工作日内付款。",
"乙方应对商业秘密和技术资料承担保密义务。",
"如乙方延期交付,双方应按约承担违约责任。",
]
y = 48
for line in lines:
draw.text((40, y), line, fill="black", font=font)
y += 86
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
pdf.drawImage(ImageReader(image), 36, 280, width=540, height=340)
pdf.save()
return pdf_buffer.getvalue()
def make_cn_multi_page_contract() -> bytes:
font = _load_cjk_font(30)
pages = [
[
"采购合同扫描样本(第1页)",
"第一条 付款节点:甲方应在验收合格并收到发票后十五个工作日内付款。",
"第二条 验收标准:系统核心流程可用且严重缺陷关闭后视为验收完成。",
],
[
"采购合同扫描样本(第2页)",
"第三条 保密义务:乙方应对商业秘密和技术资料承担三年保密义务。",
"第四条 违约责任:如乙方延期交付,双方应按约承担违约责任。",
],
]
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
for lines in pages:
image = Image.new("RGB", (980, 620), "white")
draw = ImageDraw.Draw(image)
y = 56
for line in lines:
draw.text((40, y), line, fill="black", font=font)
y += 94
pdf.drawImage(ImageReader(image), 30, 220, width=550, height=360)
pdf.showPage()
pdf.save()
return pdf_buffer.getvalue()
def make_table_scan_pdf() -> bytes:
image = Image.new("RGB", (1100, 760), "white")
draw = ImageDraw.Draw(image)
font = _load_cjk_font(28)
draw.text((40, 30), "表格型采购合同扫描样本", fill="black", font=font)
left = 40
top = 100
col_widths = [220, 760]
row_height = 90
rows = [
("条款项", "内容"),
("付款节点", "验收合格并收到发票后十五个工作日内付款"),
("验收标准", "核心功能可用且严重缺陷关闭"),
("保密义务", "商业秘密和技术资料保密三年"),
("违约责任", "延期交付按约承担违约责任"),
]
for row_index, row in enumerate(rows):
current_top = top + row_index * row_height
x = left
for cell_index, cell_text in enumerate(row):
width = col_widths[cell_index]
draw.rectangle((x, current_top, x + width, current_top + row_height), outline="black", width=2)
draw.text((x + 16, current_top + 28), cell_text, fill="black", font=font)
x += width
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
pdf.drawImage(ImageReader(image), 24, 180, width=560, height=390)
pdf.save()
return pdf_buffer.getvalue()
def make_signature_noise_scan_pdf() -> bytes:
font = _load_cjk_font(28)
pages = [
[
"服务合同扫描样本",
"甲方应在验收合格并收到发票后十五个工作日内付款。",
"乙方应对商业秘密和技术资料承担保密义务。",
"如乙方延期交付,双方应按约承担违约责任。",
"验收标准应以核心流程可用且严重缺陷关闭为准。",
],
[
"签署页",
"甲方(盖章)",
"乙方(签字)",
"签署日期:2026年05月04日",
],
]
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
for lines in pages:
image = Image.new("RGB", (900, 620), "white")
draw = ImageDraw.Draw(image)
y = 70
for line in lines:
draw.text((50, y), line, fill="black", font=font)
y += 100
pdf.drawImage(ImageReader(image), 36, 230, width=520, height=340)
pdf.showPage()
pdf.save()
return pdf_buffer.getvalue()
def make_image_only_pdf() -> bytes:
return _make_image_contract(low_res=False)
def make_low_res_image_only_pdf() -> bytes:
return _make_image_contract(low_res=True)
def build_cases(sample_contract_path: Optional[str]) -> List[PdfCase]:
cases: List[PdfCase] = [
PdfCase(
case_id="A-001",
category="A",
label="干净短文本 PDF",
expectation="must_succeed",
file_name="gate-clean-short.pdf",
build_bytes=make_clean_short_pdf,
require_preview=["Service Agreement", "Party A shall pay within 30 days after acceptance."],
),
PdfCase(
case_id="A-002",
category="A",
label="干净长文本 PDF",
expectation="must_succeed",
file_name="gate-clean-long.pdf",
build_bytes=make_clean_long_pdf,
require_preview=["Technology Development Agreement", "Party A shall pay milestone one within 15 days"],
),
PdfCase(
case_id="A-003",
category="A",
label="页眉页脚边界 PDF",
expectation="must_succeed",
file_name="gate-header-footer.pdf",
build_bytes=make_header_footer_pdf,
require_preview=["Procurement Agreement", "Party A shall pay within 15 business days"],
forbid_preview=["Page 1 / 2", "Page 2 / 2", "PDF extraction boundary sample"],
require_clause_absent=["Page 1 / 2", "Page 2 / 2", "PDF extraction boundary sample"],
),
PdfCase(
case_id="B-001",
category="B",
label="最小坏样本 PDF",
expectation="must_fail",
file_name="gate-broken-minimal.pdf",
build_bytes=make_broken_minimal_pdf,
require_message_contains=None,
),
PdfCase(
case_id="B-002",
category="B",
label="平台审查报告 PDF",
expectation="must_fail",
file_name="review-report-gate.pdf",
build_bytes=make_report_like_pdf,
require_message_contains="疑似平台审查报告 PDF",
),
PdfCase(
case_id="C-001",
category="C",
label="图片型 PDF(OCR第一批)",
expectation="ocr_supported",
file_name="gate-image-only.pdf",
build_bytes=make_image_only_pdf,
allow_ocr_statuses=["PASS", "WARN"],
notes="OCR 第一批实现后,这类清晰图片型 PDF 应能进入正式审查。",
),
PdfCase(
case_id="C-002",
category="C",
label="低清晰度图片型 PDF(OCR第一批)",
expectation="ocr_quality_gate",
file_name="gate-image-lowres.pdf",
build_bytes=make_low_res_image_only_pdf,
allow_ocr_statuses=["WARN"],
require_message_contains="OCR 识别质量不足",
notes="OCR 第一批实现后,这类样本允许 WARN 或 BLOCK,但不能伪装成高质量 PASS。",
),
PdfCase(
case_id="C-003",
category="C",
label="中文扫描合同 PDF(OCR第一批)",
expectation="ocr_supported",
file_name="gate-cn-image.pdf",
build_bytes=make_cn_image_contract,
allow_ocr_statuses=["PASS", "WARN"],
require_preview=["采购合同扫描样本", "甲方应在验收合格并收到发票后十五个工作日内付款。"],
notes="OCR 第一批实现后,中文扫描合同应至少进入正式审查,并暴露 OCR 门禁结果。",
),
PdfCase(
case_id="C-004",
category="C",
label="多页中文扫描合同 PDF(OCR第三批)",
expectation="ocr_supported",
file_name="gate-cn-multipage.pdf",
build_bytes=make_cn_multi_page_contract,
allow_ocr_statuses=["PASS", "WARN"],
require_preview=["采购合同扫描样本", "第一条 付款节点", "第三条 保密义务"],
notes="OCR 第三批:多页中文扫描合同应能进入正式审查。",
),
PdfCase(
case_id="C-005",
category="C",
label="表格型扫描 PDF(OCR第三批)",
expectation="ocr_quality_gate",
file_name="gate-table-scan.pdf",
build_bytes=make_table_scan_pdf,
allow_ocr_statuses=["WARN"],
require_message_contains="OCR 识别质量不足",
notes="OCR 第三批:表格型扫描 PDF 先纳入受控门禁,后续继续提升到正式支持。",
),
PdfCase(
case_id="C-006",
category="C",
label="盖章/签章干扰扫描 PDF(OCR第三批)",
expectation="ocr_supported",
file_name="gate-signature-scan.pdf",
build_bytes=make_signature_noise_scan_pdf,
allow_ocr_statuses=["PASS", "WARN"],
require_preview=["服务合同扫描样本", "甲方应在验收合格并收到发票后十五个工作日内付款。"],
forbid_preview=["甲方(盖章)", "乙方(签字)", "签署日期:2026年05月04日", "签署页"],
require_clause_absent=["甲方(盖章)", "乙方(签字)", "签署日期:2026年05月04日", "签署页"],
notes="OCR 第三批:签章页噪声不应污染正文摘要和条款列表。",
),
]
if sample_contract_path:
sample_path = Path(sample_contract_path)
if sample_path.exists():
cases.append(
PdfCase(
case_id="B-003",
category="B",
label="固定回归样本 sample-contract.pdf",
expectation="must_fail",
file_name=sample_path.name,
file_path=str(sample_path),
require_message_contains="无法从上传文件中提取可审查文本",
notes="按当前验收分级清单,这个样本被冻结为必须失败。",
)
)
return cases
def load_case_bytes(case: PdfCase) -> bytes:
if case.build_bytes is not None:
return case.build_bytes()
if case.file_path:
return Path(case.file_path).read_bytes()
raise RuntimeError(f"Case {case.case_id} has no content source")
def build_md(summary: Dict) -> str:
lines = [
"# PDF Acceptance Gate",
"",
f"- Status: {summary['status']}",
f"- Generated at: {summary['generated_at']}",
f"- API Base: `{summary['api_base']}`",
f"- Artifact dir: `{summary['artifact_dir']}`",
"",
"## Category Totals",
"",
"| Category | Total | Passed | Failed |",
"|---|---:|---:|---:|",
]
for category in ["A", "B", "C"]:
bucket = summary["category_totals"].get(category, {"total": 0, "passed": 0, "failed": 0})
lines.append(f"| {category} | {bucket['total']} | {bucket['passed']} | {bucket['failed']} |")
lines.extend(
[
"",
"## Case Results",
"",
"| Case | Category | Expectation | Status | Detail |",
"|---|---|---|---|---|",
]
)
for case in summary["cases"]:
detail = case.get("detail", "").replace("|", "/")
lines.append(f"| {case['case_id']} | {case['category']} | {case['expectation']} | {case['status']} | {detail} |")
return "\n".join(lines)
def normalize_noise_assertion_text(value: str) -> str:
normalized = value.lower()
bracket_map = str.maketrans(
{
"(": "(",
")": ")",
"[": "(",
"]": ")",
"【": "(",
"】": ")",
"〔": "(",
"〕": ")",
"〈": "(",
"〉": ")",
"《": "(",
"》": ")",
"{": "(",
"}": ")",
}
)
normalized = normalized.translate(bracket_map)
normalized = "".join(normalized.split())
return normalized
def register_and_login(client: httpx.Client, api_base: str, password: str) -> Dict[str, str]:
seed = int(time.time() * 1000)
username = f"pdf.gate.{seed}@example.com"
display_name = f"PDF Gate {seed}"
register = client.post(
f"{api_base}/auth/register",
json={"username": username, "password": password, "display_name": display_name},
)
register.raise_for_status()
login = client.post(
f"{api_base}/auth/login",
json={"account": username, "password": password},
)
login.raise_for_status()
login_data = login.json()["data"]
return {"username": username, "token": login_data["access_token"]}
def request_with_retry(
client: httpx.Client,
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
max_attempts: int = 4,
retry_delay_seconds: float = 1.0,
) -> httpx.Response:
last_error: Optional[Exception] = None
for attempt in range(1, max_attempts + 1):
try:
response = client.request(method, url, headers=headers)
response.raise_for_status()
return response
except (httpx.TimeoutException, httpx.ReadError, httpx.ConnectError) as exc:
last_error = exc
if attempt == max_attempts:
raise
time.sleep(retry_delay_seconds * attempt)
assert last_error is not None
raise last_error
def wait_for_review_completion(
client: httpx.Client,
api_base: str,
review_id: str,
headers: Dict[str, str],
*,
max_polls: int = 6,
poll_delay_seconds: float = 1.5,
) -> Dict:
last_detail_json: Optional[Dict] = None
for poll in range(1, max_polls + 1):
detail = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}", headers=headers)
detail_json = detail.json()["data"]
last_detail_json = detail_json
if detail_json.get("status") == "completed":
return detail_json
time.sleep(poll_delay_seconds)
assert last_detail_json is not None
return last_detail_json
def validate_success_case(client: httpx.Client, api_base: str, headers: Dict[str, str], case: PdfCase, submit_json: Dict) -> Dict:
review_id = submit_json["data"]["id"]
detail_json = wait_for_review_completion(client, api_base, review_id, headers)
result = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}/result", headers=headers)
result_json = result.json()["data"]
download = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}/report/download", headers=headers)
preview_text = detail_json.get("source_text_preview") or ""
clause_text = "\n".join(item.get("content", "") for item in result_json.get("clauses", []))
confidence_summary = result_json.get("confidence_summary") or {}
document_analysis = confidence_summary.get("document_analysis") or {}
normalized_preview_text = normalize_noise_assertion_text(preview_text)
normalized_clause_text = normalize_noise_assertion_text(clause_text)
problems: List[str] = []
if detail_json.get("status") != "completed":
problems.append(f"detail.status={detail_json.get('status')}")
if not review_id:
problems.append("missing review_id")
if not download.content:
problems.append("empty report download")
for needle in case.require_preview or []:
if needle not in preview_text:
problems.append(f"preview missing: {needle}")
for needle in case.forbid_preview or []:
if normalize_noise_assertion_text(needle) in normalized_preview_text:
problems.append(f"preview contains noise: {needle}")
for needle in case.require_clause_absent or []:
if normalize_noise_assertion_text(needle) in normalized_clause_text:
problems.append(f"clauses contain noise: {needle}")
if case.allow_ocr_statuses:
ocr_status = document_analysis.get("ocr_gate_status")
if ocr_status not in case.allow_ocr_statuses:
problems.append(f"ocr gate status {ocr_status} not in {case.allow_ocr_statuses}")
return {
"review_id": review_id,
"detail": detail_json,
"result": result_json,
"download_bytes": len(download.content),
"document_analysis": document_analysis,
"problems": problems,
}
def validate_failure_case(case: PdfCase, submit_json: Dict) -> List[str]:
problems: List[str] = []
if submit_json.get("code") != 40002:
problems.append(f"expected code=40002 got {submit_json.get('code')}")
message = submit_json.get("message", "")
if case.require_message_contains and case.require_message_contains not in message:
problems.append(f"message missing: {case.require_message_contains}")
return problems
def run_gate(api_base: str, artifact_dir: Path, sample_contract_path: Optional[str], password: str) -> Dict:
artifact_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).isoformat()
client = httpx.Client(timeout=240.0)
auth = register_and_login(client, api_base, password)
headers = {"Authorization": f"Bearer {auth['token']}"}
cases_summary = []
category_totals: Dict[str, Dict[str, int]] = {}
for case in build_cases(sample_contract_path):
payload = load_case_bytes(case)
submit = client.post(
f"{api_base}/reviews",
headers=headers,
files={"file": (case.file_name, payload, "application/pdf")},
data={"review_as": "party_a"},
)
submit.raise_for_status()
submit_json = submit.json()
case_record = {
"case_id": case.case_id,
"category": case.category,
"label": case.label,
"expectation": case.expectation,
"submit": submit_json,
"status": "PASS",
"detail": "",
"notes": case.notes,
}
problems: List[str] = []
if case.expectation == "must_succeed":
if submit_json.get("code") != 0 or not submit_json.get("data"):
problems.append(f"expected success but got code={submit_json.get('code')} message={submit_json.get('message')}")
else:
success_detail = validate_success_case(client, api_base, headers, case, submit_json)
case_record["success_detail"] = success_detail
problems.extend(success_detail["problems"])
elif case.expectation == "ocr_supported":
if submit_json.get("code") != 0 or not submit_json.get("data"):
problems.append(f"expected OCR success but got code={submit_json.get('code')} message={submit_json.get('message')}")
else:
success_detail = validate_success_case(client, api_base, headers, case, submit_json)
case_record["success_detail"] = success_detail
problems.extend(success_detail["problems"])
elif case.expectation == "ocr_quality_gate":
if submit_json.get("code") == 0 and submit_json.get("data"):
success_detail = validate_success_case(client, api_base, headers, case, submit_json)
case_record["success_detail"] = success_detail
problems.extend(success_detail["problems"])
else:
problems.extend(validate_failure_case(case, submit_json))
else:
problems.extend(validate_failure_case(case, submit_json))
if problems:
case_record["status"] = "FAIL"
case_record["detail"] = "; ".join(problems)
else:
if case.expectation in {"must_succeed", "ocr_supported", "ocr_quality_gate"} and case_record.get("success_detail"):
case_record["detail"] = f"review_id={case_record['success_detail']['review_id']}"
else:
case_record["detail"] = submit_json.get("message", "")
bucket = category_totals.setdefault(case.category, {"total": 0, "passed": 0, "failed": 0})
bucket["total"] += 1
if case_record["status"] == "PASS":
bucket["passed"] += 1
else:
bucket["failed"] += 1
cases_summary.append(case_record)
status = "PASS" if all(item["status"] == "PASS" for item in cases_summary) else "FAIL"
summary = {
"name": "pdf-acceptance-gate",
"status": status,
"generated_at": timestamp,
"api_base": api_base,
"artifact_dir": str(artifact_dir),
"registered_user": auth["username"],
"category_totals": category_totals,
"cases": cases_summary,
}
(artifact_dir / "pdf-acceptance-gate-summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(artifact_dir / "pdf-acceptance-gate-summary.md").write_text(build_md(summary), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return summary
def main() -> int:
parser = argparse.ArgumentParser(description="Run the PDF acceptance gate against the live API.")
parser.add_argument("--api-base", required=True)
parser.add_argument("--artifact-dir", required=True)
parser.add_argument("--sample-contract-path", default="")
parser.add_argument("--password", default="123456")
args = parser.parse_args()
summary = run_gate(
api_base=args.api_base.rstrip("/"),
artifact_dir=Path(args.artifact_dir),
sample_contract_path=args.sample_contract_path or None,
password=args.password,
)
return 0 if summary["status"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())