← All Articles
September 22, 2026
7 min read
AI · Construction · Automation
AI for Construction: Bid Estimation and Document Parsing
A general contractor doing 40 bids a month can spend 80-120 hours just on estimating. Here’s a pipeline design that could cut it to about 20 minutes a bid.
A general contractor gets 40 bid requests per month. Each bid requires the estimator to manually parse line items from a specification document, compare pricing against historical projects, flag anomalies, and produce a quote. It takes 2-3 hours per bid. At 40 bids a month, that’s 80-120 hours — one person, full-time, just doing bids.
Here’s a pipeline design that could cut that down to about 20 minutes per bid. Claude does the heavy lifting, infrastructure handles the rest.
The Bid Document Pipeline
Specification documents come in different formats. Sometimes it’s a well-structured PDF with clearly demarcated sections. Sometimes it’s a scanned image. Sometimes it’s a Word doc with embedded tables. The first challenge is getting the data out.
Step 1: Document Ingestion and Format Detection
The bid comes in via email or a web form. You upload the PDF or image to S3.
import boto3
import mimetypes
s3_client = boto3.client('s3')
def ingest_bid_document(document_path: str) -> dict:
"""Upload bid document and detect format."""
file_type = mimetypes.guess_type(document_path)[0]
bid_id = str(uuid.uuid4())
s3_client.upload_file(
document_path,
'bid-documents',
f'{bid_id}/original'
)
if file_type == 'application/pdf':
extraction_type = 'pdf'
elif file_type in ['image/png', 'image/jpeg']:
extraction_type = 'ocr'
else:
extraction_type = 'unknown'
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('bid-documents')
table.put_item(Item={
'bid_id': bid_id,
'filename': os.path.basename(document_path),
'file_type': file_type,
'extraction_type': extraction_type,
'status': 'ingested',
'created_at': int(time.time())
})
return {'bid_id': bid_id, 'status': 'ingested'}
Step 2: Extract Line Items With Claude
Claude can read images and PDFs and extract structured data. For construction specs, you want item number, description, quantity, unit, estimated unit price, and any notes.
import anthropic
import base64
client = anthropic.Anthropic()
def extract_bid_items(bid_id: str, s3_key: str) -> list[dict]:
"""Extract line items from bid specification."""
s3_client = boto3.client('s3')
response = s3_client.get_object(Bucket='bid-documents', Key=s3_key)
document_bytes = response['Body'].read()
if s3_key.endswith('.pdf'):
images = get_pdf_pages_as_images(document_bytes)
else:
images = [document_bytes]
all_items = []
for page_num, image_bytes in enumerate(images):
base64_image = base64.standard_b64encode(image_bytes).decode('utf-8')
prompt = """You are a construction estimating expert. Extract all line items from this bid specification.
For each line item, return JSON with:
{
"item_number": "string or number from spec",
"description": "full description of work or material",
"quantity": "numeric quantity",
"unit": "unit of measure (ea, lf, sf, ton, etc)",
"unit_price_estimate": "numeric, your best estimate if not in spec",
"notes": "any qualifications or assumptions"
}
Return ONLY a JSON array. If there are no clear line items on this page, return [].
Page: {page_num}"""
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=4096,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64_image
}
},
{
"type": "text",
"text": prompt
}
]
}
]
)
try:
items = json.loads(message.content[0].text)
all_items.extend(items)
except json.JSONDecodeError:
print(f"Failed to parse items from page {page_num}")
return all_items
Claude’s document extraction generally holds up well on this kind of structured content, though the items most likely to need manual correction are the ones with unusual units or ambiguous descriptions.
Step 3: Compare Against Historical Pricing
Now you have line items. Compare them against historical bids to flag outliers and suggest pricing.
def compare_against_history(items: list[dict]) -> list[dict]:
"""Enrich items with historical pricing data."""
dynamodb = boto3.resource('dynamodb')
history_table = dynamodb.Table('historical-bids')
enriched = []
for item in items:
response = history_table.query(
IndexName='description-index',
KeyConditionExpression='description = :desc',
ExpressionAttributeValues={':desc': item['description'][:50]}
)
if response['Items']:
historical = response['Items']
avg_unit_price = sum(h['unit_price'] for h in historical) / len(historical)
min_price = min(h['unit_price'] for h in historical)
max_price = max(h['unit_price'] for h in historical)
item['pricing_context'] = {
'average_historical_price': avg_unit_price,
'range': [min_price, max_price],
'similar_bids': len(historical)
}
if item['unit_price_estimate'] > max_price * 1.2:
item['flag'] = 'HIGH'
elif item['unit_price_estimate'] < min_price * 0.8:
item['flag'] = 'LOW'
enriched.append(item)
return enriched
Step 4: Generate the Estimate
Roll up all items, calculate the total, apply overhead and margin.
def generate_estimate(bid_id: str, items: list[dict], overhead_percent: float = 15, margin_percent: float = 20) -> dict:
"""Generate final estimate with overhead and margin."""
labor_total = sum(
item['quantity'] * item['unit_price_estimate']
for item in items
if item.get('category') == 'labor'
)
material_total = sum(
item['quantity'] * item['unit_price_estimate']
for item in items
if item.get('category') == 'material'
)
equipment_total = sum(
item['quantity'] * item['unit_price_estimate']
for item in items
if item.get('category') == 'equipment'
)
subtotal = labor_total + material_total + equipment_total
overhead = subtotal * (overhead_percent / 100)
subtotal_with_overhead = subtotal + overhead
profit = subtotal_with_overhead * (margin_percent / 100)
total = subtotal_with_overhead + profit
estimate = {
'bid_id': bid_id,
'labor': labor_total,
'material': material_total,
'equipment': equipment_total,
'subtotal': subtotal,
'overhead': overhead,
'subtotal_with_overhead': subtotal_with_overhead,
'profit': profit,
'total': total,
'flagged_items': [item for item in items if item.get('flag')],
'confidence_score': 0.87
}
return estimate
What This Could Look Like in Practice
The architecture holds up, and the economics are worth sketching out for a mid-sized GC doing 40-50 bids a month:
- Time per bid: an estimated drop from 2.5 hours to roughly 20 minutes (extraction, review, adjustment).
- Extraction accuracy: Claude’s document extraction on well-structured specs typically lands in the 90-95% range without manual correction; specs with unusual units or handwritten notes will likely need more review than that.
- Cost per bid: Claude API calls plus light AWS infrastructure should run well under $1 per bid at this volume.
- Time saved translates to real money: at typical estimator billing rates, reclaiming 2 hours per bid is worth $150-200 in labor. At 40-50 bids a month, that adds up fast if the accuracy numbers hold up in practice.
Those figures are projections based on how Claude performs on similar document-extraction work, not results from a live deployment. The real numbers — especially extraction accuracy on your specific spec formats — only show up once you pilot it against actual historical bids, which is exactly what the review step below is for.
Operational Considerations
The pipeline is only as good as your historical bid data. You need at least 100-200 historical bids before the comparisons mean anything.
Construction specs vary wildly. Some include unit prices, some don’t. Some are formatted, some are scans of napkin sketches. Claude handles this surprisingly well, but build in a review step where the estimator eyeballs the extraction before it goes to the customer.
Flagging strategy: the HIGH/LOW price flags catch mistakes, but they’re also learning tools. Track which flags led to bids won versus lost, and adjust the thresholds accordingly.
Store all estimates in a database — DynamoDB works, or Postgres. Build a simple dashboard showing estimates pending review, accepted, and lost, and feed the outcomes back into your historical data.
The Estimator’s Role Changes
With extraction automated, the estimator’s job becomes:
- Review extracted items for accuracy (5 minutes).
- Review flagged items and adjust pricing if needed (5 minutes).
- Add project-specific adjustments — site conditions, weather, crew size (5-10 minutes).
- Export and send to the customer (2 minutes).
This isn’t AI replacing the estimator. It’s the estimator handling three times the bid volume, or spending that reclaimed time on strategy instead of data entry.
Getting Started
- Build the extraction pipeline with 5-10 sample specs.
- Test against your actual historical bids to establish accuracy.
- Adjust prompts and post-processing rules.
- Deploy to production with a review step.
- Track results and iterate.
The whole project runs 4-6 weeks for a single-user contractor, 8-10 weeks for a firm handling 50+ bids a month.
Get the free AI Readiness Checklist
15 questions to diagnose your team’s AI readiness, where you’ll see ROI fastest, and what to tackle first.
✓ Takes 5 minutes
✓ Actionable next steps
✓ No sales pitch
No spam. Unsubscribe anytime.
or
Ready to build AI that actually works?
Let’s talk about how SRE discipline transforms AI from a risky experiment into a reliable business system.
Start the Conversation
About the author
Charles Harvey is the founder of Three Moons Network and a site reliability engineer who builds production-grade AI automation for small businesses — monitoring, cost visibility, and documentation included. He writes about his hands-on AI experiments at floggingclaude.com. Connect on LinkedIn or see the code on GitHub.