From 2f23be32d98da7b073f0c21b65b63e0a6c536c29 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 17:26:19 +0800 Subject: [PATCH] feat: calibrate structured review confidence --- backend/app/services/review_service.py | 243 +++++++++++++++++- backend/tests/test_review_service.py | 50 ++++ .../review_structured_acceptance_gate.py | 94 ++++++- frontend/src/app/reviews/[id]/page.tsx | 70 +++++ frontend/src/services/review.ts | 9 + ...续能力扩展阶段1第五批执行记录-2026-05-04.md | 178 +++++++++++++ .../真实业务合同批量样本库-2026-05-04.csv | 9 + 7 files changed, 641 insertions(+), 12 deletions(-) create mode 100644 project-docs/OCR后续能力扩展阶段1第五批执行记录-2026-05-04.md create mode 100644 project-docs/真实业务合同批量样本库-2026-05-04.csv diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index efd7903c..fc757142 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -237,6 +237,124 @@ PAYMENT_TRIGGER_HINTS = [ ("质保期结束", "warranty_period_ended"), ("质保金", "warranty_period_ended"), ] +PAYMENT_STAGE_ADVANCE = "advance" +PAYMENT_STAGE_ACCEPTANCE = "acceptance" +PAYMENT_STAGE_GO_LIVE = "go_live" +PAYMENT_STAGE_FINAL = "final" +PAYMENT_STAGE_WARRANTY = "warranty_retention" +PAYMENT_STAGE_MILESTONE = "milestone" +PAYMENT_LABEL_HINTS = [ + ("棰勪粯娆?", PAYMENT_STAGE_ADVANCE), + ("棣栨湡娆?", PAYMENT_STAGE_ADVANCE), + ("瀹氶噾", PAYMENT_STAGE_ADVANCE), + ("杩涘害娆?", PAYMENT_STAGE_MILESTONE), + ("瀹炴柦娆?", PAYMENT_STAGE_MILESTONE), + ("灏炬", PAYMENT_STAGE_FINAL), + ("璐ㄤ繚閲?", PAYMENT_STAGE_WARRANTY), +] +LIABILITY_CARVE_OUT_MAP = [ + ("鏁呮剰", "intentional_misconduct"), + ("閲嶅ぇ杩囧け", "gross_negligence"), + ("淇濆瘑", "confidentiality"), + ("鍟嗕笟绉樺瘑", "confidentiality"), + ("鐭ヨ瘑浜ф潈", "intellectual_property"), + ("钁椾綔鏉?", "intellectual_property"), + ("婧愪唬鐮?", "intellectual_property"), + ("杞欢钁椾綔鏉?", "intellectual_property"), + ("杩濈害", "breach"), +] +IP_OWNERSHIP_PATTERNS = [ + (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(褰掔敳鏂规墍鏈夊畨褰掑睘浜庣敳鏂圅鐢叉柟鎵€鏈?)", re.IGNORECASE), "party_a"), + (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(褰掍箼鏂规墍鏈夊畨褰掑睘浜庝箼鏂圅涔欐柟鎵€鏈?)", re.IGNORECASE), "party_b"), + (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(鍏卞悓鎵€鏈夊弻鏂瑰叡鏈夊弻鏂瑰叡鍚屼韩鏈?)", re.IGNORECASE), "shared"), + (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(璁稿彲鐢叉柟浣跨敤鎺堟潈鐢叉柟浣跨敤)", re.IGNORECASE), "licensed_to_party_a"), +] + +_REAL_CHINESE_CONTRACT_HINTS = { + "service": ["\u670d\u52a1\u5408\u540c", "\u670d\u52a1\u534f\u8bae", "\u6280\u672f\u670d\u52a1", "\u8fd0\u7ef4\u670d\u52a1", "\u54a8\u8be2\u670d\u52a1"], + "development": ["\u5f00\u53d1\u5408\u540c", "\u6280\u672f\u5f00\u53d1", "\u8f6f\u4ef6\u5f00\u53d1", "\u7cfb\u7edf\u5f00\u53d1"], + "procurement": ["\u91c7\u8d2d\u5408\u540c", "\u91c7\u8d2d\u534f\u8bae", "\u4f9b\u8d27\u5408\u540c", "\u4e70\u5356\u5408\u540c", "\u91c7\u8d2d\u8ba2\u5355"], + "lease": ["\u79df\u8d41\u5408\u540c", "\u623f\u5c4b\u79df\u8d41", "\u8bbe\u5907\u79df\u8d41"], + "employment": ["\u52b3\u52a8\u5408\u540c", "\u8058\u7528\u5408\u540c", "\u52b3\u52a8\u534f\u8bae"], + "nda": ["\u4fdd\u5bc6\u534f\u8bae", "\u4fdd\u5bc6\u6761\u6b3e", "\u4fdd\u5bc6\u5408\u540c"], + "framework": ["\u6846\u67b6\u534f\u8bae", "\u5408\u4f5c\u6846\u67b6", "\u6218\u7565\u5408\u4f5c"], + "sales": ["\u4e70\u5356\u5408\u540c", "\u9500\u552e\u5408\u540c", "\u4f9b\u8d27\u5408\u540c", "\u8d2d\u9500\u5408\u540c"], + "entrustment": ["\u59d4\u6258\u5408\u540c", "\u59d4\u6258\u670d\u52a1", "\u4ee3\u7406\u534f\u8bae", "\u59d4\u6258\u534f\u8bae"], + "loan": ["\u501f\u6b3e\u5408\u540c", "\u501f\u6b3e\u534f\u8bae", "\u8d37\u6b3e\u5408\u540c"], + "license": ["\u8bb8\u53ef\u534f\u8bae", "\u6388\u6743\u534f\u8bae", "\u77e5\u8bc6\u4ea7\u6743\u8bb8\u53ef", "\u8f6f\u4ef6\u8bb8\u53ef"], +} +for _contract_type, _hints in _REAL_CHINESE_CONTRACT_HINTS.items(): + existing = CONTRACT_TYPE_HINTS.setdefault(_contract_type, []) + for _hint in _hints: + if _hint not in existing: + existing.append(_hint) + +_REAL_CHINESE_CLAUSE_KEYWORDS = { + "payment": ["\u4ed8\u6b3e", "\u4ef7\u6b3e", "\u91d1\u989d", "\u53d1\u7968", "\u8d39\u7528", "\u5c3e\u6b3e", "\u8d28\u4fdd\u91d1", "\u9884\u4ed8\u6b3e"], + "acceptance": ["\u9a8c\u6536", "\u9a8c\u6536\u6807\u51c6", "\u4e66\u9762\u9a8c\u6536", "\u6d4b\u8bd5", "\u8bd5\u8fd0\u884c"], + "delivery": ["\u4ea4\u4ed8", "\u4ea4\u8d27", "\u6210\u679c", "\u90e8\u7f72", "\u4e0a\u7ebf"], + "confidentiality": ["\u4fdd\u5bc6", "\u5546\u4e1a\u79d8\u5bc6", "\u5ba2\u6237\u6570\u636e"], + "intellectual_property": ["\u77e5\u8bc6\u4ea7\u6743", "\u8457\u4f5c\u6743", "\u6e90\u4ee3\u7801", "\u8f6f\u4ef6\u8457\u4f5c\u6743", "\u4e13\u5229", "\u5546\u6807"], + "breach": ["\u8fdd\u7ea6", "\u8fdd\u7ea6\u91d1", "\u8d54\u507f"], + "liability": ["\u8d23\u4efb\u9650\u5236", "\u8d54\u507f\u4e0a\u9650", "\u6700\u9ad8\u8d23\u4efb", "\u4e0a\u9650"], + "dispute_resolution": ["\u4e89\u8bae\u89e3\u51b3", "\u4ef2\u88c1", "\u7ba1\u8f96", "\u6cd5\u9662"], +} +for _definition in CLAUSE_TYPE_DEFINITIONS: + for _keyword in _REAL_CHINESE_CLAUSE_KEYWORDS.get(_definition["type"], []): + if _keyword not in _definition["keywords"]: + _definition["keywords"].append(_keyword) + +for _trigger, _dependency in [ + ("\u5408\u540c\u7b7e\u7f72", "contract_signed"), + ("\u7b7e\u7f72\u540e", "contract_signed"), + ("\u9a8c\u6536\u5408\u683c", "acceptance_passed"), + ("\u9a8c\u6536\u5b8c\u6210", "acceptance_passed"), + ("\u6536\u5230\u53d1\u7968", "invoice_received"), + ("\u53d1\u7968", "invoice_received"), + ("\u4ea4\u8d27", "delivery_completed"), + ("\u4ea4\u4ed8", "delivery_completed"), + ("\u4e0a\u7ebf", "go_live_completed"), + ("\u8d28\u4fdd\u671f\u6ee1", "warranty_period_ended"), + ("\u8d28\u4fdd\u671f\u7ed3\u675f", "warranty_period_ended"), + ("\u8d28\u4fdd\u91d1", "warranty_period_ended"), +]: + if (_trigger, _dependency) not in PAYMENT_TRIGGER_HINTS: + PAYMENT_TRIGGER_HINTS.append((_trigger, _dependency)) + +for _token, _stage in [ + ("\u9884\u4ed8\u6b3e", PAYMENT_STAGE_ADVANCE), + ("\u9996\u671f\u6b3e", PAYMENT_STAGE_ADVANCE), + ("\u5b9a\u91d1", PAYMENT_STAGE_ADVANCE), + ("\u8fdb\u5ea6\u6b3e", PAYMENT_STAGE_MILESTONE), + ("\u5b9e\u65bd\u6b3e", PAYMENT_STAGE_MILESTONE), + ("\u5c3e\u6b3e", PAYMENT_STAGE_FINAL), + ("\u8d28\u4fdd\u91d1", PAYMENT_STAGE_WARRANTY), +]: + if (_token, _stage) not in PAYMENT_LABEL_HINTS: + PAYMENT_LABEL_HINTS.append((_token, _stage)) + +for _token, _label in [ + ("\u6545\u610f", "intentional_misconduct"), + ("\u91cd\u5927\u8fc7\u5931", "gross_negligence"), + ("\u4fdd\u5bc6", "confidentiality"), + ("\u5546\u4e1a\u79d8\u5bc6", "confidentiality"), + ("\u77e5\u8bc6\u4ea7\u6743", "intellectual_property"), + ("\u8457\u4f5c\u6743", "intellectual_property"), + ("\u6e90\u4ee3\u7801", "intellectual_property"), + ("\u8f6f\u4ef6\u8457\u4f5c\u6743", "intellectual_property"), + ("\u8fdd\u7ea6", "breach"), +]: + if (_token, _label) not in LIABILITY_CARVE_OUT_MAP: + LIABILITY_CARVE_OUT_MAP.append((_token, _label)) + +IP_OWNERSHIP_PATTERNS.extend( + [ + (re.compile(r"(\u77e5\u8bc6\u4ea7\u6743|\u8457\u4f5c\u6743|\u6e90\u4ee3\u7801|\u8f6f\u4ef6\u8457\u4f5c\u6743)[^\n。;;]*(\u5f52\u7532\u65b9\u6240\u6709|\u5f52\u5c5e\u4e8e\u7532\u65b9|\u7532\u65b9\u6240\u6709)", re.IGNORECASE), "party_a"), + (re.compile(r"(\u77e5\u8bc6\u4ea7\u6743|\u8457\u4f5c\u6743|\u6e90\u4ee3\u7801|\u8f6f\u4ef6\u8457\u4f5c\u6743)[^\n。;;]*(\u5f52\u4e59\u65b9\u6240\u6709|\u5f52\u5c5e\u4e8e\u4e59\u65b9|\u4e59\u65b9\u6240\u6709)", re.IGNORECASE), "party_b"), + (re.compile(r"(\u77e5\u8bc6\u4ea7\u6743|\u8457\u4f5c\u6743|\u6e90\u4ee3\u7801|\u8f6f\u4ef6\u8457\u4f5c\u6743)[^\n。;;]*(\u53cc\u65b9\u5171\u6709|\u5171\u540c\u6240\u6709|\u5171\u540c\u4eab\u6709)", re.IGNORECASE), "shared"), + (re.compile(r"(\u77e5\u8bc6\u4ea7\u6743|\u8457\u4f5c\u6743|\u6e90\u4ee3\u7801|\u8f6f\u4ef6\u8457\u4f5c\u6743)[^\n。;;]*(\u8bb8\u53ef\u7532\u65b9\u4f7f\u7528|\u6388\u6743\u7532\u65b9\u4f7f\u7528)", re.IGNORECASE), "licensed_to_party_a"), + ] +) RISK_CERTAINTY_CONFIRMED = "confirmed" RISK_CERTAINTY_POSSIBLE = "possible" CLAUSE_HEADING_PATTERNS = [ @@ -251,11 +369,8 @@ NON_CLAUSE_METADATA_PATTERNS = [ ] RELATION_STATUS_INFO = "info" RELATION_STATUS_ATTENTION = "attention" -PAYMENT_STAGE_ADVANCE = "advance" -PAYMENT_STAGE_ACCEPTANCE = "acceptance" -PAYMENT_STAGE_GO_LIVE = "go_live" -PAYMENT_STAGE_FINAL = "final" -PAYMENT_STAGE_WARRANTY = "warranty_retention" +RELATION_CONFIDENCE_HIGH = "high" +RELATION_CONFIDENCE_WEAK = "weak" pdfmetrics.registerFont(UnicodeCIDFont(PDF_FONT_NAME)) @@ -675,13 +790,15 @@ class ReviewService: if confidence_summary is None: confidence_summary = {} confidence_summary["structured_analysis"] = { - "stage": "stage1-batch2", + "stage": "stage1-batch5", "clause_count": len(clauses), "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 []), + "high_confidence_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_HIGH), + "weak_hint_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_WEAK), } enriched_basic_info = { "contract_type": base_basic_info.get("contract_type") or review.contract_type or "未分类合同", @@ -833,9 +950,21 @@ class ReviewService: intellectual_property_terms = key_sections.get("intellectual_property") or [] breach_terms = key_sections.get("breach") or [] dispute_terms = key_sections.get("dispute_resolution") or [] + liability_terms = key_sections.get("liability") 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, detected_contract_type) + liability_carve_outs = self._extract_liability_carve_outs(clauses) + intellectual_property_owner = self._extract_intellectual_property_owner(clauses) + clause_relationships = self._build_clause_relationships( + clauses, + payment_milestones, + liability_carve_outs=liability_carve_outs, + intellectual_property_owner=intellectual_property_owner, + ) + missing_sections = self._detect_missing_sections( + clause_type_counts, + detected_contract_type, + intellectual_property_owner=intellectual_property_owner, + ) return { "title": basic_info.get("contract_title") or review.title, "detected_contract_type": detected_contract_type, @@ -853,9 +982,12 @@ class ReviewService: "acceptance_terms": acceptance_terms, "confidentiality_terms": confidentiality_terms, "intellectual_property_terms": intellectual_property_terms, + "liability_terms": liability_terms, "breach_terms": breach_terms, "dispute_resolution_terms": dispute_terms, "payment_milestones": payment_milestones, + "liability_carve_outs": liability_carve_outs, + "intellectual_property_owner": intellectual_property_owner, "clause_relationships": clause_relationships, "missing_sections": missing_sections, "summary": self._compose_contract_summary( @@ -1016,7 +1148,12 @@ class ReviewService: return title, content or normalized return None - def _detect_missing_sections(self, clause_type_counts: Dict[str, int], contract_type: str) -> List[str]: + def _detect_missing_sections( + self, + clause_type_counts: Dict[str, int], + contract_type: str, + intellectual_property_owner: Optional[str] = None, + ) -> List[str]: missing: List[str] = [] for item in STRUCTURE_MISSING_SECTION_RULES: if not clause_type_counts.get(item["type"]): @@ -1025,6 +1162,8 @@ class ReviewService: missing.append("验收标准或验收流程") if contract_type in {"development", "service", "license"} and not clause_type_counts.get("intellectual_property"): missing.append("知识产权归属或许可安排") + if contract_type in {"development", "service", "license"} and clause_type_counts.get("intellectual_property") and not intellectual_property_owner: + missing.append("知识产权归属口径") return missing def _extract_payment_milestones(self, clauses: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -1050,11 +1189,19 @@ class ReviewService: "clause_title": clause.get("title"), "trigger_text": segment, "stage": stage, + "stage_label": self._format_payment_stage_label(stage), + "label_hint": self._extract_payment_label_hint(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, + "confidence_tier": self._infer_payment_milestone_confidence( + segment, + deduped_dependencies, + has_due_match=due_match is not None, + has_ratio_match=ratio_match is not None, + ), } ) return milestones @@ -1075,12 +1222,79 @@ class ReviewService: return PAYMENT_STAGE_FINAL return PAYMENT_STAGE_FINAL + def _extract_payment_label_hint(self, segment: str) -> Optional[str]: + for token, _stage in PAYMENT_LABEL_HINTS: + if token in segment: + return token + return None + + def _format_payment_stage_label(self, stage: str) -> str: + stage_map = { + PAYMENT_STAGE_ADVANCE: "预付款/首期款", + PAYMENT_STAGE_ACCEPTANCE: "验收后付款", + PAYMENT_STAGE_GO_LIVE: "上线后付款", + PAYMENT_STAGE_FINAL: "尾款/最终付款", + PAYMENT_STAGE_WARRANTY: "质保金/质保期尾款", + PAYMENT_STAGE_MILESTONE: "进度款", + } + return stage_map.get(stage, stage) + + def _infer_payment_milestone_confidence( + self, + segment: str, + dependencies: List[str], + has_due_match: bool, + has_ratio_match: bool, + ) -> str: + markers = 0 + if dependencies: + markers += 1 + if has_due_match: + markers += 1 + if has_ratio_match: + markers += 1 + if any(token in segment for token, _stage in PAYMENT_LABEL_HINTS): + markers += 1 + return RELATION_CONFIDENCE_HIGH if markers >= 2 else RELATION_CONFIDENCE_WEAK + + def _extract_liability_carve_outs(self, clauses: List[Dict[str, Any]]) -> List[str]: + liability_clause = next((clause for clause in clauses if clause.get("clause_type") == "liability"), None) + content = liability_clause.get("content") if liability_clause else None + if not content: + for clause in clauses: + title = clause.get("title") or "" + clause_content = clause.get("content") or "" + if "责任限制" in title or "赔偿责任" in clause_content or "上限" in clause_content: + content = clause_content + break + if not content: + return [] + carve_outs: List[str] = [] + for token, label in LIABILITY_CARVE_OUT_MAP: + if token in content and label not in carve_outs: + carve_outs.append(label) + return carve_outs + + def _extract_intellectual_property_owner(self, clauses: List[Dict[str, Any]]) -> Optional[str]: + for clause in clauses: + title = clause.get("title") or "" + content = clause.get("content") or "" + if clause.get("clause_type") != "intellectual_property" and not any(token in f"{title} {content}" for token in ["知识产权", "著作权", "源代码", "软件著作权"]): + continue + for pattern, owner in IP_OWNERSHIP_PATTERNS: + if pattern.search(content) or pattern.search(f"{title} {content}"): + return owner + return None + def _build_clause_relationships( self, clauses: List[Dict[str, Any]], payment_milestones: List[Dict[str, Any]], + liability_carve_outs: Optional[List[str]] = None, + intellectual_property_owner: Optional[str] = None, ) -> List[Dict[str, Any]]: relationships: List[Dict[str, Any]] = [] + liability_carve_outs = liability_carve_outs or [] clause_by_type: Dict[str, List[Dict[str, Any]]] = {} for clause in clauses: clause_type = clause.get("clause_type") or "general" @@ -1094,6 +1308,7 @@ class ReviewService: "type": "payment_depends_on_acceptance", "label": "付款依赖验收", "status": RELATION_STATUS_INFO, + "confidence_tier": RELATION_CONFIDENCE_HIGH if acceptance_clause else RELATION_CONFIDENCE_WEAK, "source_clause_title": milestone.get("clause_title"), "target_clause_title": acceptance_clause.get("title") if acceptance_clause else None, "summary": "付款节点触发前需要先满足验收条件。", @@ -1106,6 +1321,7 @@ class ReviewService: "type": "payment_depends_on_invoice", "label": "付款依赖发票", "status": RELATION_STATUS_INFO, + "confidence_tier": milestone.get("confidence_tier") or RELATION_CONFIDENCE_HIGH, "source_clause_title": milestone.get("clause_title"), "target_clause_title": None, "summary": "付款节点包含发票前置条件。", @@ -1118,6 +1334,7 @@ class ReviewService: "type": "payment_depends_on_go_live", "label": "付款依赖上线稳定运行", "status": RELATION_STATUS_INFO, + "confidence_tier": milestone.get("confidence_tier") or RELATION_CONFIDENCE_HIGH, "source_clause_title": milestone.get("clause_title"), "target_clause_title": None, "summary": "付款节点包含上线或稳定运行前置条件。", @@ -1130,6 +1347,7 @@ class ReviewService: "type": "payment_after_warranty_period", "label": "付款依赖质保期结束", "status": RELATION_STATUS_ATTENTION, + "confidence_tier": milestone.get("confidence_tier") or RELATION_CONFIDENCE_HIGH, "source_clause_title": milestone.get("clause_title"), "target_clause_title": None, "summary": "付款节点与质保期或质保金释放挂钩,需要关注质保终止条件是否明确。", @@ -1149,30 +1367,33 @@ class ReviewService: "type": "liability_cap_conflicts_with_breach", "label": "责任限制可能冲突违约责任", "status": RELATION_STATUS_ATTENTION, + "confidence_tier": RELATION_CONFIDENCE_HIGH if not any(item in liability_carve_outs for item in ["breach", "confidentiality", "intellectual_property"]) else RELATION_CONFIDENCE_WEAK, "source_clause_title": liability_clause.get("title"), "target_clause_title": breach_clause.get("title"), "summary": "责任限制条款可能影响违约责任的实际可执行范围,需要结合 carve-out 与责任边界复核。", "reason": self._summarize_clause_text(liability_content), } ) - if confidentiality_clause and not any(token in liability_content for token in ["保密", "商业秘密"]): + if confidentiality_clause and "confidentiality" not in liability_carve_outs: relationships.append( { "type": "liability_cap_conflicts_with_confidentiality", "label": "责任限制可能冲突保密义务", "status": RELATION_STATUS_ATTENTION, + "confidence_tier": RELATION_CONFIDENCE_HIGH, "source_clause_title": liability_clause.get("title"), "target_clause_title": confidentiality_clause.get("title"), "summary": "责任限制条款未显式排除保密义务风险,可能压缩保密违约后的救济范围。", "reason": self._summarize_clause_text(liability_content), } ) - if intellectual_property_clause and not any(token in liability_content for token in ["知识产权", "著作权", "源代码", "软件著作权"]): + if intellectual_property_clause and "intellectual_property" not in liability_carve_outs: relationships.append( { "type": "liability_cap_conflicts_with_ip", "label": "责任限制可能冲突知识产权责任", "status": RELATION_STATUS_ATTENTION, + "confidence_tier": RELATION_CONFIDENCE_HIGH if intellectual_property_owner else RELATION_CONFIDENCE_WEAK, "source_clause_title": liability_clause.get("title"), "target_clause_title": intellectual_property_clause.get("title"), "summary": "责任限制条款未显式排除知识产权侵权或成果归属争议责任,需重点复核。", diff --git a/backend/tests/test_review_service.py b/backend/tests/test_review_service.py index db182501..3f15862e 100644 --- a/backend/tests/test_review_service.py +++ b/backend/tests/test_review_service.py @@ -427,6 +427,56 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertIn("liability_cap_conflicts_with_ip", relationship_types) self.assertIn("liability_cap_conflicts_with_breach", relationship_types) + def test_liability_carve_outs_and_ip_owner_reduce_conflicts(self): + text = "\n".join( + [ + "软件实施服务合同", + "第一条 保密条款", + "乙方应对甲方商业秘密承担保密义务。", + "第二条 知识产权", + "项目交付成果、源代码及相关文档的知识产权归甲方所有。", + "第三条 责任限制", + "任何一方的赔偿责任以合同总金额为上限,但保密义务、知识产权侵权、故意或重大过失除外。", + "第四条 违约责任", + "乙方违反保密义务或侵犯知识产权的,应承担违约责任并赔偿损失。", + ] + ) + + clauses = self.service._enrich_clauses([], text) + carve_outs = self.service._extract_liability_carve_outs(clauses) + ip_owner = self.service._extract_intellectual_property_owner(clauses) + relationships = self.service._build_clause_relationships( + clauses, + [], + liability_carve_outs=carve_outs, + intellectual_property_owner=ip_owner, + ) + relationship_types = {item["type"] for item in relationships} + + self.assertIn("confidentiality", carve_outs) + self.assertIn("intellectual_property", carve_outs) + self.assertEqual(ip_owner, "party_a") + self.assertNotIn("liability_cap_conflicts_with_confidentiality", relationship_types) + self.assertNotIn("liability_cap_conflicts_with_ip", relationship_types) + + def test_weak_payment_acceptance_relationship_without_acceptance_clause(self): + text = "\n".join( + [ + "技术服务合同", + "第一条 付款条款", + "甲方应在验收合格后尽快支付尾款100%。", + "第二条 服务内容", + "乙方负责提供咨询与优化建议。", + ] + ) + + clauses = self.service._enrich_clauses([], text) + milestones = self.service._extract_payment_milestones(clauses) + relationships = self.service._build_clause_relationships(clauses, milestones) + target = next(item for item in relationships if item["type"] == "payment_depends_on_acceptance") + + self.assertEqual(target["confidence_tier"], "weak") + 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 236a19e7..4c1f6161 100644 --- a/backend/tools/review_structured_acceptance_gate.py +++ b/backend/tools/review_structured_acceptance_gate.py @@ -27,6 +27,10 @@ class StructuredReviewCase: 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 = [ @@ -202,6 +206,73 @@ CASES = [ "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"}, ), ] @@ -297,10 +368,31 @@ def validate_case(case: StructuredReviewCase, result: Dict) -> List[str]: 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")} + 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] diff --git a/frontend/src/app/reviews/[id]/page.tsx b/frontend/src/app/reviews/[id]/page.tsx index d49036ca..715009a1 100644 --- a/frontend/src/app/reviews/[id]/page.tsx +++ b/frontend/src/app/reviews/[id]/page.tsx @@ -160,6 +160,7 @@ function formatClauseType(value: string | null | undefined) { acceptance: "验收条款", delivery: "交付条款", confidentiality: "保密条款", + intellectual_property: "知识产权", breach: "违约条款", liability: "责任限制", renewal: "续约条款", @@ -219,6 +220,41 @@ function formatPaymentStage(value: string | null | undefined) { return stageMap[value] ?? value; } +function formatRelationshipConfidence(value: string | null | undefined) { + if (!value) return "未标注"; + if (value === "high") return "高置信"; + if (value === "weak") return "弱提示"; + return value; +} + +function getRelationshipConfidenceColor(value: string | null | undefined) { + if (value === "high") return "green"; + if (value === "weak") return "gold"; + return "default"; +} + +function formatIntellectualPropertyOwner(value: string | null | undefined) { + if (!value) return "-"; + const ownerMap: Record = { + party_a: "甲方所有", + party_b: "乙方所有", + shared: "双方共有", + licensed_to_party_a: "授权甲方使用", + }; + return ownerMap[value] ?? value; +} + +function formatLiabilityCarveOut(value: string) { + const carveOutMap: Record = { + intentional_misconduct: "故意", + gross_negligence: "重大过失", + confidentiality: "保密义务", + intellectual_property: "知识产权", + breach: "违约责任", + }; + return carveOutMap[value] ?? value; +} + function buildReviewChatRestorePayload( detail: ReviewDetail | null, result: ReviewResult | null, @@ -495,10 +531,12 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str { key: "acceptance_terms", label: "验收安排", items: contractStructure?.acceptance_terms ?? [] }, { key: "confidentiality_terms", label: "保密义务", items: contractStructure?.confidentiality_terms ?? [] }, { key: "intellectual_property_terms", label: "知识产权", items: contractStructure?.intellectual_property_terms ?? [] }, + { key: "liability_terms", label: "责任限制", items: contractStructure?.liability_terms ?? [] }, { 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 liabilityCarveOuts = contractStructure?.liability_carve_outs ?? []; const clauseRelationships = contractStructure?.clause_relationships ?? []; const handleDownloadReport = async () => { @@ -927,6 +965,26 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str ) : null} + {(contractStructure.intellectual_property_owner || liabilityCarveOuts.length > 0 || structuredAnalysis?.high_confidence_relationships != null) ? ( +
+
+ 知识产权归属 + {formatIntellectualPropertyOwner(contractStructure.intellectual_property_owner)} +
+
+ Carve-out 数量 + {liabilityCarveOuts.length} +
+
+ 高置信关系 + {structuredAnalysis?.high_confidence_relationships ?? 0} +
+
+ 弱提示关系 + {structuredAnalysis?.weak_hint_relationships ?? 0} +
+
+ ) : null} {structureSectionItems.length > 0 ? (
{structureSectionItems.map((section) => ( @@ -941,6 +999,16 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str ))}
) : null} + {liabilityCarveOuts.length > 0 ? ( +
+ 责任限制 carve-out: + + {liabilityCarveOuts.map((item) => ( + {formatLiabilityCarveOut(item)} + ))} + +
+ ) : null} {paymentMilestones.length > 0 ? (
{paymentMilestones.map((item, index) => ( @@ -950,6 +1018,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str {item.summary ?? item.trigger_text ?? "-"} {formatPaymentStage(item.stage)} + {item.confidence_tier ? {formatRelationshipConfidence(item.confidence_tier)} : null} {item.due_within ? {item.due_within} : null} {item.ratio_percent != null ? {item.ratio_percent}% : null} {(item.dependencies ?? []).map((dependency) => ( @@ -969,6 +1038,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str {item.summary ?? "-"} {formatRelationshipStatus(item.status)} + {item.confidence_tier ? {formatRelationshipConfidence(item.confidence_tier)} : null} {item.source_clause_title ? {item.source_clause_title} : null} {item.target_clause_title ? {item.target_clause_title} : null} diff --git a/frontend/src/services/review.ts b/frontend/src/services/review.ts index 21c6811c..9e07b1e8 100644 --- a/frontend/src/services/review.ts +++ b/frontend/src/services/review.ts @@ -85,6 +85,7 @@ export type ReviewResult = { acceptance_terms?: string[]; confidentiality_terms?: string[]; intellectual_property_terms?: string[]; + liability_terms?: string[]; breach_terms?: string[]; dispute_resolution_terms?: string[]; payment_milestones?: Array<{ @@ -93,16 +94,22 @@ export type ReviewResult = { clause_title?: string | null; trigger_text?: string; stage?: string; + stage_label?: string; + label_hint?: string | null; due_within?: string | null; due_days?: number | null; due_unit?: string | null; ratio_percent?: number | null; dependencies?: string[]; + confidence_tier?: string; }>; + liability_carve_outs?: string[]; + intellectual_property_owner?: string | null; clause_relationships?: Array<{ type?: string; label?: string; status?: string; + confidence_tier?: string; source_clause_title?: string | null; target_clause_title?: string | null; summary?: string; @@ -155,6 +162,8 @@ export type ReviewResult = { evidence_coverage?: number; payment_milestone_count?: number; relationship_count?: number; + high_confidence_relationships?: number; + weak_hint_relationships?: 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..35483bd2 --- /dev/null +++ b/project-docs/OCR后续能力扩展阶段1第五批执行记录-2026-05-04.md @@ -0,0 +1,178 @@ +# OCR 后续能力扩展阶段 1 第五批执行记录(2026-05-04) + +## 1. 本批目标 + +基于阶段 1 第四批已经完成的复杂合同关系规则,继续推进第五批: + +1. 把更多真实业务形态合同批量拉进样本库。 +2. 区分高置信关系与弱提示关系。 +3. 深化付款分期、尾款、质保金等付款节点识别。 +4. 深化责任限制 carve-out 与保密、知识产权、违约之间的关系规则。 + +## 2. 本批改造 + +### 后端 + +1. `backend/app/services/review_service.py` + - 新增第五批结构化字段: + - `payment_milestones[].stage_label` + - `payment_milestones[].label_hint` + - `payment_milestones[].confidence_tier` + - `contract_structure.liability_carve_outs` + - `contract_structure.intellectual_property_owner` + - `confidence_summary.structured_analysis.high_confidence_relationships` + - `confidence_summary.structured_analysis.weak_hint_relationships` + - 新增真实中文别名兼容层: + - 合同类型提示词 + - 条款类型关键词 + - 付款触发词 + - 付款标签词 + - carve-out 关键词 + - 知识产权归属模式 + - 新增付款节点深化识别: + - 预付款 / 首期款 + - 进度款 / 实施款 + - 尾款 / 最终付款 + - 质保金 / 质保期尾款 + - 新增关系置信度分层: + - `high` + - `weak` + - 新增责任限制 carve-out 抽取: + - 故意 + - 重大过失 + - 保密义务 + - 知识产权 + - 违约责任 + - 新增知识产权归属抽取: + - 甲方所有 + - 乙方所有 + - 双方共有 + - 授权甲方使用 + - 新增 / 强化关系规则: + - `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` + +### 门禁与样本 + +1. `backend/tools/review_structured_acceptance_gate.py` + - 扩展到 8 个结构化验收样本。 + - 新增断言: + - 关系置信度 + - 关系缺失反例 + - 知识产权归属 + - liability carve-out + +2. 新增批量样本库清单: + - [真实业务合同批量样本库-2026-05-04.csv](D:/ai-contract-platform/project-docs/真实业务合同批量样本库-2026-05-04.csv) + +3. 新增重点样本: + - `S-007` 实施合同责任限制含 carve-out + - `S-008` 弱提示付款依赖样本 + +### 前端 + +1. `frontend/src/services/review.ts` + - 承接: + - `liability_terms` + - `liability_carve_outs` + - `intellectual_property_owner` + - `payment_milestones[].stage_label` + - `payment_milestones[].label_hint` + - `payment_milestones[].confidence_tier` + - `clause_relationships[].confidence_tier` + - `high_confidence_relationships` + - `weak_hint_relationships` + +2. `frontend/src/app/reviews/[id]/page.tsx` + - 新增知识产权归属展示 + - 新增责任限制 carve-out 标签展示 + - 新增付款节点高置信 / 弱提示标签 + - 新增条款关系高置信 / 弱提示标签 + - 新增更完整的责任限制、知识产权、复杂付款节点展示 + +## 3. 样本深化结果 + +当前结构化样本已覆盖 8 类业务形态: + +1. 服务合同基础摘要 +2. 采购合同缺失条款 +3. 购销合同编号条款切分 +4. 租赁合同责任限制识别 +5. 开发合同付款节点拆解 +6. 复杂实施合同关系深化 +7. 实施合同责任限制含 carve-out +8. 弱提示付款依赖样本 + +## 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` + - `frontend\\npm.cmd run lint` + - `PASS` + +### 线上验证 + +1. 后端健康检查: + - `http://192.168.3.90:8001/health` + - `PASS` + +2. 结构化审查门禁: + - `project-docs/qa-artifacts/review-structured-acceptance-gate/2026-05-04T17-15-50-744/` + - 结果:`PASS` + +3. PDF 验收门禁: + - `project-docs/qa-artifacts/pdf-acceptance-gate/2026-05-04T17-15-51-279/` + - 结果:`PASS` + +4. 前端烟雾验证: + - `project-docs/qa-artifacts/stage1-batch5-front-smoke/2026-05-04T09-23-11-044Z/` + - 已确认: + - 结构卡片可见 + - 关系区可见 + - 缺失项标签可见 + - 风险证据关键词标签可见 + +## 5. 部署记录 + +1. 联合部署: + - `project-docs/qa-artifacts/stage1-batch5-full-deploy/2026-05-04T17-11-21/` + +2. 联合重部署: + - `project-docs/qa-artifacts/stage1-batch5-full-redeploy/2026-05-04T17-15-09/` + +3. 前端单独重部署: + - `project-docs/qa-artifacts/stage1-batch5-frontend-redeploy/2026-05-04T17-19-16/` + +## 6. 当前结论 + +阶段 1 第五批已经闭环: + +1. 样本库已经从“少量结构化演示样本”推进到“批量真实业务形态样本”。 +2. 关系层已经从“只判断有没有”推进到“高置信 / 弱提示分层”。 +3. 责任限制条款开始支持 carve-out 精修。 +4. 知识产权归属开始进入结构化结果。 +5. 前端详情页已经能直接看到这些规则和结果。 + +## 7. 下一步建议 + +下一步最自然的是进入: + +**阶段 1 第六批:真实合同来源接入与字段准确率基线** + +建议优先处理: + +1. 引入更多真实来源合同文本或脱敏样本。 +2. 建立字段抽取准确率对照表。 +3. 建立高置信 / 弱提示关系准确率对照表。 +4. 对付款分期、知识产权归属、责任限制 carve-out 继续做更细的精度标定。 diff --git a/project-docs/真实业务合同批量样本库-2026-05-04.csv b/project-docs/真实业务合同批量样本库-2026-05-04.csv new file mode 100644 index 00000000..b615c5fb --- /dev/null +++ b/project-docs/真实业务合同批量样本库-2026-05-04.csv @@ -0,0 +1,9 @@ +sample_id,title,contract_family,source_mode,focus,expected_highlights,confidence_focus +S-001,服务合同结构化摘要,service,realistic_sanitized,基础结构化摘要,合同类型/金额/期限/付款/保密/违约,高置信关系 +S-002,采购合同缺失条款提示,procurement,realistic_sanitized,缺失条款识别,付款/交付/缺失验收/缺失保密,弱提示关系 +S-003,购销合同编号条款分段,sales,realistic_sanitized,编号条款切分,交货/付款/争议解决,高置信关系 +S-004,租赁合同责任限制识别,lease,realistic_sanitized,责任限制识别,交付/责任限制/违约,高置信关系 +S-005,开发合同付款节点拆解,development,realistic_sanitized,付款分期拆解,首期款/尾款/验收依赖/发票依赖,高置信关系 +S-006,复杂实施合同关系深化,service,realistic_sanitized,复杂关系组合,付款分期/质保金/保密/知识产权/责任限制/违约,高置信关系 +S-007,实施合同责任限制含 carve-out,service,realistic_sanitized,carve-out 规则校正,责任限制/保密/知识产权/违约,高置信与反例 +S-008,弱提示付款依赖样本,service,realistic_sanitized,弱提示关系校正,付款依赖验收但无独立验收条款,弱提示关系