feat: deepen structured field extraction
This commit is contained in:
@@ -216,6 +216,23 @@ TERM_PATTERNS = [
|
||||
re.compile(r"(?:合同期限|服务期限|履行期限|期限)\s*[::]?\s*([^\n]{4,40})", re.IGNORECASE),
|
||||
re.compile(r"(自[^。\n]{4,40}至[^。\n]{4,40})"),
|
||||
]
|
||||
DATE_RANGE_PATTERN = re.compile(
|
||||
r"自\s*(\d{4}年\d{1,2}月\d{1,2}日|\d{4}[/-]\d{1,2}[/-]\d{1,2})\s*至\s*(\d{4}年\d{1,2}月\d{1,2}日|\d{4}[/-]\d{1,2}[/-]\d{1,2})"
|
||||
)
|
||||
DATE_POINT_PATTERN = re.compile(r"(\d{4}年\d{1,2}月\d{1,2}日|\d{4}[/-]\d{1,2}[/-]\d{1,2})")
|
||||
PAYMENT_DUE_PATTERN = re.compile(r"(\d{1,3})\s*(个)?\s*(工作日|自然日|日|天)内")
|
||||
PAYMENT_RATIO_PATTERN = re.compile(r"(\d{1,3})\s*%")
|
||||
PAYMENT_TRIGGER_HINTS = [
|
||||
("合同签署", "contract_signed"),
|
||||
("签署后", "contract_signed"),
|
||||
("验收合格", "acceptance_passed"),
|
||||
("验收完成", "acceptance_passed"),
|
||||
("收到发票", "invoice_received"),
|
||||
("发票", "invoice_received"),
|
||||
("交货", "delivery_completed"),
|
||||
("交付", "delivery_completed"),
|
||||
("上线", "go_live_completed"),
|
||||
]
|
||||
RISK_CERTAINTY_CONFIRMED = "confirmed"
|
||||
RISK_CERTAINTY_POSSIBLE = "possible"
|
||||
CLAUSE_HEADING_PATTERNS = [
|
||||
@@ -228,6 +245,8 @@ NON_CLAUSE_METADATA_PATTERNS = [
|
||||
re.compile(r"^(甲方|乙方|丙方|丁方|Party\s*A|Party\s*B)\s*[::]", re.IGNORECASE),
|
||||
re.compile(r"^(合同金额|总价|金额|价税合计|服务期限|合同期限|履行期限|签署日期|签约日期|日期)\s*[::]"),
|
||||
]
|
||||
RELATION_STATUS_INFO = "info"
|
||||
RELATION_STATUS_ATTENTION = "attention"
|
||||
|
||||
pdfmetrics.registerFont(UnicodeCIDFont(PDF_FONT_NAME))
|
||||
|
||||
@@ -535,6 +554,24 @@ class ReviewService:
|
||||
clause_summary = item.get("summary") or item.get("content") or "暂无条款摘要"
|
||||
lines.append(f"{index}. {item.get('title') or f'条款{index}'}({clause_label}):{clause_summary}")
|
||||
|
||||
payment_milestones = contract_structure.get("payment_milestones") or []
|
||||
if payment_milestones:
|
||||
lines.extend(["", "付款节点:"])
|
||||
for index, item in enumerate(payment_milestones, start=1):
|
||||
dependencies = "、".join(item.get("dependencies") or []) or "无明确依赖"
|
||||
due_within = item.get("due_within") or "未识别付款时限"
|
||||
lines.append(
|
||||
f"{index}. {item.get('title') or f'付款节点{index}'}:{item.get('summary') or '-'}(依赖:{dependencies};时限:{due_within})"
|
||||
)
|
||||
|
||||
clause_relationships = contract_structure.get("clause_relationships") or []
|
||||
if clause_relationships:
|
||||
lines.extend(["", "条款关系:"])
|
||||
for index, item in enumerate(clause_relationships, start=1):
|
||||
lines.append(
|
||||
f"{index}. {item.get('label') or item.get('type')}:{item.get('summary') or '-'}(来源:{item.get('source_clause_title') or '-'};目标:{item.get('target_clause_title') or '-'})"
|
||||
)
|
||||
|
||||
lines.extend(["", "原始文本摘要:", (review.input_text or "未提供合同文本,仅记录了任务元数据。")[:500]])
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -634,6 +671,8 @@ class ReviewService:
|
||||
"detected_clause_types": [item["type"] for item in CLAUSE_TYPE_DEFINITIONS if contract_structure["clause_type_counts"].get(item["type"])],
|
||||
"missing_sections": contract_structure["missing_sections"],
|
||||
"evidence_coverage": sum(1 for item in risk_items if item.get("evidence", {}).get("quote")),
|
||||
"payment_milestone_count": len(contract_structure.get("payment_milestones") or []),
|
||||
"relationship_count": len(contract_structure.get("clause_relationships") or []),
|
||||
}
|
||||
enriched_basic_info = {
|
||||
"contract_type": base_basic_info.get("contract_type") or review.contract_type or "未分类合同",
|
||||
@@ -769,6 +808,8 @@ class ReviewService:
|
||||
text: str,
|
||||
) -> Dict[str, Any]:
|
||||
detected_contract_type = self._detect_contract_type(review, text)
|
||||
amount_details = self._extract_amount_details(text)
|
||||
term_details = self._extract_term_details(text)
|
||||
clause_type_counts: Dict[str, int] = {}
|
||||
key_sections: Dict[str, List[str]] = {item["type"]: [] for item in CLAUSE_TYPE_DEFINITIONS}
|
||||
for clause in clauses:
|
||||
@@ -782,6 +823,8 @@ class ReviewService:
|
||||
confidentiality_terms = key_sections.get("confidentiality") or []
|
||||
breach_terms = key_sections.get("breach") or []
|
||||
dispute_terms = key_sections.get("dispute_resolution") or []
|
||||
payment_milestones = self._extract_payment_milestones(clauses)
|
||||
clause_relationships = self._build_clause_relationships(clauses, payment_milestones)
|
||||
missing_sections = self._detect_missing_sections(clause_type_counts)
|
||||
return {
|
||||
"title": basic_info.get("contract_title") or review.title,
|
||||
@@ -789,8 +832,10 @@ class ReviewService:
|
||||
"detected_contract_type_label": CONTRACT_TYPE_DISPLAY_MAP.get(detected_contract_type, detected_contract_type),
|
||||
"party_a": self._extract_party_name(text, "party_a") or basic_info.get("party_a") or DEFAULT_PARTY_A,
|
||||
"party_b": self._extract_party_name(text, "party_b") or basic_info.get("party_b") or DEFAULT_PARTY_B,
|
||||
"amount": self._extract_amount(text) or basic_info.get("amount") or DEFAULT_AMOUNT,
|
||||
"term": self._extract_term(text) or basic_info.get("term") or DEFAULT_TERM,
|
||||
"amount": amount_details["display"] or basic_info.get("amount") or DEFAULT_AMOUNT,
|
||||
"amount_details": amount_details,
|
||||
"term": term_details["display"] or basic_info.get("term") or DEFAULT_TERM,
|
||||
"term_details": term_details,
|
||||
"clause_count": len(clauses),
|
||||
"clause_type_counts": clause_type_counts,
|
||||
"key_sections": key_sections,
|
||||
@@ -799,6 +844,8 @@ class ReviewService:
|
||||
"confidentiality_terms": confidentiality_terms,
|
||||
"breach_terms": breach_terms,
|
||||
"dispute_resolution_terms": dispute_terms,
|
||||
"payment_milestones": payment_milestones,
|
||||
"clause_relationships": clause_relationships,
|
||||
"missing_sections": missing_sections,
|
||||
"summary": self._compose_contract_summary(
|
||||
contract_type=detected_contract_type,
|
||||
@@ -807,6 +854,8 @@ class ReviewService:
|
||||
acceptance_terms=acceptance_terms,
|
||||
confidentiality_terms=confidentiality_terms,
|
||||
breach_terms=breach_terms,
|
||||
payment_milestones=payment_milestones,
|
||||
clause_relationships=clause_relationships,
|
||||
missing_sections=missing_sections,
|
||||
),
|
||||
}
|
||||
@@ -828,17 +877,99 @@ class ReviewService:
|
||||
return None
|
||||
|
||||
def _extract_amount(self, text: str) -> Optional[str]:
|
||||
details = self._extract_amount_details(text)
|
||||
return details["display"]
|
||||
|
||||
def _extract_amount_details(self, text: str) -> Dict[str, Any]:
|
||||
for pattern in AMOUNT_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
raw = match.group(1).strip()
|
||||
normalized = raw.replace("人民币", "").replace("RMB", "").replace("¥", "").replace("¥", "").replace(",", "").strip()
|
||||
unit = "元"
|
||||
multiplier = 1
|
||||
if normalized.endswith("万元"):
|
||||
unit = "万元"
|
||||
normalized = normalized[:-2]
|
||||
multiplier = 10000
|
||||
elif normalized.endswith("万"):
|
||||
unit = "万"
|
||||
normalized = normalized[:-1]
|
||||
multiplier = 10000
|
||||
elif normalized.endswith("元"):
|
||||
normalized = normalized[:-1]
|
||||
try:
|
||||
numeric_value = float(normalized)
|
||||
except ValueError:
|
||||
numeric_value = None
|
||||
normalized_amount = int(numeric_value * multiplier) if numeric_value is not None else None
|
||||
return {
|
||||
"raw": raw,
|
||||
"display": raw,
|
||||
"currency": "CNY" if any(token in raw for token in ["人民币", "¥", "¥", "RMB", "元", "万"]) else None,
|
||||
"value": numeric_value,
|
||||
"unit": unit,
|
||||
"normalized_amount_cny": normalized_amount,
|
||||
}
|
||||
return {
|
||||
"raw": None,
|
||||
"display": None,
|
||||
"currency": None,
|
||||
"value": None,
|
||||
"unit": None,
|
||||
"normalized_amount_cny": None,
|
||||
}
|
||||
|
||||
def _extract_term(self, text: str) -> Optional[str]:
|
||||
details = self._extract_term_details(text)
|
||||
return details["display"]
|
||||
|
||||
def _extract_term_details(self, text: str) -> Dict[str, Any]:
|
||||
range_match = DATE_RANGE_PATTERN.search(text)
|
||||
if range_match:
|
||||
start_raw = range_match.group(1).strip()
|
||||
end_raw = range_match.group(2).strip()
|
||||
start_date = self._normalize_cn_date(start_raw)
|
||||
end_date = self._normalize_cn_date(end_raw)
|
||||
return {
|
||||
"raw": range_match.group(0).strip(),
|
||||
"display": range_match.group(0).strip(),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"duration_text": None,
|
||||
}
|
||||
for pattern in TERM_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
raw = match.group(1).strip()
|
||||
points = DATE_POINT_PATTERN.findall(raw)
|
||||
start_date = self._normalize_cn_date(points[0]) if len(points) >= 1 else None
|
||||
end_date = self._normalize_cn_date(points[1]) if len(points) >= 2 else None
|
||||
return {
|
||||
"raw": raw,
|
||||
"display": raw,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"duration_text": raw if start_date is None and end_date is None else None,
|
||||
}
|
||||
return {
|
||||
"raw": None,
|
||||
"display": None,
|
||||
"start_date": None,
|
||||
"end_date": None,
|
||||
"duration_text": None,
|
||||
}
|
||||
|
||||
def _normalize_cn_date(self, value: str) -> Optional[str]:
|
||||
normalized = value.strip()
|
||||
match = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", normalized)
|
||||
if match:
|
||||
year, month, day = match.groups()
|
||||
return f"{year}-{int(month):02d}-{int(day):02d}"
|
||||
match = re.match(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})", normalized)
|
||||
if match:
|
||||
year, month, day = match.groups()
|
||||
return f"{year}-{int(month):02d}-{int(day):02d}"
|
||||
return None
|
||||
|
||||
def _looks_like_document_title(self, line: str) -> bool:
|
||||
@@ -882,6 +1013,92 @@ class ReviewService:
|
||||
missing.append("验收标准或验收流程")
|
||||
return missing
|
||||
|
||||
def _extract_payment_milestones(self, clauses: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
milestones: List[Dict[str, Any]] = []
|
||||
for clause in clauses:
|
||||
if clause.get("clause_type") != "payment":
|
||||
continue
|
||||
content = clause.get("content") or ""
|
||||
segments = [segment.strip() for segment in re.split(r"[。;;]", content) if segment.strip()]
|
||||
for index, segment in enumerate(segments, start=1):
|
||||
dependencies = [dependency for trigger, dependency in PAYMENT_TRIGGER_HINTS if trigger in segment]
|
||||
deduped_dependencies: List[str] = []
|
||||
for dependency in dependencies:
|
||||
if dependency not in deduped_dependencies:
|
||||
deduped_dependencies.append(dependency)
|
||||
due_match = PAYMENT_DUE_PATTERN.search(segment)
|
||||
ratio_match = PAYMENT_RATIO_PATTERN.search(segment)
|
||||
milestones.append(
|
||||
{
|
||||
"title": f"{clause.get('title') or '付款条款'}-节点{index}",
|
||||
"summary": self._summarize_clause_text(segment),
|
||||
"clause_title": clause.get("title"),
|
||||
"trigger_text": segment,
|
||||
"due_within": due_match.group(0) if due_match else None,
|
||||
"due_days": int(due_match.group(1)) if due_match else None,
|
||||
"due_unit": due_match.group(3) if due_match else None,
|
||||
"ratio_percent": int(ratio_match.group(1)) if ratio_match else None,
|
||||
"dependencies": deduped_dependencies,
|
||||
}
|
||||
)
|
||||
return milestones
|
||||
|
||||
def _build_clause_relationships(
|
||||
self,
|
||||
clauses: List[Dict[str, Any]],
|
||||
payment_milestones: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
relationships: List[Dict[str, Any]] = []
|
||||
clause_by_type: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for clause in clauses:
|
||||
clause_type = clause.get("clause_type") or "general"
|
||||
clause_by_type.setdefault(clause_type, []).append(clause)
|
||||
|
||||
for milestone in payment_milestones:
|
||||
if "acceptance_passed" in milestone.get("dependencies", []):
|
||||
acceptance_clause = (clause_by_type.get("acceptance") or [None])[0]
|
||||
relationships.append(
|
||||
{
|
||||
"type": "payment_depends_on_acceptance",
|
||||
"label": "付款依赖验收",
|
||||
"status": RELATION_STATUS_INFO,
|
||||
"source_clause_title": milestone.get("clause_title"),
|
||||
"target_clause_title": acceptance_clause.get("title") if acceptance_clause else None,
|
||||
"summary": "付款节点触发前需要先满足验收条件。",
|
||||
"reason": milestone.get("trigger_text"),
|
||||
}
|
||||
)
|
||||
if "invoice_received" in milestone.get("dependencies", []):
|
||||
relationships.append(
|
||||
{
|
||||
"type": "payment_depends_on_invoice",
|
||||
"label": "付款依赖发票",
|
||||
"status": RELATION_STATUS_INFO,
|
||||
"source_clause_title": milestone.get("clause_title"),
|
||||
"target_clause_title": None,
|
||||
"summary": "付款节点包含发票前置条件。",
|
||||
"reason": milestone.get("trigger_text"),
|
||||
}
|
||||
)
|
||||
|
||||
liability_clause = (clause_by_type.get("liability") or [None])[0]
|
||||
breach_clause = (clause_by_type.get("breach") or [None])[0]
|
||||
if liability_clause and breach_clause:
|
||||
liability_content = liability_clause.get("content") or ""
|
||||
if any(token in liability_content for token in ["上限", "cap", "最高", "赔偿责任以"]):
|
||||
relationships.append(
|
||||
{
|
||||
"type": "liability_cap_conflicts_with_breach",
|
||||
"label": "责任限制可能冲突违约责任",
|
||||
"status": RELATION_STATUS_ATTENTION,
|
||||
"source_clause_title": liability_clause.get("title"),
|
||||
"target_clause_title": breach_clause.get("title"),
|
||||
"summary": "责任限制条款可能影响违约责任的实际可执行范围,需要结合 carve-out 与责任边界复核。",
|
||||
"reason": self._summarize_clause_text(liability_content),
|
||||
}
|
||||
)
|
||||
return relationships
|
||||
|
||||
def _compose_contract_summary(
|
||||
self,
|
||||
contract_type: str,
|
||||
@@ -890,6 +1107,8 @@ class ReviewService:
|
||||
acceptance_terms: List[str],
|
||||
confidentiality_terms: List[str],
|
||||
breach_terms: List[str],
|
||||
payment_milestones: List[Dict[str, Any]],
|
||||
clause_relationships: List[Dict[str, Any]],
|
||||
missing_sections: List[str],
|
||||
) -> str:
|
||||
observed: List[str] = []
|
||||
@@ -903,6 +1122,10 @@ class ReviewService:
|
||||
observed.append("违约责任")
|
||||
observed_text = "、".join(observed) if observed else "关键条款仍较少"
|
||||
summary = f"识别为{CONTRACT_TYPE_DISPLAY_MAP.get(contract_type, contract_type)},已提取 {len(clauses)} 条摘要条款,当前已覆盖{observed_text}。"
|
||||
if payment_milestones:
|
||||
summary += f" 已识别 {len(payment_milestones)} 个付款节点。"
|
||||
if clause_relationships:
|
||||
summary += f" 已识别 {len(clause_relationships)} 条关键条款关系。"
|
||||
if missing_sections:
|
||||
summary += f" 建议补充{missing_sections[0]}等关键安排。"
|
||||
return summary
|
||||
|
||||
@@ -367,6 +367,34 @@ class ReviewServiceTestCase(unittest.TestCase):
|
||||
|
||||
self.assertEqual(detected, "sales")
|
||||
|
||||
def test_extract_amount_term_and_payment_milestones(self):
|
||||
text = "\n".join(
|
||||
[
|
||||
"软件开发合同",
|
||||
"合同金额:人民币800000元",
|
||||
"服务期限:自2026年7月1日至2027年1月31日",
|
||||
"第一条 付款条款",
|
||||
"甲方应在合同签署后5个工作日内支付首期款30%;验收合格并收到发票后10个工作日内支付尾款70%。",
|
||||
"第二条 验收条款",
|
||||
"乙方交付系统并完成验收后,方可进入尾款结算。",
|
||||
]
|
||||
)
|
||||
|
||||
amount = self.service._extract_amount_details(text)
|
||||
term = self.service._extract_term_details(text)
|
||||
clauses = self.service._enrich_clauses([], text)
|
||||
milestones = self.service._extract_payment_milestones(clauses)
|
||||
relationships = self.service._build_clause_relationships(clauses, milestones)
|
||||
|
||||
self.assertEqual(amount["normalized_amount_cny"], 800000)
|
||||
self.assertEqual(term["start_date"], "2026-07-01")
|
||||
self.assertEqual(term["end_date"], "2027-01-31")
|
||||
self.assertGreaterEqual(len(milestones), 2)
|
||||
self.assertIn("contract_signed", milestones[0]["dependencies"])
|
||||
self.assertIn("acceptance_passed", milestones[1]["dependencies"])
|
||||
self.assertIn("invoice_received", milestones[1]["dependencies"])
|
||||
self.assertIn("payment_depends_on_acceptance", {item["type"] for item in relationships})
|
||||
|
||||
def test_validate_review_upload_accepts_supported_suffix(self):
|
||||
self.service.validate_review_upload("contract.pdf", "application/pdf", b"%PDF-1.7")
|
||||
docx_buffer = io.BytesIO()
|
||||
|
||||
@@ -22,6 +22,11 @@ class StructuredReviewCase:
|
||||
expected_contract_type: str
|
||||
expected_clause_types: List[str]
|
||||
expected_missing_sections: List[str]
|
||||
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
|
||||
|
||||
|
||||
CASES = [
|
||||
@@ -46,6 +51,11 @@ CASES = [
|
||||
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",
|
||||
@@ -66,6 +76,8 @@ CASES = [
|
||||
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",
|
||||
@@ -89,6 +101,8 @@ CASES = [
|
||||
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",
|
||||
@@ -113,6 +127,37 @@ CASES = [
|
||||
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"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -176,6 +221,16 @@ def validate_case(case: StructuredReviewCase, result: Dict) -> List[str]:
|
||||
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:
|
||||
@@ -193,6 +248,15 @@ def validate_case(case: StructuredReviewCase, result: Dict) -> List[str]:
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
relationship_types = {item.get("type") for item in contract_structure.get("clause_relationships") or [] 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}")
|
||||
|
||||
if risk_items:
|
||||
first_risk = risk_items[0]
|
||||
|
||||
@@ -183,6 +183,30 @@ function getEvidenceCertaintyColor(value: string | null | undefined) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
function formatPaymentDependency(value: string) {
|
||||
const dependencyMap: Record<string, string> = {
|
||||
acceptance_passed: "依赖验收",
|
||||
invoice_received: "依赖发票",
|
||||
delivery_completed: "依赖交付",
|
||||
contract_signed: "依赖签署",
|
||||
go_live_completed: "依赖上线",
|
||||
};
|
||||
return dependencyMap[value] ?? value;
|
||||
}
|
||||
|
||||
function formatRelationshipStatus(value: string | null | undefined) {
|
||||
if (!value) return "一般";
|
||||
if (value === "attention") return "需重点复核";
|
||||
if (value === "info") return "关系提示";
|
||||
return value;
|
||||
}
|
||||
|
||||
function getRelationshipStatusColor(value: string | null | undefined) {
|
||||
if (value === "attention") return "orange";
|
||||
if (value === "info") return "blue";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function buildReviewChatRestorePayload(
|
||||
detail: ReviewDetail | null,
|
||||
result: ReviewResult | null,
|
||||
@@ -461,6 +485,8 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
{ key: "breach_terms", label: "违约责任", items: contractStructure?.breach_terms ?? [] },
|
||||
{ key: "dispute_resolution_terms", label: "争议解决", items: contractStructure?.dispute_resolution_terms ?? [] },
|
||||
].filter((item) => item.items.length > 0);
|
||||
const paymentMilestones = contractStructure?.payment_milestones ?? [];
|
||||
const clauseRelationships = contractStructure?.clause_relationships ?? [];
|
||||
|
||||
const handleDownloadReport = async () => {
|
||||
if (!report) {
|
||||
@@ -868,6 +894,26 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
<strong>{contractStructure.term ?? result?.basic_info.term ?? "-"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{(contractStructure.amount_details?.normalized_amount_cny != null || contractStructure.term_details?.start_date || contractStructure.term_details?.end_date) ? (
|
||||
<div className="review-detail-structure-grid">
|
||||
<div className="review-detail-structure-card">
|
||||
<span>标准金额</span>
|
||||
<strong>{contractStructure.amount_details?.normalized_amount_cny == null ? "-" : `${contractStructure.amount_details.normalized_amount_cny} 元`}</strong>
|
||||
</div>
|
||||
<div className="review-detail-structure-card">
|
||||
<span>开始日期</span>
|
||||
<strong>{contractStructure.term_details?.start_date ?? "-"}</strong>
|
||||
</div>
|
||||
<div className="review-detail-structure-card">
|
||||
<span>结束日期</span>
|
||||
<strong>{contractStructure.term_details?.end_date ?? "-"}</strong>
|
||||
</div>
|
||||
<div className="review-detail-structure-card">
|
||||
<span>付款节点</span>
|
||||
<strong>{structuredAnalysis?.payment_milestone_count ?? paymentMilestones.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{structureSectionItems.length > 0 ? (
|
||||
<div className="review-detail-structure-section-grid">
|
||||
{structureSectionItems.map((section) => (
|
||||
@@ -882,6 +928,41 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{paymentMilestones.length > 0 ? (
|
||||
<div className="review-detail-structure-section-grid">
|
||||
{paymentMilestones.map((item, index) => (
|
||||
<article className="review-detail-structure-section-card" key={`${item.title ?? "payment"}-${index}`}>
|
||||
<span>付款节点</span>
|
||||
<strong>{item.title ?? `付款节点${index + 1}`}</strong>
|
||||
<Paragraph className="review-detail-structure-section-text">{item.summary ?? item.trigger_text ?? "-"}</Paragraph>
|
||||
<Space wrap size={[8, 8]}>
|
||||
{item.due_within ? <Tag color="purple">{item.due_within}</Tag> : null}
|
||||
{item.ratio_percent != null ? <Tag color="blue">{item.ratio_percent}%</Tag> : null}
|
||||
{(item.dependencies ?? []).map((dependency) => (
|
||||
<Tag key={`${item.title ?? "payment"}-${dependency}`}>{formatPaymentDependency(dependency)}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{clauseRelationships.length > 0 ? (
|
||||
<div className="review-detail-structure-section-grid">
|
||||
{clauseRelationships.map((item, index) => (
|
||||
<article className="review-detail-structure-section-card" key={`${item.type ?? "relation"}-${index}`}>
|
||||
<span>条款关系</span>
|
||||
<strong>{item.label ?? item.type ?? `关系${index + 1}`}</strong>
|
||||
<Paragraph className="review-detail-structure-section-text">{item.summary ?? "-"}</Paragraph>
|
||||
<Space wrap size={[8, 8]}>
|
||||
<Tag color={getRelationshipStatusColor(item.status)}>{formatRelationshipStatus(item.status)}</Tag>
|
||||
{item.source_clause_title ? <Tag>{item.source_clause_title}</Tag> : null}
|
||||
{item.target_clause_title ? <Tag>{item.target_clause_title}</Tag> : null}
|
||||
</Space>
|
||||
{item.reason ? <Text type="secondary">{item.reason}</Text> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{contractStructure.missing_sections?.length ? (
|
||||
<div className="review-detail-missing-tags">
|
||||
<Text type="secondary">建议补充:</Text>
|
||||
|
||||
@@ -62,7 +62,22 @@ export type ReviewResult = {
|
||||
party_a?: string;
|
||||
party_b?: string;
|
||||
amount?: string;
|
||||
amount_details?: {
|
||||
raw?: string | null;
|
||||
display?: string | null;
|
||||
currency?: string | null;
|
||||
value?: number | null;
|
||||
unit?: string | null;
|
||||
normalized_amount_cny?: number | null;
|
||||
};
|
||||
term?: string;
|
||||
term_details?: {
|
||||
raw?: string | null;
|
||||
display?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
duration_text?: string | null;
|
||||
};
|
||||
clause_count?: number;
|
||||
clause_type_counts?: Record<string, number>;
|
||||
key_sections?: Record<string, string[]>;
|
||||
@@ -71,6 +86,26 @@ export type ReviewResult = {
|
||||
confidentiality_terms?: string[];
|
||||
breach_terms?: string[];
|
||||
dispute_resolution_terms?: string[];
|
||||
payment_milestones?: Array<{
|
||||
title?: string;
|
||||
summary?: string;
|
||||
clause_title?: string | null;
|
||||
trigger_text?: string;
|
||||
due_within?: string | null;
|
||||
due_days?: number | null;
|
||||
due_unit?: string | null;
|
||||
ratio_percent?: number | null;
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
clause_relationships?: Array<{
|
||||
type?: string;
|
||||
label?: string;
|
||||
status?: string;
|
||||
source_clause_title?: string | null;
|
||||
target_clause_title?: string | null;
|
||||
summary?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
missing_sections?: string[];
|
||||
summary?: string;
|
||||
};
|
||||
@@ -116,6 +151,8 @@ export type ReviewResult = {
|
||||
detected_clause_types?: string[];
|
||||
missing_sections?: string[];
|
||||
evidence_coverage?: number;
|
||||
payment_milestone_count?: number;
|
||||
relationship_count?: number;
|
||||
};
|
||||
document_analysis?: {
|
||||
document_kind?: string;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# OCR 后续能力扩展阶段 1 第三批执行记录(2026-05-04)
|
||||
|
||||
## 1. 本批目标
|
||||
|
||||
基于阶段 1 第二批已经完成的结构化理解增强,继续推进第三批:
|
||||
|
||||
1. 更细的金额抽取。
|
||||
2. 更细的期限抽取。
|
||||
3. 更细的付款节点抽取。
|
||||
4. 条款关系理解显式化:
|
||||
- 付款依赖验收
|
||||
- 付款依赖发票
|
||||
- 责任限制可能冲突违约责任
|
||||
|
||||
## 2. 本批改造
|
||||
|
||||
### 后端
|
||||
|
||||
1. `backend/app/services/review_service.py`
|
||||
- 新增金额明细抽取:
|
||||
- `amount_details.raw`
|
||||
- `amount_details.currency`
|
||||
- `amount_details.value`
|
||||
- `amount_details.unit`
|
||||
- `amount_details.normalized_amount_cny`
|
||||
- 新增期限明细抽取:
|
||||
- `term_details.raw`
|
||||
- `term_details.start_date`
|
||||
- `term_details.end_date`
|
||||
- `term_details.duration_text`
|
||||
- 新增付款节点抽取:
|
||||
- `contract_structure.payment_milestones`
|
||||
- 支持识别:
|
||||
- 签署后付款
|
||||
- 验收合格后付款
|
||||
- 收到发票后付款
|
||||
- 百分比节点
|
||||
- 节点时限
|
||||
- 新增条款关系理解:
|
||||
- `payment_depends_on_acceptance`
|
||||
- `payment_depends_on_invoice`
|
||||
- `liability_cap_conflicts_with_breach`
|
||||
- 结构化分析元数据新增:
|
||||
- `payment_milestone_count`
|
||||
- `relationship_count`
|
||||
- 审查报告导出内容同步增强:
|
||||
- 付款节点
|
||||
- 条款关系
|
||||
|
||||
2. 条款分段与分类继续增强:
|
||||
- `第一条 交货条款` 这类编号标题会保留真实标题语义
|
||||
- 中文业务里常见的 `交货` 已纳入交付条款识别
|
||||
- 责任限制标题优先级继续加强
|
||||
|
||||
### 前端
|
||||
|
||||
1. `frontend/src/services/review.ts`
|
||||
- 承接:
|
||||
- `amount_details`
|
||||
- `term_details`
|
||||
- `payment_milestones`
|
||||
- `clause_relationships`
|
||||
- `payment_milestone_count`
|
||||
- `relationship_count`
|
||||
|
||||
2. `frontend/src/app/reviews/[id]/page.tsx`
|
||||
- 新增金额标准化展示
|
||||
- 新增开始/结束日期展示
|
||||
- 新增付款节点展示区
|
||||
- 新增条款关系展示区
|
||||
- 风险证据卡继续保留并承接关键词标签
|
||||
|
||||
### 门禁与样本
|
||||
|
||||
1. `backend/tools/review_structured_acceptance_gate.py`
|
||||
- 扩展结构化验收门禁断言:
|
||||
- 合同类型
|
||||
- 条款类型
|
||||
- 金额片段
|
||||
- 期限开始/结束日期
|
||||
- 付款节点数量
|
||||
- 条款关系类型
|
||||
|
||||
2. 新增 / 扩展结构化样本:
|
||||
- `S-001` 服务合同结构化摘要
|
||||
- `S-002` 采购合同缺失条款提示
|
||||
- `S-003` 购销合同编号条款分段
|
||||
- `S-004` 租赁合同责任限制识别
|
||||
- `S-005` 开发合同付款节点拆解
|
||||
|
||||
## 3. 实际效果
|
||||
|
||||
第三批之后,系统不再只停在:
|
||||
|
||||
1. “这是什么合同”
|
||||
2. “大概有哪些条款”
|
||||
|
||||
而是进一步具备:
|
||||
|
||||
1. 金额标准化识别
|
||||
2. 期限起止识别
|
||||
3. 付款节点拆解
|
||||
4. 付款触发条件识别
|
||||
5. 条款之间关系的显式提示
|
||||
|
||||
## 4. 验证结果
|
||||
|
||||
### 本地验证
|
||||
|
||||
1. 后端定向:
|
||||
- `backend\\.venv\\Scripts\\python.exe -m unittest tests.test_review_service tests.test_review_service_rules tests.test_reviews_api`
|
||||
- `PASS`
|
||||
|
||||
2. 前端:
|
||||
- `frontend\\npm.cmd run build`
|
||||
- `PASS`
|
||||
|
||||
### 线上验证
|
||||
|
||||
1. 后端健康检查:
|
||||
- `http://192.168.3.90:8001/health`
|
||||
- `PASS`
|
||||
|
||||
2. 结构化审查门禁:
|
||||
- `project-docs/qa-artifacts/review-structured-acceptance-gate/2026-05-04T15-43-02-521/`
|
||||
- 结果:`PASS`
|
||||
|
||||
3. PDF 验收门禁:
|
||||
- `project-docs/qa-artifacts/pdf-acceptance-gate/2026-05-04T15-45-29-481/`
|
||||
- 结果:`PASS`
|
||||
|
||||
4. 前端烟雾验证:
|
||||
- `project-docs/qa-artifacts/stage1-batch3-front-smoke/2026-05-04T07-45-08-124Z/`
|
||||
- 已确认:
|
||||
- 结构化字段卡片可见
|
||||
- 付款节点区可见
|
||||
- 条款关系区可见
|
||||
- 风险证据关键词标签可见
|
||||
|
||||
## 5. 部署记录
|
||||
|
||||
1. 联合部署:
|
||||
- `project-docs/qa-artifacts/stage1-batch3-full-deploy/2026-05-04T15-37-14/`
|
||||
|
||||
2. 前端单独重部署:
|
||||
- `project-docs/qa-artifacts/stage1-batch3-frontend-redeploy/2026-05-04T15-40-37/`
|
||||
|
||||
## 6. 当前结论
|
||||
|
||||
阶段 1 第三批已经闭环:
|
||||
|
||||
1. 字段抽取从粗粒度进入更细粒度。
|
||||
2. 付款节点已能拆解并携带依赖条件。
|
||||
3. 条款关系已从隐含逻辑变成显式结构。
|
||||
4. 扩展后的结构化样本与 PDF 门禁均已通过。
|
||||
5. 线上页面已可直接展示第三批结果。
|
||||
|
||||
## 7. 下一步建议
|
||||
|
||||
下一步最自然的是进入:
|
||||
|
||||
**阶段 1 第四批:真实合同样本深化与关系规则精修**
|
||||
|
||||
建议优先处理:
|
||||
|
||||
1. 用更多真实复杂合同替换部分合成样本。
|
||||
2. 深化付款节点分期、尾款、质保金等细分识别。
|
||||
3. 深化责任限制与违约、保密、知识产权之间的关系规则。
|
||||
4. 建立更细的字段和关系验收基线。
|
||||
Reference in New Issue
Block a user