feat: deepen structured field extraction

This commit is contained in:
Codex
2026-05-04 15:51:31 +08:00
parent 4a9b1ee8a3
commit fda8b4d0a0
6 changed files with 607 additions and 5 deletions
+228 -5
View File
@@ -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
+28
View File
@@ -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]
+81
View File
@@ -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>
+37
View File
@@ -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. 建立更细的字段和关系验收基线。