Parse redlined legal documents and extract insertions, deletions, and annotations as structured data
Reducto’s change_tracking feature extracts strikethroughs, underlines, and annotations from redlined contracts as structured HTML tags—making it easy to list changes programmatically, categorize by type, and build approval workflows.
For this cookbook, we use a 165-page union labor agreement (AFSCME Local 328 vs. Oregon Health & Science University) with extensive redlines showing proposed contract changes. The document includes:
Reducto works with both Word documents (which have native track changes metadata) and PDFs (where it visually detects underlines and strikethroughs). Word documents give the best results since the change metadata is embedded in the file.
Why change_tracking?Without this option, Reducto returns plain text. With it enabled, revisions appear as HTML tags that you can parse programmatically:
For large documents like our 165-page contract, Reducto returns results as a URL rather than inline data. This keeps response sizes manageable.
import requests# Check if result is a URLif hasattr(result.result, 'url'): print(f"Result URL: {result.result.url[:80]}...") response = requests.get(result.result.url) data = response.json() chunks = data.get('chunks', []) full_content = "\n".join([c.get('content', '') for c in chunks])else: full_content = "\n".join([c.content for c in result.result.chunks])print(f"Content length: {len(full_content)} characters")
let fullContent;// Check if result is a URL (large documents)if (result.result.type === "url") { console.log(`Result URL: ${result.result.url.slice(0, 80)}...`); const response = await fetch(result.result.url); const data = await response.json(); const chunks = data.chunks || []; fullContent = chunks.map(c => c.content || "").join("\n");} else { fullContent = result.result.chunks.map(c => c.content).join("\n");}console.log(`Content length: ${fullContent.length} characters`);
Result URL: https://prod-storage20241010144745140900000001.s3.amazonaws.com/ac80631f...Content length: 365675 characters
<change><s>a. An employee's chosen form of dues or payment in lieu of duesshall recommence upon reinstatement following a period of layoff orextended leave.</s></change>
Now we parse the HTML tags to get a structured list of all changes. This function uses regex to find every <change> block and extract the deletions and insertions within it.
import redef extract_changes(content): """Extract all revision regions from parsed content.""" changes = [] pattern = r'<change>(.*?)</change>' for match in re.finditer(pattern, content, re.DOTALL): change_text = match.group(1) # Extract deletions (strikethrough) deletions = re.findall(r'<s>(.*?)</s>', change_text, re.DOTALL) # Extract insertions (underline) insertions = re.findall(r'<u>(.*?)</u>', change_text, re.DOTALL) changes.append({ "deleted": deletions, "inserted": insertions, }) return changes
function extractChanges(content) { const changes = []; // Match all <change>...</change> blocks const changeRegex = /<change>([\s\S]*?)<\/change>/g; let match; while ((match = changeRegex.exec(content)) !== null) { const changeText = match[1]; // Extract deletions (strikethrough) const deletions = []; const delRegex = /<s>([\s\S]*?)<\/s>/g; let delMatch; while ((delMatch = delRegex.exec(changeText)) !== null) { deletions.push(delMatch[1]); } // Extract insertions (underline) const insertions = []; const insRegex = /<u>([\s\S]*?)<\/u>/g; let insMatch; while ((insMatch = insRegex.exec(changeText)) !== null) { insertions.push(insMatch[1]); } changes.push({ deleted: deletions, inserted: insertions }); } return changes;}
Why regex?The HTML tags are simple and well-structured. For basic extraction, regex is fast and sufficient. For documents with complex nested changes, consider using an HTML parser like BeautifulSoup.
Found 555 revisionsRevision 1: Deleted: Employees in the bargaining unit are required either to b...Revision 2: Deleted: a. An employee's chosen form of dues or payment in lieu o...Revision 3: Deleted: b. Dues and payments in-lieu-of dues for employees workin...Revision 4: Inserted: Employees covered by this Agreement shall have the right...Revision 5: Inserted: 1.2.2 Holder of Record. During the life of this Agreemen...
Not all changes are equal. Some are pure deletions (language removed), some are pure insertions (new language added), and some are replacements (old swapped for new). Categorizing helps prioritize review.
deletions_only = sum(1 for c in changes if c["deleted"] and not c["inserted"])insertions_only = sum(1 for c in changes if c["inserted"] and not c["deleted"])replacements = sum(1 for c in changes if c["deleted"] and c["inserted"])print(f"Total revisions: {len(changes)}")print(f" - Deletions only: {deletions_only}")print(f" - Insertions only: {insertions_only}")print(f" - Replacements: {replacements}")
In the Configurations tab, switch to Advanced mode. Expand the Formatting section and check change_tracking.
3
Run and review
Click Run. The results show the parsed content with <change>, <s>, and <u> tags visible in the output. You can search for specific changes using Ctrl+F.
4
Export or deploy
Copy the results, download as JSON, or deploy the pipeline with these settings for repeated use on similar documents.
Here’s a full script that parses a redlined contract and generates a change summary:
import reimport requestsfrom pathlib import Pathfrom reducto import Reductodef extract_changes(content): """Extract revision regions from content.""" changes = [] pattern = r'<change>(.*?)</change>' for match in re.finditer(pattern, content, re.DOTALL): change_text = match.group(1) deletions = re.findall(r'<s>(.*?)</s>', change_text, re.DOTALL) insertions = re.findall(r'<u>(.*?)</u>', change_text, re.DOTALL) changes.append({"deleted": deletions, "inserted": insertions}) return changes# Parse the documentclient = Reducto()upload = client.upload(file=Path("redlined_contract.pdf"))result = client.parse.run( input=upload.file_id, formatting={"include": ["change_tracking"]})# Handle URL result for large documentsif hasattr(result.result, 'url'): response = requests.get(result.result.url) data = response.json() chunks = data.get('chunks', []) full_content = "\n".join([c.get('content', '') for c in chunks])else: full_content = "\n".join([c.content for c in result.result.chunks])# Extract and categorizechanges = extract_changes(full_content)deletions_only = sum(1 for c in changes if c["deleted"] and not c["inserted"])insertions_only = sum(1 for c in changes if c["inserted"] and not c["deleted"])replacements = sum(1 for c in changes if c["deleted"] and c["inserted"])# Print summaryprint(f"Document: {result.usage.num_pages} pages")print(f"Total revisions: {len(changes)}")print(f" - Deletions only: {deletions_only}")print(f" - Insertions only: {insertions_only}")print(f" - Replacements: {replacements}")
Reducto uses different detection methods depending on document type:
Document type
Detection method
Word (.docx)
Reads native track changes metadata. Most accurate.
PDF (digital)
Detects colored text and formatting via embedded character data.
PDF (scanned)
Uses ML models to visually identify underlines and strikethroughs.
For best results, use Word documents with Track Changes enabled. The metadata is preserved natively. PDFs require visual detection, which works well but depends on clear formatting.
Word’s native Track Changes stores revision metadata directly in the file. This gives Reducto exact information about what was added or removed, including author and timestamp. PDFs require visual detection.
Categorize changes for efficient review
Replacements (where old text is swapped for new) often need careful review. Pure insertions may be less risky. Route different categories to appropriate reviewers.
Combine with Extract for clause analysis
Use Parse with change tracking to get the revisions, then pipe specific clauses through Extract to pull structured fields like dates, amounts, or party names.
Handle nested changes carefully
Some documents have nested revisions (changes within changes). The regex patterns above handle simple cases. For complex documents, consider using an HTML parser like BeautifulSoup.
Extract all changes from incoming redlines and route them to the appropriate reviewer based on clause type. Send indemnification changes to legal, pricing changes to finance.
Change approval workflows
Build approval queues where each revision must be explicitly accepted or rejected before finalizing the agreement. Track who approved what.
Compliance tracking
Monitor changes to policies and procedures. Flag modifications to critical sections for compliance review before they go into effect.
Negotiation summaries
Generate executive summaries showing what the counterparty changed. Brief stakeholders without requiring them to read a 165-page document.