
Contract Review Pro
- 81 installs
- 64 repo stars
- Updated July 31, 2026
- lovstudio/skills
Helps with ai & agent building tasks.
About
contract-review-pro is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- contract-review-pro
- AI & Agent Building
- AI-coding skill
Contract Review Pro by the numbers
- 81 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #5,216 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lovstudio/skills --skill contract-review-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 64 |
| Last updated | July 31, 2026 |
| Repository | lovstudio/skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Contract Review Skill
Overview
This skill performs contract reviews by adding comments only (no edits to the original text). It follows a four-layer review (entity verification, basic, business, legal) and generates:
- Annotated contract (.docx)
- Contract summary (.docx)
- Consolidated review opinion (.docx)
- Business flowchart (Mermaid + rendered image)
Language rule: detect the contract’s dominant language and output all generated content (comments, summary, opinion, flowchart text) in that language. Use the guidance in [references/language.md](references/language.md).
Workflow
1. Unpack the contract (.docx) for XML operations 2. Read contract text (pandoc or XML) 3. Extract and verify contracting parties (Layer 0) 4. Execute three-layer clause review (Layer 1–3) 5. Add comments to the document 6. Generate contract summary 7. Generate consolidated opinion 8. Generate business flowchart and render image 9. Repack to .docx
Output Naming
- Output directory:
审核结果:{ContractName}for Chinese orReview_Result_{ContractName}for English - Reviewed contract:
{ContractName}_审核版.docxfor Chinese or{ContractName}_Reviewed.docxfor English - Review report:
审核报告.txtfor Chinese orReview_Report.txtfor English
Comment Principles
- Comments only: do not modify the original text or formatting
- Precise anchoring: comment should target specific clauses/paragraphs
- Structured content: each comment includes issue type, risk reason, and revision suggestion
- Risk level: carried by reviewer name; do not include a “risk level” line in comment body
- Output language: use labels in the contract’s language (see
references/language.md)
Comment example (English):
[Issue Type] Payment Terms
[Risk Reason] The total amount is stated as USD 100,000 in Section 3.2, but the payment clause lists USD 1,000,000 in Section 5.1. This inconsistency may cause disputes.
[Revision Suggestion] Align the total amount across clauses and clarify whether tax is included.Review Standards
Use the four-layer review model and the detailed checklist in [references/checklist.md](references/checklist.md).
Layer 0: Entity verification (subject authenticity)
- Extract all contracting parties (full legal names, credit codes, legal representatives)
- Verify each entity's registered name accuracy and business registration status
- Verification tool priority:
1. If an MCP tool for business registration lookup is available in the current environment (e.g., enterprise info query, company lookup, 企业查询, 工商查询), use it to query each party's name or Unified Social Credit Code. 2. If no such MCP tool is available, use Web Search to look up "[entity name] 工商登记信息" or "[entity name] business registration". 3. Record the verification source (MCP tool name / Web Search) in the comment.
Layer 1: Basic (text quality)
- Accuracy of numbers, dates, terms
- Consistent numbering and references
- Clarity and lack of ambiguity
- Formatting and punctuation quality
Layer 2: Business terms
- Scope, deliverables, quantity/specs
- Pricing and payment schedule
- Delivery/acceptance procedures
- Rights/obligations and performance guarantees
Layer 3: Legal terms
- Effectiveness and term/termination
- Liability/penalties and remedies
- Dispute resolution and governing law
- Confidentiality, force majeure, IP, notice, authorization
Risk levels (encoded in reviewer name):
- 🔴 High: core business ambiguity (price, scope, rights/obligations)
- 🟡 Medium: material but non-core ambiguity
- 🔵 Low: minimal practical impact
Contract Summary
Generate a structured, objective summary in the contract’s language.
- See [references/summary.md](references/summary.md) (English template)
- Use [references/language.md](references/language.md) for language selection and Chinese labels
Output file: 合同概要.docx for Chinese or Contract_Summary.docx for English (default font: 仿宋; adjust if language requires)
Consolidated Opinion
Generate a concise, two-paragraph response for the business team in the contract’s language.
- See [references/opinion.md](references/opinion.md)
Output file: 综合审核意见.docx for Chinese or Consolidated_Opinion.docx for English (default font: 仿宋; adjust if language requires)
Business Flowchart (Mermaid)
Generate Mermaid flowchart per requirements and render to image.
- See [references/flowchart.md](references/flowchart.md)
Outputs:
business_flowchart.mmdbusiness_flowchart.png
li## Technical Notes
Core workflow: 1. Unpack → 2. Entity verification → 3. Add comments → 4. Summary → 5. Opinion → 6. Flowchart → 7. Repack
API & implementation details:
- [references/technical.md](references/technical.md)
Dependencies
- Python 3.9+ (3.10+ recommended)
- pandoc (system install)
- defusedxml
- Mermaid CLI (
mmdc) for rendering - python-docx for rich text output
Troubleshooting (Short)
- Comments missing in Word: run
doc.verify_comments()and re-save - find_paragraph fails: shorten search text; confirm actual paragraph text
- Mermaid render fails: ensure
mmdcinstalled; use Chrome path or Puppeteer config
Examples
See [references/examples.md](references/examples.md) for a full workflow example.
Important Rules
1. Never alter original contract text 2. Entity verification (Layer 0) must complete before clause review (Layers 1–3) 3. Review all four layers, do not skip items 4. Ensure risk level is accurate and consistent 5. Keep comments precise, professional, and actionable 6. Flowchart must come strictly from the contract text 7. Summary is objective only; no risk analysis 8. Opinion only reflects findings already identified
License
SPDX-License-Identifier: Apache-2.0
Copyright (c) 2026 JiCheng
Licensed under the Apache License, Version 2.0. See repository root LICENSE.
Contract Review Pro / 合同审阅(专业版)
Professional-grade contract review skill. Adds comment-based issue annotations without modifying the original text. Enforces a four-layer review methodology and produces a full review deliverable.
专业级合同审阅 skill。只加批注、不改原文;四层方法论审查,并生成完整的审核交付物。
What you get / 产出
- Annotated contract (.docx) — inline comments anchored to specific clauses
- Contract summary (.docx) — key terms, amounts, parties at a glance
- Consolidated review opinion (.docx) — prioritized issue list with recommendations
- Business flowchart — Mermaid source + rendered image
- 批注版合同(.docx) — 精准锚定条款的批注
- 合同摘要(.docx) — 关键条款 / 金额 / 主体一览
- 综合审核意见(.docx) — 按优先级排序的问题清单与建议
- 业务流程图 — Mermaid 源码 + 渲染图
Four-layer methodology / 四层方法论
0. Entity verification — 主体核验:确认签约方资质 1. Basic review — 基础审查:标题、日期、条款编号、引用一致性 2. Business review — 业务审查:商业条款合理性与内部一致性 3. Legal review — 法务审查:风险条款、责任分配、争议解决
Language
Output language follows the contract's dominant language (detected automatically). All comments, summary, opinion, and flowchart labels are generated in the detected language.
输出语言跟随合同主导语言自动适配。
Install
npx skills add lovstudio/contract-review-pro-skill --all -gSee also
- `review-doc` — lightweight daily version for general document review
- `review-doc` — 日常轻量版,适用于普通文档审阅
License
MIT — content adapted from jicheng's contract-review methodology.
Contract Review Checklist
Layer 0: Entity Verification (Subject Authenticity)
1. Entity Extraction
- [ ] All contracting parties are identified (full legal names)
- [ ] Unified Social Credit Code (统一社会信用代码) is extracted if present
- [ ] Legal representative names are noted if stated
- [ ] Entity type is identified (company, individual business, natural person, etc.)
2. Name Accuracy
- [ ] Registered name matches exactly (no typos, missing/extra characters)
- [ ] Entity type suffix is correct (有限公司 vs 有限责任公司, etc.)
- [ ] If trade name differs from registered name, both are verified
3. Authenticity & Status
- [ ] Entity exists in business registration records
- [ ] Entity is not in revoked/cancelled/abnormal status
- [ ] Business scope covers the contract's subject matter (if verifiable)
4. Verification Method
- [ ] Use MCP business lookup tool (企业详情查询) if available
- [ ] Fall back to Web Search if MCP tool is unavailable
- [ ] Record verification source in comment
Risk level guidance:
- 🔴 High: entity does not exist, or name significantly wrong, or revoked/cancelled
- 🟡 Medium: minor name discrepancy, or status could not be fully verified
- 🔵 Low: minor formatting difference in name (e.g., punctuation)
---
Layer 1: Basic Review (Text Quality)
1. Text Accuracy
- [ ] Key terms and spellings are correct
- [ ] Numbers, amounts, and ratios are accurate
- [ ] Amounts in words match numerals
- [ ] Dates are precise (avoid vague terms like “soon”)
2. Formatting Consistency
- [ ] Punctuation is correct
- [ ] Clause numbering is sequential
- [ ] No duplicate numbering
- [ ] Layout is clean
- [ ] Signature blocks have enough space
3. Clarity of Expression
- [ ] No grammar errors
- [ ] No unclear statements
- [ ] No ambiguity in time/quantity/quality
- [ ] Terminology is used correctly
4. Internal Consistency
- [ ] Same concept uses consistent naming
- [ ] Cross‑references are correct
- [ ] No logical conflicts across clauses
- [ ] Attachments match the main text
Risk level guidance:
- 🔴 High: ambiguity in core terms (price, subject matter, rights/obligations)
- 🟡 Medium: ambiguity in non‑core terms
- 🔵 Low: minimal practical impact
---
Layer 2: Business Terms
1. Purpose & Term
- [ ] Purpose is clear
- [ ] Background is stated
- [ ] Start/end dates are clear
- [ ] Renewal terms are clear (if any)
2. Subject Matter
- [ ] Quantity is specific
- [ ] Category/brand/model/specs are clear
- [ ] Quality standards are clear
- [ ] Acceptance terms are operable
- [ ] Legality/ownership status is clear
3. Price & Payment
- [ ] Price structure is clear
- [ ] Pricing method is clear
- [ ] Currency is clear
- [ ] Tax separation is clear
- [ ] Tax responsibility is clear
- [ ] Payment method is clear
- [ ] Payment milestones align with performance
- [ ] Payment conditions are operable
- [ ] Invoice/receipt terms are clear
4. Performance
- [ ] Performance timeline is specific
- [ ] Performance location is specific
- [ ] Performance method is detailed
- [ ] Performance process is structured
- [ ] Title transfer point is clear
- [ ] Risk transfer point is clear
- [ ] Notice obligations are clear
5. Rights & Obligations
- [ ] Main rights are complete
- [ ] No implied waiver
- [ ] Exemption clauses are reasonable
- [ ] Main obligations are complete
- [ ] Standards for obligations are clear
- [ ] Obligations are feasible
- [ ] Post‑contract obligations are clear
- [ ] Ancillary rights/obligations are clear
6. Intellectual Property
- [ ] Existing IP ownership is clear
- [ ] IP created during performance is clear
- [ ] Scope/purpose/term of IP use is clear
- [ ] IP transfer/license terms are clear
- [ ] Protection responsibilities are clear
- [ ] Confidentiality/competition limits are reasonable
Risk level guidance:
- 🔴 High: core business cannot proceed due to contradictions/ambiguity/gaps
- 🟡 Medium: material dispute risk but business can still proceed
- 🔵 Low: minimal impact on business
---
Layer 3: Legal Terms
1. Effectiveness
- [ ] Formation vs. effectiveness is distinguished
- [ ] Effectiveness conditions are clear
- [ ] Feasibility of conditions is considered
- [ ] Pre‑effect legal responsibility is addressed
2. Liability/Default
- [ ] Default types are clearly defined
- [ ] Remedies are clear
- [ ] Penalty ratio is reasonable
- [ ] Liability is balanced
- [ ] Calculation method is clear
3. Amendment/Termination
- [ ] Amendment conditions are clear
- [ ] Amendment procedure is clear
- [ ] Termination conditions are reasonable
- [ ] Termination procedure is operable
- [ ] End‑of‑term conditions are clear
- [ ] Survival clauses are reasonable
- [ ] Post‑termination duties are clear
4. Governing Law
- [ ] Governing law is specified
- [ ] Choice of law is reasonable
- [ ] No conflict with mandatory rules
- [ ] Enforceability is considered
5. Confidentiality
- [ ] Confidential info is defined
- [ ] Confidentiality term is clear
- [ ] Exceptions are limited and reasonable
- [ ] Breach liability is clear
6. Force Majeure
- [ ] Events are defined reasonably
- [ ] Notice duty is clear
- [ ] Exemption conditions are fair
- [ ] Follow‑up measures are clear
7. Dispute Resolution
- [ ] Dispute method is clear
- [ ] Jurisdiction/arbitration body is clear
- [ ] No conflict between arbitration/litigation
- [ ] Governing law matches dispute forum
8. Notice
- [ ] Notice method is clear
- [ ] Address/contact is complete
- [ ] Effective time/conditions are clear
- [ ] Change‑of‑address notice duty is clear
9. Authorization
- [ ] Authorized persons are clear
- [ ] Scope/authority is clear
- [ ] Term is reasonable
- [ ] Revocation/change mechanism is clear
10. Other Legal Terms
- [ ] Interpretation rules are clear
- [ ] Signing time/place is clear
- [ ] Severability is clear
Risk level guidance:
- 🔴 High: missing legal terms or unreasonable liability
- 🟡 Medium: other material issues
- 🔵 Low: minor issues
---
Usage Notes
Review Process
1. Review in order: Layer 0 → Layer 1 → Layer 2 → Layer 3 2. Evaluate each checklist item 3. Record findings and risk levels 4. Add structured comments to the document
Comment Format
Each comment must include:
- Issue type (matches checklist)
- Risk reason (impact and reasoning)
- Revision suggestion (actionable fix)
Risk level is encoded by reviewer name; do not add a “risk level” line inside the comment body.
Priority
- High: address first
- Medium: address next
- Low: optional cleanup
Contract Review Examples
Quick Start
# -*- coding: utf-8 -*-
from scripts.workflow import ContractReviewWorkflow
comments = [
{
"search": "Total Price",
"comment": """[Issue Type] Payment Terms
[Risk Reason] The total amount is stated as USD 100,000 in Section 3.2, but the payment clause lists USD 1,000,000 in Section 5.1.
[Revision Suggestion] Align the total amount across clauses and clarify tax inclusion.""",
"risk_level": "High",
}
]
workflow = ContractReviewWorkflow("Contract.docx", "Reviewer")
workflow.run_full_workflow(comments, "Contract_Reviewed.docx")Contract Summary (English)
# -*- coding: utf-8 -*-
summary_text = """I. Basic Contract Information
Item\tContent
Contract Name\tNot specified
Contract Type\tNot specified
Parties\tParty A: Not specified
Party B: Not specified
Signing Date\tNot specified
Term\tNot specified
Contract Amount\tNot specified
II. Business Model Overview
Brief description: Not specified
III. Key Clause Elements
3.1 Transaction Elements
Element\tDetails
Subject Matter/Services\tNot specified
Quantity/Specs\tNot specified
Pricing Structure\tNot specified
Payment Terms\tNot specified
Delivery Terms\tNot specified
3.2 Rights and Obligations
Party A main rights/obligations:
Not specified
Party B main rights/obligations:
Not specified
3.3 Performance Safeguards
Clause Type\tDetails
Liability/Default\tNot specified
Guarantees/Security\tNot specified
Acceptance Standards\tNot specified
Quality Warranty\tNot specified
3.4 Risk Allocation & Special Terms
Risk Allocation:
Not specified
Special Terms:
Not specified
3.5 Dispute Resolution & Termination
Item\tDetails
Dispute Resolution\tNot specified
Amendment\tNot specified
Termination\tNot specified
Governing Law\tNot specified
IV. Key Timeline Milestones
Not specified
"""
workflow = ContractReviewWorkflow("Contract.docx", "Reviewer")
workflow.run_full_workflow(
comments,
"Contract_Reviewed.docx",
summary_text=summary_text,
summary_filename="Contract_Summary.docx",
summary_font="Times New Roman",
)Consolidated Opinion (English)
# -*- coding: utf-8 -*-
opinion_text = """This agreement is a goods sales contract under which our side purchases specific devices from the counterparty for a total amount of USD 100,000, payable as a 30% prepayment and 70% balance after acceptance, with delivery and acceptance milestones defined in the contract.
After review, the following key risks require attention: 1. Product model names are inconsistent across clauses, which may cause delivery disputes; 2. The prepayment amount does not match the stated percentage, potentially causing payment execution issues; 3. Delivery timing is stated as “reasonable time,” which is ambiguous and may lead to delay disputes."""
workflow = ContractReviewWorkflow("Contract.docx", "Reviewer")
workflow.run_full_workflow(
comments,
"Contract_reviewed.docx",
opinion_text=opinion_text,
opinion_filename="Consolidated_Opinion.docx",
opinion_font="Times New Roman",
)Business Flowchart (Mermaid)
# -*- coding: utf-8 -*-
flowchart_mermaid = """flowchart TD
A[Contract Signed] -->|?| B[Performance]
"""
workflow = ContractReviewWorkflow("Contract.docx", "Reviewer")
workflow.run_full_workflow(
comments,
"Contract_reviewed.docx",
flowchart_mermaid=flowchart_mermaid,
)Full Workflow Example
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
import sys
from pathlib import Path
skill_dir = Path(__file__).parent.parent
sys.path.insert(0, str(skill_dir))
from scripts.workflow import ContractReviewWorkflow
contract_path = "path/to/contract.docx"
output_path = "contract_reviewed.docx"
report_path = "review_report.txt"
reviewer = "Reviewer"
comments = [
{
"search": "Total Price",
"comment": """[Issue Type] Payment Terms
[Risk Reason] Amount mismatch between pricing and payment clauses.
[Revision Suggestion] Align total amount and clarify tax.""",
"risk_level": "High"
}
]
summary_text = """I. Basic Contract Information
Item\tContent
Contract Name\tNot specified
Contract Type\tNot specified
Parties\tParty A: Not specified
Party B: Not specified
Signing Date\tNot specified
Term\tNot specified
Contract Amount\tNot specified
"""
opinion_text = """This agreement is a services contract between our side and the counterparty with defined scope, pricing, and milestones.
After review, the following key risks require attention: 1. Payment timing is unclear; 2. Acceptance criteria are missing."""
flowchart_mermaid = """flowchart TD
A[Contract Signed] -->|?| B[Performance]
"""
workflow = ContractReviewWorkflow(
contract_path=contract_path,
reviewer_name=reviewer,
enable_smart_keyword_expansion=False,
)
workflow.run_full_workflow(
comments=comments,
output_docx_filename=output_path,
report_filename=report_path,
summary_text=summary_text,
summary_filename="Contract_Summary.docx",
summary_font="Times New Roman",
opinion_text=opinion_text,
opinion_filename="Consolidated_Opinion.docx",
opinion_font="Times New Roman",
flowchart_mermaid=flowchart_mermaid,
render_flowchart=True,
parallel_outputs=True,
)
print(f"✓ Added comments: {len(workflow.comments_added)}")
print(f"✗ Failed comments: {len(workflow.comments_failed)}")Language Notes
- Output must follow the contract’s language.
- For Chinese contracts, use the Chinese labels in [language.md](language.md) and set
summary_font/opinion_fontto Fangsong (仿宋).
Business Flowchart Extraction (Mermaid)
Generate a Mermaid flowchart by extracting the full business transaction process from the contract.
Language rule: use the contract’s language for node text and labels. See [language.md](language.md).
Extraction Requirements
1. Process Completeness
- Cover the full lifecycle from contract signing to performance completion
- Include normal performance, breach handling, and termination
- Show interactions, rights, and obligations between both parties
2. Precision of Information
- Time points: extract explicit time requirements and triggers (e.g., X business days, X hours)
- Amounts: extract amounts and ratios (e.g., 30% prepayment, total amount)
- Quantity/specs: extract quantities, models, technical specs
- Locations: delivery/acceptance locations
- Standards: acceptance standards, quality requirements, technical specs
- Strict source: all extracted data must come from the contract text
3. Node Format
- Each node uses brackets
[] - Use
<br>to break multiple items - Edge labels show trigger/time requirement:
|condition|; if missing, use?
4. Flow Logic
- Use
-->for normal flow - Include decision branches (e.g., acceptance pass/fail)
- Include parallel flows (e.g., risk transfer vs. title transfer)
- Show escalation of breach outcomes (minor → serious → termination)
5. Visual Styles
Add styles at the end:
- Normal performance nodes:
style [nodeId] fill:#e6e6fa - Breach-related nodes:
style [nodeId] fill:#ffff99 - Termination nodes:
style [nodeId] fill:#ff6666 - Normal completion nodes:
style [nodeId] fill:#90ee90
Output Format
- Output only Mermaid code, starting with
flowchart TD - No extra explanation or code fences
- Syntax must be valid and renderable
Output Language Guide
Use the contract’s dominant language for all outputs: comment labels, summary headings, opinion text, and flowchart node text.
Language Selection Rules
1. Detect dominant language by character ratio in the contract body:
- If Chinese (CJK) characters are the clear majority (≈60%+), use Chinese.
- If Latin letters are the clear majority (≈60%+), use English.
2. If bilingual, follow the language used in section headings/titles. 3. If still ambiguous, ask the user; if no response, default to English.
Do not mix languages within the same output.
Comment Label Mapping
English
[Issue Type][Risk Reason][Revision Suggestion]
Chinese
【问题类型】【风险原因】【修订建议】
Layer 0 Label Mapping
English
[Verification Source][Registration Status]
Chinese
【核查来源】【登记状态】
Summary Heading Mapping (Chinese)
When the contract is Chinese, use these headings/labels exactly:
一、合同基本信息项目/内容合同名称/合同类型/合同当事人(含主体核查结果:登记状态、核查来源) /签订时间/合同期限/合同金额二、业务模式概述简要描述:...三、核心条款要素3.1 交易要素要素/具体内容标的物/服务内容/数量规格/价格构成/支付方式/交付方式3.2 权利义务分配甲方主要权利义务:...乙方主要权利义务:...3.3 履行保障条款条款类型/具体约定违约责任/担保措施/验收标准/质量保证3.4 风险分担与特殊约定风险分担:...特殊约定:...3.5 争议解决与合同终止项目/约定内容争议解决方式/合同变更/合同解除/适用法律四、关键时间节点
Opinion Wording (Chinese)
For the second paragraph, use a lead-in similar to:
经审核,本合同存在以下几项主要风险需提请关注:
Then list numbered items in separate paragraphs.
Consolidated Review Opinion
Based on the contract summary and audit findings, draft a concise response to the business team. The output must contain two natural paragraphs and must follow the contract’s language. See [language.md](language.md).
Default output file name: 综合审核意见.docx for Chinese or Consolidated_Opinion.docx for English.
Paragraph 1: Contract Core Overview
Write one coherent paragraph summarizing the contract’s core content:
- Contract nature (e.g., “a three‑year equipment procurement framework agreement”)
- Core business model (what is traded, total amount, payment schedule)
- Key timing milestones
Referencing rules:
- Use “our side” / “counterparty” or specific company names
- Avoid “Party A / Party B” labels
Paragraph 2: Major Risks
Write one paragraph that lists the key risks discovered only from audit findings:
- Start with a summary sentence, e.g., “After review, the following key risks require attention:”
- Use numbered items (1. 2. 3.)
- Each item must describe: risk type, concrete issue, potential impact, and attention level
- Do not introduce new risks not in the audit findings
Output Requirements
- Two paragraphs only; no section headings
- Numbered items should be separate paragraphs in rich text output
- Language concise, professional, objective
Contract Summary Extraction
Objectively summarize the contract’s basic content to help legal teams understand the agreement quickly. Do not provide risk assessments or legal advice.
Default output is a DOCX rich-text file (合同概要.docx for Chinese, Contract_Summary.docx for English). Font defaults to Fangsong; adjust summary_font for non‑Chinese languages.
Language rule: output in the contract’s language. See [language.md](language.md) for selection rules and label mappings.
Output Format
I. Basic Contract Information Item Content Contract Name [Extract title or designated name] Contract Type [e.g., Sales, Services, Lease] Parties Party A: [Name, Address] Party B: [Name, Address] Signing Date [Signature date] Term [Start/end date or performance period] Contract Amount [Total amount & currency; include installment schedule if any]
II. Business Model Overview Brief description: [1–2 paragraphs summarizing the core transaction and relationship]
III. Key Clause Elements 3.1 Transaction Elements Element Details Subject Matter/Services [Goods, services, or deliverables] Quantity/Specs [Quantity, model, technical specs] Pricing Structure [Unit price, total price, adjustment mechanism] Payment Terms [Payment ratio, milestones, method] Delivery Terms [Location, time, acceptance standards]
3.2 Rights and Obligations Party A main rights/obligations:
[Main rights] [Main obligations]
Party B main rights/obligations:
[Main rights] [Main obligations]
3.3 Performance Safeguards Clause Type Details Liability/Default [Consequences for each party] Guarantees/Security [Deposit, guarantee, etc.] Acceptance Standards [Procedure, criteria, dispute handling] Quality Warranty [Warranty period, responsibilities, after-sales]
3.4 Risk Allocation & Special Terms Risk Allocation:
[Force majeure clause] [Risk transfer trigger] [Loss allocation]
Special Terms:
[Intellectual property] [Confidentiality] [Exclusivity] [Other special conditions or restrictions]
3.5 Dispute Resolution & Termination Item Details Dispute Resolution [Negotiation, mediation, arbitration, litigation & jurisdiction] Amendment [Conditions and procedure] Termination [Triggers, procedure, consequences] Governing Law [Applicable laws/regulations]
IV. Key Timeline Milestones [List key milestones in chronological order: signing, payment, delivery, acceptance, etc.]
Output Requirements
- Objectivity: only extract and describe clauses; no subjective judgments
- Completeness: if a clause is missing, write “Not specified”
- Accuracy: faithfully reflect the contract text; quote critical wording when needed
- Conciseness: 500–800 words (simple), 800–1200 (standard), 1200–1500 (complex)
- Structure: strictly follow the above format for quick navigation
OOXML Notes for Comment-Only Reviews
This reference documents the minimal OOXML pieces needed by this skill. It is intentionally lightweight and focused on comment insertion only.
Scope
- Supported: Adding Word comments to an existing .docx without changing document text.
- Not supported: Tracked changes, rich styling, or full schema validation.
- Validation: Only file presence + XML well-formedness.
Required Files
The comment workflow touches only these parts inside a .docx package:
word/document.xml(the main body)word/comments.xml(comment bodies)word/_rels/document.xml.rels(relationship tocomments.xml)[Content_Types].xml(content type override forcomments.xml)
Namespaces
Use the WordprocessingML namespace for comment elements:
w=http://schemas.openxmlformats.org/wordprocessingml/2006/main
Comment Injection Model
To add a comment:
1. Insert a w:commentRangeStart element before the target paragraph content. 2. Insert a w:commentRangeEnd element at the end of the paragraph. 3. Insert a w:commentReference run right after the range end. 4. Append a w:comment entry in word/comments.xml. 5. Ensure document.xml.rels and [Content_Types].xml include the comments part.
Range Markup Example
<w:commentRangeStart w:id="12"/>
<!-- paragraph content -->
<w:commentRangeEnd w:id="12"/>
<w:r><w:commentReference w:id="12"/></w:r>Comment Body Example
<w:comment w:id="12" w:author="Reviewer" w:initials="RV" w:date="2025-01-10T12:00:00Z">
<w:p><w:r><w:t>Comment text line 1</w:t></w:r></w:p>
<w:p><w:r><w:t>Comment text line 2</w:t></w:r></w:p>
</w:comment>If a w:t node begins or ends with whitespace, add xml:space="preserve" so Word preserves spacing.
Relationships
word/_rels/document.xml.rels must reference the comments part:
<Relationship Id="rIdX"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
Target="comments.xml"/>Content Types
[Content_Types].xml must include:
<Override PartName="/word/comments.xml"
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>Common Failure Modes
- Comments do not show up: missing relationship or content type override.
- Comments show but are empty:
comments.xmlmalformed orw:tmissing text node. - Spaces missing: add
xml:space="preserve"on thew:tnodes.
When to Extend
If you need tracked changes, comment replies, or advanced metadata (e.g., commentsExtended), add separate modules and document them here. Keep this file short and only include what this skill actually uses.
"""Package marker for contract-review scripts."""
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
合同智能分析模块
自动提取合同文本,识别合同类型和关键条款位置,
为批注提供智能搜索关键词或行号定位。
"""
import subprocess
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from defusedxml import minidom
class ContractAnalyzer:
"""合同智能分析器"""
def __init__(self, contract_path: str):
"""
初始化分析器
Args:
contract_path: 合同文件路径
"""
self.contract_path = Path(contract_path)
self.full_text = ""
self.paragraphs = []
self.contract_type = "unknown"
self.key_clauses = {}
def extract_text(self) -> str:
"""
使用pandoc提取合同纯文本
Returns:
str: 合同的纯文本内容
"""
try:
result = subprocess.run(
["pandoc", "-f", "docx", "-t", "plain", str(self.contract_path)],
capture_output=True,
text=True,
check=True,
timeout=30
)
self.full_text = result.stdout
return self.full_text
except subprocess.CalledProcessError as e:
print(f"⚠️ pandoc提取失败: {e}")
return ""
except subprocess.TimeoutExpired:
print(f"⚠️ pandoc提取超时")
return ""
except FileNotFoundError:
print(f"⚠️ 未找到pandoc命令,请先安装: sudo apt-get install pandoc")
return ""
def extract_paragraphs_with_line_numbers(self) -> List[Tuple[int, str]]:
"""
提取段落文本及对应的行号
Returns:
List[Tuple[int, str]]: [(行号, 段落文本), ...]列表
"""
if not self.full_text:
self.extract_text()
lines = self.full_text.split('\n')
self.paragraphs = []
for i, line in enumerate(lines, 1):
stripped = line.strip()
if stripped: # 跳过空行
self.paragraphs.append((i, stripped))
return self.paragraphs
def identify_contract_type(self) -> str:
"""
识别合同类型
Returns:
str: 合同类型 (training/purchase/service/cooperation/unknown)
"""
if not self.full_text:
self.extract_text()
text_lower = self.full_text.lower()
# 关键词匹配规则
type_keywords = {
"training": ["培训", "训练", "课程", "讲师", "学员"],
"purchase": ["采购", "购销", "买方", "卖方", "订购", "货物"],
"service": ["服务", "提供", "服务费", "维护", "技术支持"],
"cooperation": ["合作", "推广", "联合", "协议", "框架"],
}
# 统计每种类型的关键词出现次数
type_scores = {}
for contract_type, keywords in type_keywords.items():
score = sum(1 for keyword in keywords if keyword in text_lower)
if score > 0:
type_scores[contract_type] = score
# 返回得分最高的类型
if type_scores:
self.contract_type = max(type_scores, key=type_scores.get)
else:
self.contract_type = "unknown"
return self.contract_type
def find_clause_location(self, search_keywords: List[str]) -> Optional[int]:
"""
查找包含指定关键词的条款所在行号
Args:
search_keywords: 搜索关键词列表
Returns:
Optional[int]: 找到的行号,未找到返回None
"""
if not self.paragraphs:
self.extract_paragraphs_with_line_numbers()
# 遍历所有段落,查找包含任一关键词的段落
for line_num, paragraph in self.paragraphs:
for keyword in search_keywords:
if keyword in paragraph:
return line_num
return None
def analyze_common_fields(self) -> Dict[str, List[str]]:
"""
分析合同中的常见字段,返回多个可能的搜索关键词
支持中英文标点符号变体
Returns:
Dict[str, List[str]]: 字段名到关键词列表的映射
"""
if not self.full_text:
self.extract_text()
# 定义常见字段,支持中英文标点符号变体
common_fields = {
"合同编号": [
"合同编号:", "协议编号:", "合同号:", "协议号:", "编号:",
"合同编号:", "协议编号:", "合同号:", "协议号:", "编号:", # 中文冒号
],
"合同金额": [
"合同总金额", "协议总金额", "合同价款", "协议价款", "费用总额", "总金额",
"¥", "人民币", "元", # 金额符号
],
"签署日期": [
"签署日期", "签订日期", "签约日期", "签署时间", "生效日期",
"签署日期:", "签订日期:", # 中文冒号
],
"甲方": [
"甲方:", "甲方(", "需方:", "买方:", "委托方:",
"甲方:", "甲方(", "需方:", # 中文冒号
],
"乙方": [
"乙方:", "乙方(", "供方:", "卖方:", "服务方:",
"乙方:", "乙方(", "供方:", # 中文冒号
],
"违约责任": [
"违约责任", "违约金", "赔偿责任", "赔偿条款",
"违约责任:", "违约金:", # 中文冒号
],
"争议解决": [
"争议解决", "纠纷解决", "管辖", "诉讼", "仲裁",
"争议解决:", # 中文冒号
],
"保密条款": [
"保密", "商业秘密", "保密义务", "保密条款",
"保密:", # 中文冒号
],
}
# 检查每个字段在合同中的实际表述
found_fields = {}
for field_name, keywords in common_fields.items():
found_keywords = []
for keyword in keywords:
if keyword in self.full_text:
found_keywords.append(keyword)
if found_keywords:
found_fields[field_name] = found_keywords
return found_fields
def generate_smart_search_keywords(self) -> Dict[str, List[str]]:
"""
生成智能搜索关键词
基于合同内容分析,为常见审核点提供最合适的搜索关键词。
Returns:
Dict[str, List[str]]: 审核点到关键词列表的映射
"""
# 先分析常见字段
common_fields = self.analyze_common_fields()
# 生成智能搜索关键词映射
smart_keywords = {
"合同编号为空": common_fields.get("合同编号", ["合同编号:", "协议编号:"]),
"金额表述不一致": common_fields.get("合同金额", ["合同总金额", "总金额", "¥"]),
"签署日期": common_fields.get("签署日期", ["签署日期", "签订日期"]),
"甲方信息": common_fields.get("甲方", ["甲方:"]),
"乙方信息": common_fields.get("乙方", ["乙方:"]),
"违约责任条款": common_fields.get("违约责任", ["违约责任", "违约金"]),
"争议解决条款": common_fields.get("争议解决", ["争议解决", "协商"]),
"保密条款": common_fields.get("保密条款", ["保密", "商业秘密"]),
}
return smart_keywords
def get_contract_summary(self) -> Dict:
"""
获取合同摘要信息
Returns:
Dict: 包含合同类型、关键词字段等信息的字典
"""
if not self.full_text:
self.extract_text()
if not self.paragraphs:
self.extract_paragraphs_with_line_numbers()
if self.contract_type == "unknown":
self.identify_contract_type()
smart_keywords = self.generate_smart_search_keywords()
return {
"contract_type": self.contract_type,
"total_paragraphs": len(self.paragraphs),
"text_length": len(self.full_text),
"smart_keywords": smart_keywords,
"found_fields": len(smart_keywords)
}
def demo():
"""演示合同分析功能"""
# 示例用法
analyzer = ContractAnalyzer("合同.docx")
# 1. 提取文本
text = analyzer.extract_text()
print(f"提取文本长度: {len(text)}")
# 2. 识别合同类型
contract_type = analyzer.identify_contract_type()
print(f"合同类型: {contract_type}")
# 3. 分析常见字段
common_fields = analyzer.analyze_common_fields()
print(f"发现字段: {list(common_fields.keys())}")
# 4. 生成智能搜索关键词
smart_keywords = analyzer.generate_smart_search_keywords()
print("智能搜索关键词:")
for field, keywords in smart_keywords.items():
print(f" {field}: {keywords}")
# 5. 获取合同摘要
summary = analyzer.get_contract_summary()
print(f"\n合同摘要: {summary}")
if __name__ == "__main__":
demo()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Minimal DOCX comment editor for contract review.
"""
from __future__ import annotations
import html
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
from .utilities import XMLEditor
COMMENTS_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"
)
COMMENTS_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
)
class Document:
"""Edit an unpacked .docx directory to insert comments."""
def __init__(self, unpacked_path: str | Path, author: str = "Contract Review Assistant", initials: str = "CR"):
self.unpacked_path = Path(unpacked_path)
if not self.unpacked_path.exists():
raise FileNotFoundError(f"Unpacked path not found: {self.unpacked_path}")
self.word_path = self.unpacked_path / "word"
self.document_path = self.word_path / "document.xml"
self.comments_path = self.word_path / "comments.xml"
self.rels_path = self.word_path / "_rels" / "document.xml.rels"
self.content_types_path = self.unpacked_path / "[Content_Types].xml"
self.author = author
self.initials = initials
self._editors: Dict[str, XMLEditor] = {}
self._ensure_comments_part()
self.next_comment_id = self._get_next_comment_id()
def __getitem__(self, xml_path: str) -> XMLEditor:
if xml_path not in self._editors:
target = self.unpacked_path / xml_path
self._editors[xml_path] = XMLEditor(target)
return self._editors[xml_path]
def save(self, validate: bool = False) -> None:
_ = validate
for editor in self._editors.values():
editor.save()
def get_paragraph_text(self, paragraph) -> str:
text_parts = []
for run in paragraph.getElementsByTagName("w:r"):
for text_node in run.getElementsByTagName("w:t"):
if text_node.firstChild:
text_parts.append(text_node.firstChild.nodeValue)
return "".join(text_parts)
def find_paragraph_by_text(self, search_text, allow_fallback: bool = True):
editor = self["word/document.xml"]
paragraphs = editor.dom.getElementsByTagName("w:p")
search_keywords = [search_text] if isinstance(search_text, str) else search_text
for keyword in search_keywords:
for para in paragraphs:
if keyword in self.get_paragraph_text(para):
return para
if not allow_fallback:
raise ValueError(f"Paragraph not found for: {search_text}")
for para in paragraphs[:20]:
if self.get_paragraph_text(para).strip():
return para
return paragraphs[0] if paragraphs else None
def add_comment(self, start, end, text: str, risk_level: str = "中风险") -> int:
_ = end
para = self._get_paragraph_node(start)
if para is None:
raise ValueError("Comment target paragraph not found")
comment_id = self.next_comment_id
self.next_comment_id += 1
reviewer = self._get_reviewer_by_risk_level(risk_level)
author = reviewer["author"]
initials = reviewer["initials"]
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self._insert_comment_range(para, comment_id)
self._append_comment_reference(para, comment_id)
self._append_comment_entry(comment_id, author, initials, timestamp, text)
return comment_id
def verify_comments(self) -> dict:
result = {"total": 0, "found": 0, "missing": 0, "comment_list": []}
if not self.comments_path.exists():
return result
comments_editor = self["word/comments.xml"]
comment_nodes = _find_by_local_name(comments_editor.dom, "comment")
result["total"] = len(comment_nodes)
document_editor = self["word/document.xml"]
range_nodes = _find_by_local_name(document_editor.dom, "commentRangeStart")
result["found"] = len(range_nodes)
result["missing"] = max(result["total"] - result["found"], 0)
for node in comment_nodes:
comment_id = node.getAttribute("w:id") or node.getAttribute("id")
author = node.getAttribute("w:author") or node.getAttribute("author")
preview = _extract_first_text(node)
result["comment_list"].append({
"id": comment_id,
"author": author,
"preview": preview,
})
return result
def _ensure_comments_part(self) -> None:
self._ensure_comments_xml()
self._ensure_comments_relationship()
self._ensure_comments_content_type()
def _ensure_comments_xml(self) -> None:
if self.comments_path.exists():
return
xml = (
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
"<w:comments xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"/>"
)
self.comments_path.write_text(xml, encoding="utf-8")
def _ensure_comments_relationship(self) -> None:
if not self.rels_path.exists():
raise FileNotFoundError(f"Missing rels file: {self.rels_path}")
rels_editor = self["word/_rels/document.xml.rels"]
root = rels_editor.dom.documentElement
for rel in _find_by_local_name(rels_editor.dom, "Relationship"):
if rel.getAttribute("Type") == COMMENTS_REL_TYPE:
return
next_id = _next_relationship_id(rels_editor.dom)
rel = rels_editor.dom.createElement("Relationship")
rel.setAttribute("Id", next_id)
rel.setAttribute("Type", COMMENTS_REL_TYPE)
rel.setAttribute("Target", "comments.xml")
root.appendChild(rel)
def _ensure_comments_content_type(self) -> None:
if not self.content_types_path.exists():
raise FileNotFoundError(f"Missing content types file: {self.content_types_path}")
types_editor = self["[Content_Types].xml"]
root = types_editor.dom.documentElement
for override in _find_by_local_name(types_editor.dom, "Override"):
if override.getAttribute("PartName") == "/word/comments.xml":
return
override = types_editor.dom.createElement("Override")
override.setAttribute("PartName", "/word/comments.xml")
override.setAttribute("ContentType", COMMENTS_CONTENT_TYPE)
root.appendChild(override)
def _get_next_comment_id(self) -> int:
if not self.comments_path.exists():
return 0
comments_editor = self["word/comments.xml"]
max_id = -1
for node in _find_by_local_name(comments_editor.dom, "comment"):
raw = node.getAttribute("w:id") or node.getAttribute("id")
if raw:
try:
max_id = max(max_id, int(raw))
except ValueError:
continue
return max_id + 1
def _insert_comment_range(self, paragraph, comment_id: int) -> None:
doc = self["word/document.xml"].dom
start_elem = doc.createElement("w:commentRangeStart")
start_elem.setAttribute("w:id", str(comment_id))
first_elem = _first_element_child(paragraph)
if first_elem is not None:
paragraph.insertBefore(start_elem, first_elem)
else:
paragraph.appendChild(start_elem)
def _append_comment_reference(self, paragraph, comment_id: int) -> None:
doc = self["word/document.xml"].dom
end_elem = doc.createElement("w:commentRangeEnd")
end_elem.setAttribute("w:id", str(comment_id))
paragraph.appendChild(end_elem)
run = doc.createElement("w:r")
ref = doc.createElement("w:commentReference")
ref.setAttribute("w:id", str(comment_id))
run.appendChild(ref)
paragraph.appendChild(run)
def _append_comment_entry(self, comment_id: int, author: str, initials: str, timestamp: str, text: str) -> None:
comments_editor = self["word/comments.xml"]
root = comments_editor.dom.documentElement
comment = comments_editor.dom.createElement("w:comment")
comment.setAttribute("w:id", str(comment_id))
comment.setAttribute("w:author", author)
comment.setAttribute("w:initials", initials)
comment.setAttribute("w:date", timestamp)
lines = text.splitlines() or [""]
for line in lines:
para = comments_editor.dom.createElement("w:p")
run = comments_editor.dom.createElement("w:r")
text_elem = comments_editor.dom.createElement("w:t")
if _needs_space_preserve(line):
text_elem.setAttribute("xml:space", "preserve")
text_elem.appendChild(comments_editor.dom.createTextNode(html.escape(line)))
run.appendChild(text_elem)
para.appendChild(run)
comment.appendChild(para)
root.appendChild(comment)
def _get_reviewer_by_risk_level(self, risk_level: str) -> dict:
risk_reviewers = {
"高风险": {"author": "高风险", "initials": "高风险"},
"中风险": {"author": "中风险", "initials": "中风险"},
"低风险": {"author": "低风险", "initials": "低风险"},
}
english_reviewers = {
"high": {"author": "High Risk", "initials": "H"},
"medium": {"author": "Medium Risk", "initials": "M"},
"low": {"author": "Low Risk", "initials": "L"},
}
if not risk_level:
return risk_reviewers["中风险"]
if risk_level in risk_reviewers:
return risk_reviewers[risk_level]
normalized = risk_level.strip().lower().replace("-", " ")
normalized = " ".join(normalized.split()).replace(" risk", "")
if normalized in english_reviewers:
return english_reviewers[normalized]
return risk_reviewers["中风险"]
def _get_paragraph_node(self, node):
current = node
while current is not None:
if getattr(current, "tagName", None) == "w:p":
return current
current = current.parentNode
return None
def _needs_space_preserve(text: str) -> bool:
if text.startswith(" ") or text.endswith(" "):
return True
if " " in text:
return True
return False
def _first_element_child(node):
child = node.firstChild
while child is not None:
if child.nodeType == child.ELEMENT_NODE:
return child
child = child.nextSibling
return None
def _find_by_local_name(dom, local: str) -> List:
matches = []
for node in dom.getElementsByTagName("*"):
if node.tagName.split(":")[-1] == local:
matches.append(node)
return matches
def _extract_first_text(node) -> str:
for text_node in node.getElementsByTagName("w:t"):
if text_node.firstChild:
return text_node.firstChild.nodeValue
return ""
def _next_relationship_id(dom) -> str:
max_id = 0
for rel in _find_by_local_name(dom, "Relationship"):
rid = rel.getAttribute("Id")
if rid.startswith("rId"):
try:
max_id = max(max_id, int(rid[3:]))
except ValueError:
continue
return f"rId{max_id + 1}"
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
错误处理和用户反馈工具
Error Handling and User Feedback Utilities
提供统一的错误处理、日志记录和用户反馈功能。
"""
import sys
import traceback
from typing import List, Dict, Optional
from datetime import datetime
from pathlib import Path
# ==================== 自定义异常类 ====================
class ReviewError(Exception):
"""合同审核基础异常类"""
pass
class CommentNotFoundError(ReviewError):
"""批注目标未找到错误"""
pass
class DocumentValidationError(ReviewError):
"""文档验证失败错误"""
pass
class PythonVersionError(ReviewError):
"""Python 版本不兼容错误"""
pass
class UnpackError(ReviewError):
"""文档解包失败错误"""
pass
class PackError(ReviewError):
"""文档打包失败错误"""
pass
# ==================== 版本检查 ====================
def check_python_version(min_version: tuple = (3, 9),
recommended_version: tuple = (3, 10)) -> None:
"""
检查 Python 版本是否满足要求
Args:
min_version: 最低版本要求 (默认 3.9)
recommended_version: 推荐版本 (默认 3.10)
Raises:
PythonVersionError: 如果版本低于最低要求
Example:
>>> check_python_version() # 默认要求 3.9+
>>> check_python_version((3, 10)) # 要求 3.10+
"""
current = (sys.version_info.major, sys.version_info.minor)
if current < min_version:
raise PythonVersionError(
f"Python {'.'.join(map(str, min_version))}+ required, "
f"current: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
if current < recommended_version:
import warnings
warnings.warn(
f"Python {'.'.join(map(str, recommended_version))}+ recommended for best compatibility. "
f"Current: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
UserWarning
)
# ==================== 错误格式化 ====================
def format_error_summary(errors: List[Dict]) -> str:
"""
格式化错误摘要为可读文本
Args:
errors: 错误列表,每个错误包含 type, location, message, suggestion 等字段
Returns:
str: 格式化的错误摘要文本
Example:
>>> errors = [
... {"type": "文本一致性", "location": "第10行", "message": "金额不一致"},
... {"type": "合同标的", "location": "第20行", "message": "数量不明确"}
... ]
>>> print(format_error_summary(errors))
"""
if not errors:
return "✓ 无错误"
lines = []
lines.append(f"✗ 发现 {len(errors)} 个错误:\n")
for i, error in enumerate(errors, 1):
lines.append(f"\n{i}. {error.get('type', 'Unknown Error')}")
lines.append(f" 位置: {error.get('location', 'N/A')}")
lines.append(f" 原因: {error.get('message', 'N/A')}")
if 'suggestion' in error:
lines.append(f" 建议: {error['suggestion']}")
if 'risk_level' in error:
lines.append(f" 风险等级: {error['risk_level']}")
return '\n'.join(lines)
# ==================== 批注批处理日志记录器 ====================
class CommentBatchLogger:
"""
批注批处理日志记录器
记录批注添加过程中的成功、失败和警告,
并生成详细的执行报告。
Attributes:
successful: 成功添加的批注列表
failed: 失败的批注列表
warnings: 警告列表
start_time: 开始时间
Example:
>>> logger = CommentBatchLogger()
>>> try:
... logger.log_success(1, "合同总价")
... except Exception as e:
... logger.log_failure("合同总价", e)
>>> print(logger.generate_summary())
"""
def __init__(self):
"""初始化日志记录器"""
self.successful = []
self.failed = []
self.warnings = []
self.start_time = datetime.now()
def log_success(self, comment_id: int, search_text: str, preview: str = ""):
"""
记录成功添加的批注
Args:
comment_id: 批注ID
search_text: 搜索文本
preview: 批注内容预览(可选)
"""
self.successful.append({
'id': comment_id,
'search': search_text,
'preview': preview,
'timestamp': datetime.now()
})
def log_failure(self, search_text: str, error: Exception):
"""
记录失败的批注
Args:
search_text: 搜索文本
error: 异常对象
"""
self.failed.append({
'search': search_text,
'error': str(error),
'error_type': type(error).__name__,
'traceback': traceback.format_exc(),
'timestamp': datetime.now()
})
def log_warning(self, message: str, details: str = ""):
"""
记录警告
Args:
message: 警告消息
details: 详细信息(可选)
"""
self.warnings.append({
'message': message,
'details': details,
'timestamp': datetime.now()
})
def generate_summary(self) -> str:
"""
生成执行摘要
Returns:
str: 格式化的摘要文本
"""
duration = (datetime.now() - self.start_time).total_seconds()
lines = []
lines.append("=" * 60)
lines.append("批注添加摘要")
lines.append("=" * 60)
lines.append(f"\n执行时间: {duration:.2f} 秒")
lines.append(f"成功: {len(self.successful)} 个")
lines.append(f"失败: {len(self.failed)} 个")
lines.append(f"警告: {len(self.warnings)} 个")
if self.failed:
lines.append("\n失败详情:")
lines.append("-" * 60)
for i, fail in enumerate(self.failed, 1):
lines.append(f"\n{i}. 搜索文本: {fail['search'][:50]}")
lines.append(f" 错误类型: {fail['error_type']}")
lines.append(f" 错误: {fail['error'][:100]}")
if self.warnings:
lines.append("\n警告:")
lines.append("-" * 60)
for i, warning in enumerate(self.warnings, 1):
lines.append(f"{i}. {warning['message']}")
if warning['details']:
lines.append(f" 详情: {warning['details'][:80]}")
lines.append("\n" + "=" * 60)
return '\n'.join(lines)
def save_to_file(self, filepath: str):
"""
保存详细日志到文件
Args:
filepath: 日志文件路径
"""
with open(filepath, 'w', encoding='utf-8') as f:
# 写入摘要
f.write(self.generate_summary())
f.write("\n\n详细错误追踪:\n")
f.write("=" * 60 + "\n\n")
# 写入每个失败的详细信息
for i, fail in enumerate(self.failed, 1):
f.write(f"错误 #{i}:\n")
f.write(f"搜索文本: {fail['search']}\n")
f.write(f"时间: {fail['timestamp'].strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"错误类型: {fail['error_type']}\n")
f.write(f"错误信息:\n{fail['traceback']}\n")
f.write("\n" + "-" * 60 + "\n\n")
# 写入警告详情
if self.warnings:
f.write("\n警告详情:\n")
f.write("=" * 60 + "\n\n")
for i, warning in enumerate(self.warnings, 1):
f.write(f"警告 #{i}:\n")
f.write(f"时间: {warning['timestamp'].strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"消息: {warning['message']}\n")
if warning['details']:
f.write(f"详情: {warning['details']}\n")
f.write("\n")
def get_statistics(self) -> Dict:
"""
获取统计信息
Returns:
dict: 包含 total, successful, failed, warnings 等统计
"""
total = len(self.successful) + len(self.failed)
success_rate = (len(self.successful) / total * 100) if total > 0 else 0
return {
'total': total,
'successful': len(self.successful),
'failed': len(self.failed),
'warnings': len(self.warnings),
'success_rate': success_rate,
'duration_seconds': (datetime.now() - self.start_time).total_seconds()
}
# ==================== 审核报告生成器 ====================
class ReviewReportGenerator:
"""
审核报告生成器
生成结构化的审核报告,包括:
- 基本信息
- 统计数据
- 详细问题列表
- 验证结果
- 总体评价
Example:
>>> generator = ReviewReportGenerator()
>>> generator.add_basic_info(contract="合同.docx", reviewer="张三")
>>> generator.add_issue(type="文本一致性", level="高风险", ...)
>>> generator.save("report.txt")
"""
def __init__(self):
"""初始化报告生成器"""
self.basic_info = {}
self.issues = {
'high': [],
'medium': [],
'low': []
}
self.statistics = {}
self.verification = {}
def add_basic_info(self, contract: str, reviewer: str, date: str = None):
"""
添加基本信息
Args:
contract: 合同文档路径
reviewer: 审核人
date: 审核日期(默认为当前时间)
"""
self.basic_info = {
'contract': contract,
'reviewer': reviewer,
'date': date or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
def add_issue(self, issue_type: str, level: str, location: str,
reason: str, suggestion: str, original_text: str = ""):
"""
添加审核问题
Args:
issue_type: 问题类型
level: 风险等级 (高风险/中风险/低风险)
location: 所在位置
reason: 风险原因
suggestion: 修订建议
original_text: 原文内容(可选)
"""
issue = {
'type': issue_type,
'location': location,
'reason': reason,
'suggestion': suggestion,
'original_text': original_text
}
# 根据风险等级分类
level_key = level.replace('风险', '').replace(' ', '').lower()
if '高' in level or level_key == 'high':
self.issues['high'].append(issue)
elif '中' in level or level_key == 'medium':
self.issues['medium'].append(issue)
else:
self.issues['low'].append(issue)
def add_statistics(self, total_comments: int, successful: int, failed: int):
"""
添加统计数据
Args:
total_comments: 总批注数
successful: 成功添加数
failed: 失败数
"""
self.statistics = {
'total': total_comments,
'successful': successful,
'failed': failed,
'success_rate': (successful / total_comments * 100) if total_comments > 0 else 0
}
def add_verification(self, total: int, found: int, missing: int):
"""
添加验证结果
Args:
total: 批注总数
found: 找到的引用数
missing: 缺失的引用数
"""
self.verification = {
'total': total,
'found': found,
'missing': missing
}
def generate(self) -> str:
"""
生成报告文本
Returns:
str: 完整的报告文本
"""
lines = []
# 标题
lines.append("=" * 60)
lines.append("合同审核报告")
lines.append("=" * 60)
lines.append("")
# 基本信息
lines.append("一、基本信息")
lines.append("-" * 60)
lines.append(f"合同文档: {self.basic_info.get('contract', 'N/A')}")
lines.append(f"审核人: {self.basic_info.get('reviewer', 'N/A')}")
lines.append(f"审核日期: {self.basic_info.get('date', 'N/A')}")
lines.append("")
# 统计数据
if self.statistics:
lines.append("二、统计数据")
lines.append("-" * 60)
lines.append(f"总批注数: {self.statistics['total']}")
lines.append(f"成功添加: {self.statistics['successful']}")
lines.append(f"添加失败: {self.statistics['failed']}")
lines.append(f"成功率: {self.statistics['success_rate']:.1f}%")
lines.append("")
# 验证结果
if self.verification:
lines.append("三、验证结果")
lines.append("-" * 60)
lines.append(f"批注总数: {self.verification['total']}")
lines.append(f"文档引用: {self.verification['found']}")
lines.append(f"缺失引用: {self.verification['missing']}")
lines.append("")
# 问题列表(按风险等级分组)
all_issues = []
if self.issues['high']:
all_issues.extend([(i, '🔴 高风险') for i in self.issues['high']])
if self.issues['medium']:
all_issues.extend([(i, '🟡 中风险') for i in self.issues['medium']])
if self.issues['low']:
all_issues.extend([(i, '🔵 低风险') for i in self.issues['low']])
if all_issues:
lines.append("四、审核问题列表")
lines.append("-" * 60)
lines.append("")
for idx, (issue, risk_label) in enumerate(all_issues, 1):
lines.append(f"{idx}. 【问题类型】{issue['type']}")
lines.append(f" 【风险等级】{risk_label}")
lines.append(f" 【所在位置】{issue['location']}")
lines.append(f" 【风险原因】{issue['reason']}")
lines.append(f" 【修订建议】{issue['suggestion']}")
if issue.get('original_text'):
lines.append(f" 【原文内容】{issue['original_text'][:80]}...")
lines.append("")
# 总体评价
lines.append("=" * 60)
lines.append("总体评价")
lines.append("=" * 60)
high_count = len(self.issues['high'])
medium_count = len(self.issues['medium'])
low_count = len(self.issues['low'])
if high_count > 0:
lines.append(f"\n发现 {high_count} 个高风险问题,建议优先修改。")
if medium_count > 0:
lines.append(f"发现 {medium_count} 个中风险问题,建议仔细评估。")
if low_count > 0:
lines.append(f"发现 {low_count} 个低风险问题,可在有时间时优化。")
if high_count == 0 and medium_count == 0:
lines.append("\n✓ 合同质量良好,仅发现少量低风险问题。")
lines.append("")
lines.append("=" * 60)
return '\n'.join(lines)
def save(self, filepath: str):
"""
保存报告到文件
Args:
filepath: 报告文件路径
"""
report_text = self.generate()
with open(filepath, 'w', encoding='utf-8') as f:
f.write(report_text)
# ==================== 使用示例 ====================
if __name__ == "__main__":
print("错误处理和用户反馈工具")
print("=" * 60)
print()
print("功能模块:")
print("1. 自定义异常类: ReviewError, CommentNotFoundError 等")
print("2. 版本检查: check_python_version()")
print("3. 错误格式化: format_error_summary()")
print("4. 批注日志: CommentBatchLogger")
print("5. 报告生成: ReviewReportGenerator")
print()
print("使用示例:")
print()
print("from scripts.error_handling import CommentBatchLogger")
print()
print("logger = CommentBatchLogger()")
print("logger.log_success(1, '合同总价', '金额不一致')")
print("logger.log_failure('培训时间', Exception('未找到'))")
print("print(logger.generate_summary())")
print("logger.save_to_file('review_log.txt')")
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Render Mermaid flowcharts to image files via mermaid-cli (mmdc).
"""
from __future__ import annotations
import shutil
import subprocess
import json
import os
import tempfile
import re
from pathlib import Path
from typing import Optional, Tuple
def normalize_mermaid_code(code: str) -> str:
"""
Normalize Mermaid code by stripping code fences and ensuring a trailing newline.
"""
cleaned = code.strip()
if cleaned.startswith("```"):
lines = cleaned.splitlines()
if len(lines) >= 2 and lines[-1].strip().startswith("```"):
cleaned = "\n".join(lines[1:-1]).strip()
if not cleaned.endswith("\n"):
cleaned += "\n"
return cleaned
def write_mermaid_file(code: str, output_dir: Path, filename: str) -> Path:
"""
Write Mermaid code to a .mmd file in output_dir.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
mmd_path = output_dir / filename
mmd_path.write_text(code, encoding="utf-8")
return mmd_path
def render_mermaid_file(
mmd_path: Path,
image_path: Path,
theme: str = "default",
background_color: str = "white",
mmdc_path: Optional[str] = None,
scale: float = 2,
width: Optional[int] = None,
height: Optional[int] = None,
puppeteer_config_path: Optional[str] = None,
) -> None:
"""
Render a Mermaid .mmd file to an image using mermaid-cli (mmdc).
"""
mmdc_path = mmdc_path or shutil.which("mmdc")
if not mmdc_path:
raise FileNotFoundError(
"mmdc not found in PATH. Install @mermaid-js/mermaid-cli to render Mermaid."
)
def build_cmd(input_path: Path, config_path: Optional[Path]) -> list[str]:
cmd = [mmdc_path, "-i", str(input_path), "-o", str(image_path)]
if theme:
cmd += ["-t", theme]
if background_color:
cmd += ["-b", background_color]
if scale and scale != 1:
cmd += ["-s", str(scale)]
if width:
cmd += ["-w", str(width)]
if height:
cmd += ["-H", str(height)]
if config_path:
cmd += ["-p", str(config_path)]
return cmd
config_path = Path(puppeteer_config_path) if puppeteer_config_path else None
created_config = False
temp_user_data_dir: Optional[Path] = None
last_error: Optional[subprocess.CalledProcessError] = None
try:
try:
subprocess.run(build_cmd(mmd_path, config_path), check=True)
return
except subprocess.CalledProcessError as exc:
last_error = exc
if config_path is None:
chrome_path = os.environ.get("PUPPETEER_EXECUTABLE_PATH") or _find_chrome_executable()
if not chrome_path:
raise last_error
config_path, temp_user_data_dir = _write_puppeteer_config(chrome_path)
created_config = True
try:
subprocess.run(build_cmd(mmd_path, config_path), check=True)
return
except subprocess.CalledProcessError as exc:
last_error = exc
try:
original = mmd_path.read_text(encoding="utf-8")
except Exception:
raise last_error
sanitized = _sanitize_mermaid_code_for_render(original)
if sanitized == original:
raise last_error
sanitized_path = _write_temp_mmd(sanitized, mmd_path)
try:
subprocess.run(build_cmd(sanitized_path, config_path), check=True)
return
except subprocess.CalledProcessError as exc:
last_error = exc
raise last_error
finally:
try:
sanitized_path.unlink(missing_ok=True)
except Exception:
pass
finally:
if created_config:
try:
config_path.unlink(missing_ok=True)
except Exception:
pass
if temp_user_data_dir:
shutil.rmtree(temp_user_data_dir, ignore_errors=True)
def render_mermaid_code(
code: str,
output_dir: Path,
mmd_filename: str,
image_filename: str,
theme: str = "default",
background_color: str = "white",
scale: float = 2,
width: Optional[int] = None,
height: Optional[int] = None,
puppeteer_config_path: Optional[str] = None,
) -> Tuple[Path, Path]:
"""
Write Mermaid code to .mmd and render to image.
"""
normalized = normalize_mermaid_code(code)
mmd_path = write_mermaid_file(normalized, output_dir, mmd_filename)
image_path = Path(output_dir) / image_filename
render_mermaid_file(
mmd_path,
image_path,
theme=theme,
background_color=background_color,
scale=scale,
width=width,
height=height,
puppeteer_config_path=puppeteer_config_path,
)
return mmd_path, image_path
def _find_chrome_executable() -> Optional[str]:
"""
Best-effort Chrome/Chromium detection for Puppeteer fallback.
"""
candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
for candidate in candidates:
if Path(candidate).exists():
return candidate
return None
def _write_puppeteer_config(executable_path: str) -> tuple[Path, Path]:
"""
Write a temporary Puppeteer config with safer sandbox args.
"""
user_data_dir = Path(tempfile.mkdtemp(prefix="puppeteer-user-data-"))
payload = {
"executablePath": executable_path,
"args": [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-crashpad",
"--no-first-run",
"--no-default-browser-check",
f"--user-data-dir={user_data_dir}",
],
}
handle, path = tempfile.mkstemp(prefix="puppeteer-", suffix=".json")
os.close(handle)
config_path = Path(path)
config_path.write_text(json.dumps(payload), encoding="utf-8")
return config_path, user_data_dir
def _sanitize_mermaid_code_for_render(code: str) -> str:
if "%" not in code and "%" not in code:
return code
replacement = "百分比" if _contains_cjk(code) else "percent"
sanitized = code.replace("%", replacement).replace("%", replacement)
sanitized = re.sub(r"(?<=\\d),(?=\\d)", "", sanitized)
sanitized = sanitized.replace("(", " ").replace(")", " ")
sanitized = re.sub(r"\\s{2,}", " ", sanitized)
return sanitized
def _contains_cjk(text: str) -> bool:
for char in text:
if "\u4e00" <= char <= "\u9fff":
return True
return False
def _write_temp_mmd(code: str, source_path: Path) -> Path:
handle, path = tempfile.mkstemp(
prefix=f"{source_path.stem}-sanitized-",
suffix=source_path.suffix,
)
os.close(handle)
temp_path = Path(path)
temp_path.write_text(code, encoding="utf-8")
return temp_path
"""OOXML helpers for packing, unpacking, and lightweight validation."""
from .pack import pack_document
from .unpack import unpack_document
__all__ = ["pack_document", "unpack_document"]
#!/usr/bin/env python3
"""
Pack an unpacked Office directory into a .docx, .pptx, or .xlsx file.
"""
import argparse
import sys
import zipfile
from pathlib import Path
import defusedxml.ElementTree as ET
DOC_REQUIRED = {
".docx": ["[Content_Types].xml", "word/document.xml", "word/_rels/document.xml.rels"],
".pptx": ["[Content_Types].xml", "ppt/presentation.xml"],
".xlsx": ["[Content_Types].xml", "xl/workbook.xml"],
}
def pack_document(input_dir: str, output_file: str, validate: bool = False) -> bool:
"""
Pack a directory into an Office file (.docx/.pptx/.xlsx).
Args:
input_dir: Path to unpacked Office document directory
output_file: Path to output Office file
validate: If True, run lightweight structural checks
Returns:
bool: True if successful, False if validation failed
"""
input_path = Path(input_dir)
output_path = Path(output_file)
if not input_path.is_dir():
raise ValueError(f"{input_dir} is not a directory")
if output_path.suffix.lower() not in DOC_REQUIRED:
raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file")
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
for item in sorted(input_path.rglob("*")):
if item.is_file():
archive.write(item, item.relative_to(input_path))
if validate:
if not validate_document(output_path):
output_path.unlink(missing_ok=True)
return False
return True
def validate_document(doc_path: Path) -> bool:
"""Lightweight validation: required files exist and XML parses."""
suffix = doc_path.suffix.lower()
required = DOC_REQUIRED.get(suffix, [])
try:
with zipfile.ZipFile(doc_path, "r") as archive:
names = set(archive.namelist())
missing = [name for name in required if name not in names]
if missing:
print(f"Validation error: missing files: {missing}", file=sys.stderr)
return False
for name in required:
if name.endswith((".xml", ".rels")):
with archive.open(name) as handle:
try:
ET.parse(handle)
except ET.ParseError as exc:
print(f"Validation error: invalid XML in {name}: {exc}", file=sys.stderr)
return False
except zipfile.BadZipFile as exc:
print(f"Validation error: invalid zip file: {exc}", file=sys.stderr)
return False
return True
def main() -> None:
parser = argparse.ArgumentParser(description="Pack a directory into an Office file")
parser.add_argument("input_directory", help="Unpacked Office document directory")
parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)")
parser.add_argument("--force", action="store_true", help="Skip validation")
args = parser.parse_args()
success = pack_document(
args.input_directory,
args.output_file,
validate=not args.force,
)
if args.force:
print("Warning: Skipped validation, file may be corrupt", file=sys.stderr)
elif not success:
print("Contents would produce an invalid file.", file=sys.stderr)
print("Use --force to skip validation and pack anyway.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Unpack Office files (.docx, .pptx, .xlsx) into a directory."""
import random
import sys
import zipfile
from pathlib import Path
def unpack_document(input_file: str, output_dir: str) -> None:
"""
Unpack an Office file into a directory.
Args:
input_file: Path to the Office file (.docx, .pptx, .xlsx)
output_dir: Directory to extract contents to
"""
input_path = Path(input_file)
output_path = Path(output_dir)
if not input_path.is_file():
raise FileNotFoundError(f"Input file not found: {input_file}")
output_path.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(input_path, "r") as archive:
archive.extractall(output_path)
if input_path.suffix.lower() == ".docx":
suggested_rsid = "".join(random.choices("0123456789ABCDEF", k=8))
print(f"Suggested RSID for edit session: {suggested_rsid}")
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("Usage: python unpack.py <office_file> <output_dir>")
unpack_document(sys.argv[1], sys.argv[2])
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Run lightweight validation on unpacked Office documents."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from validation import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator
def _validators_for(ext: str):
if ext == ".docx":
return [DOCXSchemaValidator, RedliningValidator]
if ext == ".pptx":
return [PPTXSchemaValidator]
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate unpacked Office XML")
parser.add_argument("unpacked_dir", help="Unpacked directory path")
parser.add_argument("--original", required=True, help="Original file (.docx/.pptx/.xlsx)")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args(argv)
unpacked_dir = Path(args.unpacked_dir)
original_file = Path(args.original)
if not unpacked_dir.is_dir():
print(f"Error: {unpacked_dir} is not a directory")
return 1
if not original_file.is_file():
print(f"Error: {original_file} is not a file")
return 1
validators = _validators_for(original_file.suffix.lower())
if not validators:
print(f"Error: validation not supported for {original_file.suffix}")
return 1
success = True
for validator_cls in validators:
validator = validator_cls(unpacked_dir, original_file, verbose=args.verbose)
if not validator.validate():
success = False
if success:
print("Validation completed successfully.")
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())
"""Validator exports for lightweight OOXML checks."""
from .docx import DOCXSchemaValidator as DocxValidator
from .pptx import PPTXSchemaValidator as PptxValidator
from .redlining import RedliningValidator as RedlineValidator
DOCXSchemaValidator = DocxValidator
PPTXSchemaValidator = PptxValidator
RedliningValidator = RedlineValidator
__all__ = [
"DOCXSchemaValidator",
"PPTXSchemaValidator",
"RedliningValidator",
]
"""Lightweight validation helpers for Office XML documents."""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
import defusedxml.ElementTree as ET
class BaseValidator:
"""Basic structural checks for unpacked Office documents."""
required_files: Iterable[str] = ()
def __init__(self, unpacked_dir: Path, original_file: Path, verbose: bool = False):
self.unpacked_dir = Path(unpacked_dir)
self.original_file = Path(original_file)
self.verbose = verbose
def validate(self) -> bool:
if not self._check_required_files(self.required_files):
return False
return self._parse_xml_files(self.required_files)
def _check_required_files(self, rel_paths: Iterable[str]) -> bool:
missing = [p for p in rel_paths if not (self.unpacked_dir / p).exists()]
if missing:
if self.verbose:
print(f"Missing required files: {missing}")
return False
return True
def _parse_xml_files(self, rel_paths: Iterable[str]) -> bool:
for rel_path in rel_paths:
if not rel_path.endswith((".xml", ".rels")):
continue
file_path = self.unpacked_dir / rel_path
try:
ET.parse(file_path)
except ET.ParseError as exc:
if self.verbose:
print(f"Invalid XML in {rel_path}: {exc}")
return False
return True
"""Lightweight DOCX validation (structure + XML well-formedness)."""
from __future__ import annotations
from .base import BaseValidator
class DOCXSchemaValidator(BaseValidator):
required_files = (
"[Content_Types].xml",
"word/document.xml",
"word/_rels/document.xml.rels",
)
"""Lightweight PPTX validation (structure + XML well-formedness)."""
from __future__ import annotations
from .base import BaseValidator
class PPTXSchemaValidator(BaseValidator):
required_files = (
"[Content_Types].xml",
"ppt/presentation.xml",
)
"""Minimal redlining validator placeholder."""
from __future__ import annotations
from .base import BaseValidator
class RedliningValidator(BaseValidator):
required_files = (
"word/document.xml",
)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Render comprehensive review opinion text to a styled DOCX document.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List
from docx import Document
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt
DEFAULT_LINE_SPACING = 1.3
NUMBERED_ITEM_PATTERN = re.compile(r"(?:^|[\s::;;])(\d+[\.、](?!\d))")
def render_opinion_docx(
opinion_text: str,
output_path: Path,
font_name: str = "仿宋",
base_font_size: int = 12,
line_spacing: float = DEFAULT_LINE_SPACING,
add_title: bool = True,
title_text: str = "综合审核意见",
) -> Path:
"""
Render opinion text into a DOCX file.
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
_set_document_font(doc, font_name, base_font_size, line_spacing)
if add_title:
_add_title(doc, title_text, font_name)
paragraphs = _split_paragraphs(opinion_text)
for para_text in paragraphs:
for segment in _split_numbered_items(para_text):
_add_paragraph(doc, segment, font_name, line_spacing)
doc.save(output_path)
return output_path
def _split_paragraphs(text: str) -> List[str]:
raw = text.strip()
if not raw:
return []
parts = [p.strip() for p in raw.split("\n\n") if p.strip()]
if parts:
return parts
return [line.strip() for line in raw.splitlines() if line.strip()]
def _split_numbered_items(text: str) -> List[str]:
cleaned = text.strip()
if not cleaned:
return []
matches = list(NUMBERED_ITEM_PATTERN.finditer(cleaned))
if not matches:
return [cleaned]
starts = [match.start(1) for match in matches]
parts: List[str] = []
lead = cleaned[:starts[0]].strip()
if lead:
parts.append(lead)
for index, start in enumerate(starts):
end = starts[index + 1] if index + 1 < len(starts) else len(cleaned)
item = cleaned[start:end].strip()
item = item.rstrip(";; ")
if item:
parts.append(item)
return parts
def _set_document_font(doc: Document, font_name: str, base_font_size: int, line_spacing: float) -> None:
for style_name in ("Normal", "Heading 1"):
if style_name not in doc.styles:
continue
style = doc.styles[style_name]
style.font.name = font_name
style.font.size = Pt(base_font_size)
_set_style_east_asia_font(style, font_name)
style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
style.paragraph_format.line_spacing = line_spacing
def _set_style_east_asia_font(style, font_name: str) -> None:
rpr = style.element.get_or_add_rPr()
rfonts = rpr.rFonts
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
rfonts.set(qn("w:eastAsia"), font_name)
def _set_run_font(run, font_name: str, size: int | None = None, bold: bool | None = None) -> None:
run.font.name = font_name
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
rpr = run._element.get_or_add_rPr()
rfonts = rpr.rFonts
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
rfonts.set(qn("w:eastAsia"), font_name)
def _add_title(doc: Document, text: str, font_name: str) -> None:
para = doc.add_paragraph()
run = para.add_run(text)
_set_run_font(run, font_name, size=16, bold=True)
para.paragraph_format.space_after = Pt(6)
para.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
def _add_paragraph(doc: Document, text: str, font_name: str, line_spacing: float) -> None:
para = doc.add_paragraph()
run = para.add_run(text)
_set_run_font(run, font_name)
para.paragraph_format.space_after = Pt(4)
para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
para.paragraph_format.line_spacing = line_spacing
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Render contract summary text to a styled DOCX document.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Optional, Tuple
from docx import Document
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt
MAIN_SECTION_RE = re.compile(r"^[一二三四五六七八九十]+、")
SUB_SECTION_RE = re.compile(r"^\\d+\\.\\d+")
DEFAULT_LINE_SPACING = 1.3
DEFAULT_FIRST_COL_RATIO = 0.28
DEFAULT_CELL_MARGIN_TOP = 160
DEFAULT_CELL_MARGIN_BOTTOM = 120
def render_summary_docx(
summary_text: str,
output_path: Path,
font_name: str = "仿宋",
base_font_size: int = 12,
line_spacing: float = DEFAULT_LINE_SPACING,
first_col_ratio: float = DEFAULT_FIRST_COL_RATIO,
cell_margin_top: int = DEFAULT_CELL_MARGIN_TOP,
cell_margin_bottom: int = DEFAULT_CELL_MARGIN_BOTTOM,
) -> Path:
"""
Render summary text into a DOCX file with headings and tables.
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
_set_document_font(doc, font_name, base_font_size, line_spacing)
lines = [line.rstrip() for line in summary_text.strip().splitlines()]
i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
if _is_main_section(line):
_add_heading(doc, line, level=1, font_name=font_name, line_spacing=line_spacing)
i += 1
continue
if _is_sub_section(line):
_add_heading(doc, line, level=2, font_name=font_name, line_spacing=line_spacing)
i += 1
continue
if "\t" in line:
rows, next_index = _collect_table_rows(lines, i)
_add_table(
doc,
rows,
font_name=font_name,
line_spacing=line_spacing,
first_col_ratio=first_col_ratio,
cell_margin_top=cell_margin_top,
cell_margin_bottom=cell_margin_bottom,
)
i = next_index
continue
if line.endswith(":") or line.endswith(":"):
_add_label_paragraph(doc, line, font_name=font_name, line_spacing=line_spacing)
i += 1
continue
_add_paragraph(doc, line, font_name=font_name, line_spacing=line_spacing)
i += 1
_add_page_numbers(doc, font_name, base_font_size)
doc.save(output_path)
return output_path
def _set_document_font(doc: Document, font_name: str, base_font_size: int, line_spacing: float) -> None:
for style_name in ("Normal", "Heading 1", "Heading 2"):
if style_name not in doc.styles:
continue
style = doc.styles[style_name]
style.font.name = font_name
style.font.size = Pt(base_font_size)
_set_style_east_asia_font(style, font_name)
style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
style.paragraph_format.line_spacing = line_spacing
def _set_style_east_asia_font(style, font_name: str) -> None:
rpr = style.element.get_or_add_rPr()
rfonts = rpr.rFonts
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
rfonts.set(qn("w:eastAsia"), font_name)
def _set_run_font(run, font_name: str, size: Optional[int] = None, bold: Optional[bool] = None) -> None:
run.font.name = font_name
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
rpr = run._element.get_or_add_rPr()
rfonts = rpr.rFonts
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:ascii"), font_name)
rfonts.set(qn("w:hAnsi"), font_name)
rfonts.set(qn("w:eastAsia"), font_name)
def _add_heading(doc: Document, text: str, level: int, font_name: str, line_spacing: float) -> None:
para = doc.add_paragraph()
run = para.add_run(text)
size = 16 if level == 1 else 14
_set_run_font(run, font_name, size=size, bold=True)
para.paragraph_format.space_before = Pt(6)
para.paragraph_format.space_after = Pt(4)
_apply_line_spacing(para, line_spacing)
if level == 1:
para.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
def _add_paragraph(doc: Document, text: str, font_name: str, line_spacing: float) -> None:
para = doc.add_paragraph()
run = para.add_run(text)
_set_run_font(run, font_name)
para.paragraph_format.space_after = Pt(2)
_apply_line_spacing(para, line_spacing)
def _add_label_paragraph(doc: Document, text: str, font_name: str, line_spacing: float) -> None:
para = doc.add_paragraph()
run = para.add_run(text)
_set_run_font(run, font_name, bold=True)
para.paragraph_format.space_after = Pt(2)
_apply_line_spacing(para, line_spacing)
def _add_table(
doc: Document,
rows: List[Tuple[str, str]],
font_name: str,
line_spacing: float,
first_col_ratio: float,
cell_margin_top: int,
cell_margin_bottom: int,
) -> None:
if not rows:
return
table = doc.add_table(rows=0, cols=2)
table.style = "Table Grid"
table.autofit = False
_set_table_column_widths(doc, table, first_col_ratio)
for idx, (left, right) in enumerate(rows):
cells = table.add_row().cells
_set_cell_margins(cells[0], top=cell_margin_top, bottom=cell_margin_bottom)
_set_cell_margins(cells[1], top=cell_margin_top, bottom=cell_margin_bottom)
_set_cell_text(cells[0], left, font_name=font_name, bold=(idx == 0), line_spacing=line_spacing)
_set_cell_text(cells[1], right, font_name=font_name, bold=(idx == 0), line_spacing=line_spacing)
def _set_cell_text(cell, text: str, font_name: str, bold: bool = False, line_spacing: float = DEFAULT_LINE_SPACING) -> None:
cell.text = ""
para = cell.paragraphs[0]
run = para.add_run(text)
_set_run_font(run, font_name, bold=bold)
_apply_line_spacing(para, line_spacing)
def _apply_line_spacing(para, line_spacing: float) -> None:
para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
para.paragraph_format.line_spacing = line_spacing
def _set_table_column_widths(doc: Document, table, first_col_ratio: float) -> None:
if not table.columns or len(table.columns) < 2:
return
section = doc.sections[0]
content_width = section.page_width - section.left_margin - section.right_margin
first_width = int(content_width * first_col_ratio)
second_width = int(content_width - first_width)
table.columns[0].width = first_width
table.columns[1].width = second_width
for row in table.rows:
row.cells[0].width = first_width
row.cells[1].width = second_width
def _set_cell_margins(cell, top: int | None = None, bottom: int | None = None, left: int | None = None, right: int | None = None) -> None:
tc_pr = cell._tc.get_or_add_tcPr()
tc_mar = tc_pr.find(qn("w:tcMar"))
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
_set_tc_margin(tc_mar, "w:top", top)
_set_tc_margin(tc_mar, "w:bottom", bottom)
_set_tc_margin(tc_mar, "w:left", left)
_set_tc_margin(tc_mar, "w:right", right)
def _set_tc_margin(tc_mar, tag: str, value: int | None) -> None:
if value is None:
return
node = tc_mar.find(qn(tag))
if node is None:
node = OxmlElement(tag)
tc_mar.append(node)
node.set(qn("w:w"), str(value))
node.set(qn("w:type"), "dxa")
def _add_page_numbers(doc: Document, font_name: str, base_font_size: int) -> None:
for section in doc.sections:
footer = section.footer
if footer.paragraphs:
para = footer.paragraphs[0]
else:
para = footer.add_paragraph()
para.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
run = para.add_run()
_set_run_font(run, font_name, size=base_font_size)
fld_char_begin = OxmlElement("w:fldChar")
fld_char_begin.set(qn("w:fldCharType"), "begin")
run._r.append(fld_char_begin)
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = "PAGE"
run._r.append(instr_text)
fld_char_end = OxmlElement("w:fldChar")
fld_char_end.set(qn("w:fldCharType"), "end")
run._r.append(fld_char_end)
def _collect_table_rows(lines: List[str], start_index: int) -> Tuple[List[Tuple[str, str]], int]:
rows: List[Tuple[str, str]] = []
i = start_index
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
break
if _is_main_section(line) or _is_sub_section(line):
break
if "\t" not in line:
break
left, right = line.split("\t", 1)
rows.append((left.strip(), right.strip()))
i += 1
return rows, i
def _is_main_section(line: str) -> bool:
return bool(MAIN_SECTION_RE.match(line))
def _is_sub_section(line: str) -> bool:
return bool(SUB_SECTION_RE.match(line))
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Lightweight XML helpers for OOXML editing.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Iterable, List, Optional
from defusedxml import minidom
def _matches_attrs(node, attrs: Optional[Dict[str, str]]) -> bool:
if not attrs:
return True
for key, value in attrs.items():
if node.getAttribute(key) != value:
return False
return True
def _parse_fragment(fragment: str) -> List[minidom.Node]:
wrapper = f"<root>{fragment}</root>"
frag_dom = minidom.parseString(wrapper)
nodes = []
for child in frag_dom.documentElement.childNodes:
if child.nodeType == child.ELEMENT_NODE:
nodes.append(child)
return nodes
class XMLEditor:
"""Simple XML editor built on minidom."""
def __init__(self, xml_path: str | Path):
self.xml_path = Path(xml_path)
if not self.xml_path.exists():
raise FileNotFoundError(f"XML not found: {self.xml_path}")
self.dom = minidom.parse(str(self.xml_path))
def save(self) -> None:
data = self.dom.toxml(encoding="utf-8")
self.xml_path.write_bytes(data)
def get_nodes(self, tag: Optional[str] = None, attrs: Optional[Dict[str, str]] = None) -> List[minidom.Element]:
if tag:
nodes = self.dom.getElementsByTagName(tag)
else:
nodes = self.dom.getElementsByTagName("*")
return [node for node in nodes if _matches_attrs(node, attrs)]
def get_node(self, tag: Optional[str] = None, attrs: Optional[Dict[str, str]] = None, line_number: Optional[int] = None):
nodes = self.get_nodes(tag=tag, attrs=attrs)
if line_number is not None:
index = max(line_number - 1, 0)
return nodes[index] if index < len(nodes) else None
return nodes[0] if nodes else None
def append_to(self, parent, xml_fragment: str) -> List[minidom.Node]:
nodes = _parse_fragment(xml_fragment)
inserted = []
for node in nodes:
imported = self.dom.importNode(node, deep=True)
parent.appendChild(imported)
inserted.append(imported)
return inserted
def insert_before(self, node, xml_fragment: str) -> List[minidom.Node]:
parent = node.parentNode
if parent is None:
return []
nodes = _parse_fragment(xml_fragment)
inserted = []
for frag in nodes:
imported = self.dom.importNode(frag, deep=True)
parent.insertBefore(imported, node)
inserted.append(imported)
return inserted
def insert_after(self, node, xml_fragment: str) -> List[minidom.Node]:
parent = node.parentNode
if parent is None:
return []
nodes = _parse_fragment(xml_fragment)
inserted = []
reference = node.nextSibling
for frag in nodes:
imported = self.dom.importNode(frag, deep=True)
parent.insertBefore(imported, reference)
inserted.append(imported)
return inserted