Claude Skill

contract-review-pro

Professional-grade contract review skill that adds comment-based issue annotations without changing original text. Enforces a four-layer review (entity verification, basic, business, legal), writes structured comments (issue type, risk reason, revision suggestion) with risk level

LLM Mart · 0 points · 8 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lovstudio-skills-skills_contract-review-pro-77d464c.zip · 51 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/contract-review-pro
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
Git git clone https://github.com/lovstudio/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.

README

合同批注专家 · Contract Reviewer

Version

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 / 四层方法论

  1. Entity verification — 主体核验:确认签约方资质
  2. Basic review — 基础审查:标题、日期、条款编号、引用一致性
  3. Business review — 业务审查:商业条款合理性与内部一致性
  4. 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 -g

See also

  • review-doc — lightweight daily version for general document review
  • review-doc — 日常轻量版,适用于普通文档审阅

License

MIT — content adapted from jicheng's contract-review methodology.

Skill manifest

合同批注专家 · Contract Reviewer

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.

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 or Review_Result_{ContractName} for English
  • Reviewed contract: {ContractName}_审核版.docx for Chinese or {ContractName}_Reviewed.docx for English
  • Review report: 审核报告.txt for Chinese or Review_Report.txt for 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.

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.

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.

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.

Outputs:

  • business_flowchart.mmd
  • business_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:

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 mmdc installed; use Chrome path or Puppeteer config

Examples

See 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.

通用反馈闭环

