diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index dfca46c1..ecc7f9a1 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -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 diff --git a/backend/tests/test_review_service.py b/backend/tests/test_review_service.py index 887278ba..54fe1757 100644 --- a/backend/tests/test_review_service.py +++ b/backend/tests/test_review_service.py @@ -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() diff --git a/backend/tools/review_structured_acceptance_gate.py b/backend/tools/review_structured_acceptance_gate.py index c1a47356..7c70d9cf 100644 --- a/backend/tools/review_structured_acceptance_gate.py +++ b/backend/tools/review_structured_acceptance_gate.py @@ -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] diff --git a/frontend/src/app/reviews/[id]/page.tsx b/frontend/src/app/reviews/[id]/page.tsx index 4b157f1d..4d5b0f94 100644 --- a/frontend/src/app/reviews/[id]/page.tsx +++ b/frontend/src/app/reviews/[id]/page.tsx @@ -183,6 +183,30 @@ function getEvidenceCertaintyColor(value: string | null | undefined) { return "default"; } +function formatPaymentDependency(value: string) { + const dependencyMap: Record = { + 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 {contractStructure.term ?? result?.basic_info.term ?? "-"} + {(contractStructure.amount_details?.normalized_amount_cny != null || contractStructure.term_details?.start_date || contractStructure.term_details?.end_date) ? ( +
+
+ 标准金额 + {contractStructure.amount_details?.normalized_amount_cny == null ? "-" : `${contractStructure.amount_details.normalized_amount_cny} 元`} +
+
+ 开始日期 + {contractStructure.term_details?.start_date ?? "-"} +
+
+ 结束日期 + {contractStructure.term_details?.end_date ?? "-"} +
+
+ 付款节点 + {structuredAnalysis?.payment_milestone_count ?? paymentMilestones.length} +
+
+ ) : null} {structureSectionItems.length > 0 ? (
{structureSectionItems.map((section) => ( @@ -882,6 +928,41 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str ))}
) : null} + {paymentMilestones.length > 0 ? ( +
+ {paymentMilestones.map((item, index) => ( +
+ 付款节点 + {item.title ?? `付款节点${index + 1}`} + {item.summary ?? item.trigger_text ?? "-"} + + {item.due_within ? {item.due_within} : null} + {item.ratio_percent != null ? {item.ratio_percent}% : null} + {(item.dependencies ?? []).map((dependency) => ( + {formatPaymentDependency(dependency)} + ))} + +
+ ))} +
+ ) : null} + {clauseRelationships.length > 0 ? ( +
+ {clauseRelationships.map((item, index) => ( +
+ 条款关系 + {item.label ?? item.type ?? `关系${index + 1}`} + {item.summary ?? "-"} + + {formatRelationshipStatus(item.status)} + {item.source_clause_title ? {item.source_clause_title} : null} + {item.target_clause_title ? {item.target_clause_title} : null} + + {item.reason ? {item.reason} : null} +
+ ))} +
+ ) : null} {contractStructure.missing_sections?.length ? (
建议补充: diff --git a/frontend/src/services/review.ts b/frontend/src/services/review.ts index 95b0abbc..d7290ff4 100644 --- a/frontend/src/services/review.ts +++ b/frontend/src/services/review.ts @@ -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; key_sections?: Record; @@ -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; diff --git a/project-docs/OCR后续能力扩展阶段1第三批执行记录-2026-05-04.md b/project-docs/OCR后续能力扩展阶段1第三批执行记录-2026-05-04.md new file mode 100644 index 00000000..f1b1b5df --- /dev/null +++ b/project-docs/OCR后续能力扩展阶段1第三批执行记录-2026-05-04.md @@ -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. 建立更细的字段和关系验收基线。