
Tabular Document Review
- 48 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
tabular-document-review is a Claude skill that extracts structured data from many documents into a cited comparison matrix for bulk legal review.
About
Tabular-document-review extracts structured data from multiple documents into a comparison matrix with page or section citations. A legal or ops team uses it for bulk contract, NDA, employment, or lease review with user-defined extraction columns. It supports parallel processing with up to 10 agents, confidence scoring, and markdown-table or JSON output.
- Extracts structured data from multiple documents into a comparison matrix with citations
- Parallel processing with up to 10 agents plus HIGH/MEDIUM/LOW confidence scoring
- Ships document-discovery and extraction-aggregator Python scripts and a 5-step pipeline
Tabular Document Review by the numbers
- 48 all-time installs (skills.sh)
- Ranked #368 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
tabular-document-review capabilities & compatibility
- Capabilities
- document extraction · contract review · comparison matrix
- Use cases
- pdf parsing · data analysis · documentation
- Pricing
- Free
What tabular-document-review says it does
Extract structured data from multiple documents into comparison matrix with citations. Use for bulk document review.
Supports user-defined extraction columns, parallel processing with up to 10 agents, confidence scoring, and output in markdown table or structured JSON.
npx skills add https://github.com/borghei/claude-skills --skill tabular-document-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 48 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Extract user-defined fields from many documents into a cited comparison matrix for bulk legal review.
Who is it for?
Legal teams doing bulk contract, NDA, employment, or lease review across many documents.
Skip if: Single-document review or tasks needing legal advice (the skill is marked experimental and not legal advice).
When should I use this skill?
You need to compare a folder of contracts or NDAs field-by-field with citations.
What you get
A comparison matrix of extracted fields per document with page/section citations and confidence scores.
- comparison matrix
- document manifest
- per-document extraction JSON
By the numbers
- up to 10 parallel agents
- 5-step review pipeline
- 3-level confidence scoring
Files
⚠️ EXPERIMENTAL — This skill is provided for educational and informational purposes only. It does NOT constitute legal advice. All responsibility for usage rests with the user. Consult qualified legal professionals before acting on any output.
Tabular Document Review Skill
Overview
Production-ready toolkit for extracting structured data from multiple legal documents into a comparison matrix with citations. Supports user-defined extraction columns, parallel processing with up to 10 agents, confidence scoring, and output in markdown table or structured JSON. Designed for legal teams performing bulk contract review, NDA comparison, employment agreement analysis, and lease review.
Table of Contents
- Tools
- Reference Guides
- Workflows
- Extraction Scenarios
- Troubleshooting
- Success Criteria
- Scope & Limitations
- Anti-Patterns
- Tool Reference
Tools
1. Document Discovery (scripts/document_discovery.py)
Scan a directory for legal documents and generate an inventory manifest.
python scripts/document_discovery.py /path/to/contracts
python scripts/document_discovery.py /path/to/ndas --types pdf,docx --json
python scripts/document_discovery.py /path/to/leases --types pdf,docx,txt,md --min-size 10242. Extraction Aggregator (scripts/extraction_aggregator.py)
Aggregate multiple extraction result JSONs into a unified comparison matrix.
python scripts/extraction_aggregator.py \
--results extraction_1.json extraction_2.json extraction_3.json
python scripts/extraction_aggregator.py \
--results-dir ./extraction_results/ --json
python scripts/extraction_aggregator.py \
--results-dir ./extraction_results/ \
--format markdown \
--output review_matrix.md
python scripts/extraction_aggregator.py \
--results extraction_1.json extraction_2.json \
--columns "Parties,Effective Date,Term,Governing Law"Reference Guides
| Reference | Purpose |
|---|---|
references/extraction_methodology.md | Document extraction best practices, JSON schema, agent prompts |
references/common_extraction_columns.md | Pre-defined column sets for contracts, NDAs, employment, leases |
Workflows
5-Step Document Review Pipeline
| Step | Action | Tool | Output |
|---|---|---|---|
| 1. Gather Requirements | Define document folder, output filename, columns to extract | Manual | Column list, file path |
| 2. Discover Documents | Scan directory for target documents | document_discovery.py | Document manifest JSON |
| 3. Process Documents | Extract values per column with citations (parallel agents) | AI agents (external) | Per-document extraction JSONs |
| 4. Collect Results | Aggregate extraction JSONs into unified matrix | extraction_aggregator.py | Consolidated matrix |
| 5. Generate Output | Export as markdown table or structured JSON | extraction_aggregator.py | Final deliverable |
Parallel Processing Strategy
| Agents | Documents per Agent | Use When |
|---|---|---|
| 1 | All | 1-5 documents |
| 2-3 | ceil(N/agents) | 6-15 documents |
| 4-6 | ceil(N/agents) | 16-40 documents |
| 7-10 | ceil(N/agents) | 41-100 documents |
| 10 (max) | ceil(N/10) | 100+ documents |
Agent Prompt Template
Each agent receives a prompt structured as:
You are reviewing {count} legal documents. For each document, extract the
following columns:
{column_definitions}
For each value extracted:
1. Provide the exact value found
2. Include the page number (PDF) or section/paragraph (DOCX/MD)
3. Rate your confidence: HIGH (exact match), MEDIUM (inferred), LOW (uncertain)
4. If not found, record "NOT FOUND" with confidence LOW
Output as JSON per the extraction schema.Confidence Scoring
| Level | Color Code | Definition |
|---|---|---|
| HIGH | Green | Exact value found with clear citation |
| MEDIUM | Yellow | Value inferred from context; multiple possible interpretations |
| LOW | Red / Not Found | Value uncertain or not found in document |
Output Format
Sheet 1: Document Review
| Document | Parties | Effective Date | Term | Governing Law | ... |
|---|---|---|---|---|---|
| contract_a.pdf | Acme / Beta [p.1] | 2026-01-15 [p.2] | 3 years [p.3] | Delaware [p.12] | ... |
| contract_b.pdf | Gamma / Delta [p.1] | NOT FOUND | 2 years [p.4] | New York [p.10] | ... |
Sheet 2: Summary
| Metric | Value |
|---|---|
| Documents processed | 25 |
| Columns extracted | 8 |
| Average confidence | 87% |
| Not found rate | 12% |
Extraction Scenarios
Contract Review
| Column | What to Extract |
|---|---|
| Parties | All contracting parties with full legal names |
| Effective Date | Contract effective or execution date |
| Term | Duration of the agreement |
| Renewal | Auto-renewal terms and notice period |
| Governing Law | Jurisdiction governing the agreement |
| Liability Cap | Maximum liability amount or formula |
| Indemnification | Indemnification obligations and scope |
| IP Ownership | Intellectual property ownership provisions |
| Termination Rights | Termination triggers and notice requirements |
| Data Protection | Data protection or privacy obligations |
NDA Review
| Column | What to Extract |
|---|---|
| Parties | Disclosing and receiving parties |
| Type | Mutual or one-way |
| Definition Scope | How "confidential information" is defined |
| Exceptions | Standard exceptions to confidentiality |
| Term | Duration of confidentiality obligations |
| Survival | Survival period after termination |
| Return/Destruction | Obligations on termination |
| Remedies | Available remedies for breach |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Discovery finds 0 documents | Wrong path or file types | Verify path exists; check --types matches actual file extensions |
| Extraction JSONs have wrong schema | Agent prompt incomplete | Use the extraction schema from extraction_methodology.md |
| Aggregator shows conflicts | Multiple values for same cell | Review source documents; aggregator marks conflicts for manual review |
| High "NOT FOUND" rate | Columns too specific for document type | Use column definitions from common_extraction_columns.md; broaden definitions |
| Confidence all LOW | Agent unable to locate values | Check column definitions are specific enough; verify document is readable |
| Aggregator crashes on large set | Too many result files loaded at once | Process in batches of 50 results; use --columns to limit output width |
| Markdown table misaligned | Long values or special characters | Use --format json for machine processing; truncate long values |
| Missing citations | Agent did not include page/section references | Reinforce citation requirement in agent prompt; check extraction schema |
Success Criteria
- Extraction Coverage: 90%+ of defined columns populated across all documents
- Confidence Distribution: 70%+ of extractions rated HIGH confidence
- Citation Accuracy: Every extracted value includes verifiable page/section citation
- Processing Speed: 50+ documents processed within 30 minutes using parallel agents
- Matrix Completeness: Final matrix includes all documents and all columns with no orphan rows
Scope & Limitations
This skill covers:
- Document inventory and discovery across PDF, DOCX, TXT, and MD formats
- Aggregation of extraction results from parallel agent processing into unified matrix
- Pre-defined column sets for contracts, NDAs, employment agreements, and leases
- Confidence scoring and conflict detection for extracted values
- Markdown and JSON output formats
This skill does NOT cover:
- Actual document parsing or text extraction (requires external libraries or AI agents)
- OCR processing for scanned documents
- Excel/XLSX output generation (use JSON output and convert externally)
- Automated legal analysis or risk assessment of extracted values
- Document comparison or redlining between versions
Anti-Patterns
| Anti-Pattern | Why It Fails | Better Approach |
|---|---|---|
| Vague column definitions | "Date" could match dozens of dates in a contract | Use specific definitions: "Effective Date" with guidance on where to look |
| Skipping document discovery | Unknown document count leads to wrong agent allocation | Always run discovery first; use manifest for pipeline planning |
| Ignoring LOW confidence results | Missing or uncertain data treated as fact | Review all LOW confidence cells manually; flag in final report |
| Processing 100+ docs with 1 agent | Slow, context window overflow, quality degradation | Use parallel processing: ceil(N/10) documents per agent, max 10 agents |
| No citation requirement | Cannot verify extracted values against source | Require page/section citation for every extraction; reject uncited values |
Tool Reference
scripts/document_discovery.py
Scan directory for legal documents and generate inventory manifest.
usage: document_discovery.py [-h] [--json]
[--types TYPES]
[--min-size MIN_SIZE]
[--max-size MAX_SIZE]
directory
positional arguments:
directory Path to directory containing documents
options:
-h, --help Show help message and exit
--json Output in JSON format
--types TYPES Comma-separated file extensions to include
(default: pdf,docx,doc,txt,md,rtf)
--min-size MIN_SIZE Minimum file size in bytes (default: 0)
--max-size MAX_SIZE Maximum file size in bytes (default: no limit)scripts/extraction_aggregator.py
Aggregate extraction results into unified comparison matrix.
usage: extraction_aggregator.py [-h] [--json]
[--results RESULTS [RESULTS ...]]
[--results-dir RESULTS_DIR]
[--format {markdown,json}]
[--columns COLUMNS]
[--output OUTPUT]
options:
-h, --help Show help message and exit
--json Output in JSON format (alias for --format json)
--results One or more extraction result JSON files
--results-dir Directory containing extraction result JSON files
--format Output format: markdown table or JSON (default: markdown)
--columns Comma-separated column names to include (default: all)
--output Write output to file instead of stdoutCommon Extraction Columns
Pre-defined column sets for common legal document review scenarios.
Table of Contents
- Contract Review Columns
- NDA Review Columns
- Employment Agreement Columns
- Lease Review Columns
- Using Column Sets
- Creating Custom Columns
Contract Review Columns
Standard extraction columns for commercial contract review.
| # | Column | Description | Where to Look | Example Values |
|---|---|---|---|---|
| 1 | Parties | All contracting parties with full legal names | Preamble, first page, recitals | "Acme Corporation, a Delaware corporation" |
| 2 | Effective Date | Date the agreement becomes effective | Preamble, definitions section, signature page | "January 15, 2026" or "upon mutual execution" |
| 3 | Term | Duration of the agreement | Term section, typically Article/Section 2 or 3 | "3 years from Effective Date" |
| 4 | Renewal | Auto-renewal terms and notice period for non-renewal | Term section, often subsection of Term | "Auto-renews for 1-year periods unless 90 days notice" |
| 5 | Governing Law | Jurisdiction whose laws govern the agreement | Near end; "Governing Law" or "Choice of Law" clause | "State of Delaware" |
| 6 | Liability Cap | Maximum aggregate liability amount or formula | Limitation of Liability section | "$5,000,000" or "12 months of fees paid" |
| 7 | Indemnification | Indemnification obligations and scope | Indemnification section, often a separate article | "Each party indemnifies for third-party claims arising from breach" |
| 8 | IP Ownership | Intellectual property ownership and license grants | IP section, license grants, work product | "All work product owned by Client" |
| 9 | Termination Rights | Events triggering termination and notice requirements | Termination section | "Either party may terminate for material breach with 30 days cure" |
| 10 | Data Protection | Data protection, privacy, or security obligations | Data protection section, exhibit, or DPA | "Processor obligations per GDPR Art. 28" |
Contract Review: Where to Find Each Column
| Column | Primary Location | Secondary Location | Fallback |
|---|---|---|---|
| Parties | Page 1, preamble | Recitals | Signature page |
| Effective Date | Preamble | Definitions | Signature page date |
| Term | "Term" section | "Duration" section | First substantive article |
| Renewal | Sub-section of Term | Separate "Renewal" clause | Termination section |
| Governing Law | End of agreement | "General Provisions" | Signature page |
| Liability Cap | "Limitation of Liability" | "Damages" section | Indemnification cap |
| Indemnification | "Indemnification" article | "Liability" section | Insurance provisions |
| IP Ownership | "Intellectual Property" section | "Work Product" | License grant section |
| Termination Rights | "Termination" section | "Default and Remedies" | "Term" section |
| Data Protection | DPA exhibit | "Data Protection" section | "Confidentiality" section |
NDA Review Columns
Standard extraction columns for non-disclosure agreement review.
| # | Column | Description | Where to Look | Example Values |
|---|---|---|---|---|
| 1 | Parties | Disclosing and receiving parties | Preamble, first page | "Disclosing Party: Acme Corp; Receiving Party: Beta Inc" |
| 2 | Type | Mutual or one-way NDA | Preamble, definition of "Confidential Information" | "Mutual" or "One-way (Acme disclosing)" |
| 3 | Definition Scope | How "Confidential Information" is defined | Definitions section, typically Section 1 | "All non-public information disclosed in writing or orally" |
| 4 | Exceptions | Standard exceptions to confidentiality | Typically Section 2 or "Exclusions" subsection | "Publicly known, independently developed, rightfully received from third party" |
| 5 | Term | Duration of confidentiality obligations | Term section | "3 years from date of disclosure" |
| 6 | Survival | How long obligations survive after termination | Term section, survival clause | "Obligations survive for 2 years after termination" |
| 7 | Return/Destruction | Obligations upon termination regarding materials | Termination section, "Return of Materials" | "Return or destroy within 30 days; certify destruction" |
| 8 | Remedies | Available remedies for breach | Remedies section | "Injunctive relief without bond; monetary damages" |
| 9 | Non-Compete | Any non-compete or non-solicitation provisions | Often added as separate section | "None" or "12-month non-solicitation of employees" |
| 10 | Residuals | Residual knowledge/information clause | Often near exceptions or IP section | "Residual knowledge retained in unaided memory is not restricted" |
NDA Review: Key Distinctions
| Feature | Mutual NDA | One-Way NDA |
|---|---|---|
| Parties | Both are Disclosing and Receiving | One Disclosing, one Receiving |
| Definition | Symmetric definition | Asymmetric; only discloser's info protected |
| Obligations | Both parties bound | Only receiving party bound |
| Preferred by | Balanced relationships | Party sharing more sensitive information |
| Risk | May over-protect non-sensitive info | Under-protects receiving party's info |
Employment Agreement Columns
Standard extraction columns for employment agreement review.
| # | Column | Description | Where to Look | Example Values |
|---|---|---|---|---|
| 1 | Employee | Employee full name | Preamble, first page | "Jane Smith" |
| 2 | Employer | Employing entity full legal name | Preamble, first page | "Acme Corporation" |
| 3 | Start Date | Employment commencement date | "Commencement" or "Start Date" section | "March 1, 2026" |
| 4 | Compensation | Base salary and any variable compensation | Compensation section | "$150,000 base + 20% target bonus" |
| 5 | Benefits | Key benefits referenced or described | Benefits section | "Standard benefits package per Employee Handbook" |
| 6 | Non-Compete Scope | Geographic and industry scope of non-compete | Restrictive covenants section | "Within 50 miles of any company office; same industry" |
| 7 | Non-Compete Duration | Duration of post-employment non-compete | Restrictive covenants, same section as scope | "12 months following termination" |
| 8 | IP Assignment | Intellectual property assignment provisions | IP section, "Inventions" or "Work Product" | "All inventions during employment assigned to Employer" |
| 9 | Termination Conditions | Conditions and process for termination | Termination section | "At-will; or for Cause as defined" |
| 10 | Notice Period | Required notice period for resignation/termination | Termination section | "30 days written notice" |
| 11 | Severance | Severance terms upon termination | Termination or Severance section | "6 months base salary if terminated without Cause" |
Employment Agreement: Jurisdiction Considerations
| Jurisdiction | Key Consideration | Impact on Extraction |
|---|---|---|
| California | Non-compete generally unenforceable | Note if non-compete exists but may be unenforceable |
| EU/EEA | Maximum probation periods; notice requirements | Extract probation period and statutory minimums |
| UK | Statutory notice minimums; garden leave | Extract garden leave provisions |
| New York | Non-compete must be reasonable in scope | Extract scope details for reasonableness assessment |
| Texas | At-will with consideration requirement | Note consideration for non-compete |
Lease Review Columns
Standard extraction columns for commercial lease review.
| # | Column | Description | Where to Look | Example Values |
|---|---|---|---|---|
| 1 | Landlord | Property owner/lessor full legal name | Preamble, first page | "ABC Property Holdings LLC" |
| 2 | Tenant | Lessee full legal name | Preamble, first page | "Acme Corporation" |
| 3 | Premises | Description of leased space | Premises section, exhibits | "Suite 400, 123 Main St, Floor 4, 5,000 sq ft" |
| 4 | Term | Lease duration with start and end dates | Term section | "5 years: Jan 1, 2026 to Dec 31, 2030" |
| 5 | Rent | Base rent amount and payment schedule | Rent section | "$50/sq ft/year, payable monthly" |
| 6 | Escalation | Rent escalation mechanism | Rent section, escalation subsection | "3% annual increase" or "CPI-adjusted" |
| 7 | Maintenance | Maintenance and repair responsibilities | Maintenance section | "Tenant: interior; Landlord: structural, roof, HVAC" |
| 8 | Insurance | Insurance requirements | Insurance section | "CGL $1M/$2M, Property coverage at replacement value" |
| 9 | Assignment/Subletting | Restrictions on assignment and subletting | Assignment section | "Landlord consent required; not unreasonably withheld" |
| 10 | Break Clause | Early termination rights | Break clause or termination section | "Tenant break at Year 3 with 6 months notice" |
Lease Review: Lease Type Impact
| Lease Type | Rent Covers | Tenant Also Pays | Key Extraction Difference |
|---|---|---|---|
| Gross | Base rent includes most costs | Typically nothing additional | Focus on what's included |
| Net | Base rent only | Taxes | Extract tax obligation |
| Double Net (NN) | Base rent only | Taxes + insurance | Extract both obligations |
| Triple Net (NNN) | Base rent only | Taxes + insurance + maintenance | Extract all three obligations |
| Modified Gross | Base rent + some costs | Varies | Extract allocation of costs |
Using Column Sets
Selecting a Column Set
1. Identify document type -- What kind of documents are you reviewing? 2. Select pre-defined set -- Use the matching column set from this reference 3. Customize -- Add, remove, or modify columns based on review objectives 4. Define specifics -- Add "Where to Look" and "Example Values" for custom columns
Combining Column Sets
For documents that span multiple types (e.g., a master services agreement with NDA provisions):
1. Start with the primary document type column set 2. Add specific columns from secondary sets 3. Remove duplicates (e.g., "Parties" appears in all sets) 4. Prioritize columns based on review objectives
Column Count Guidelines
| Review Type | Recommended Columns | Maximum |
|---|---|---|
| Quick triage | 3-5 | 8 |
| Standard review | 6-10 | 15 |
| Comprehensive review | 10-15 | 20 |
| Due diligence | 15-20 | 25 |
More columns means more processing time and lower average confidence. Balance comprehensiveness with efficiency.
Creating Custom Columns
Custom Column Template
Column: [Specific name]
Description: [Exactly what to extract]
Where to look: [Typical location(s) in the document]
Format: [Expected format of the value]
Example values:
- [Example 1]
- [Example 2]
Edge cases:
- [What if not explicitly stated?]
- [What if multiple values exist?]Custom Column Examples
Revenue Share:
Column: Revenue Share Percentage
Description: The percentage of revenue shared between parties
Where to look: Payment terms, revenue sharing section, commercial terms
Format: Percentage (e.g., "70/30" or "70% to Licensor")
Example values:
- "70% to Licensor, 30% to Licensee"
- "Net revenue split 60/40"
Edge cases:
- If tiered, extract all tiers
- If different rates for different products, note "varies by product"Force Majeure:
Column: Force Majeure
Description: Whether force majeure clause exists and what events are covered
Where to look: Force Majeure section, typically in General Provisions
Format: Yes/No + key events listed
Example values:
- "Yes: natural disasters, war, pandemic, government action"
- "No force majeure clause"
Edge cases:
- If referenced in another document (e.g., Master Agreement), note cross-reference
- If clause exists but is limited, extract the limitationsCustom Column Quality Checklist
- [ ] Name is specific and unambiguous
- [ ] Description tells agent exactly what to look for
- [ ] "Where to look" guidance provided
- [ ] Expected format specified
- [ ] At least 2 example values given
- [ ] Edge cases documented
- [ ] Column is relevant to the review objective
Document Extraction Methodology
Best practices for extracting structured data from legal documents into comparison matrices.
Table of Contents
- Column Definition Guidelines
- Extraction Schema
- Citation Standards
- Confidence Scoring
- Agent Processing Strategy
- Conflict Resolution
- Error Handling
- Output Formats
Column Definition Guidelines
Principles
| Principle | Bad Example | Good Example | Why |
|---|---|---|---|
| Be specific | "Date" | "Effective Date" | A contract may contain dozens of dates |
| Use domain terms | "Money limit" | "Liability Cap" | Domain-specific terms reduce ambiguity |
| Define scope | "Parties" | "Contracting Parties (full legal names)" | Clarifies what exactly to extract |
| One value per column | "Dates and Terms" | Separate "Effective Date" and "Term" columns | Enables clean tabular output |
| Include guidance | "Governing Law" | "Governing Law (jurisdiction and choice of law clause)" | Tells agent where to look |
Column Definition Format
Each column should be defined with:
Column Name: [Specific name]
Description: [What to extract]
Where to look: [Typical location in document]
Format: [Expected format of the value]
Example: [Example of a correct extraction]Example Column Definitions
Column: Effective Date
Description: The date on which the agreement becomes effective
Where to look: First page, preamble, or "Effective Date" definition section
Format: YYYY-MM-DD
Example: 2026-01-15
Column: Governing Law
Description: The jurisdiction whose laws govern the agreement
Where to look: Governing law or choice of law clause, typically near end of agreement
Format: State/Country name
Example: State of Delaware
Column: Liability Cap
Description: Maximum aggregate liability amount or formula
Where to look: Limitation of liability clause
Format: Dollar amount or formula description
Example: $5,000,000 or "12 months of fees paid"Extraction Schema
Per-Document Extraction JSON
{
"filename": "contract_a.pdf",
"extractions": {
"Parties": {
"value": "Acme Corporation and Beta Industries LLC",
"citation": "p.1, Preamble",
"confidence": "HIGH"
},
"Effective Date": {
"value": "2026-01-15",
"citation": "p.1, Section 1.1",
"confidence": "HIGH"
},
"Liability Cap": {
"value": "NOT FOUND",
"citation": "",
"confidence": "NOT_FOUND"
}
}
}Multi-Document Agent Result JSON
{
"agent_id": "agent_1",
"processed_date": "2026-04-10T14:30:00",
"document_count": 5,
"documents": [
{
"filename": "contract_a.pdf",
"extractions": {
"Column Name": {
"value": "extracted value or NOT FOUND",
"citation": "p.3, Section 2.1",
"confidence": "HIGH"
}
}
}
]
}Field Definitions
| Field | Type | Required | Description |
|---|---|---|---|
| filename | string | Yes | Original filename of the document |
| extractions | object | Yes | Map of column name to extraction result |
| value | string | Yes | Extracted value or "NOT FOUND" |
| citation | string | Yes | Page number (PDF) or section/paragraph (DOCX) |
| confidence | string | Yes | HIGH, MEDIUM, LOW, or NOT_FOUND |
Citation Standards
By Document Type
| Document Type | Citation Format | Example |
|---|---|---|
| Page number | "p.3" or "pp.3-4" | |
| PDF (with sections) | Page + section | "p.3, Section 2.1" |
| DOCX | Section/paragraph | "Section 5.2, para 3" |
| DOCX (with headers) | Header path | "Article III > Section 3.2(a)" |
| TXT/MD | Line number or heading | "Line 45" or "Under 'Definitions'" |
| Multi-page clause | Start-end pages | "pp.7-9, Article IV" |
Citation Requirements
| Requirement | Detail |
|---|---|
| Every value needs citation | No extracted value should lack a source reference |
| Precision matters | "p.3" is better than "somewhere in the document" |
| Quote when ambiguous | If the value is inferred, include the source quote |
| NOT FOUND citation | Leave citation empty for NOT FOUND values |
| Multiple locations | If value appears in multiple places, cite the most authoritative |
Citation Examples
| Scenario | Citation |
|---|---|
| Clear, single location | "p.5, Section 3.1" |
| Defined term | "p.2, 'Effective Date' definition" |
| Scattered information | "p.3 (parties), p.1 (preamble confirms)" |
| Inferred from context | "p.7, para 2 (inferred from termination clause language)" |
| Cross-reference | "p.4, Section 2.3 (cross-referencing Exhibit A)" |
Confidence Scoring
Confidence Levels
| Level | Score | Definition | When to Use |
|---|---|---|---|
| HIGH | 3 | Exact value found with clear, unambiguous citation | The document explicitly states the value in a clearly labeled section |
| MEDIUM | 2 | Value inferred from context or multiple possible interpretations exist | The value is implied but not explicitly stated; or the section is ambiguous |
| LOW | 1 | Value uncertain; weak evidence; may require human verification | Partial match; the value is mentioned in a different context; or conflicting information |
| NOT_FOUND | 0 | Value not found in the document | Thorough search reveals no matching information |
Confidence Decision Tree
Is the value explicitly stated in a clearly labeled section?
├── YES → Is the citation unambiguous?
│ ├── YES → HIGH
│ └── NO → MEDIUM (multiple possible source locations)
└── NO → Is the value inferable from context?
├── YES → Is the inference strong?
│ ├── YES → MEDIUM
│ └── NO → LOW
└── NO → NOT_FOUNDConfidence Calibration Examples
| Extraction | Value | Confidence | Rationale |
|---|---|---|---|
| Effective Date from "This Agreement is effective as of January 15, 2026" | 2026-01-15 | HIGH | Explicit, unambiguous |
| Term from "This Agreement shall continue for a period of three (3) years" | 3 years | HIGH | Explicit duration |
| Governing Law from "shall be governed by the laws of the State of Delaware" | Delaware | HIGH | Standard clause, clear |
| Liability Cap when only indemnification cap is stated | $5M (indemnification only) | MEDIUM | Related but not identical |
| Renewal from termination clause mentioning "auto-renewal unless 90 days notice" | Auto-renew, 90 days notice | MEDIUM | Embedded in termination clause |
| IP Ownership when document discusses license but not ownership | License granted, ownership unclear | LOW | Inference from license terms |
| Non-compete when not mentioned anywhere | NOT FOUND | NOT_FOUND | No relevant clause found |
Agent Processing Strategy
Document Distribution
Number of documents: N
Maximum agents: 10
Documents per agent: ceil(N / min(agents, 10))
Example:
25 documents, 5 agents → 5 documents each
100 documents, 10 agents → 10 documents each
3 documents, 1 agent → 3 documentsAllocation Table
| Documents | Agents | Per Agent | Estimated Time |
|---|---|---|---|
| 1-5 | 1 | All | 2-5 min |
| 6-15 | 2-3 | 3-5 | 5-10 min |
| 16-40 | 4-6 | 4-7 | 10-15 min |
| 41-100 | 7-10 | 5-10 | 15-25 min |
| 100+ | 10 | 10+ | 25+ min |
Agent Prompt Template
You are a legal document extraction agent. Your task is to extract specific
data points from {count} legal documents.
For each document, extract the following columns:
{for each column}
- **{column_name}**: {column_description}
Look for this in: {where_to_look}
Expected format: {format}
{end for}
For EACH extracted value, provide:
1. "value": The exact value found (or "NOT FOUND")
2. "citation": Page number/section reference (e.g., "p.3, Section 2.1")
3. "confidence": One of HIGH, MEDIUM, LOW, NOT_FOUND
Output your results as JSON matching this schema:
{schema}
Important rules:
- Extract values exactly as stated in the document
- Always include a citation for every non-NOT_FOUND value
- Do not infer values that are not present
- When uncertain between two values, choose the one from the more authoritative section
- If a value appears in multiple places, use the definition sectionConflict Resolution
When Conflicts Occur
Conflicts arise when multiple agents or extraction passes produce different values for the same document and column.
| Conflict Type | Resolution Strategy |
|---|---|
| Different values, different confidence | Keep higher confidence value |
| Different values, same confidence | Mark as conflict; require manual review |
| Same value, different citations | Keep both citations for completeness |
| One found, one NOT_FOUND | Keep the found value if confidence >= MEDIUM |
| Both NOT_FOUND | Mark as NOT_FOUND |
Conflict Resolution Process
1. Detect: Aggregator identifies cells where multiple values exist 2. Compare: Check confidence levels of competing values 3. Auto-resolve: If confidence clearly differs, keep higher 4. Flag: If cannot auto-resolve, mark as conflict 5. Review: Human reviews all flagged conflicts against source documents 6. Resolve: Update matrix with verified value
Error Handling
Common Errors
| Error | Cause | Handling |
|---|---|---|
| Unreadable document | Corrupted file, password-protected, scanned image | Log error; mark all columns as NOT_FOUND with note |
| Agent timeout | Document too large or complex | Retry with single document; increase timeout |
| Column not found in any document | Column definition too specific or wrong document type | Review column definition; confirm applicability |
| Malformed extraction JSON | Agent output formatting error | Attempt JSON repair; if fails, re-process document |
| Partial results | Agent processed some but not all documents | Identify missing documents; reassign to new agent |
| Encoding errors | Non-UTF-8 characters in document | Attempt encoding detection; process with fallback encoding |
Error Recovery Strategy
For each document in the pipeline:
1. Attempt extraction
2. If error → Log error type and document
3. If recoverable error → Retry once with adjusted parameters
4. If persistent error → Mark document as "ERROR" in matrix
5. Continue with remaining documents
6. Report all errors in summary statisticsOutput Formats
Markdown Table
Best for: Human review, documentation, reports.
| Document | Parties | Effective Date | Term |
|----------|---------|---------------|------|
| contract_a.pdf | Acme / Beta [p.1] | 2026-01-15 [p.2] | 3 years [p.3] |JSON
Best for: Programmatic consumption, further processing, database import.
{
"matrix": [
{
"document": "contract_a.pdf",
"columns": {
"Parties": {
"value": "Acme Corporation and Beta Industries LLC",
"citation": "p.1, Preamble",
"confidence": "HIGH"
}
}
}
]
}Color Coding (for Excel/Spreadsheet)
When converting to Excel (external tool):
| Confidence | Cell Color | Hex Code |
|---|---|---|
| HIGH | Green | #C6EFCE |
| MEDIUM | Yellow | #FFEB9C |
| LOW | Red | #FFC7CE |
| NOT_FOUND | Gray | #D9D9D9 |
| Conflict | Orange | #FFA500 |
Excel Output Structure
| Sheet | Contents |
|---|---|
| Document Review | Main matrix with values, citations in cell comments |
| Summary | Statistics: documents processed, coverage per column, confidence distribution |
| Conflicts | All detected conflicts requiring manual review |
| Metadata | Extraction parameters, agent assignments, processing timestamps |
#!/usr/bin/env python3
"""
Document Discovery Tool
Scans a directory for legal documents (PDF, DOCX, TXT, MD, etc.) and
generates an inventory manifest with metadata for pipeline processing.
Usage:
python document_discovery.py /path/to/contracts
python document_discovery.py /path/to/ndas --types pdf,docx --json
python document_discovery.py /path/to/leases --min-size 1024
"""
import argparse
import json
import math
import os
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
DEFAULT_TYPES = ["pdf", "docx", "doc", "txt", "md", "rtf"]
# Estimated processing time per document (seconds) by file type
PROCESSING_ESTIMATES: Dict[str, float] = {
"pdf": 30.0,
"docx": 20.0,
"doc": 25.0,
"txt": 10.0,
"md": 10.0,
"rtf": 20.0,
}
MAX_AGENTS = 10
def format_size(size_bytes: int) -> str:
"""Format file size in human-readable form."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
def get_file_extension(filename: str) -> str:
"""Get lowercase file extension without dot."""
_, ext = os.path.splitext(filename)
return ext.lower().lstrip(".")
def scan_directory(
directory: str,
file_types: List[str],
min_size: int = 0,
max_size: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Scan directory recursively for matching documents."""
documents: List[Dict[str, Any]] = []
if not os.path.isdir(directory):
return documents
for root, dirs, files in os.walk(directory):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith(".")]
for filename in sorted(files):
if filename.startswith("."):
continue
ext = get_file_extension(filename)
if ext not in file_types:
continue
filepath = os.path.join(root, filename)
try:
stat = os.stat(filepath)
size = stat.st_size
except OSError:
continue
# Size filters
if size < min_size:
continue
if max_size is not None and size > max_size:
continue
rel_path = os.path.relpath(filepath, directory)
mod_time = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
documents.append({
"filename": filename,
"relative_path": rel_path,
"absolute_path": filepath,
"extension": ext,
"size_bytes": size,
"size_human": format_size(size),
"modified": mod_time,
})
return documents
def compute_statistics(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Compute summary statistics for discovered documents."""
if not documents:
return {
"total_documents": 0,
"total_size_bytes": 0,
"total_size_human": "0 B",
"by_type": {},
"size_range": {"min": "0 B", "max": "0 B", "avg": "0 B"},
}
total_size = sum(d["size_bytes"] for d in documents)
sizes = [d["size_bytes"] for d in documents]
by_type: Dict[str, Dict[str, Any]] = {}
for doc in documents:
ext = doc["extension"]
if ext not in by_type:
by_type[ext] = {"count": 0, "total_size_bytes": 0}
by_type[ext]["count"] += 1
by_type[ext]["total_size_bytes"] += doc["size_bytes"]
for ext_data in by_type.values():
ext_data["total_size_human"] = format_size(ext_data["total_size_bytes"])
avg_size = total_size // len(documents) if documents else 0
return {
"total_documents": len(documents),
"total_size_bytes": total_size,
"total_size_human": format_size(total_size),
"by_type": by_type,
"size_range": {
"min": format_size(min(sizes)),
"max": format_size(max(sizes)),
"avg": format_size(avg_size),
},
}
def estimate_processing(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Estimate processing time and recommend agent allocation."""
if not documents:
return {
"estimated_seconds": 0,
"estimated_human": "0 seconds",
"recommended_agents": 1,
"documents_per_agent": 0,
}
total_seconds = 0.0
for doc in documents:
ext = doc["extension"]
est = PROCESSING_ESTIMATES.get(ext, 20.0)
# Scale by file size (larger files take longer)
size_factor = max(1.0, doc["size_bytes"] / (100 * 1024)) # Baseline 100KB
total_seconds += est * min(size_factor, 5.0) # Cap at 5x
doc_count = len(documents)
if doc_count <= 5:
recommended_agents = 1
elif doc_count <= 15:
recommended_agents = min(3, doc_count)
elif doc_count <= 40:
recommended_agents = min(6, doc_count)
else:
recommended_agents = min(MAX_AGENTS, doc_count)
docs_per_agent = math.ceil(doc_count / recommended_agents)
# Parallel processing reduces total time
parallel_seconds = total_seconds / recommended_agents
if parallel_seconds < 60:
time_human = f"{parallel_seconds:.0f} seconds"
elif parallel_seconds < 3600:
time_human = f"{parallel_seconds / 60:.1f} minutes"
else:
time_human = f"{parallel_seconds / 3600:.1f} hours"
# Build agent assignments
agent_assignments: List[Dict[str, Any]] = []
for i in range(recommended_agents):
start = i * docs_per_agent
end = min(start + docs_per_agent, doc_count)
if start >= doc_count:
break
assigned_docs = [d["filename"] for d in documents[start:end]]
agent_assignments.append({
"agent_number": i + 1,
"document_count": len(assigned_docs),
"documents": assigned_docs,
})
return {
"estimated_total_seconds": round(total_seconds, 1),
"estimated_parallel_seconds": round(parallel_seconds, 1),
"estimated_human": time_human,
"recommended_agents": recommended_agents,
"documents_per_agent": docs_per_agent,
"agent_assignments": agent_assignments,
}
def run_discovery(args: argparse.Namespace) -> Dict[str, Any]:
"""Run document discovery on the specified directory."""
directory = os.path.abspath(args.directory)
file_types = [t.strip().lower().lstrip(".")
for t in args.types.split(",")]
min_size = args.min_size
max_size = args.max_size if args.max_size and args.max_size > 0 else None
if not os.path.isdir(directory):
return {
"error": True,
"message": f"Directory not found: {directory}",
}
documents = scan_directory(directory, file_types, min_size, max_size)
statistics = compute_statistics(documents)
processing = estimate_processing(documents)
return {
"discovery_date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"directory": directory,
"file_types": file_types,
"filters": {
"min_size": min_size,
"max_size": max_size,
},
"statistics": statistics,
"processing_estimate": processing,
"documents": documents,
}
def format_text(result: Dict[str, Any]) -> str:
"""Format discovery result as human-readable text."""
if result.get("error"):
return f"Error: {result['message']}"
lines = []
lines.append("=" * 70)
lines.append("DOCUMENT DISCOVERY REPORT")
lines.append("=" * 70)
lines.append(f"Directory: {result['directory']}")
lines.append(f"Date: {result['discovery_date']}")
lines.append(f"File types: {', '.join(result['file_types'])}")
lines.append("")
stats = result["statistics"]
lines.append(f"Total documents: {stats['total_documents']}")
lines.append(f"Total size: {stats['total_size_human']}")
lines.append(f"Size range: {stats['size_range']['min']} - {stats['size_range']['max']} "
f"(avg: {stats['size_range']['avg']})")
lines.append("")
# By type
if stats["by_type"]:
lines.append("BY FILE TYPE:")
lines.append(f"{'Type':<10} {'Count':>8} {'Total Size':>12}")
lines.append("-" * 32)
for ext, data in sorted(stats["by_type"].items()):
lines.append(f".{ext:<9} {data['count']:>8} {data['total_size_human']:>12}")
# Processing estimate
proc = result["processing_estimate"]
lines.append("")
lines.append("-" * 70)
lines.append("PROCESSING ESTIMATE")
lines.append("-" * 70)
lines.append(f"Estimated time (parallel): {proc['estimated_human']}")
lines.append(f"Recommended agents: {proc['recommended_agents']}")
lines.append(f"Documents per agent: {proc['documents_per_agent']}")
if proc.get("agent_assignments"):
lines.append("")
lines.append("AGENT ASSIGNMENTS:")
for assignment in proc["agent_assignments"]:
lines.append(f" Agent {assignment['agent_number']}: "
f"{assignment['document_count']} documents")
for doc in assignment["documents"][:5]:
lines.append(f" - {doc}")
if len(assignment["documents"]) > 5:
lines.append(f" ... and {len(assignment['documents']) - 5} more")
# Document list
lines.append("")
lines.append("-" * 70)
lines.append("DOCUMENTS FOUND:")
lines.append("-" * 70)
lines.append(f"{'#':<5} {'Filename':<40} {'Type':<6} {'Size':>10} {'Modified'}")
lines.append("-" * 70)
for i, doc in enumerate(result["documents"], 1):
name = doc["filename"]
if len(name) > 38:
name = name[:35] + "..."
lines.append(f"{i:<5} {name:<40} .{doc['extension']:<5} "
f"{doc['size_human']:>10} {doc['modified']}")
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description="Scan directory for legal documents and generate inventory manifest."
)
parser.add_argument("directory", help="Path to directory containing documents")
parser.add_argument("--json", action="store_true", help="Output in JSON format")
parser.add_argument("--types", default=",".join(DEFAULT_TYPES),
help=f"Comma-separated file extensions (default: {','.join(DEFAULT_TYPES)})")
parser.add_argument("--min-size", type=int, default=0,
help="Minimum file size in bytes (default: 0)")
parser.add_argument("--max-size", type=int, default=0,
help="Maximum file size in bytes (default: no limit)")
args = parser.parse_args()
try:
result = run_discovery(args)
if result.get("error"):
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Error: {result['message']}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
print(format_text(result))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Extraction Aggregator
Takes multiple extraction result JSONs (from parallel processing agents)
and aggregates into a unified comparison matrix with conflict detection,
confidence scoring, and summary statistics.
Usage:
python extraction_aggregator.py --results extract_1.json extract_2.json
python extraction_aggregator.py --results-dir ./results/ --json
python extraction_aggregator.py --results-dir ./results/ --format markdown --output matrix.md
"""
import argparse
import glob
import json
import os
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
# Expected extraction result JSON schema:
# {
# "agent_id": "agent_1",
# "documents": [
# {
# "filename": "contract_a.pdf",
# "extractions": {
# "Column Name": {
# "value": "extracted value",
# "citation": "p.3, Section 2.1",
# "confidence": "HIGH" // HIGH, MEDIUM, LOW
# }
# }
# }
# ]
# }
CONFIDENCE_LEVELS = {"HIGH": 3, "MEDIUM": 2, "LOW": 1, "NOT_FOUND": 0}
def load_result_file(filepath: str) -> Optional[Dict[str, Any]]:
"""Load a single extraction result JSON file."""
try:
with open(filepath, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
return None
def load_results(file_paths: List[str] = None,
results_dir: str = None) -> Tuple[List[Dict], List[str]]:
"""Load all extraction results from files or directory."""
results: List[Dict] = []
errors: List[str] = []
paths_to_load: List[str] = []
if file_paths:
paths_to_load.extend(file_paths)
if results_dir:
if os.path.isdir(results_dir):
json_files = sorted(glob.glob(os.path.join(results_dir, "*.json")))
paths_to_load.extend(json_files)
else:
errors.append(f"Directory not found: {results_dir}")
for path in paths_to_load:
data = load_result_file(path)
if data is None:
errors.append(f"Failed to load: {path}")
else:
# Handle both single-document and multi-document formats
if "documents" in data:
results.append(data)
elif "filename" in data and "extractions" in data:
# Single document wrapped in expected format
results.append({
"agent_id": os.path.basename(path),
"documents": [data],
})
else:
errors.append(f"Unexpected format: {path}")
return results, errors
def aggregate_results(
results: List[Dict[str, Any]],
column_filter: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Aggregate extraction results into unified matrix."""
# Collect all documents and columns
doc_data: Dict[str, Dict[str, Dict[str, Any]]] = {} # filename -> column -> extraction
all_columns: List[str] = []
column_set: set = set()
conflicts: List[Dict[str, Any]] = []
total_extractions = 0
confidence_counts: Dict[str, int] = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "NOT_FOUND": 0}
for result in results:
agent_id = result.get("agent_id", "unknown")
for doc in result.get("documents", []):
filename = doc.get("filename", "unknown")
if filename not in doc_data:
doc_data[filename] = {}
for col_name, extraction in doc.get("extractions", {}).items():
# Apply column filter
if column_filter and col_name not in column_filter:
continue
if col_name not in column_set:
column_set.add(col_name)
all_columns.append(col_name)
total_extractions += 1
value = extraction.get("value", "NOT FOUND")
citation = extraction.get("citation", "")
confidence = extraction.get("confidence", "LOW").upper()
if confidence not in CONFIDENCE_LEVELS:
confidence = "LOW"
confidence_counts[confidence] = confidence_counts.get(confidence, 0) + 1
# Check for conflicts (same document, same column, different value)
if col_name in doc_data[filename]:
existing = doc_data[filename][col_name]
if existing["value"] != value and value != "NOT FOUND":
conflicts.append({
"document": filename,
"column": col_name,
"existing_value": existing["value"],
"new_value": value,
"existing_agent": existing.get("agent_id", "unknown"),
"new_agent": agent_id,
})
# Keep higher confidence value
if CONFIDENCE_LEVELS.get(confidence, 0) > CONFIDENCE_LEVELS.get(
existing["confidence"], 0):
doc_data[filename][col_name] = {
"value": value,
"citation": citation,
"confidence": confidence,
"agent_id": agent_id,
"conflict": True,
}
continue
doc_data[filename][col_name] = {
"value": value,
"citation": citation,
"confidence": confidence,
"agent_id": agent_id,
"conflict": False,
}
# Apply column filter ordering if specified
if column_filter:
all_columns = [c for c in column_filter if c in column_set]
# Build matrix rows
matrix_rows: List[Dict[str, Any]] = []
not_found_count = 0
total_cells = 0
for filename in sorted(doc_data.keys()):
row: Dict[str, Any] = {"document": filename, "columns": {}}
for col in all_columns:
total_cells += 1
if col in doc_data[filename]:
extraction = doc_data[filename][col]
row["columns"][col] = extraction
if extraction["value"] == "NOT FOUND":
not_found_count += 1
else:
row["columns"][col] = {
"value": "NOT FOUND",
"citation": "",
"confidence": "NOT_FOUND",
"conflict": False,
}
not_found_count += 1
matrix_rows.append(row)
# Summary statistics
extraction_coverage = {}
for col in all_columns:
found = sum(1 for row in matrix_rows
if row["columns"].get(col, {}).get("value", "NOT FOUND") != "NOT FOUND")
extraction_coverage[col] = {
"found": found,
"total": len(matrix_rows),
"coverage_pct": round((found / len(matrix_rows)) * 100, 1) if matrix_rows else 0,
}
high_count = confidence_counts.get("HIGH", 0)
med_count = confidence_counts.get("MEDIUM", 0)
total_with_value = high_count + med_count + confidence_counts.get("LOW", 0)
avg_confidence_pct = round(
((high_count * 100 + med_count * 66 + confidence_counts.get("LOW", 0) * 33)
/ total_with_value) if total_with_value > 0 else 0, 1
)
return {
"aggregation_date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"documents_processed": len(matrix_rows),
"columns": all_columns,
"column_count": len(all_columns),
"total_cells": total_cells,
"total_extractions": total_extractions,
"not_found_count": not_found_count,
"not_found_rate_pct": round((not_found_count / total_cells) * 100, 1) if total_cells else 0,
"average_confidence_pct": avg_confidence_pct,
"confidence_distribution": confidence_counts,
"conflicts": conflicts,
"conflict_count": len(conflicts),
"extraction_coverage": extraction_coverage,
"matrix": matrix_rows,
}
def format_markdown(result: Dict[str, Any]) -> str:
"""Format aggregated result as markdown table."""
lines = []
lines.append("# Document Review Matrix")
lines.append("")
lines.append(f"Generated: {result['aggregation_date']}")
lines.append(f"Documents: {result['documents_processed']} | "
f"Columns: {result['column_count']} | "
f"Avg Confidence: {result['average_confidence_pct']}%")
lines.append("")
columns = result["columns"]
if not columns or not result["matrix"]:
lines.append("*No data to display.*")
return "\n".join(lines)
# Header
header = "| Document |"
separator = "|----------|"
for col in columns:
col_display = col[:20] if len(col) > 20 else col
header += f" {col_display} |"
separator += f"{'---' * max(1, len(col_display) // 3 + 1)}|"
lines.append(header)
lines.append(separator)
# Rows
for row in result["matrix"]:
doc_name = row["document"]
if len(doc_name) > 25:
doc_name = doc_name[:22] + "..."
line = f"| {doc_name} |"
for col in columns:
cell = row["columns"].get(col, {})
value = cell.get("value", "NOT FOUND")
citation = cell.get("citation", "")
confidence = cell.get("confidence", "NOT_FOUND")
if value == "NOT FOUND":
display = "*Not found*"
else:
# Truncate long values
if len(str(value)) > 30:
value = str(value)[:27] + "..."
if citation:
display = f"{value} [{citation}]"
else:
display = str(value)
line += f" {display} |"
lines.append(line)
# Summary section
lines.append("")
lines.append("## Summary")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Documents processed | {result['documents_processed']} |")
lines.append(f"| Columns extracted | {result['column_count']} |")
lines.append(f"| Average confidence | {result['average_confidence_pct']}% |")
lines.append(f"| Not found rate | {result['not_found_rate_pct']}% |")
lines.append(f"| Conflicts detected | {result['conflict_count']} |")
# Coverage per column
lines.append("")
lines.append("## Column Coverage")
lines.append("")
lines.append("| Column | Found | Total | Coverage |")
lines.append("|--------|-------|-------|----------|")
for col, cov in result["extraction_coverage"].items():
lines.append(f"| {col} | {cov['found']} | {cov['total']} | {cov['coverage_pct']}% |")
# Conflicts
if result["conflicts"]:
lines.append("")
lines.append("## Conflicts (Requires Manual Review)")
lines.append("")
lines.append("| Document | Column | Value 1 | Value 2 |")
lines.append("|----------|--------|---------|---------|")
for conflict in result["conflicts"]:
lines.append(f"| {conflict['document']} | {conflict['column']} | "
f"{conflict['existing_value']} | {conflict['new_value']} |")
return "\n".join(lines)
def format_text(result: Dict[str, Any]) -> str:
"""Format as plain text summary."""
lines = []
lines.append("=" * 70)
lines.append("EXTRACTION AGGREGATION REPORT")
lines.append("=" * 70)
lines.append(f"Date: {result['aggregation_date']}")
lines.append(f"Documents: {result['documents_processed']}")
lines.append(f"Columns: {result['column_count']}")
lines.append(f"Total cells: {result['total_cells']}")
lines.append(f"Avg confidence: {result['average_confidence_pct']}%")
lines.append(f"Not found rate: {result['not_found_rate_pct']}%")
lines.append(f"Conflicts: {result['conflict_count']}")
lines.append("")
dist = result["confidence_distribution"]
lines.append("CONFIDENCE DISTRIBUTION:")
lines.append(f" HIGH: {dist.get('HIGH', 0)}")
lines.append(f" MEDIUM: {dist.get('MEDIUM', 0)}")
lines.append(f" LOW: {dist.get('LOW', 0)}")
lines.append(f" NOT FOUND: {dist.get('NOT_FOUND', 0)}")
if result.get("conflicts"):
lines.append("")
lines.append("CONFLICTS (Requires Manual Review):")
for c in result["conflicts"]:
lines.append(f" {c['document']} / {c['column']}: "
f"'{c['existing_value']}' vs '{c['new_value']}'")
lines.append("")
lines.append("=" * 70)
lines.append("Use --format markdown for full matrix output.")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description="Aggregate extraction results into unified comparison matrix."
)
parser.add_argument("--json", action="store_true",
help="Output in JSON format")
parser.add_argument("--results", nargs="+", default=None,
help="One or more extraction result JSON files")
parser.add_argument("--results-dir", default=None,
help="Directory containing extraction result JSON files")
parser.add_argument("--format", choices=["markdown", "json"], default=None,
help="Output format (default: text summary)")
parser.add_argument("--columns", default=None,
help="Comma-separated column names to include")
parser.add_argument("--output", default=None,
help="Write output to file instead of stdout")
args = parser.parse_args()
if not args.results and not args.results_dir:
parser.error("Must specify --results or --results-dir")
try:
results, errors = load_results(args.results, args.results_dir)
if errors:
for err in errors:
print(f"Warning: {err}", file=sys.stderr)
if not results:
print("Error: No valid extraction results loaded.", file=sys.stderr)
sys.exit(1)
column_filter = None
if args.columns:
column_filter = [c.strip() for c in args.columns.split(",")]
aggregated = aggregate_results(results, column_filter)
# Determine output format
output_format = args.format
if args.json:
output_format = "json"
if output_format == "json":
output_text = json.dumps(aggregated, indent=2)
elif output_format == "markdown":
output_text = format_markdown(aggregated)
else:
output_text = format_text(aggregated)
if args.output:
with open(args.output, "w") as f:
f.write(output_text)
print(f"Output written to {args.output} "
f"({aggregated['documents_processed']} documents, "
f"{aggregated['column_count']} columns)")
else:
print(output_text)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Related skills
FAQ
How does confidence scoring work?
Each extracted value is rated HIGH (exact match), MEDIUM (inferred from context), or LOW (uncertain or not found).
How many documents can it process in parallel?
Up to 10 agents in parallel, scaling to 100+ documents at roughly ceil(N/10) documents per agent.