← All Articles

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()) # Upload to S3 s3_client.upload_file( document_path, 'bid-documents', f'{bid_id}/original' ) # Determine processing strategy if file_type == 'application/pdf': extraction_type = 'pdf' elif file_type in ['image/png', 'image/jpeg']: extraction_type = 'ocr' # Will use Claude's vision else: extraction_type = 'unknown' # Store metadata 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.""" # Get document from S3 s3_client = boto3.client('s3') response = s3_client.get_object(Bucket='bid-documents', Key=s3_key) document_bytes = response['Body'].read() # Determine if this is PDF or image if s3_key.endswith('.pdf'): # For PDFs, convert to images first (e.g., with pdfplumber) # For this example, assume it's already converted to images in S3 images = get_pdf_pages_as_images(document_bytes) else: images = [document_bytes] # Extract from each page 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 } ] } ] ) # Parse the response 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: # Query historical data for similar items response = history_table.query( IndexName='description-index', KeyConditionExpression='description = :desc', ExpressionAttributeValues={':desc': item['description'][:50]} # Partial match ) 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) } # Flag if the bid price is an outlier (e.g., >20% above historical max) 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.""" # Sum line items 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 # Based on extraction accuracy + historical coverage } 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:

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:

  1. Review extracted items for accuracy (5 minutes).
  2. Review flagged items and adjust pricing if needed (5 minutes).
  3. Add project-specific adjustments — site conditions, weather, crew size (5-10 minutes).
  4. 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

  1. Build the extraction pipeline with 5-10 sample specs.
  2. Test against your actual historical bids to establish accuracy.
  3. Adjust prompts and post-processing rules.
  4. Deploy to production with a review step.
  5. 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.