714 lines
26 KiB
Python
714 lines
26 KiB
Python
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())
|