用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:

  1. 先判断意见是 task-specific(仅本次)还是 reusable(可跨任务复用)。
  2. task-specific 只修改当前任务,不改 Skill。
  3. reusable 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
  4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
  5. reusable 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
  • references
    • checklist.md 6.8 KB
      # 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
      
    • examples.md 5.7 KB
      # Contract Review Examples
      
      ## Quick Start
      
      ```python
      # -*- 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)
      
      ```python
      # -*- 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)
      
      ```python
      # -*- 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)
      
      ```python
      # -*- 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
      
      ```python
      # -*- 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_font` to Fangsong (仿宋).
      
    • flowchart.md 1.8 KB
      # 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
      
    • language.md 2.2 KB
      # 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.
      
    • opinion.md 1.4 KB
      # 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
      
    • summary.md 2.9 KB
      # 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
      
    • technical.md 2.7 KB
      # 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 to `comments.xml`)
      - `[Content_Types].xml` (content type override for `comments.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
      
      ```xml
      <w:commentRangeStart w:id="12"/>
      <!-- paragraph content -->
      <w:commentRangeEnd w:id="12"/>
      <w:r><w:commentReference w:id="12"/></w:r>
      ```
      
      ### Comment Body Example
      
      ```xml
      <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:
      
      ```xml
      <Relationship Id="rIdX"
        Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
        Target="comments.xml"/>
      ```
      
      ## Content Types
      
      `[Content_Types].xml` must include:
      
      ```xml
      <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.xml` malformed or `w:t` missing text node.
      - **Spaces missing**: add `xml:space="preserve"` on the `w:t` nodes.
      
      ## 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.
      
  • scripts
    • ooxml
      • validation
        • base.py 1.5 KB
          """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
          
        • docx.py 324 B
          """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",
              )
          
        • pptx.py 286 B
          """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",
              )
          
        • redlining.py 228 B
          """Minimal redlining validator placeholder."""
          
          from __future__ import annotations
          
          from .base import BaseValidator
          
          
          class RedliningValidator(BaseValidator):
              required_files = (
                  "word/document.xml",
              )
          
        • __init__.py 448 B
          """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",
          ]
          
      • pack.py 3.6 KB
        #!/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()
        
      • unpack.py 1.1 KB
        #!/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])
        
      • validate.py 1.7 KB
        #!/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())
        
      • __init__.py 195 B
        """OOXML helpers for packing, unpacking, and lightweight validation."""
        
        from .pack import pack_document
        from .unpack import unpack_document
        
        __all__ = ["pack_document", "unpack_document"]
        
    • contract_analyzer.py 9.5 KB
      #!/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()
      
    • document.py 11.6 KB
      #!/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}"
      
    • error_handling.py 16.8 KB
      #!/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')")
      
    • mermaid_renderer.py 7.1 KB
      #!/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
      
    • opinion_renderer.py 4.3 KB
      #!/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
      
    • summary_renderer.py 9.4 KB
      #!/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))
      
    • utilities.py 3.1 KB
      #!/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
      
    • workflow.py 60.6 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """
      标准合同审核工作流程
      Contract Review Standard Workflow
      
      提供完整的合同审核流程,包括:
      - 智能合同分析
      - 解包文档
      - 初始化Document对象
      - 批量添加批注(支持多关键词搜索)
      - 自动验证批注
      - 保存并打包文档
      - 生成合同概要
      - 生成业务流程图(Mermaid)并渲染图片
      - 生成审核报告
      
      使用示例:
          from scripts.workflow import ContractReviewWorkflow
      
          comments = [
              {
                  "search": ["合同总价", "协议总金额", "总金额"],
                  "comment": "【问题类型】合同价款条款\\n【风险等级】🔴 高风险..."
              }
          ]
      
          workflow = ContractReviewWorkflow("合同.docx", "审核人")
          workflow.run_full_workflow(comments, "合同_审核版.docx")
      """
      
      import sys
      import os
      import shutil
      import zipfile
      import xml.etree.ElementTree as ET
      from pathlib import Path
      from datetime import datetime
      from typing import List, Dict, Optional
      from concurrent.futures import ThreadPoolExecutor, as_completed
      
      # 添加技能路径
      skill_dir = Path(__file__).parent.parent
      if str(skill_dir) not in sys.path:
          sys.path.insert(0, str(skill_dir))
      
      try:
          from scripts.contract_analyzer import ContractAnalyzer
      except ImportError:
          from contract_analyzer import ContractAnalyzer
      
      from scripts.document import Document
      from scripts.summary_renderer import render_summary_docx
      from scripts.opinion_renderer import render_opinion_docx
      from scripts.mermaid_renderer import (
          normalize_mermaid_code,
          render_mermaid_file,
          write_mermaid_file,
      )
      from scripts.ooxml.unpack import unpack_document
      from scripts.ooxml.pack import pack_document
      
      
      def _detect_output_language(*texts: Optional[str]) -> Optional[str]:
          combined = "\n".join([text for text in texts if text])
          if not combined:
              return None
          cjk_count = 0
          latin_count = 0
          for char in combined:
              if "\u4e00" <= char <= "\u9fff":
                  cjk_count += 1
              elif "A" <= char <= "Z" or "a" <= char <= "z":
                  latin_count += 1
          if cjk_count == 0 and latin_count == 0:
              return None
          if cjk_count >= latin_count:
              return "zh"
          return "en"
      
      
      def _detect_output_language_from_contract(contract_path: Path) -> Optional[str]:
          try:
              with zipfile.ZipFile(contract_path) as zf:
                  xml = zf.read("word/document.xml")
          except Exception:
              return None
      
          try:
              root = ET.fromstring(xml)
          except Exception:
              return None
      
          ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
          texts = []
          for node in root.findall(".//w:t", ns):
              if node.text:
                  texts.append(node.text)
          combined = "".join(texts)
          return _detect_output_language(combined)
      
      
      _CN_SECTION_LABELS = {
          1: "一",
          2: "二",
          3: "三",
          4: "四",
          5: "五",
          6: "六",
          7: "七",
          8: "八",
          9: "九",
          10: "十",
      }
      
      
      def _section_cn(index: int) -> str:
          return _CN_SECTION_LABELS.get(index, str(index))
      
      
      class ContractReviewWorkflow:
          """
          完整的合同审核工作流程
      
          该类封装了合同审核的所有步骤,确保每个步骤都正确执行,
          并提供详细的反馈和验证机制。
          """
      
          def __init__(
              self,
              contract_path: str,
              reviewer_name: str = "合同审核助手",
              output_dir: str = None,
              enable_analysis: bool = True,
              enable_smart_keyword_expansion: bool = False,
          ):
              """
              初始化工作流程
      
              Args:
                  contract_path: 合同文档路径(.docx文件)
                  reviewer_name: 审核人姓名,用于批注作者
                  output_dir: 输出目录(如果为None,自动创建"审核结果:「原合同文件名」"文件夹)
                  enable_analysis: 是否启用智能合同分析(默认True)
                  enable_smart_keyword_expansion: 是否启用智能关键词扩展(默认False)
              """
              self.contract_path = Path(contract_path)
              self.reviewer_name = reviewer_name
              self.reviewer_initials = "审核"
              self.enable_analysis = enable_analysis
              self.enable_smart_keyword_expansion = enable_smart_keyword_expansion
              self.output_language = None
              self.output_dir_default = output_dir is None
      
              # 如果未指定输出目录,创建审核结果文件夹
              if output_dir is None:
                  original_name = self.contract_path.stem
                  output_dir = self.contract_path.parent / f"审核结果:{original_name}"
      
              self.output_dir = Path(output_dir)
              self.unpacked_dir = None
              self.doc = None
              self.comments_added = []  # type: List[Dict]
              self.comments_failed = []  # type: List[Dict]
              self.start_time = datetime.now()
              self.contract_analyzer = None  # type: Optional[ContractAnalyzer]
              self.flowchart_mmd_path = None  # type: Optional[Path]
              self.flowchart_image_path = None  # type: Optional[Path]
              self.flowchart_error = None  # type: Optional[str]
              self.flowchart_rendered = False
              self.summary_path = None  # type: Optional[Path]
              self.summary_error = None  # type: Optional[str]
              self.opinion_path = None  # type: Optional[Path]
              self.opinion_error = None  # type: Optional[str]
      
              # 创建输出目录
              self.output_dir.mkdir(parents=True, exist_ok=True)
      
              # 初始化智能分析器
              if self.enable_analysis:
                  try:
                      print(f"\n🔍 初始化智能合同分析...")
                      self.contract_analyzer = ContractAnalyzer(str(self.contract_path))
                      summary = self.contract_analyzer.get_contract_summary()
                      print(f"✓ 合同类型: {summary['contract_type']}")
                      print(f"✓ 段落数量: {summary['total_paragraphs']}")
                      print(f"✓ 识别字段: {summary['found_fields']}个")
                  except Exception as e:
                      print(f"⚠️  智能分析初始化失败: {e}")
                      print(f"  将继续使用标准模式")
      
          @staticmethod
          def _strip_risk_level_line(comment_text: str) -> str:
              """
              Remove any line that contains the risk level label from comment text.
      
              The reviewer name already encodes risk level, so we omit the line
              like "【风险等级】..." from the comment content.
              """
              if not comment_text:
                  return comment_text
      
              lines = comment_text.splitlines()
              kept = [line for line in lines if "风险等级" not in line]
      
              cleaned = []
              previous_blank = False
              for line in kept:
                  if line.strip():
                      cleaned.append(line)
                      previous_blank = False
                  else:
                      if not previous_blank:
                          cleaned.append(line)
                      previous_blank = True
      
              while cleaned and not cleaned[0].strip():
                  cleaned.pop(0)
              while cleaned and not cleaned[-1].strip():
                  cleaned.pop()
      
              return "\n".join(cleaned)
      
          def _ensure_output_dir_for_language(self, output_language: Optional[str]) -> None:
              if output_language != "en" or not self.output_dir_default:
                  return
      
              original_name = self.contract_path.stem
              english_dir = self.contract_path.parent / f"Review_Result_{original_name}"
              if self.output_dir == english_dir:
                  return
      
              old_dir = self.output_dir
              self.output_dir = english_dir
              self.output_dir.mkdir(parents=True, exist_ok=True)
              try:
                  if old_dir.exists() and not any(old_dir.iterdir()):
                      old_dir.rmdir()
              except Exception:
                  pass
      
          def step0_copy_contract(self) -> bool:
              """
              步骤0: 复制原合同到审核目录
      
              将原始合同文件复制到审核结果目录,作为备份和审核基础。
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤0: 复制原合同")
              print(f"{'='*60}")
              print(f"📄 复制原合同到审核目录...")
      
              try:
                  # 复制原合同到输出目录
                  target_path = self.output_dir / self.contract_path.name
                  shutil.copy2(self.contract_path, target_path)
                  print(f"✓ 已复制原合同: {target_path.name}")
                  print(f"  📁 审核目录: {self.output_dir}")
                  return True
              except Exception as e:
                  print(f"✗ 复制失败: {e}")
                  self.comments_failed.append({
                      'step': '复制原合同',
                      'error': str(e)
                  })
                  return False
      
          def step1_unpack(self, unpacked_subdir: str = "unpacked") -> bool:
              """
              步骤1: 解包文档
      
              将.docx文件解包为XML文件,以便进行编辑和批注。
              解包后的文件将存放在输出目录中的unpacked子目录。
      
              注意:使用审核目录中的合同副本,而不是原始合同
      
              Args:
                  unpacked_subdir: 解包子目录名称(相对于output_dir)
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤1: 解包文档")
              print(f"{'='*60}")
              print(f"📦 解包文档: {self.contract_path.name}")
      
              try:
                  # 使用审核目录中的合同副本
                  contract_copy = self.output_dir / self.contract_path.name
      
                  # 在输出目录中创建解包子目录
                  self.unpacked_dir = str(self.output_dir / unpacked_subdir)
                  unpack_document(str(contract_copy), self.unpacked_dir)
                  print(f"✓ 解包完成: {self.unpacked_dir}")
                  return True
              except Exception as e:
                  print(f"✗ 解包失败: {e}")
                  self.comments_failed.append({
                      'step': 'unpack',
                      'error': str(e)
                  })
                  return False
      
          def step2_initialize(self) -> bool:
              """
              步骤2: 初始化Document对象
      
              创建Document对象,用于后续的批注操作。
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤2: 初始化文档对象")
              print(f"{'='*60}")
              print(f"🔧 初始化Document对象")
      
              try:
                  self.doc = Document(
                      self.unpacked_dir,
                      author=self.reviewer_name,
                      initials=self.reviewer_initials
                  )
                  print(f"✓ 初始化完成")
                  print(f"  - 审核人: {self.reviewer_name}")
                  print(f"  - 工作目录: {self.unpacked_dir}")
                  return True
              except Exception as e:
                  print(f"✗ 初始化失败: {e}")
                  self.comments_failed.append({
                      'step': 'initialize',
                      'error': str(e)
                  })
                  return False
      
          def step3_add_comments(self, comments: List[Dict]) -> bool:
              """
              步骤3: 批量添加批注
      
              根据提供的批注列表,批量添加批注到文档中。
              使用跨节点文本搜索,处理文本被分割在多个XML节点的情况。
      
              支持多种搜索方式:
              - 单个关键词: "search": "合同编号:"
              - 多个关键词: "search": ["合同编号:", "协议编号:", "合同号:"]
              - 系统会依次尝试每个关键词,直到找到匹配
      
              智能优化(启用 enable_smart_keyword_expansion 时):
              - 如果提供了单个关键词,会自动基于合同内容扩展为多个关键词
              - 例如: "合同编号:" -> ["合同编号:", "协议编号:", "合同号:"]
      
              Args:
                  comments: 批注列表,每个元素包含'search'、'comment'和可选的'risk_level'字段
                      - 批注正文不需要包含“【风险等级】”行(如包含会自动移除)
      
              Returns:
                  bool: 全部成功返回True,部分或全部失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤3: 添加批注 (使用跨节点搜索 + 精准匹配优先)")
              print(f"{'='*60}")
              print(f"💬 添加 {len(comments)} 个批注...")
      
              smart_keywords = None
              if self.contract_analyzer:
                  smart_keywords = self.contract_analyzer.generate_smart_search_keywords()
                  print(f"\n🧠 智能搜索关键词建议:")
                  for field, keywords in list(smart_keywords.items())[:3]:  # 只显示前3个
                      print(f"   {field}: {keywords}")
      
              all_success = True
              precise_match_count = 0
              fallback_count = 0
      
              for i, comment_data in enumerate(comments, 1):
                  try:
                      # 获取搜索文本和批注内容
                      search_text = comment_data['search']
                      comment_text = comment_data['comment']
                      comment_text = self._strip_risk_level_line(comment_text)
                      risk_level = comment_data.get('risk_level', '中风险')  # 默认中风险
      
                      # 支持多关键词搜索
                      search_keywords = [search_text] if isinstance(search_text, str) else search_text
      
                      # 智能优化:如果启用扩展,尝试扩展关键词
                      if len(search_keywords) == 1 and self.enable_smart_keyword_expansion and smart_keywords:
                          original_keyword = search_keywords[0]
                          # 标准化关键词(去除标点符号)进行模糊匹配
                          normalized_original = original_keyword.rstrip('::')
      
                          # 查找包含标准化关键词的字段(使用更宽松的匹配)
                          for field, keywords in smart_keywords.items():
                              # 标准化该字段的所有关键词
                              normalized_keywords = [k.rstrip('::') for k in keywords]
      
                              # 宽松匹配:如果用户搜索"合同编号",匹配包含"编号"的字段
                              if (normalized_original in normalized_keywords or
                                  any(normalized_original in nk or nk in normalized_original
                                      for nk in normalized_keywords)):
                                  # 使用完整的原始关键词列表
                                  search_keywords = keywords
                                  print(f"  🧠 智能扩展: '{original_keyword}' -> {keywords}")
                                  break
      
                      # 使用跨节点搜索查找目标段落 (允许fallback到标题)
                      para = self.doc.find_paragraph_by_text(search_keywords, allow_fallback=True)
      
                      # 判断是否使用了fallback (检查段落是否包含任一关键词)
                      para_text = self.doc.get_paragraph_text(para)
                      used_fallback = not any(keyword in para_text for keyword in search_keywords)
      
                      if used_fallback:
                          fallback_count += 1
                          match_type = "🔄 Fallback到标题"
                      else:
                          precise_match_count += 1
                          match_type = "🎯 精准匹配"
      
                      # 添加批注(包含风险等级)
                      comment_id = self.doc.add_comment(
                          start=para,
                          end=para,
                          text=comment_text,
                          risk_level=risk_level
                      )
                      self.comments_added.append({
                          'id': comment_id,
                          'search': search_keywords[0] if len(search_keywords) == 1 else search_keywords,
                          'risk_level': risk_level,
                          'status': 'success',
                          'fallback_used': used_fallback
                      })
      
                      # 显示匹配的关键词
                      matched_keyword = search_keywords[0] if used_fallback else next((k for k in search_keywords if k in para_text), search_keywords[0])
                      print(f"✓ {i}/{len(comments)}: {match_type} - {matched_keyword[:40]}")
      
                  except Exception as e:
                      # 添加批注时出错
                      self.comments_failed.append({
                          'search': comment_data.get('search', 'unknown'),
                          'reason': str(e)
                      })
                      print(f"✗ {i}/{len(comments)}: 失败 - {str(e)[:80]}")
      
              # 打印详细统计
              success_count = len(self.comments_added)
              failed_count = len(self.comments_failed)
              precision_rate = precise_match_count / success_count * 100 if success_count > 0 else 0
      
              print(f"\n批注添加完成:")
              print(f"  ✓ 成功: {success_count} 个")
              print(f"    ├── 🎯 精准匹配: {precise_match_count} 个 ({precision_rate:.1f}%)")
              print(f"    └── 🔄 Fallback: {fallback_count} 个 ({100-precision_rate:.1f}%)")
              print(f"  ✗ 失败: {failed_count} 个")
      
              # 检查是否达到90%精准匹配目标
              if success_count > 0:
                  if precision_rate >= 90:
                      print(f"\n✅ 优秀!精准匹配率达到{precision_rate:.1f}% (目标: ≥90%)")
                  elif precision_rate >= 70:
                      print(f"\n⚠️ 良好,但精准匹配率{precision_rate:.1f}%未达到90%目标")
                  else:
                      print(f"\n❌ 精准匹配率{precision_rate:.1f}%过低,建议优化搜索关键词")
      
              return failed_count == 0
      
          def step4_verify(self) -> dict:
              """
              步骤4: 验证批注
      
              验证所有批注是否成功添加到文档中,包括:
              - comments.xml中存在批注
              - document.xml中存在批注引用
      
              Returns:
                  dict: 验证结果字典
              """
              print(f"\n{'='*60}")
              print(f"步骤4: 验证批注")
              print(f"{'='*60}")
              print(f"🔍 验证批注是否成功添加...")
      
              verification = self.doc.verify_comments()
      
              print(f"\n验证结果:")
              print(f"  📊 批注总数: {verification['total']}")
              print(f"  ✓ 文档引用: {verification['found']}")
              if verification['missing'] > 0:
                  print(f"  ✗ 缺失引用: {verification['missing']}")
                  print(f"  ⚠️  警告: 有{verification['missing']}个批注可能无法正常显示")
              else:
                  print(f"  ✓ 所有批注引用完整")
      
              # 显示批注列表预览
              if verification['comment_list']:
                  print(f"\n批注列表预览 (前5个):")
                  for i, comment in enumerate(verification['comment_list'][:5], 1):
                      preview = comment['preview'].replace('\n', ' ')
                      print(f"  {i}. [{comment['id']}] {comment['author']}: {preview}")
                  if len(verification['comment_list']) > 5:
                      print(f"  ... 还有 {len(verification['comment_list']) - 5} 个")
      
              return verification
      
          def step5_save(self, output_filename: str = None, validate: bool = True) -> bool:
              """
              步骤5: 保存并打包文档
      
              保存所有修改并打包为.docx文件。
              输出文件将保存在输出目录中。
      
              Args:
                  output_filename: 输出文件名(如"合同_审核版.docx"),如果为None则使用原文件名加上"_reviewed"后缀
                  validate: 是否验证文档
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤5: 保存并打包文档")
              print(f"{'='*60}")
              print(f"💾 保存文档...")
      
              try:
                  # 保存修改到临时目录
                  self.doc.save(validate=validate)
                  print(f"✓ 文档已保存到临时目录")
      
                  # 如果未指定输出文件名,使用原文件名加上_reviewed后缀
                  if output_filename is None:
                      original_name = self.contract_path.stem
                      output_filename = f"{original_name}_reviewed.docx"
      
                  # 构建完整的输出路径
                  output_path = str(self.output_dir / output_filename)
      
                  # 打包为.docx文件
                  pack_document(self.doc.unpacked_path, output_path, validate=False)
                  file_size = Path(output_path).stat().st_size / 1024  # KB
                  print(f"✓ 文档已打包: {output_path} ({file_size:.1f} KB)")
      
                  return True
              except Exception as e:
                  print(f"✗ 保存失败: {e}")
                  print(f"  提示: 如果遇到验证错误,可以尝试设置 validate=False")
                  self.comments_failed.append({
                      'step': 'save',
                      'error': str(e)
                  })
                  return False
      
          def step6_generate_summary(
              self,
              summary_text: Optional[str],
              summary_filename: str = "合同概要.docx",
              summary_font: str = "仿宋",
          ) -> bool:
              """
              步骤6: 合同概要提取
      
              将合同概要内容保存为DOCX富文本,输出到审核结果目录。
      
              Args:
                  summary_text: 合同概要文本(严格按格式输出)
                  summary_filename: 输出文件名
                  summary_font: 概要字体(默认仿宋)
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤6: 合同概要提取")
              print(f"{'='*60}")
              print(f"🧾 生成合同概要...")
      
              if not summary_text:
                  print("⚠️  未提供合同概要内容,跳过该步骤")
                  return True
      
              try:
                  content = summary_text.strip()
                  if not content.endswith("\n"):
                      content += "\n"
                  summary_path = self.output_dir / summary_filename
                  render_summary_docx(content, summary_path, font_name=summary_font)
                  self.summary_path = summary_path
                  print(f"✓ 已生成合同概要: {summary_path.name}")
                  return True
              except Exception as e:
                  self.summary_error = str(e)
                  self.comments_failed.append({
                      'step': 'summary',
                      'error': str(e)
                  })
                  print(f"✗ 合同概要生成失败: {e}")
                  return False
      
          def step7_generate_opinion(
              self,
              opinion_text: Optional[str],
              opinion_filename: str = "综合审核意见.docx",
              opinion_font: str = "仿宋",
          ) -> bool:
              """
              步骤7: 生成综合审核意见
      
              将综合审核意见保存为DOCX富文本,输出到审核结果目录。
      
              Args:
                  opinion_text: 综合审核意见文本(两段自然段落)
                  opinion_filename: 输出文件名
                  opinion_font: 意见字体(默认仿宋)
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤7: 生成综合审核意见")
              print(f"{'='*60}")
              print(f"📝 生成综合审核意见...")
      
              if not opinion_text:
                  print("⚠️  未提供综合审核意见内容,跳过该步骤")
                  return True
      
              try:
                  content = opinion_text.strip()
                  if not content.endswith("\n"):
                      content += "\n"
                  opinion_path = self.output_dir / opinion_filename
                  title_text = "综合审核意见"
                  if self.output_language == "en":
                      title_text = "Consolidated Review Opinion"
                  render_opinion_docx(
                      content,
                      opinion_path,
                      font_name=opinion_font,
                      title_text=title_text,
                  )
                  self.opinion_path = opinion_path
                  print(f"✓ 已生成综合审核意见: {opinion_path.name}")
                  return True
              except Exception as e:
                  self.opinion_error = str(e)
                  self.comments_failed.append({
                      'step': 'opinion',
                      'error': str(e)
                  })
                  print(f"✗ 综合审核意见生成失败: {e}")
                  return False
      
          def step6_generate_flowchart(
              self,
              mermaid_code: Optional[str],
              mmd_filename: str = "business_flowchart.mmd",
              image_filename: str = "business_flowchart.png",
              render_image: bool = True,
              theme: str = "default",
              background_color: str = "white",
          ) -> bool:
              """
              步骤8: 生成业务流程图 (Mermaid)
      
              将 Mermaid flowchart 代码保存为 .mmd 文件并渲染为图片。
              输出文件保存在审核结果目录中。
      
              Args:
                  mermaid_code: Mermaid flowchart 代码
                  mmd_filename: .mmd 文件名
                  image_filename: 图片文件名(.png/.svg)
                  render_image: 是否渲染图片(默认True)
                  theme: Mermaid 主题
                  background_color: 背景色
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤8: 生成业务流程图")
              print(f"{'='*60}")
              print(f"🗺️  生成业务流程图...")
      
              if not mermaid_code:
                  print("⚠️  未提供Mermaid流程图代码,跳过该步骤")
                  return True
      
              self.flowchart_error = None
              self.flowchart_rendered = False
      
              try:
                  normalized = normalize_mermaid_code(mermaid_code)
                  self.flowchart_mmd_path = write_mermaid_file(
                      normalized,
                      self.output_dir,
                      mmd_filename,
                  )
                  print(f"✓ 已保存Mermaid源文件: {self.flowchart_mmd_path.name}")
      
                  image_path = self.output_dir / image_filename
                  self.flowchart_image_path = image_path
                  if render_image:
                      if image_path.exists():
                          try:
                              image_path.unlink()
                          except Exception:
                              pass
                      render_mermaid_file(
                          self.flowchart_mmd_path,
                          image_path,
                          theme=theme,
                          background_color=background_color,
                      )
                      self.flowchart_rendered = True
                      print(f"✓ 已渲染流程图图片: {self.flowchart_image_path.name}")
                  else:
                      print("⚠️  已跳过图片渲染(仅保存.mmd文件)")
      
                  return True
              except Exception as e:
                  self.flowchart_error = str(e)
                  self.comments_failed.append({
                      'step': 'flowchart',
                      'error': str(e)
                  })
                  if self.flowchart_image_path and self.flowchart_image_path.exists():
                      try:
                          self.flowchart_image_path.unlink()
                      except Exception:
                          pass
                  print(f"✗ 流程图生成失败: {e}")
                  if isinstance(e, FileNotFoundError):
                      print("  提示: 请安装 Mermaid CLI: npm i -g @mermaid-js/mermaid-cli")
                  return False
      
          def step7_generate_report(self, report_filename: str = "review_report.txt") -> bool:
              """
              步骤9: 生成审核报告
      
              生成详细的审核报告,包括:
              - 审核基本信息
              - 批注统计
              - 失败批注详情
              - 验证结果
              - 执行时间
      
              报告将保存在输出目录中。
      
              Args:
                  report_filename: 报告文件名(如"review_report.txt")
      
              Returns:
                  bool: 成功返回True,失败返回False
              """
              print(f"\n{'='*60}")
              print(f"步骤9: 生成审核报告")
              print(f"{'='*60}")
              print(f"📄 生成审核报告...")
      
              try:
                  duration = (datetime.now() - self.start_time).total_seconds()
      
                  # 构建完整的报告路径
                  report_path = str(self.output_dir / report_filename)
      
                  precise_matches = [c for c in self.comments_added if not c.get('fallback_used', False)]
                  fallback_matches = [c for c in self.comments_added if c.get('fallback_used', False)]
                  comment_failures = [c for c in self.comments_failed if 'search' in c]
                  other_failures = [c for c in self.comments_failed if 'search' not in c]
                  language = self.output_language or "zh"
      
                  with open(report_path, 'w', encoding='utf-8') as f:
                      if language == "en":
                          f.write("=" * 60 + "\n")
                          f.write("Contract Review Comment Report\n")
                          f.write("=" * 60 + "\n\n")
      
                          f.write("1. Basic Information\n")
                          f.write("-" * 60 + "\n")
                          f.write(f"Reviewer: {self.reviewer_name}\n")
                          f.write(f"Document: {self.contract_path}\n")
                          f.write(f"Review Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                          f.write(f"Duration: {duration:.2f} seconds\n")
                          if self.summary_path or self.summary_error:
                              if self.summary_path:
                                  f.write(f"Contract Summary: {self.summary_path.name}\n")
                              elif self.summary_error:
                                  f.write(f"Contract Summary: Failed ({self.summary_error})\n")
                          if self.opinion_path or self.opinion_error:
                              if self.opinion_path:
                                  f.write(f"Consolidated Opinion: {self.opinion_path.name}\n")
                              elif self.opinion_error:
                                  f.write(f"Consolidated Opinion: Failed ({self.opinion_error})\n")
      
                          flowchart_image_path = self.flowchart_image_path
                          if flowchart_image_path is None:
                              candidate = self.output_dir / "business_flowchart.png"
                              if candidate.exists():
                                  flowchart_image_path = candidate
                                  self.flowchart_image_path = candidate
      
                          if self.flowchart_mmd_path or self.flowchart_error or self.flowchart_rendered:
                              if self.flowchart_rendered and flowchart_image_path and flowchart_image_path.exists():
                                  f.write(f"Flowchart Image: {flowchart_image_path.name}\n")
                              elif self.flowchart_error:
                                  f.write(f"Flowchart Image: Failed ({self.flowchart_error})\n")
                              if self.flowchart_mmd_path:
                                  f.write(f"Flowchart Source: {self.flowchart_mmd_path.name}\n")
                          f.write("\n")
      
                          f.write("2. Comment Statistics\n")
                          f.write("-" * 60 + "\n")
                          f.write(f"Added Successfully: {len(self.comments_added)}\n")
                          if len(self.comments_added) > 0:
                              precise_rate = len(precise_matches) / len(self.comments_added) * 100
                              f.write(f"  - Exact Match: {len(precise_matches)} ({precise_rate:.1f}%)\n")
                              f.write(f"  - Fallback: {len(fallback_matches)} ({100-precise_rate:.1f}%)\n")
                          f.write(f"Failed: {len(comment_failures)}\n")
                          total_attempts = len(self.comments_added) + len(comment_failures)
                          success_rate = len(self.comments_added) / total_attempts * 100 if total_attempts else 0
                          f.write(f"Success Rate: {success_rate:.1f}%\n\n")
      
                          section_index = 3
                          if fallback_matches:
                              f.write(f"{section_index}. Fallback Comment Details\n")
                              f.write("-" * 60 + "\n")
                              f.write(
                                  f"{len(fallback_matches)} comments were added to the document title because no exact match was found.\n\n"
                              )
                              for i, comment in enumerate(fallback_matches, 1):
                                  f.write(f"{i}. Search Text: {comment['search']}\n")
                                  f.write(f"   Risk Level: {comment['risk_level']}\n\n")
                              f.write("Note: These comments may require manual location in the document.\n\n")
                              section_index += 1
      
                          if comment_failures:
                              f.write(f"{section_index}. Failed Comment Details\n")
                              f.write("-" * 60 + "\n")
                              for i, failed in enumerate(comment_failures, 1):
                                  f.write(f"{i}. Search Text: {failed['search']}\n")
                                  f.write(f"   Failure Reason: {failed['reason']}\n\n")
                              section_index += 1
      
                          if other_failures:
                              f.write(f"{section_index}. Other Step Errors\n")
                              f.write("-" * 60 + "\n")
                              for i, failed in enumerate(other_failures, 1):
                                  f.write(f"{i}. Step: {failed.get('step', 'unknown')}\n")
                                  f.write(f"   Error: {failed['error']}\n\n")
                              section_index += 1
      
                          verification = self.doc.verify_comments()
                          f.write(f"\n{section_index}. Verification Results\n")
                          f.write("-" * 60 + "\n")
                          f.write(f"Total Comments: {verification['total']}\n")
                          f.write(f"References Found: {verification['found']}\n")
                          f.write(f"Missing References: {verification['missing']}\n")
                          section_index += 1
      
                          if verification['comment_list']:
                              f.write(f"\n{section_index}. Comment List\n")
                              f.write("-" * 60 + "\n")
                              for i, comment in enumerate(verification['comment_list'], 1):
                                  f.write(f"{i}. [ID:{comment['id']}] {comment['author']}\n")
                                  f.write(f"   Preview: {comment['preview']}\n\n")
      
                          f.write("\n" + "=" * 60 + "\n")
                          f.write(f"Report Generated At: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                          f.write("=" * 60 + "\n")
                      else:
                          f.write("=" * 60 + "\n")
                          f.write("合同审核批注报告\n")
                          f.write("=" * 60 + "\n\n")
      
                          # 基本信息
                          f.write("一、基本信息\n")
                          f.write("-" * 60 + "\n")
                          f.write(f"审核人: {self.reviewer_name}\n")
                          f.write(f"文档: {self.contract_path}\n")
                          f.write(f"审核时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                          f.write(f"执行时长: {duration:.2f} 秒\n")
                          if self.summary_path or self.summary_error:
                              if self.summary_path:
                                  f.write(f"合同概要: {self.summary_path.name}\n")
                              elif self.summary_error:
                                  f.write(f"合同概要: 生成失败 ({self.summary_error})\n")
                          if self.opinion_path or self.opinion_error:
                              if self.opinion_path:
                                  f.write(f"综合审核意见: {self.opinion_path.name}\n")
                              elif self.opinion_error:
                                  f.write(f"综合审核意见: 生成失败 ({self.opinion_error})\n")
                          flowchart_image_path = self.flowchart_image_path
                          if flowchart_image_path is None:
                              candidate = self.output_dir / "business_flowchart.png"
                              if candidate.exists():
                                  flowchart_image_path = candidate
                                  self.flowchart_image_path = candidate
      
                          if self.flowchart_mmd_path or self.flowchart_error or self.flowchart_rendered:
                              if self.flowchart_rendered and flowchart_image_path and flowchart_image_path.exists():
                                  f.write(f"流程图图片: {flowchart_image_path.name}\n")
                              elif self.flowchart_error:
                                  f.write(f"流程图图片: 生成失败 ({self.flowchart_error})\n")
                              if self.flowchart_mmd_path:
                                  f.write(f"流程图源码: {self.flowchart_mmd_path.name}\n")
                          f.write("\n")
      
                          # 批注统计
                          f.write("二、批注统计\n")
                          f.write("-" * 60 + "\n")
                          f.write(f"成功添加: {len(self.comments_added)} 个\n")
      
                          if len(self.comments_added) > 0:
                              precise_rate = len(precise_matches) / len(self.comments_added) * 100
                              f.write(f"  ├── 精准匹配: {len(precise_matches)} 个 ({precise_rate:.1f}%)\n")
                              f.write(f"  └── Fallback: {len(fallback_matches)} 个 ({100-precise_rate:.1f}%)\n")
      
                          f.write(f"添加失败: {len(comment_failures)} 个\n")
                          total_attempts = len(self.comments_added) + len(comment_failures)
                          success_rate = len(self.comments_added) / total_attempts * 100 if total_attempts else 0
                          f.write(f"成功率: {success_rate:.1f}%\n\n")
      
                          section_index = 3
                          if fallback_matches:
                              f.write(f"{_section_cn(section_index)}、Fallback批注详情\n")
                              f.write("-" * 60 + "\n")
                              f.write(f"以下{len(fallback_matches)}个批注因未找到精确匹配,已添加到文档标题:\n\n")
                              for i, comment in enumerate(fallback_matches, 1):
                                  f.write(f"{i}. 搜索文本: {comment['search']}\n")
                                  f.write(f"   风险等级: {comment['risk_level']}\n\n")
                              f.write("注意: 这些批注可能需要您手动定位到相关条款。\n\n")
                              section_index += 1
      
                          if comment_failures:
                              f.write(f"{_section_cn(section_index)}、失败批注详情\n")
                              f.write("-" * 60 + "\n")
                              for i, failed in enumerate(comment_failures, 1):
                                  f.write(f"{i}. 搜索文本: {failed['search']}\n")
                                  f.write(f"   失败原因: {failed['reason']}\n\n")
                              section_index += 1
      
                          if other_failures:
                              f.write(f"{_section_cn(section_index)}、其他步骤错误\n")
                              f.write("-" * 60 + "\n")
                              for i, failed in enumerate(other_failures, 1):
                                  f.write(f"{i}. 步骤: {failed.get('step', 'unknown')}\n")
                                  f.write(f"   错误信息: {failed['error']}\n\n")
                              section_index += 1
      
                          verification = self.doc.verify_comments()
                          f.write(f"\n{_section_cn(section_index)}、验证结果\n")
                          section_index += 1
      
                          f.write("-" * 60 + "\n")
                          f.write(f"批注总数: {verification['total']}\n")
                          f.write(f"文档引用: {verification['found']}\n")
                          f.write(f"缺失引用: {verification['missing']}\n")
      
                          if verification['comment_list']:
                              f.write(f"\n{_section_cn(section_index)}、批注列表\n")
      
                              f.write("-" * 60 + "\n")
                              for i, comment in enumerate(verification['comment_list'], 1):
                                  f.write(f"{i}. [ID:{comment['id']}] {comment['author']}\n")
                                  f.write(f"   预览: {comment['preview']}\n\n")
      
                          f.write("\n" + "=" * 60 + "\n")
                          f.write(f"报告生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                          f.write("=" * 60 + "\n")
      
                  print(f"✓ 报告已生成: {report_path}")
                  return True
      
              except Exception as e:
                  print(f"✗ 报告生成失败: {e}")
                  return False
      
          def step8_cleanup_output(self,
          output_docx_filename: str,
          report_filename: str) -> Path:
              """
              步骤10: 清理输出,只保留最终结果文件
      
              在审核结果目录中,删除临时文件,只保留:
              1. 原合同(docx) - 已在step0复制到审核目录
              2. 审核后的合同(docx)
              3. 审核报告(txt)
      
              注意: 审核目录本身就是最终输出目录,无需创建新目录
      
              Args:
                  output_docx_filename: 输出文档文件名
                  report_filename: 报告文件名
      
              Returns:
                  Path: 最终输出目录路径(即self.output_dir)
              """
              print(f"\n{'='*60}")
              print(f"步骤10: 清理输出文件")
              print(f"{'='*60}")
              print(f"🧹 清理中间文件,只保留最终结果...")
      
              try:
                  # 最终输出目录就是当前工作目录
                  final_output_dir = self.output_dir
      
                  # 重命名审核后的合同
                  source_docx = self.output_dir / output_docx_filename
                  original_name = self.contract_path.stem
                  if self.output_language == "en":
                      target_docx = final_output_dir / f"{original_name}_Reviewed.docx"
                  else:
                      target_docx = final_output_dir / f"{original_name}_审核版.docx"
      
                  if source_docx.exists() and source_docx != target_docx:
                      shutil.move(str(source_docx), str(target_docx))
                      print(f"✓ 已重命名审核后的合同: {target_docx.name}")
                  elif source_docx.exists():
                      print(f"✓ 审核后的合同: {target_docx.name}")
      
                  # 重命名审核报告
                  source_report = self.output_dir / report_filename
                  if self.output_language == "en":
                      target_report = final_output_dir / "Review_Report.txt"
                  else:
                      target_report = final_output_dir / "审核报告.txt"
      
                  if source_report.exists() and source_report != target_report:
                      shutil.move(str(source_report), str(target_report))
                      print(f"✓ 已重命名审核报告: {target_report.name}")
                  elif source_report.exists():
                      print(f"✓ 审核报告: {target_report.name}")
      
                  # 删除unpacked临时目录
                  unpacked_dir = self.output_dir / "unpacked"
                  if unpacked_dir.exists():
                      try:
                          shutil.rmtree(unpacked_dir)
                          print(f"✓ 已删除临时目录: {unpacked_dir.name}")
                      except Exception as e:
                          print(f"⚠️  删除临时目录失败: {e}")
      
                  print(f"\n✓ 清理完成!")
                  print(f"  📁 最终输出目录: {final_output_dir}")
                  output_files = [
                      f"{self.contract_path.name} (原合同)",
                      target_docx.name,
                      target_report.name,
                  ]
      
                  if self.summary_path and self.summary_path.exists():
                      output_files.append(self.summary_path.name)
                  if self.opinion_path and self.opinion_path.exists():
                      output_files.append(self.opinion_path.name)
                  if self.flowchart_rendered and self.flowchart_image_path and self.flowchart_image_path.exists():
                      output_files.append(self.flowchart_image_path.name)
                  if self.flowchart_mmd_path and self.flowchart_mmd_path.exists():
                      output_files.append(self.flowchart_mmd_path.name)
      
                  print(f"  📄 包含文件:")
                  for i, filename in enumerate(output_files, 1):
                      print(f"    {i}. {filename}")
      
                  return final_output_dir
      
              except Exception as e:
                  print(f"✗ 清理失败: {e}")
                  print(f"⚠️  最终文件仍在: {self.output_dir}")
                  return self.output_dir
      
          def run_full_workflow(self,
                               comments: List[Dict],
                               output_docx_filename: str = None,
                               report_filename: str = "review_report.txt",
                               validate_doc: bool = False,
                               cleanup: bool = True,
                               parallel_outputs: bool = True,
                               summary_text: Optional[str] = None,
                               summary_filename: str = "合同概要.docx",
                               summary_font: str = "仿宋",
                               opinion_text: Optional[str] = None,
                               opinion_filename: str = "综合审核意见.docx",
                               opinion_font: str = "仿宋",
                               flowchart_mermaid: Optional[str] = None,
                               flowchart_mmd_filename: str = "business_flowchart.mmd",
                               flowchart_image_filename: str = "business_flowchart.png",
                               render_flowchart: bool = True) -> bool:
              """
              运行完整工作流程
      
              按顺序执行所有步骤:复制原合同→解包→初始化→添加批注→验证→保存→生成合同概要→生成综合审核意见→生成流程图→生成报告→清理输出
      
              工作流程:
              1. 创建审核结果目录: 中文为“审核结果:原合同文件名”,英文为“Review_Result_{原合同文件名}”
              2. 复制原合同到审核目录
              3. 在审核目录中进行审核操作
              4. 清理临时文件,只保留基础文件(如有概要/意见/流程图输出,会一并保留):
                 - 原合同(docx)
                 - 审核后的合同(docx)
                 - 审核报告(txt)
      
              Args:
                  comments: 批注列表
                  output_docx_filename: 输出文档文件名(如"合同_审核版.docx"),如果为None则自动生成
                  report_filename: 报告文件名(如"review_report.txt")
                  validate_doc: 是否验证文档(默认False,避免OOXML兼容性问题导致保存失败)
                  cleanup: 是否清理中间文件(默认True)
                  summary_text: 合同概要文本(如提供则输出概要文件)
                  summary_filename: 合同概要文件名
                  summary_font: 合同概要字体(默认仿宋)
                  parallel_outputs: 是否并行生成概要/意见/流程图(默认True)
                  opinion_text: 综合审核意见文本(如提供则输出意见文件)
                  opinion_filename: 综合审核意见文件名
                  opinion_font: 综合审核意见字体(默认仿宋)
                  flowchart_mermaid: Mermaid流程图代码(如提供则生成流程图文件)
                  flowchart_mmd_filename: Mermaid源文件名(.mmd)
                  flowchart_image_filename: Mermaid渲染图片名(.png/.svg)
                  render_flowchart: 是否渲染图片(默认True)
      
              Returns:
                  bool: 全部步骤成功返回True,否则返回False
      
              注意:
                  默认禁用Schema验证(validate_doc=False),原因:
                  1. 部分Word文档包含不间断空格(\\xa0),需要xml:space='preserve'属性
                  2. 部分文档已有批注扩展文件(commentsExtensible.xml等)
                  3. 这些格式问题不影响Word正常使用,但会导致验证失败
                  4. 如需严格验证,可手动设置validate_doc=True
              """
              print("\n" + "=" * 60)
              print("合同审核工作流程")
              print("Contract Review Workflow")
              print("=" * 60)
      
              output_language = _detect_output_language(summary_text, opinion_text, flowchart_mermaid)
              if output_language is None:
                  output_language = _detect_output_language_from_contract(self.contract_path)
              if output_language is None:
                  output_language = "en"
              self.output_language = output_language
              self._ensure_output_dir_for_language(output_language)
              if output_language == "en":
                  if self.reviewer_name == "合同审核助手":
                      self.reviewer_name = "Contract Review Assistant"
                  if self.reviewer_initials == "审核":
                      self.reviewer_initials = "CR"
                  if summary_font == "仿宋":
                      summary_font = "Times New Roman"
                  if opinion_font == "仿宋":
                      opinion_font = "Times New Roman"
                  if report_filename == "review_report.txt":
                      report_filename = "Review_Report.txt"
              if output_language == "en":
                  if summary_filename == "合同概要.docx":
                      summary_filename = "Contract_Summary.docx"
                  if opinion_filename == "综合审核意见.docx":
                      opinion_filename = "Consolidated_Opinion.docx"
              print(f"\n📁 审核输出目录: {self.output_dir}")
      
              # 执行所有步骤
              success = True
      
              # 步骤0: 复制原合同到审核目录
              if not self.step0_copy_contract():
                  return False
      
              if not self.step1_unpack():
                  return False
      
              if not self.step2_initialize():
                  return False
      
              if not self.step3_add_comments(comments):
                  print("\n⚠️  部分批注添加失败,但继续保存...")
                  success = False
      
              verification = self.step4_verify()
              if verification['missing'] > 0:
                  print("\n⚠️  验证发现问题,但继续保存...")
                  success = False
      
              if not self.step5_save(output_docx_filename, validate=validate_doc):
                  return False
      
              if parallel_outputs and (summary_text or opinion_text or flowchart_mermaid):
                  tasks = {}
                  with ThreadPoolExecutor(max_workers=3) as executor:
                      if summary_text:
                          tasks[executor.submit(
                              self.step6_generate_summary,
                              summary_text,
                              summary_filename,
                              summary_font,
                          )] = "summary"
                      if opinion_text:
                          tasks[executor.submit(
                              self.step7_generate_opinion,
                              opinion_text,
                              opinion_filename,
                              opinion_font,
                          )] = "opinion"
                      if flowchart_mermaid:
                          tasks[executor.submit(
                              self.step6_generate_flowchart,
                              flowchart_mermaid,
                              flowchart_mmd_filename,
                              flowchart_image_filename,
                              render_flowchart,
                          )] = "flowchart"
      
                      for future in as_completed(tasks):
                          try:
                              ok = future.result()
                          except Exception as e:
                              ok = False
                              step_name = tasks[future]
                              self.comments_failed.append({
                                  'step': step_name,
                                  'error': str(e)
                              })
                              print(f"✗ 输出生成失败: {step_name} - {e}")
                          if not ok:
                              success = False
              else:
                  if not self.step6_generate_summary(summary_text, summary_filename, summary_font):
                      success = False
      
                  if not self.step7_generate_opinion(opinion_text, opinion_filename, opinion_font):
                      success = False
      
                  if not self.step6_generate_flowchart(
                      flowchart_mermaid,
                      flowchart_mmd_filename,
                      flowchart_image_filename,
                      render_image=render_flowchart,
                  ):
                      success = False
      
              if not self.step7_generate_report(report_filename):
                  success = False
      
              # 清理输出,只保留最终结果
              if cleanup:
                  # 构建输出文件名
                  if output_docx_filename is None:
                      original_name = self.contract_path.stem
                      output_docx_filename = f"{original_name}_reviewed.docx"
      
                  # 执行清理
                  final_output_dir = self.step8_cleanup_output(output_docx_filename, report_filename)
      
                  # 获取最终文件路径
                  original_name = self.contract_path.stem
                  if self.output_language == "en":
                      final_docx = final_output_dir / f"{original_name}_Reviewed.docx"
                      final_report = final_output_dir / "Review_Report.txt"
                  else:
                      final_docx = final_output_dir / f"{original_name}_审核版.docx"
                      final_report = final_output_dir / "审核报告.txt"
              else:
                  # 不清理,使用临时目录
                  final_output_dir = self.output_dir
                  if output_docx_filename is None:
                      output_docx_filename = f"{self.contract_path.stem}_reviewed.docx"
                  final_docx = final_output_dir / output_docx_filename
                  final_report = final_output_dir / report_filename
      
              # 最终总结
              print("\n" + "=" * 60)
              print("工作流程完成!")
              print("=" * 60)
              print(f"\n📊 最终统计:")
              print(f"  ✓ 成功添加批注: {len(self.comments_added)} 个")
              comment_failures = [c for c in self.comments_failed if 'search' in c]
              print(f"  ✗ 添加失败: {len(comment_failures)} 个")
      
              # 统计精准匹配和fallback
              precise_matches = [c for c in self.comments_added if not c.get('fallback_used', False)]
              fallback_matches = [c for c in self.comments_added if c.get('fallback_used', False)]
      
              if len(self.comments_added) > 0:
                  precise_rate = len(precise_matches) / len(self.comments_added) * 100
                  print(f"\n精准匹配情况:")
                  print(f"  🎯 精准匹配: {len(precise_matches)} 个 ({precise_rate:.1f}%)")
                  print(f"  🔄 Fallback: {len(fallback_matches)} 个 ({100-precise_rate:.1f}%)")
      
              print(f"\n📁 最终输出:")
              print(f"  📄 审核后的合同: {final_docx}")
              print(f"  📋 审核报告: {final_report}")
              if summary_text:
                  if self.summary_path:
                      print(f"  🧾 合同概要: {self.summary_path}")
                  elif self.summary_error:
                      print(f"  ⚠️ 合同概要生成失败: {self.summary_error}")
              if opinion_text:
                  if self.opinion_path:
                      print(f"  📝 综合审核意见: {self.opinion_path}")
                  elif self.opinion_error:
                      print(f"  ⚠️ 综合审核意见生成失败: {self.opinion_error}")
              if flowchart_mermaid:
                  if self.flowchart_rendered and self.flowchart_image_path and self.flowchart_image_path.exists():
                      print(f"  🗺️ 业务流程图: {self.flowchart_image_path}")
                  elif self.flowchart_error:
                      print(f"  ⚠️ 业务流程图生成失败: {self.flowchart_error}")
                  if self.flowchart_mmd_path:
                      print(f"  🧾 Mermaid源文件: {self.flowchart_mmd_path}")
              print(f"  📂 输出目录: {final_output_dir}")
              print(f"  ⏱️  总耗时: {(datetime.now() - self.start_time).total_seconds():.2f} 秒")
      
              if success:
                  print(f"\n✅ 所有步骤执行成功!")
              else:
                  print(f"\n⚠️  工作流程完成,但部分步骤存在问题,请查看报告详情。")
      
              return success
      
      
      # 便捷函数
      def review_contract(contract_path: str,
                         comments: List[Dict],
                         output_docx_filename: str = None,
                         reviewer_name: str = "合同审核助手",
                         report_filename: str = "review_report.txt",
                         output_dir: str = None,
                         enable_smart_keyword_expansion: bool = False,
                         summary_text: Optional[str] = None,
                         summary_filename: str = "合同概要.docx",
                         summary_font: str = "仿宋",
                         opinion_text: Optional[str] = None,
                         opinion_filename: str = "综合审核意见.docx",
                         opinion_font: str = "仿宋",
                         flowchart_mermaid: Optional[str] = None,
                         flowchart_mmd_filename: str = "business_flowchart.mmd",
                         flowchart_image_filename: str = "business_flowchart.png",
                         render_flowchart: bool = True,
                         parallel_outputs: bool = True) -> bool:
          """
          便捷函数:一键完成合同审核
      
          创建审核结果目录: 中文为“审核结果:原合同文件名”,英文为“Review_Result_{原合同文件名}”
          所有文件(原合同、审核后的合同、审核报告)将保存在审核结果目录中。
      
          Args:
              contract_path: 合同文档路径
              comments: 批注列表
              output_docx_filename: 输出文档文件名(如"合同_审核版.docx"),如果为None则自动生成
              reviewer_name: 审核人姓名
              report_filename: 报告文件名(如"review_report.txt")
              output_dir: 输出目录路径(如果为None,自动创建默认审核结果文件夹)
              enable_smart_keyword_expansion: 是否启用智能关键词扩展(默认False)
              summary_text: 合同概要文本(如提供则输出概要文件)
              summary_filename: 合同概要文件名
              summary_font: 合同概要字体(默认仿宋)
              opinion_text: 综合审核意见文本(如提供则输出意见文件)
              opinion_filename: 综合审核意见文件名
              opinion_font: 综合审核意见字体(默认仿宋)
              flowchart_mermaid: Mermaid流程图代码(如提供则生成流程图文件)
              flowchart_mmd_filename: Mermaid源文件名(.mmd)
              flowchart_image_filename: Mermaid渲染图片名(.png/.svg)
              render_flowchart: 是否渲染图片(默认True)
              parallel_outputs: 是否并行生成概要/意见/流程图(默认True)
      
          Returns:
              bool: 成功返回True,失败返回False
      
          Example:
              >>> comments = [{"search": "合同总价", "comment": "批注内容"}]
              >>> review_contract("合同.docx", comments, "�
    • __init__.py 51 B
      """Package marker for contract-review scripts."""
      
  • CHANGELOG.md 317 B
    # Changelog
    
    All notable changes to this skill are documented here.
    Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
    
    ## [0.2.0] - 2026-08-24
    
    ### Added
    
    - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill
    
  • README.md 2 KB
    # 合同批注专家 · Contract Reviewer
    
    ![Version](https://img.shields.io/badge/version-0.2.0-CC785C)
    
    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
    
    ```bash
    npx skills add lovstudio/contract-review-pro-skill --all -g
    ```
    
    ## See also
    
    - [`review-doc`](https://github.com/lovstudio/review-doc-skill) — lightweight daily version for general document review
    - [`review-doc`](https://github.com/lovstudio/review-doc-skill) — 日常轻量版,适用于普通文档审阅
    
    ## License
    
    MIT — content adapted from jicheng's contract-review methodology.
    
  • SKILL.md 7.2 KB
    ---
    name: contract-review-pro
    description: "Professional-grade contract review skill that adds comment-based issue annotations without changing original text. Enforces a four-layer review (entity verification, basic, business, legal), writes structured comments (issue type, risk reason, revision suggestion) with risk level encoded via reviewer name, and generates a contract summary, consolidated opinion, and Mermaid business flowchart (with rendered image). Output language must follow the contract’s language."
    metadata:
      version: "0.2.0"
    ---
    
    # 合同批注专家 · Contract Reviewer
    
    ## 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 or `Review_Result_{ContractName}` for English
    - Reviewed contract: `{ContractName}_审核版.docx` for Chinese or `{ContractName}_Reviewed.docx` for English
    - Review report: `审核报告.txt` for Chinese or `Review_Report.txt` for 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.mmd`
    - `business_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 `mmdc` installed; 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`.
    
    ## 通用反馈闭环
    
    用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
    
    1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。
    2. `task-specific` 只修改当前任务,不改 Skill。
    3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
    4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
    5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related