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

1138 lines
54 KiB
Python

import argparse
import json
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
import httpx
@dataclass
class StructuredReviewCase:
case_id: str
label: str
contract_type: Optional[str]
review_as: str
file_name: str
content: str
expected_contract_type: str
expected_clause_types: List[str]
expected_missing_sections: List[str]
batch_id: str = "B-000"
source_origin: str = "deidentified_realistic"
redaction_level: str = "sanitized"
expected_amount_contains: Optional[str] = None
expected_term_start: Optional[str] = None
expected_term_end: Optional[str] = None
expected_payment_milestones: int = 0
expected_relationship_types: Optional[List[str]] = None
expected_absent_relationship_types: Optional[List[str]] = None
expected_relationship_confidence: Optional[Dict[str, str]] = None
expected_ip_owner: Optional[str] = None
expected_liability_carve_outs: Optional[List[str]] = None
CASES = [
StructuredReviewCase(
case_id="S-001",
label="服务合同结构化摘要",
contract_type="service",
review_as="party_a",
file_name="structured-service.txt",
content="\n".join(
[
"服务合同",
"甲方:甲方科技有限公司",
"乙方:乙方软件有限公司",
"合同金额:人民币500000元",
"服务期限:自2026年5月1日至2027年5月1日",
"付款条款:甲方应在验收合格并收到发票后10个工作日内付款。",
"保密条款:乙方应对商业秘密承担保密义务。",
"违约条款:乙方延迟交付应承担违约责任。",
]
),
expected_contract_type="service",
expected_clause_types=["payment", "confidentiality", "breach"],
expected_missing_sections=["争议解决条款"],
expected_amount_contains="500000",
expected_term_start="2026-05-01",
expected_term_end="2027-05-01",
expected_payment_milestones=1,
expected_relationship_types=["payment_depends_on_acceptance", "payment_depends_on_invoice"],
),
StructuredReviewCase(
case_id="S-002",
label="采购合同缺失条款提示",
contract_type="procurement",
review_as="party_a",
file_name="structured-procurement.txt",
content="\n".join(
[
"采购合同",
"甲方:甲方商贸有限公司",
"乙方:乙方供应链有限公司",
"总价:¥120000元",
"付款方式:货到后付款,具体节点另行协商。",
"交付条款:乙方应在30日内交货。",
]
),
expected_contract_type="procurement",
expected_clause_types=["payment", "delivery"],
expected_missing_sections=["验收标准或验收流程", "保密范围与期限"],
expected_amount_contains="120000",
expected_payment_milestones=1,
),
StructuredReviewCase(
case_id="S-003",
label="购销合同编号条款分段",
contract_type=None,
review_as="party_a",
file_name="structured-sales.txt",
content="\n".join(
[
"购销合同",
"甲方:甲方制造有限公司",
"乙方:乙方贸易有限公司",
"第一条 交货条款",
"乙方应在收到订单后十五日内完成交货并配合到货验收。",
"第二条 付款条款",
"甲方应在验收合格并收到增值税专用发票后七个工作日内付款。",
"第三条 争议解决",
"双方同意提交上海仲裁委员会仲裁。",
]
),
expected_contract_type="sales",
expected_clause_types=["delivery", "payment", "dispute_resolution"],
expected_missing_sections=["保密范围与期限", "违约责任安排"],
expected_payment_milestones=1,
expected_relationship_types=["payment_depends_on_acceptance", "payment_depends_on_invoice"],
),
StructuredReviewCase(
case_id="S-004",
label="租赁合同责任限制识别",
contract_type=None,
review_as="party_a",
file_name="structured-lease.txt",
content="\n".join(
[
"设备租赁合同",
"甲方:甲方设备有限公司",
"乙方:乙方工程有限公司",
"租赁期限:自2026年6月1日至2027年6月1日",
"第一条 租赁物交付",
"甲方应在合同生效后五日内完成设备交付。",
"第二条 责任限制",
"任何一方的赔偿责任以六个月租金总额为上限,但故意或重大过失除外。",
"第三条 违约责任",
"乙方逾期返还设备的,应按日承担违约责任。",
]
),
expected_contract_type="lease",
expected_clause_types=["delivery", "liability", "breach"],
expected_missing_sections=["付款与结算安排", "争议解决条款"],
expected_term_start="2026-06-01",
expected_term_end="2027-06-01",
expected_relationship_types=["liability_cap_conflicts_with_breach"],
),
StructuredReviewCase(
case_id="S-005",
label="开发合同付款节点拆解",
contract_type=None,
review_as="party_a",
file_name="structured-development.txt",
content="\n".join(
[
"软件开发合同",
"甲方:甲方信息有限公司",
"乙方:乙方科技有限公司",
"合同金额:人民币800000元",
"服务期限:自2026年7月1日至2027年1月31日",
"第一条 付款条款",
"甲方应在合同签署后5个工作日内支付首期款30%;验收合格并收到发票后10个工作日内支付尾款70%。",
"第二条 验收条款",
"乙方交付系统并完成验收后,方可进入尾款结算。",
]
),
expected_contract_type="development",
expected_clause_types=["payment", "acceptance"],
expected_missing_sections=["保密范围与期限", "违约责任安排", "争议解决条款"],
expected_amount_contains="800000",
expected_term_start="2026-07-01",
expected_term_end="2027-01-31",
expected_payment_milestones=2,
expected_relationship_types=["payment_depends_on_acceptance", "payment_depends_on_invoice"],
),
StructuredReviewCase(
case_id="S-006",
label="复杂实施合同关系深化",
contract_type=None,
review_as="party_a",
file_name="structured-implementation.txt",
content="\n".join(
[
"软件实施服务合同",
"甲方:甲方集团有限公司",
"乙方:乙方实施服务有限公司",
"合同金额:人民币1500000元",
"服务期限:自2026年8月1日至2027年8月1日",
"第一条 付款条款",
"甲方应在合同签署后5个工作日内支付预付款20%;系统验收合格并收到增值税专用发票后10个工作日内支付实施款50%;系统上线稳定运行30日后支付尾款20%;剩余10%作为质保金,在质保期满后10个工作日内支付。",
"第二条 验收条款",
"乙方完成实施并通过甲方书面验收后,方可进入后续付款节点。",
"第三条 保密条款",
"乙方应对甲方商业秘密、客户数据和项目资料承担严格保密义务。",
"第四条 知识产权",
"项目交付成果、源代码及相关文档的知识产权归甲方所有。",
"第五条 责任限制",
"任何一方因本合同产生的赔偿责任以合同总金额为上限,但故意或重大过失除外。",
"第六条 违约责任",
"乙方延迟交付、泄露保密信息或侵犯第三方知识产权的,应承担违约责任并赔偿损失。",
]
),
expected_contract_type="service",
expected_clause_types=["payment", "acceptance", "confidentiality", "intellectual_property", "liability", "breach"],
expected_missing_sections=["争议解决条款"],
expected_amount_contains="1500000",
expected_term_start="2026-08-01",
expected_term_end="2027-08-01",
expected_payment_milestones=4,
expected_relationship_types=[
"payment_depends_on_acceptance",
"payment_depends_on_invoice",
"payment_depends_on_go_live",
"payment_after_warranty_period",
"liability_cap_conflicts_with_breach",
"liability_cap_conflicts_with_confidentiality",
"liability_cap_conflicts_with_ip",
],
expected_relationship_confidence={
"payment_depends_on_acceptance": "high",
"payment_after_warranty_period": "high",
"liability_cap_conflicts_with_ip": "high",
},
expected_ip_owner="party_a",
),
StructuredReviewCase(
case_id="S-007",
label="实施合同责任限制含 carve-out",
contract_type=None,
review_as="party_a",
file_name="structured-carveout.txt",
content="\n".join(
[
"软件实施服务合同",
"甲方:甲方集团有限公司",
"乙方:乙方实施服务有限公司",
"合同金额:人民币960000元",
"第一条 付款条款",
"甲方应在验收合格并收到发票后15个工作日内支付全部合同款。",
"第二条 保密条款",
"乙方应对甲方商业秘密承担保密义务。",
"第三条 知识产权",
"项目交付成果、源代码及相关文档的知识产权归甲方所有。",
"第四条 责任限制",
"任何一方的赔偿责任以合同总金额为上限,但保密义务、知识产权侵权、故意或重大过失除外。",
"第五条 违约责任",
"乙方违反保密义务或侵犯知识产权的,应承担违约责任并赔偿损失。",
]
),
expected_contract_type="service",
expected_clause_types=["payment", "confidentiality", "intellectual_property", "liability", "breach"],
expected_missing_sections=["验收标准或验收流程", "争议解决条款"],
expected_amount_contains="960000",
expected_payment_milestones=1,
expected_relationship_types=["payment_depends_on_acceptance", "payment_depends_on_invoice", "liability_cap_conflicts_with_breach"],
expected_absent_relationship_types=["liability_cap_conflicts_with_confidentiality", "liability_cap_conflicts_with_ip"],
expected_relationship_confidence={"liability_cap_conflicts_with_breach": "weak"},
expected_ip_owner="party_a",
expected_liability_carve_outs=["confidentiality", "intellectual_property", "intentional_misconduct", "gross_negligence"],
),
StructuredReviewCase(
case_id="S-008",
label="弱提示付款依赖样本",
contract_type=None,
review_as="party_a",
file_name="structured-weak-acceptance.txt",
content="\n".join(
[
"技术服务合同",
"甲方:甲方科技有限公司",
"乙方:乙方顾问有限公司",
"合同金额:人民币300000元",
"第一条 付款条款",
"甲方应在验收合格后尽快支付尾款100%。",
"第二条 服务内容",
"乙方负责提供咨询与优化建议。",
]
),
expected_contract_type="service",
expected_clause_types=["payment"],
expected_missing_sections=["验收标准或验收流程", "保密范围与期限", "违约责任安排", "争议解决条款"],
expected_amount_contains="300000",
expected_payment_milestones=1,
expected_relationship_types=["payment_depends_on_acceptance"],
expected_relationship_confidence={"payment_depends_on_acceptance": "weak"},
),
StructuredReviewCase(
case_id="S-009",
label="软件许可协议归属与许可关系",
contract_type=None,
review_as="party_a",
file_name="structured-license.txt",
content="\n".join(
[
"软件许可协议",
"甲方:甲方制造有限公司",
"乙方:乙方软件有限公司",
"合同金额:人民币420000元",
"服务期限:自2026年9月1日至2027年8月31日",
"第一条 付款条款",
"甲方应在合同签署后5个工作日内支付首期许可费50%;收到发票后10个工作日内支付剩余许可费50%。",
"第二条 知识产权",
"软件著作权、源代码及相关知识产权归乙方所有,甲方在许可期内享有非独占使用权。",
"第三条 责任限制",
"任何一方的赔偿责任以合同总金额为上限,但故意、重大过失和知识产权侵权除外。",
]
),
expected_contract_type="license",
expected_clause_types=["payment", "intellectual_property", "liability"],
expected_missing_sections=["验收标准或验收流程", "保密范围与期限", "争议解决条款"],
expected_amount_contains="420000",
expected_term_start="2026-09-01",
expected_term_end="2027-08-31",
expected_payment_milestones=2,
expected_relationship_types=["payment_depends_on_invoice"],
expected_absent_relationship_types=["liability_cap_conflicts_with_ip"],
expected_ip_owner="party_b",
expected_liability_carve_outs=["intentional_misconduct", "gross_negligence", "intellectual_property"],
),
StructuredReviewCase(
case_id="S-010",
label="委托服务合同多节点验收关系",
contract_type=None,
review_as="party_a",
file_name="structured-entrustment.txt",
content="\n".join(
[
"委托服务合同",
"甲方:甲方运营有限公司",
"乙方:乙方咨询有限公司",
"合同金额:人民币560000元",
"合同期限:自2026年10月1日至2027年3月31日",
"第一条 付款条款",
"甲方应在合同签署后5个工作日内支付预付款30%;阶段成果验收合格后7个工作日内支付进度款40%;项目最终验收合格并收到发票后10个工作日内支付尾款30%。",
"第二条 验收条款",
"乙方提交阶段成果和最终成果后,应由甲方书面验收确认。",
"第三条 保密条款",
"乙方应对项目资料、商业秘密和客户信息承担保密义务。",
"第四条 争议解决",
"双方同意提交甲方所在地人民法院管辖。",
]
),
expected_contract_type="entrustment",
expected_clause_types=["payment", "acceptance", "confidentiality", "dispute_resolution"],
expected_missing_sections=["违约责任安排"],
expected_amount_contains="560000",
expected_term_start="2026-10-01",
expected_term_end="2027-03-31",
expected_payment_milestones=3,
expected_relationship_types=["payment_depends_on_acceptance", "payment_depends_on_invoice"],
expected_relationship_confidence={"payment_depends_on_acceptance": "high"},
),
]
SAMPLE_MANIFEST_PATH = Path(__file__).resolve().parents[2] / "project-docs" / "structured-review-samples" / "manifest.json"
BASELINE_ROOT = SAMPLE_MANIFEST_PATH.parent / "baselines"
BASELINE_SNAPSHOTS_DIR = BASELINE_ROOT / "snapshots"
BASELINE_HISTORY_JSONL = BASELINE_ROOT / "review-structured-baseline-history.jsonl"
def load_cases_from_manifest(manifest_path: Path) -> List[StructuredReviewCase]:
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_dir = manifest_path.parent
cases: List[StructuredReviewCase] = []
for entry in payload.get("samples", []):
content_file = entry.pop("content_file")
content = (manifest_dir / content_file).read_text(encoding="utf-8")
cases.append(StructuredReviewCase(content=content, **entry))
return cases
CASES = load_cases_from_manifest(SAMPLE_MANIFEST_PATH)
def register_and_login(client: httpx.Client, api_base: str, password: str) -> Dict[str, str]:
seed = int(time.time() * 1000)
username = f"structured.gate.{seed}@example.com"
display_name = f"Structured 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 create_review(client: httpx.Client, api_base: str, token: str, case: StructuredReviewCase) -> Dict:
files = {
"file": (case.file_name, case.content.encode("utf-8"), "text/plain"),
}
data = {
"review_as": case.review_as,
}
if case.contract_type:
data["contract_type"] = case.contract_type
response = client.post(
f"{api_base}/reviews",
headers={"Authorization": f"Bearer {token}"},
data=data,
files=files,
)
response.raise_for_status()
return response.json()
def fetch_review_result(client: httpx.Client, api_base: str, token: str, review_id: str) -> Dict:
response = client.get(
f"{api_base}/reviews/{review_id}/result",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
return response.json()["data"]
def validate_case(case: StructuredReviewCase, result: Dict) -> List[str]:
problems: List[str] = []
contract_structure = result.get("contract_structure") or {}
clauses = result.get("clauses") or []
risk_items = result.get("risk_items") or []
confidence_summary = result.get("confidence_summary") or {}
structured_analysis = confidence_summary.get("structured_analysis") or {}
if contract_structure.get("detected_contract_type") != case.expected_contract_type:
problems.append(
f"detected_contract_type={contract_structure.get('detected_contract_type')} expected={case.expected_contract_type}"
)
amount_text = contract_structure.get("amount") or ""
if case.expected_amount_contains and case.expected_amount_contains not in amount_text:
problems.append(f"amount missing expected fragment: {case.expected_amount_contains}")
term_details = contract_structure.get("term_details") or {}
if case.expected_term_start and term_details.get("start_date") != case.expected_term_start:
problems.append(f"term start={term_details.get('start_date')} expected={case.expected_term_start}")
if case.expected_term_end and term_details.get("end_date") != case.expected_term_end:
problems.append(f"term end={term_details.get('end_date')} expected={case.expected_term_end}")
clause_types = {item.get("clause_type") for item in clauses if item.get("clause_type")}
for expected_type in case.expected_clause_types:
if expected_type not in clause_types:
problems.append(f"missing clause type: {expected_type}")
missing_sections = set(contract_structure.get("missing_sections") or [])
for expected_section in case.expected_missing_sections:
if expected_section not in missing_sections:
problems.append(f"missing section hint not found: {expected_section}")
if contract_structure.get("clause_count", 0) <= 0:
problems.append("contract_structure.clause_count <= 0")
if not contract_structure.get("summary"):
problems.append("missing contract_structure.summary")
if not structured_analysis.get("stage"):
problems.append("missing confidence_summary.structured_analysis.stage")
if case.expected_payment_milestones and structured_analysis.get("payment_milestone_count", 0) < case.expected_payment_milestones:
problems.append(
f"payment_milestone_count={structured_analysis.get('payment_milestone_count')} expected>={case.expected_payment_milestones}"
)
relationships = contract_structure.get("clause_relationships") or []
relationship_types = {item.get("type") for item in relationships if item.get("type")}
for expected_type in case.expected_relationship_types or []:
if expected_type not in relationship_types:
problems.append(f"missing relationship type: {expected_type}")
for absent_type in case.expected_absent_relationship_types or []:
if absent_type in relationship_types:
problems.append(f"unexpected relationship type: {absent_type}")
confidence_map = {item.get("type"): item.get("confidence_tier") for item in relationships if item.get("type")}
for relationship_type, expected_confidence in (case.expected_relationship_confidence or {}).items():
if confidence_map.get(relationship_type) != expected_confidence:
problems.append(
f"relationship {relationship_type} confidence={confidence_map.get(relationship_type)} expected={expected_confidence}"
)
if case.expected_ip_owner and contract_structure.get("intellectual_property_owner") != case.expected_ip_owner:
problems.append(
f"intellectual_property_owner={contract_structure.get('intellectual_property_owner')} expected={case.expected_ip_owner}"
)
carve_outs = set(contract_structure.get("liability_carve_outs") or [])
for expected_carve_out in case.expected_liability_carve_outs or []:
if expected_carve_out not in carve_outs:
problems.append(f"missing liability carve-out: {expected_carve_out}")
if risk_items:
first_risk = risk_items[0]
evidence = first_risk.get("evidence") or {}
if not evidence.get("quote"):
problems.append("first risk item missing evidence.quote")
if not first_risk.get("certainty"):
problems.append("first risk item missing certainty")
return problems
def build_field_baseline(summary: Dict) -> List[Dict[str, object]]:
rows: List[Dict[str, object]] = []
def append_metric(metric: str, passed: int, total: int):
rows.append(
{
"metric": metric,
"passed": passed,
"total": total,
"accuracy": round((passed / total) * 100, 2) if total else 0.0,
}
)
contract_type_pass = 0
contract_type_total = 0
amount_pass = 0
amount_total = 0
term_start_pass = 0
term_start_total = 0
term_end_pass = 0
term_end_total = 0
payment_milestone_pass = 0
payment_milestone_total = 0
ip_owner_pass = 0
ip_owner_total = 0
liability_carve_out_pass = 0
liability_carve_out_total = 0
clause_type_pass = 0
clause_type_total = 0
for item in summary["cases"]:
if item.get("status") == "FAIL" and "result" not in item:
continue
result = item.get("result") or {}
contract_structure = result.get("contract_structure") or {}
case = next(case for case in CASES if case.case_id == item["case_id"])
contract_type_total += 1
if contract_structure.get("detected_contract_type") == case.expected_contract_type:
contract_type_pass += 1
if case.expected_amount_contains:
amount_total += 1
if case.expected_amount_contains in (contract_structure.get("amount") or ""):
amount_pass += 1
if case.expected_term_start:
term_start_total += 1
if (contract_structure.get("term_details") or {}).get("start_date") == case.expected_term_start:
term_start_pass += 1
if case.expected_term_end:
term_end_total += 1
if (contract_structure.get("term_details") or {}).get("end_date") == case.expected_term_end:
term_end_pass += 1
if case.expected_payment_milestones:
payment_milestone_total += 1
if len(contract_structure.get("payment_milestones") or []) >= case.expected_payment_milestones:
payment_milestone_pass += 1
if case.expected_ip_owner:
ip_owner_total += 1
if contract_structure.get("intellectual_property_owner") == case.expected_ip_owner:
ip_owner_pass += 1
for expected_type in case.expected_clause_types:
clause_type_total += 1
if expected_type in {entry.get("clause_type") for entry in result.get("clauses") or [] if entry.get("clause_type")}:
clause_type_pass += 1
for expected_carve_out in case.expected_liability_carve_outs or []:
liability_carve_out_total += 1
if expected_carve_out in set(contract_structure.get("liability_carve_outs") or []):
liability_carve_out_pass += 1
append_metric("contract_type", contract_type_pass, contract_type_total)
append_metric("amount", amount_pass, amount_total)
append_metric("term_start", term_start_pass, term_start_total)
append_metric("term_end", term_end_pass, term_end_total)
append_metric("payment_milestones", payment_milestone_pass, payment_milestone_total)
append_metric("clause_types", clause_type_pass, clause_type_total)
append_metric("intellectual_property_owner", ip_owner_pass, ip_owner_total)
append_metric("liability_carve_outs", liability_carve_out_pass, liability_carve_out_total)
return rows
def build_relationship_baseline(summary: Dict) -> List[Dict[str, object]]:
rows: List[Dict[str, object]] = []
presence_pass = 0
presence_total = 0
absence_pass = 0
absence_total = 0
confidence_pass = 0
confidence_total = 0
high_tier_total = 0
weak_tier_total = 0
for item in summary["cases"]:
result = item.get("result") or {}
relationships = result.get("contract_structure", {}).get("clause_relationships") or []
relationship_types = {entry.get("type") for entry in relationships if entry.get("type")}
confidence_map = {entry.get("type"): entry.get("confidence_tier") for entry in relationships if entry.get("type")}
case = next(case for case in CASES if case.case_id == item["case_id"])
for expected_type in case.expected_relationship_types or []:
presence_total += 1
if expected_type in relationship_types:
presence_pass += 1
for absent_type in case.expected_absent_relationship_types or []:
absence_total += 1
if absent_type not in relationship_types:
absence_pass += 1
for relationship_type, expected_confidence in (case.expected_relationship_confidence or {}).items():
confidence_total += 1
if expected_confidence == "high":
high_tier_total += 1
if expected_confidence == "weak":
weak_tier_total += 1
if confidence_map.get(relationship_type) == expected_confidence:
confidence_pass += 1
rows.append({"metric": "relationship_presence", "passed": presence_pass, "total": presence_total, "accuracy": round((presence_pass / presence_total) * 100, 2) if presence_total else 0.0})
rows.append({"metric": "relationship_absence", "passed": absence_pass, "total": absence_total, "accuracy": round((absence_pass / absence_total) * 100, 2) if absence_total else 0.0})
rows.append({"metric": "relationship_confidence", "passed": confidence_pass, "total": confidence_total, "accuracy": round((confidence_pass / confidence_total) * 100, 2) if confidence_total else 0.0})
rows.append({"metric": "relationship_high_confidence_expected", "passed": high_tier_total, "total": high_tier_total, "accuracy": 100.0 if high_tier_total else 0.0})
rows.append({"metric": "relationship_weak_hint_expected", "passed": weak_tier_total, "total": weak_tier_total, "accuracy": 100.0 if weak_tier_total else 0.0})
return rows
def build_field_baseline_by_contract(summary: Dict) -> List[Dict[str, object]]:
rows: List[Dict[str, object]] = []
grouped: Dict[str, Dict[str, int]] = {}
for item in summary["cases"]:
result = item.get("result") or {}
contract_structure = result.get("contract_structure") or {}
case = next(case for case in CASES if case.case_id == item["case_id"])
family = case.expected_contract_type
bucket = grouped.setdefault(family, {"contract_type_pass": 0, "contract_type_total": 0, "payment_pass": 0, "payment_total": 0, "relationship_pass": 0, "relationship_total": 0})
bucket["contract_type_total"] += 1
if contract_structure.get("detected_contract_type") == case.expected_contract_type:
bucket["contract_type_pass"] += 1
if case.expected_payment_milestones:
bucket["payment_total"] += 1
if len(contract_structure.get("payment_milestones") or []) >= case.expected_payment_milestones:
bucket["payment_pass"] += 1
if case.expected_relationship_types:
bucket["relationship_total"] += len(case.expected_relationship_types)
relationship_types = {entry.get("type") for entry in contract_structure.get("clause_relationships") or [] if entry.get("type")}
bucket["relationship_pass"] += sum(1 for relationship_type in case.expected_relationship_types if relationship_type in relationship_types)
for family, bucket in grouped.items():
rows.append(
{
"contract_family": family,
"contract_type_passed": bucket["contract_type_pass"],
"contract_type_total": bucket["contract_type_total"],
"contract_type_accuracy": round((bucket["contract_type_pass"] / bucket["contract_type_total"]) * 100, 2) if bucket["contract_type_total"] else 0.0,
"payment_milestone_passed": bucket["payment_pass"],
"payment_milestone_total": bucket["payment_total"],
"payment_milestone_accuracy": round((bucket["payment_pass"] / bucket["payment_total"]) * 100, 2) if bucket["payment_total"] else 0.0,
"relationship_presence_passed": bucket["relationship_pass"],
"relationship_presence_total": bucket["relationship_total"],
"relationship_presence_accuracy": round((bucket["relationship_pass"] / bucket["relationship_total"]) * 100, 2) if bucket["relationship_total"] else 0.0,
}
)
return rows
def build_field_baseline_by_batch(summary: Dict) -> List[Dict[str, object]]:
rows: List[Dict[str, object]] = []
grouped: Dict[str, Dict[str, int]] = {}
for item in summary["cases"]:
result = item.get("result") or {}
contract_structure = result.get("contract_structure") or {}
case = next(case for case in CASES if case.case_id == item["case_id"])
bucket = grouped.setdefault(case.batch_id, {"contract_type_pass": 0, "contract_type_total": 0, "payment_pass": 0, "payment_total": 0})
bucket["contract_type_total"] += 1
if contract_structure.get("detected_contract_type") == case.expected_contract_type:
bucket["contract_type_pass"] += 1
if case.expected_payment_milestones:
bucket["payment_total"] += 1
if len(contract_structure.get("payment_milestones") or []) >= case.expected_payment_milestones:
bucket["payment_pass"] += 1
for batch_id, bucket in grouped.items():
rows.append(
{
"batch_id": batch_id,
"contract_type_passed": bucket["contract_type_pass"],
"contract_type_total": bucket["contract_type_total"],
"contract_type_accuracy": round((bucket["contract_type_pass"] / bucket["contract_type_total"]) * 100, 2) if bucket["contract_type_total"] else 0.0,
"payment_milestone_passed": bucket["payment_pass"],
"payment_milestone_total": bucket["payment_total"],
"payment_milestone_accuracy": round((bucket["payment_pass"] / bucket["payment_total"]) * 100, 2) if bucket["payment_total"] else 0.0,
}
)
return rows
def build_relationship_baseline_by_type(summary: Dict) -> List[Dict[str, object]]:
rows: List[Dict[str, object]] = []
grouped: Dict[str, Dict[str, int]] = {}
for item in summary["cases"]:
result = item.get("result") or {}
relationships = result.get("contract_structure", {}).get("clause_relationships") or []
relationship_types = {entry.get("type") for entry in relationships if entry.get("type")}
confidence_map = {entry.get("type"): entry.get("confidence_tier") for entry in relationships if entry.get("type")}
case = next(case for case in CASES if case.case_id == item["case_id"])
for relationship_type in case.expected_relationship_types or []:
bucket = grouped.setdefault(relationship_type, {"present_pass": 0, "present_total": 0, "confidence_pass": 0, "confidence_total": 0})
bucket["present_total"] += 1
if relationship_type in relationship_types:
bucket["present_pass"] += 1
expected_confidence = (case.expected_relationship_confidence or {}).get(relationship_type)
if expected_confidence:
bucket["confidence_total"] += 1
if confidence_map.get(relationship_type) == expected_confidence:
bucket["confidence_pass"] += 1
for relationship_type, bucket in grouped.items():
rows.append(
{
"relationship_type": relationship_type,
"presence_passed": bucket["present_pass"],
"presence_total": bucket["present_total"],
"presence_accuracy": round((bucket["present_pass"] / bucket["present_total"]) * 100, 2) if bucket["present_total"] else 0.0,
"confidence_passed": bucket["confidence_pass"],
"confidence_total": bucket["confidence_total"],
"confidence_accuracy": round((bucket["confidence_pass"] / bucket["confidence_total"]) * 100, 2) if bucket["confidence_total"] else 0.0,
}
)
return rows
def build_sample_inventory() -> List[Dict[str, object]]:
return [
{
"case_id": case.case_id,
"batch_id": case.batch_id,
"label": case.label,
"contract_family": case.expected_contract_type,
"source_origin": case.source_origin,
"redaction_level": case.redaction_level,
"expected_relationships": len(case.expected_relationship_types or []),
"expected_payment_milestones": case.expected_payment_milestones,
}
for case in CASES
]
def build_md(summary: Dict) -> str:
lines = [
"# Review Structured Acceptance Gate",
"",
f"- Status: {summary['status']}",
f"- Generated at: {summary['generated_at']}",
f"- API Base: `{summary['api_base']}`",
f"- Artifact dir: `{summary['artifact_dir']}`",
"",
"| Case | Status | Detail |",
"|---|---|---|",
]
for case in summary["cases"]:
lines.append(f"| {case['case_id']} | {case['status']} | {case['detail'].replace('|', '/')} |")
return "\n".join(lines)
def build_baseline_md(field_rows: List[Dict[str, object]], relationship_rows: List[Dict[str, object]]) -> str:
lines = [
"# Structured Accuracy Baseline",
"",
"## Field Accuracy",
"",
"| Metric | Passed | Total | Accuracy |",
"|---|---:|---:|---:|",
]
for row in field_rows:
lines.append(f"| {row['metric']} | {row['passed']} | {row['total']} | {row['accuracy']}% |")
lines.extend(
[
"",
"## Relationship Accuracy",
"",
"| Metric | Passed | Total | Accuracy |",
"|---|---:|---:|---:|",
]
)
for row in relationship_rows:
lines.append(f"| {row['metric']} | {row['passed']} | {row['total']} | {row['accuracy']}% |")
return "\n".join(lines)
def build_dashboard_html(
summary: Dict[str, object],
field_rows: List[Dict[str, object]],
relationship_rows: List[Dict[str, object]],
contract_rows: List[Dict[str, object]],
batch_rows: List[Dict[str, object]],
relationship_type_rows: List[Dict[str, object]],
) -> str:
data = {
"generated_at": summary["generated_at"],
"sample_count": len(summary.get("sample_inventory") or []),
"field_rows": field_rows,
"relationship_rows": relationship_rows,
"contract_rows": contract_rows,
"batch_rows": batch_rows,
"relationship_type_rows": relationship_type_rows,
}
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Structured Review Baseline Dashboard</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 0; padding: 24px; background: #f5f7fb; color: #111827; }}
h1, h2 {{ margin: 0 0 12px; }}
p {{ margin: 0 0 8px; color: #475569; }}
.grid {{ display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin-bottom: 24px; }}
.card {{ background: #fff; border: 1px solid #dbe3f0; border-radius: 12px; padding: 16px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); }}
.metric {{ font-size: 28px; font-weight: 700; margin-top: 8px; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }}
th, td {{ padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; font-size: 14px; }}
th {{ background: #f8fafc; }}
.section {{ margin-bottom: 24px; }}
.bar-wrap {{ background: #eef2ff; border-radius: 999px; height: 10px; overflow: hidden; }}
.bar {{ height: 10px; background: linear-gradient(90deg, #2563eb, #22c55e); border-radius: 999px; }}
.muted {{ color: #64748b; font-size: 12px; }}
</style>
</head>
<body>
<h1>Structured Review Baseline Dashboard</h1>
<p>Latest snapshot: {summary["generated_at"]}</p>
<div class="grid">
<div class="card"><div class="muted">Samples</div><div class="metric">{len(summary.get("sample_inventory") or [])}</div></div>
<div class="card"><div class="muted">Field Metrics</div><div class="metric">{len(field_rows)}</div></div>
<div class="card"><div class="muted">Relationship Metrics</div><div class="metric">{len(relationship_rows)}</div></div>
<div class="card"><div class="muted">Contract Families</div><div class="metric">{len(contract_rows)}</div></div>
</div>
<div id="app" data-json='{json.dumps(data, ensure_ascii=False).replace("'", "&apos;")}'></div>
<script>
const host = document.getElementById('app');
const data = JSON.parse(host.dataset.json);
function table(title, keyField, rows, metrics) {{
let html = `<div class="section"><h2>${{title}}</h2><table><thead><tr><th>${{keyField}}</th>`;
for (const metric of metrics) html += `<th>${{metric}}</th><th>Trend</th>`;
html += `</tr></thead><tbody>`;
for (const row of rows) {{
html += `<tr><td>${{row[keyField] ?? row.metric}}</td>`;
for (const metric of metrics) {{
const value = row[metric];
const numeric = typeof value === 'number' ? value : parseFloat(value);
const safe = Number.isFinite(numeric) ? numeric : 0;
html += `<td>${{value}}</td><td><div class="bar-wrap"><div class="bar" style="width:${{Math.max(0, Math.min(100, safe))}}%"></div></div></td>`;
}}
html += `</tr>`;
}}
html += `</tbody></table></div>`;
return html;
}}
host.innerHTML =
table('Field Accuracy', 'metric', data.field_rows, ['accuracy']) +
table('Relationship Accuracy', 'metric', data.relationship_rows, ['accuracy']) +
table('By Contract Family', 'contract_family', data.contract_rows, ['contract_type_accuracy','payment_milestone_accuracy','relationship_presence_accuracy']) +
table('By Batch', 'batch_id', data.batch_rows, ['contract_type_accuracy','payment_milestone_accuracy']) +
table('By Relationship Type', 'relationship_type', data.relationship_type_rows, ['presence_accuracy','confidence_accuracy']);
</script>
</body>
</html>"""
def write_csv(path: Path, rows: List[Dict[str, object]]) -> None:
if not rows:
path.write_text("", encoding="utf-8")
return
headers = list(rows[0].keys())
lines = [",".join(headers)]
for row in rows:
values = []
for header in headers:
value = row.get(header, "")
text = str(value).replace('"', '""')
if "," in text or '"' in text or "\n" in text:
text = f'"{text}"'
values.append(text)
lines.append(",".join(values))
path.write_text("\n".join(lines), encoding="utf-8")
def index_rows(rows: List[Dict[str, object]], key_field: str) -> Dict[str, Dict[str, object]]:
return {str(row[key_field]): row for row in rows if key_field in row}
def build_trend_rows(
current_rows: List[Dict[str, object]],
previous_rows: List[Dict[str, object]],
key_field: str,
metric_fields: List[str],
) -> List[Dict[str, object]]:
previous_index = index_rows(previous_rows, key_field)
trend_rows: List[Dict[str, object]] = []
for row in current_rows:
key = str(row[key_field])
previous_row = previous_index.get(key, {})
trend_row: Dict[str, object] = {key_field: key}
for metric_field in metric_fields:
current_value = row.get(metric_field)
previous_value = previous_row.get(metric_field)
trend_row[f"current_{metric_field}"] = current_value
trend_row[f"previous_{metric_field}"] = previous_value if previous_value is not None else "N/A"
if isinstance(current_value, (int, float)) and isinstance(previous_value, (int, float)):
trend_row[f"delta_{metric_field}"] = round(float(current_value) - float(previous_value), 2)
else:
trend_row[f"delta_{metric_field}"] = "N/A"
trend_rows.append(trend_row)
return trend_rows
def load_previous_snapshot(baseline_root: Path = BASELINE_ROOT) -> Optional[Dict[str, object]]:
latest_path = baseline_root / "latest-summary.json"
if not latest_path.exists():
return None
return json.loads(latest_path.read_text(encoding="utf-8"))
def persist_baseline_history(summary: Dict[str, object], baseline_root: Path = BASELINE_ROOT) -> None:
snapshots_dir = baseline_root / "snapshots"
history_jsonl = baseline_root / "review-structured-baseline-history.jsonl"
baseline_root.mkdir(parents=True, exist_ok=True)
snapshots_dir.mkdir(parents=True, exist_ok=True)
previous_snapshot = load_previous_snapshot(baseline_root)
generated_at = str(summary["generated_at"])
safe_stamp = generated_at.replace(":", "-").replace(".", "-")
snapshot_payload = {
"generated_at": generated_at,
"artifact_dir": summary["artifact_dir"],
"sample_count": len(summary.get("sample_inventory") or []),
"field_baseline": summary.get("field_baseline") or [],
"relationship_baseline": summary.get("relationship_baseline") or [],
"field_baseline_by_contract": summary.get("field_baseline_by_contract") or [],
"field_baseline_by_batch": summary.get("field_baseline_by_batch") or [],
"relationship_baseline_by_type": summary.get("relationship_baseline_by_type") or [],
"sample_inventory": summary.get("sample_inventory") or [],
}
snapshot_path = snapshots_dir / f"{safe_stamp}-review-structured-baseline-snapshot.json"
snapshot_path.write_text(json.dumps(snapshot_payload, ensure_ascii=False, indent=2), encoding="utf-8")
history_row = {
"generated_at": generated_at,
"artifact_dir": summary["artifact_dir"],
"snapshot_path": str(snapshot_path),
"sample_count": len(summary.get("sample_inventory") or []),
"field_metric_count": len(summary.get("field_baseline") or []),
"relationship_metric_count": len(summary.get("relationship_baseline") or []),
}
with history_jsonl.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(history_row, ensure_ascii=False) + "\n")
latest_path = baseline_root / "latest-summary.json"
latest_path.write_text(json.dumps(snapshot_payload, ensure_ascii=False, indent=2), encoding="utf-8")
write_csv(baseline_root / "latest-field-baseline.csv", summary.get("field_baseline") or [])
write_csv(baseline_root / "latest-relationship-baseline.csv", summary.get("relationship_baseline") or [])
write_csv(baseline_root / "latest-field-baseline-by-contract.csv", summary.get("field_baseline_by_contract") or [])
write_csv(baseline_root / "latest-field-baseline-by-batch.csv", summary.get("field_baseline_by_batch") or [])
write_csv(baseline_root / "latest-relationship-baseline-by-type.csv", summary.get("relationship_baseline_by_type") or [])
write_csv(baseline_root / "latest-sample-inventory.csv", summary.get("sample_inventory") or [])
field_trend_rows = build_trend_rows(
summary.get("field_baseline") or [],
(previous_snapshot or {}).get("field_baseline") or [],
"metric",
["accuracy"],
)
relationship_trend_rows = build_trend_rows(
summary.get("relationship_baseline") or [],
(previous_snapshot or {}).get("relationship_baseline") or [],
"metric",
["accuracy"],
)
contract_trend_rows = build_trend_rows(
summary.get("field_baseline_by_contract") or [],
(previous_snapshot or {}).get("field_baseline_by_contract") or [],
"contract_family",
["contract_type_accuracy", "payment_milestone_accuracy", "relationship_presence_accuracy"],
)
batch_trend_rows = build_trend_rows(
summary.get("field_baseline_by_batch") or [],
(previous_snapshot or {}).get("field_baseline_by_batch") or [],
"batch_id",
["contract_type_accuracy", "payment_milestone_accuracy"],
)
relationship_type_trend_rows = build_trend_rows(
summary.get("relationship_baseline_by_type") or [],
(previous_snapshot or {}).get("relationship_baseline_by_type") or [],
"relationship_type",
["presence_accuracy", "confidence_accuracy"],
)
write_csv(baseline_root / "baseline-trend-overall.csv", field_trend_rows)
write_csv(baseline_root / "baseline-trend-relationship-overall.csv", relationship_trend_rows)
write_csv(baseline_root / "baseline-trend-by-contract.csv", contract_trend_rows)
write_csv(baseline_root / "baseline-trend-by-batch.csv", batch_trend_rows)
write_csv(baseline_root / "baseline-trend-by-relationship-type.csv", relationship_type_trend_rows)
dashboard_html = build_dashboard_html(
summary,
summary.get("field_baseline") or [],
summary.get("relationship_baseline") or [],
summary.get("field_baseline_by_contract") or [],
summary.get("field_baseline_by_batch") or [],
summary.get("relationship_baseline_by_type") or [],
)
(baseline_root / "baseline-trend-dashboard.html").write_text(dashboard_html, encoding="utf-8")
trend_summary_md = baseline_root / "baseline-trend-summary.md"
lines = [
"# Structured Baseline Trend Summary",
"",
f"- Latest snapshot: `{generated_at}`",
f"- Sample count: `{len(summary.get('sample_inventory') or [])}`",
f"- Previous snapshot: `{(previous_snapshot or {}).get('generated_at') or 'N/A'}`",
"",
"Generated trend files:",
"",
"1. `baseline-trend-overall.csv`",
"2. `baseline-trend-relationship-overall.csv`",
"3. `baseline-trend-by-contract.csv`",
"4. `baseline-trend-by-batch.csv`",
"5. `baseline-trend-by-relationship-type.csv`",
]
trend_summary_md.write_text("\n".join(lines), encoding="utf-8")
def run_gate(api_base: str, artifact_dir: Path, password: str) -> Dict:
artifact_dir.mkdir(parents=True, exist_ok=True)
client = httpx.Client(timeout=120.0)
auth = register_and_login(client, api_base, password)
summary = {
"name": "review-structured-acceptance-gate",
"status": "PASS",
"generated_at": datetime.now(timezone.utc).isoformat(),
"api_base": api_base,
"artifact_dir": str(artifact_dir),
"registered_user": auth["username"],
"cases": [],
}
for case in CASES:
submit_json = create_review(client, api_base, auth["token"], case)
if submit_json.get("code") != 0:
summary["status"] = "FAIL"
summary["cases"].append(
{
"case_id": case.case_id,
"label": case.label,
"status": "FAIL",
"detail": submit_json.get("message") or "create review failed",
}
)
continue
review_id = submit_json["data"]["id"]
result = fetch_review_result(client, api_base, auth["token"], review_id)
problems = validate_case(case, result)
case_status = "PASS" if not problems else "FAIL"
if problems:
summary["status"] = "FAIL"
summary["cases"].append(
{
"case_id": case.case_id,
"label": case.label,
"status": case_status,
"detail": "; ".join(problems) if problems else f"review_id={review_id}",
"review_id": review_id,
"result": result,
}
)
field_baseline = build_field_baseline(summary)
relationship_baseline = build_relationship_baseline(summary)
field_baseline_by_contract = build_field_baseline_by_contract(summary)
field_baseline_by_batch = build_field_baseline_by_batch(summary)
relationship_baseline_by_type = build_relationship_baseline_by_type(summary)
sample_inventory = build_sample_inventory()
summary["field_baseline"] = field_baseline
summary["relationship_baseline"] = relationship_baseline
summary["field_baseline_by_contract"] = field_baseline_by_contract
summary["field_baseline_by_batch"] = field_baseline_by_batch
summary["relationship_baseline_by_type"] = relationship_baseline_by_type
summary["sample_inventory"] = sample_inventory
return summary
def main() -> int:
parser = argparse.ArgumentParser(description="Run structured review acceptance gate against the reviews API.")
parser.add_argument("--api-base", required=True)
parser.add_argument("--artifact-dir", required=True)
parser.add_argument("--password", default="123456")
parser.add_argument("--baseline-root", default=str(BASELINE_ROOT))
args = parser.parse_args()
artifact_dir = Path(args.artifact_dir)
baseline_root = Path(args.baseline_root)
summary = run_gate(api_base=args.api_base.rstrip("/"), artifact_dir=artifact_dir, password=args.password)
summary_path = artifact_dir / "review-structured-acceptance-summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
md_path = artifact_dir / "review-structured-acceptance-summary.md"
md_path.write_text(build_md(summary), encoding="utf-8")
field_csv_path = artifact_dir / "review-structured-field-baseline.csv"
write_csv(field_csv_path, summary.get("field_baseline") or [])
relationship_csv_path = artifact_dir / "review-structured-relationship-baseline.csv"
write_csv(relationship_csv_path, summary.get("relationship_baseline") or [])
field_by_contract_csv_path = artifact_dir / "review-structured-field-baseline-by-contract.csv"
write_csv(field_by_contract_csv_path, summary.get("field_baseline_by_contract") or [])
field_by_batch_csv_path = artifact_dir / "review-structured-field-baseline-by-batch.csv"
write_csv(field_by_batch_csv_path, summary.get("field_baseline_by_batch") or [])
relationship_by_type_csv_path = artifact_dir / "review-structured-relationship-baseline-by-type.csv"
write_csv(relationship_by_type_csv_path, summary.get("relationship_baseline_by_type") or [])
sample_inventory_csv_path = artifact_dir / "review-structured-sample-inventory.csv"
write_csv(sample_inventory_csv_path, summary.get("sample_inventory") or [])
baseline_md_path = artifact_dir / "review-structured-baseline.md"
baseline_md_path.write_text(
build_baseline_md(summary.get("field_baseline") or [], summary.get("relationship_baseline") or []),
encoding="utf-8",
)
(artifact_dir / "review-structured-baseline-dashboard.html").write_text(
build_dashboard_html(
summary,
summary.get("field_baseline") or [],
summary.get("relationship_baseline") or [],
summary.get("field_baseline_by_contract") or [],
summary.get("field_baseline_by_batch") or [],
summary.get("relationship_baseline_by_type") or [],
),
encoding="utf-8",
)
persist_baseline_history(summary, baseline_root=baseline_root)
print(f"REVIEW_STRUCTURED_ACCEPTANCE_{summary['status']} {artifact_dir}")
return 0 if summary["status"] == "PASS" else 1
if __name__ == "__main__":
sys.exit(main())