60 min readContent generation

Implementation Guide: Generate rfp responses and bid proposals from project specs

Step-by-step implementation guide for deploying AI to generate rfp responses and bid proposals from project specs for Construction & Contractors clients.

Hardware Procurement

Document Scanner for RFP/Plan Digitization

Fujitsu (Ricoh)ScanSnap iX1600Qty: 1

$320 MSP cost / $475 suggested resale

High-speed duplex scanning of paper RFPs, addenda, legacy proposals, and smaller plan sets for ingestion into the AI knowledge base. 40 ppm scan speed with intelligent document detection and direct-to-cloud upload to SharePoint.

Wide-Format Scanner for Construction Drawings

CanonimagePROGRAF TX-4100 MFP Z36Qty: 1

$4,500 MSP cost / $5,800 suggested resale

Scan large-format blueprints, site plans, and construction drawings (up to 36-inch width) that accompany RFPs. Enables the AI system to reference scope details from plan sets. Only required if client regularly receives paper plans; skip if client uses digital plan rooms exclusively.

Workstation RAM Upgrade

Workstation RAM Upgrade

Crucial / KingstonCT2K16G4DFRA32A (Crucial 32GB DDR4-3200 Kit)Qty: 5

$55 MSP cost per kit / $95 suggested resale per kit

Upgrade estimator and PM workstations from 16GB to 32GB RAM to handle simultaneous operation of Bluebeam Revu, browser-based AI tools, Procore, and large PDF plan sets without performance degradation.

NVMe SSD Upgrade

NVMe SSD Upgrade

SamsungSamsung 990 EVO Plus 1TB (MZ-V9E1T0B/AM)Qty: 5

$90 MSP cost / $150 suggested resale per unit

Upgrade estimator workstations to 1TB NVMe storage to accommodate local proposal archives, template libraries, and cached project data. Replaces aging SATA SSDs or smaller NVMe drives.

Dual Monitor Setup for Proposal Writers

Dell U2723QE UltraSharp 27" 4K USB-C

DellDell U2723QE UltraSharp 27" 4K USB-CQty: 4

$450 MSP cost / $600 suggested resale per monitor

Dual 27-inch 4K monitors for estimators and proposal writers who need to view RFP requirements on one screen while drafting responses on the other. USB-C daisy-chain simplifies cabling.

Software Procurement

Microsoft 365 Business Premium

Microsoftper-seat SaaS (CSP)Qty: 10 users

$22/user/month × 10 users = $220/month; MSP margin 12-21% through CSP

Foundation platform providing Exchange Online, SharePoint Online (document storage and knowledge base source), Teams (collaboration), Word (proposal editing), and Azure AD (SSO/identity). SharePoint serves as the primary document repository for past proposals, templates, and certifications.

Microsoft 365 Copilot

MicrosoftQty: 5 key users (estimators, PMs, principals)

$30/user/month = $150/month; 15% CSP new subscription discount available

Enables AI-assisted drafting directly inside Word, Excel, PowerPoint, and Outlook. Used for first-pass proposal sections, executive summary generation, email response drafting to owners/GCs, and spreadsheet-based cost formatting. Complements the custom RAG pipeline for users who prefer working in native Office apps.

AutoRFP.ai - Accelerate Plan

AutoRFP.aiAccelerate PlanQty: Unlimited users; 50 projects/year

$1,299/month (paid annually = $15,588/year); Starter plan available at $199/month for lower-volume clients

Primary AI RFP response platform. Parses incoming RFPs, extracts compliance requirements, maps them to the contractor's content library, and generates section-by-section draft responses. Chrome extension enables direct response within bid portal interfaces (BidExpress, BuildingConnected). Includes 50 projects/year on Accelerate plan. ISO 27001 certified.

Azure OpenAI Service (GPT-4.1)

Microsoft AzureGPT-4.1

$2.00/million input tokens, $8.00/million output tokens; estimated $100-300/month for 20-50 bids; MSP margin 10-15% through Azure CSP

Powers the custom RAG pipeline for construction-specific proposal generation. Azure OpenAI provides the same GPT-4.1 models as OpenAI but with enterprise data privacy guarantees — client bid data is never used for model training. Required for the custom knowledge base integration that makes proposals uniquely tailored to the contractor's experience and pricing.

$50/month minimum commitment + usage; estimated $70-150/month for mid-size contractor knowledge base

Stores vector embeddings of all past proposals, project descriptions, crew qualifications, certifications, and boilerplate sections. Enables semantic search so the RAG pipeline can retrieve the most relevant past content when generating new proposals. Serverless option available for lower-volume clients.

PandaDoc Business Plan

PandaDocBusiness PlanQty: 3 users (proposal senders)

$49/user/month × 3 users = $147/month

Final proposal formatting, branded templates, e-signature collection, and proposal analytics (open tracking, time-on-page). Integrates with CRM for bid tracking. Construction-specific templates available. Provides the professional delivery layer after AI generates the content.

Bluebeam Revu Complete

Bluebeam (Nemetschek)Revu CompleteQty: 3 estimators

$599/user/year × 3 estimators = $1,797/year ($150/month)

PDF markup and quantity takeoff tool used by estimators to review plan sets accompanying RFPs. Takeoff data and annotations feed into the AI system as context for scope-accurate proposal generation. Industry standard for construction document collaboration.

Free; development labor is the cost

Orchestration framework for the custom RAG pipeline. Manages the chain of operations: RFP document parsing → requirement extraction → vector similarity search → prompt assembly → LLM generation → output formatting. Runs on Azure App Service or Azure Functions.

Procore - Standard Plan

Procore TechnologiesStandard Plan

Starting at $375/month; varies by ACV (client likely already has this)

Construction project management platform that serves as a data source for the AI system. API provides access to project history, bid packages, subcontractor lists, RFIs, submittals, and project documentation. Integration pulls relevant project context into proposal generation.

Prerequisites

  • Active Microsoft 365 Business Premium tenant with SharePoint Online and at least 10 licensed users
  • Azure subscription linked to the M365 tenant with billing configured (CSP or direct EA)
  • Client must have a minimum of 25-50 past proposals (winning preferred) in digital format (Word, PDF) for knowledge base seeding
  • Procore, Buildertrend, or equivalent construction management platform with API access enabled
  • Accounting system (QuickBooks, Sage 300, or Foundation Software) with exportable job cost history for at least 2 years
  • Dedicated SharePoint document library structure: /Proposals/Won, /Proposals/Lost, /Templates, /Certifications, /Resumes, /Boilerplate
  • Global admin access to the M365 tenant for SSO configuration and app registration
  • Azure AD accounts for all users who will interact with the AI proposal system
  • Minimum 50 Mbps download / 20 Mbps upload internet connectivity at each office location
  • Current antivirus/EDR solution deployed on all workstations (Microsoft Defender for Business recommended)
  • Identified 2-3 power users (typically lead estimator, senior PM, and operations manager) for pilot phase
  • Client has documented their standard proposal sections and formatting requirements (cover letter, executive summary, scope, schedule, qualifications, pricing, appendices)
  • If client pursues federal contracts: inventory of CUI handling requirements and determination of CMMC level needed
  • DNS admin access for custom domain SSO configuration
  • Firewall must allow outbound HTTPS to: *.openai.azure.com, *.pinecone.io, *.autorfp.ai, *.pandadoc.com

Installation Steps

...

Step 1: Environment Assessment and Document Inventory

Conduct a thorough assessment of the client's current proposal workflow, software stack, and document assets. Interview the lead estimator, senior PM, and principals to understand bid types (GC, sub, design-build, public, private), typical proposal sections, and pain points. Inventory all past proposals, identify the document management system in use, and catalog integration points with Procore/Buildertrend and the accounting system. Create a SharePoint site structure for the AI proposal system.

Connect to SharePoint Online and create document library structure with metadata columns
powershell
# Connect to SharePoint Online via PowerShell
Install-Module -Name PnP.PowerShell -Scope CurrentUser
Connect-PnPOnline -Url https://[tenant].sharepoint.com/sites/proposals -Interactive
# Create document library structure
New-PnPList -Title 'Won Proposals' -Template DocumentLibrary
New-PnPList -Title 'Lost Proposals' -Template DocumentLibrary
New-PnPList -Title 'Templates' -Template DocumentLibrary
New-PnPList -Title 'Certifications' -Template DocumentLibrary
New-PnPList -Title 'Crew Resumes' -Template DocumentLibrary
New-PnPList -Title 'Boilerplate Sections' -Template DocumentLibrary
New-PnPList -Title 'RFP Inbox' -Template DocumentLibrary
# Add metadata columns for proposal categorization
Add-PnPField -List 'Won Proposals' -DisplayName 'Project Type' -InternalName 'ProjectType' -Type Choice -Choices 'Commercial','Residential','Industrial','Infrastructure','Government'
Add-PnPField -List 'Won Proposals' -DisplayName 'Bid Type' -InternalName 'BidType' -Type Choice -Choices 'GC','Subcontractor','Design-Build','CM at Risk','IDIQ'
Add-PnPField -List 'Won Proposals' -DisplayName 'Contract Value' -InternalName 'ContractValue' -Type Currency
Add-PnPField -List 'Won Proposals' -DisplayName 'Win Date' -InternalName 'WinDate' -Type DateTime
Note

This step typically requires 4-8 hours on-site. Request access to the client's file server or cloud storage where proposals are currently kept. Many contractors store proposals in a chaotic folder structure on a local NAS or in individual email accounts. Budget extra time for discovery. Take screenshots of their current proposal format and brand guidelines for template creation later.

Step 2: Deploy and Configure Microsoft 365 Copilot

Activate Microsoft 365 Copilot licenses for the 5 key proposal-writing users (lead estimator, 2 PMs, operations manager, company principal). Configure Copilot data access policies to ensure it can reference the SharePoint proposal libraries. Set up Sensitivity Labels to protect bid pricing data. Verify Copilot works correctly in Word by testing with a sample proposal draft.

Assign Copilot licenses, configure Sensitivity Labels, and set SharePoint search indexing via PowerShell
powershell
# Assign Copilot licenses via Microsoft 365 Admin Center PowerShell
Connect-MgGraph -Scopes 'User.ReadWrite.All','Organization.Read.All'
# Get the Copilot SKU ID
Get-MgSubscribedSku | Where-Object {$_.SkuPartNumber -like '*Copilot*'} | Select-Object SkuId, SkuPartNumber
# Assign to specific users (repeat for each user)
$userId = (Get-MgUser -Filter "userPrincipalName eq 'estimator@contoso.com'").Id
$copilotSkuId = '<SKU-ID-FROM-ABOVE>'
Set-MgUserLicense -UserId $userId -AddLicenses @(@{SkuId=$copilotSkuId}) -RemoveLicenses @()
# Configure Sensitivity Labels for bid data
Connect-IPPSSession
New-Label -DisplayName 'Confidential - Bid Pricing' -Name 'ConfidentialBidPricing' -Tooltip 'Apply to all documents containing bid pricing, cost estimates, or margin calculations' -ContentType 'File,Email'
# Configure SharePoint search for Copilot indexing
# Ensure the Proposals site is included in Copilot's content sources
Set-PnPSearchConfiguration -Scope Site -Path 'searchconfig.xml'
Note

Copilot requires that SharePoint content be properly indexed and permissioned. If the client has overly permissive SharePoint access, Copilot will surface content to anyone with a license. Review and tighten SharePoint permissions BEFORE enabling Copilot. Allow 24-48 hours after license assignment for Copilot to fully index SharePoint content. The promotional 25% discount on Business Premium + Copilot bundles for 10-300 licenses is available through Q1 2026 — take advantage if timing allows.

Step 3: Provision Azure OpenAI Service

Create the Azure OpenAI Service resource that will power the custom RAG pipeline. Deploy GPT-4.1 and text-embedding-ada-002 models. Configure networking, API keys, and content filtering policies. Set up cost alerts to prevent unexpected API spend.

bash
# Login to Azure CLI
az login
# Create resource group for the AI proposal system
az group create --name rg-proposal-ai --location eastus2
# Create Azure OpenAI resource
az cognitiveservices account create \
  --name proposal-ai-openai \
  --resource-group rg-proposal-ai \
  --kind OpenAI \
  --sku S0 \
  --location eastus2 \
  --custom-domain proposal-ai-openai
# Deploy GPT-4.1 model for proposal generation
az cognitiveservices account deployment create \
  --name proposal-ai-openai \
  --resource-group rg-proposal-ai \
  --deployment-name gpt-41-proposal \
  --model-name gpt-4.1 \
  --model-version 2025-04-14 \
  --model-format OpenAI \
  --sku-capacity 80 \
  --sku-name Standard
# Deploy embedding model for vector generation
az cognitiveservices account deployment create \
  --name proposal-ai-openai \
  --resource-group rg-proposal-ai \
  --deployment-name text-embedding-ada-002 \
  --model-name text-embedding-ada-002 \
  --model-version 2 \
  --model-format OpenAI \
  --sku-capacity 120 \
  --sku-name Standard
# Get API keys
az cognitiveservices account keys list \
  --name proposal-ai-openai \
  --resource-group rg-proposal-ai
# Set up cost alert at $300/month
az consumption budget create \
  --budget-name proposal-ai-budget \
  --amount 300 \
  --category Cost \
  --resource-group rg-proposal-ai \
  --time-grain Monthly \
  --start-date 2025-08-01 \
  --end-date 2026-07-31 \
  --notification-key alert1 \
  --notification-threshold 80 \
  --notification-enabled true \
  --notification-contact-emails admin@msp.com finance@client.com
Note

Use eastus2 or westus for best GPT-4.1 availability. The S0 SKU is pay-as-you-go with no minimum commitment. Set the sku-capacity (tokens per minute in thousands) based on expected usage — 80K TPM is sufficient for most mid-size contractors. If the client handles federal/DoD contracts with CUI, use Azure Government region (usgovvirginia) instead and ensure FedRAMP High compliance. Store API keys in Azure Key Vault, never in application code.

Step 4: Set Up Pinecone Vector Database

Create the Pinecone index that will store vector embeddings of all past proposals, boilerplate sections, certifications, and project descriptions. Configure the index dimensions to match the Azure OpenAI embedding model (1536 dimensions for text-embedding-ada-002). Set up namespaces to organize content by type.

bash
# Install Pinecone CLI / Python SDK
pip install pinecone-client
# Python script to initialize Pinecone index
cat > init_pinecone.py << 'EOF'
from pinecone import Pinecone, ServerlessSpec
import os

pc = Pinecone(api_key=os.environ['PINECONE_API_KEY'])

# Create the proposal knowledge base index
pc.create_index(
    name='proposal-knowledge-base',
    dimension=1536,  # Matches text-embedding-ada-002
    metric='cosine',
    spec=ServerlessSpec(
        cloud='azure',
        region='eastus2'
    )
)

 print('Index created successfully')

# Verify index is ready
index = pc.Index('proposal-knowledge-base')
print(index.describe_index_stats())
EOF

# Set environment variable and run
export PINECONE_API_KEY='your-api-key-here'
python init_pinecone.py
Note

Pinecone Serverless on Azure is preferred since the rest of the stack is Azure-based, minimizing cross-cloud latency. The index uses cosine similarity which works best for document retrieval use cases. Namespaces will be configured during the knowledge base ingestion step. The $50/month minimum commitment covers approximately 1 million vectors, which is sufficient for 500+ past proposals with chunked sections. No credit card should be on the client's account — MSP should manage billing through their Pinecone organization.

Step 5: Deploy AutoRFP.ai Platform

Set up the AutoRFP.ai account for the client, configure SSO via Azure AD, import the initial content library, and set up the Chrome extension for bid portal integration. AutoRFP.ai serves as the user-facing RFP response tool while the custom RAG pipeline provides construction-specific intelligence behind the scenes.

1
Navigate to https://app.autorfp.ai/signup
2
Select Accelerate Plan ($1,299/month annual billing)
3
Configure SSO: In Azure AD, register new Enterprise Application — App name: AutoRFP.ai Production | SSO Type: SAML 2.0 | Identifier (Entity ID): https://app.autorfp.ai/saml/metadata | Reply URL: https://app.autorfp.ai/saml/acs | Sign-on URL: https://app.autorfp.ai/saml/login
4
Navigate to Chrome Web Store and search 'AutoRFP.ai' to install the Chrome extension for bid portal integration. Deploy via Google Admin (if using Chrome Enterprise) or manual install. Extension enables in-portal RFP response on BuildingConnected, BidExpress, etc.
Azure AD App Registration for AutoRFP.ai SSO
bash
# Azure AD App Registration for SSO
az ad app create \
  --display-name 'AutoRFP.ai Production' \
  --web-redirect-uris 'https://app.autorfp.ai/saml/acs' \
  --sign-in-audience AzureADMyOrg
Note

AutoRFP.ai setup takes as little as 48 hours per their documentation, but allow a full week for SSO configuration, content library import, and initial user training. The Chrome extension is a key differentiator — it lets estimators respond to RFPs directly within bid portal interfaces without switching apps. All plans include unlimited users with no per-seat fees, making this cost-effective for growing teams. Request a demo and negotiate pricing — annual commitments sometimes come with additional discounts for MSP partners.

Step 6: Build the Custom RAG Pipeline Application

Deploy the custom LangChain-based RAG application on Azure App Service. This application serves as the intelligence layer that connects the vector knowledge base to the LLM, enabling construction-specific proposal generation with the contractor's actual project history, pricing context, and brand voice. The application exposes a REST API that both AutoRFP.ai (via webhook) and a standalone web interface can consume.

bash
# Create Azure App Service for the RAG pipeline
az appservice plan create \
  --name proposal-rag-plan \
  --resource-group rg-proposal-ai \
  --sku B2 \
  --is-linux

az webapp create \
  --name proposal-rag-api \
  --resource-group rg-proposal-ai \
  --plan proposal-rag-plan \
  --runtime 'PYTHON:3.11'

# Configure app settings (environment variables)
az webapp config appsettings set \
  --name proposal-rag-api \
  --resource-group rg-proposal-ai \
  --settings \
    AZURE_OPENAI_ENDPOINT=https://proposal-ai-openai.openai.azure.com/ \
    AZURE_OPENAI_API_KEY=@Microsoft.KeyVault(VaultName=proposal-ai-kv;SecretName=openai-key) \
    AZURE_OPENAI_DEPLOYMENT=gpt-41-proposal \
    AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-ada-002 \
    PINECONE_API_KEY=@Microsoft.KeyVault(VaultName=proposal-ai-kv;SecretName=pinecone-key) \
    PINECONE_INDEX=proposal-knowledge-base \
    PINECONE_ENVIRONMENT=eastus2

# Create Key Vault for secrets
az keyvault create \
  --name proposal-ai-kv \
  --resource-group rg-proposal-ai \
  --location eastus2

# Store secrets
az keyvault secret set --vault-name proposal-ai-kv --name openai-key --value '<OPENAI_API_KEY>'
az keyvault secret set --vault-name proposal-ai-kv --name pinecone-key --value '<PINECONE_API_KEY>'

# Grant App Service access to Key Vault
az webapp identity assign --name proposal-rag-api --resource-group rg-proposal-ai
$principalId=$(az webapp identity show --name proposal-rag-api --resource-group rg-proposal-ai --query principalId -o tsv)
az keyvault set-policy --name proposal-ai-kv --object-id $principalId --secret-permissions get list

# Deploy application code (see custom_ai_components for full source)
# Clone repo and deploy
git clone https://github.com/[msp-org]/proposal-rag-pipeline.git
cd proposal-rag-pipeline
az webapp deployment source config-zip \
  --name proposal-rag-api \
  --resource-group rg-proposal-ai \
  --src deploy.zip
Note

The B2 App Service plan ($54/month) provides 2 vCPUs and 3.5 GB RAM, sufficient for the RAG pipeline serving 10-20 concurrent users. Scale to B3 or P1v3 if response times exceed 10 seconds under load. Use Azure Key Vault references for all secrets — never hardcode API keys. Enable Application Insights for monitoring API call volumes, latency, and error rates. The full application code is provided in the custom_ai_components section.

Step 7: Ingest Historical Proposals into Knowledge Base

Process and vectorize the client's existing proposal library. This is the most labor-intensive step and the single biggest determinant of output quality. Each proposal is chunked into logical sections (executive summary, scope, qualifications, schedule, pricing approach, safety plan, etc.), enriched with metadata (project type, bid type, contract value, outcome), and embedded into Pinecone. Aim for minimum 50 proposals, ideally 100+.

Run the ingestion script (full source in custom_ai_components).
bash
# First, ensure all proposals are in the SharePoint 'Won Proposals' library
# with metadata fields populated.

# Install dependencies
pip install langchain langchain-community langchain-openai pinecone-client \
  python-docx PyPDF2 openpyxl beautifulsoup4 tiktoken

# Run ingestion
python ingest_proposals.py \
  --sharepoint-site 'https://[tenant].sharepoint.com/sites/proposals' \
  --library 'Won Proposals' \
  --namespace 'won_proposals' \
  --chunk-size 1500 \
  --chunk-overlap 200

# Ingest boilerplate sections
python ingest_proposals.py \
  --sharepoint-site 'https://[tenant].sharepoint.com/sites/proposals' \
  --library 'Boilerplate Sections' \
  --namespace 'boilerplate' \
  --chunk-size 1000 \
  --chunk-overlap 150

# Ingest certifications and qualifications
python ingest_proposals.py \
  --sharepoint-site 'https://[tenant].sharepoint.com/sites/proposals' \
  --library 'Certifications' \
  --namespace 'certifications' \
  --chunk-size 800 \
  --chunk-overlap 100

# Ingest crew resumes and qualifications
python ingest_proposals.py \
  --sharepoint-site 'https://[tenant].sharepoint.com/sites/proposals' \
  --library 'Crew Resumes' \
  --namespace 'resumes' \
  --chunk-size 600 \
  --chunk-overlap 100

# Verify ingestion
python verify_knowledge_base.py --index proposal-knowledge-base
Critical

Quality in = quality out. Spend time with the client's lead estimator to identify their best 50 proposals. Tag each with outcome (won/lost), project type, contract value, and bid type. For lost proposals, include them in a separate namespace — they provide useful negative examples. The chunking strategy matters enormously: chunk by logical proposal section (not arbitrary character count) when possible. Use the section headers in each document to create semantically meaningful chunks. Expect this step to take 8-16 hours depending on document quality and volume. Many older proposals will be scanned PDFs requiring OCR — use Azure AI Document Intelligence (Form Recognizer) for OCR preprocessing.

Step 8: Configure Procore API Integration

Set up the bidirectional integration with Procore to pull project history, active bid packages, subcontractor lists, and project documentation into the AI system. This enables the proposal generator to reference the contractor's actual completed project portfolio with real data (square footage, contract values, completion dates, client references).

bash
# Register Procore API application
# Navigate to https://developers.procore.com
# Create new App > Data Connection App
# Set redirect URI: https://proposal-rag-api.azurewebsites.net/auth/procore/callback
# Request scopes: read (projects, bids, documents, directory)

# Store Procore credentials in Key Vault
az keyvault secret set --vault-name proposal-ai-kv --name procore-client-id --value '<CLIENT_ID>'
az keyvault secret set --vault-name proposal-ai-kv --name procore-client-secret --value '<CLIENT_SECRET>'

# Add Procore settings to App Service
az webapp config appsettings set \
  --name proposal-rag-api \
  --resource-group rg-proposal-ai \
  --settings \
    PROCORE_CLIENT_ID=@Microsoft.KeyVault(VaultName=proposal-ai-kv;SecretName=procore-client-id) \
    PROCORE_CLIENT_SECRET=@Microsoft.KeyVault(VaultName=proposal-ai-kv;SecretName=procore-client-secret) \
    PROCORE_COMPANY_ID='<COMPANY_ID>'

# Test the Procore connection
curl -X GET 'https://proposal-rag-api.azurewebsites.net/api/procore/test' \
  -H 'Authorization: Bearer <APP_TOKEN>'

# Run initial project history sync
python sync_procore_projects.py --full-sync --company-id <COMPANY_ID>
Note

Procore API rate limits are 3,600 requests per hour per app. The initial full sync may take several hours for contractors with 100+ projects. After initial sync, configure a daily incremental sync via Azure Functions timer trigger. If the client uses Buildertrend instead of Procore, substitute the Buildertrend API (REST-based) with equivalent endpoints for projects, proposals, and client data. For clients on Foundation Software or Sage 300 for accounting, set up a nightly CSV export job from the accounting system to SharePoint, which the ingestion pipeline will pick up automatically.

Step 9: Configure PandaDoc for Proposal Delivery

Set up PandaDoc Business accounts for proposal senders, create branded construction proposal templates, and configure the API integration so AI-generated proposals automatically flow into PandaDoc for final formatting, e-signature, and delivery tracking.

1
Create account at https://app.pandadoc.com
2
Business Plan: $49/user/month × 3 users
3
Configure branding: Upload client logo (SVG preferred), set brand colors and fonts, create letterhead template
4
Navigate to Settings > API > Developer Dashboard
5
Create new API Key for the RAG pipeline integration
Store API key in Key Vault, configure App Service settings, and verify PandaDoc API connectivity
bash
# Store PandaDoc API key
az keyvault secret set --vault-name proposal-ai-kv --name pandadoc-api-key --value '<PANDADOC_API_KEY>'

# Add to App Service settings
az webapp config appsettings set \
  --name proposal-rag-api \
  --resource-group rg-proposal-ai \
  --settings \
    PANDADOC_API_KEY=@Microsoft.KeyVault(VaultName=proposal-ai-kv;SecretName=pandadoc-api-key)

# Test PandaDoc API connection
curl -X GET 'https://api.pandadoc.com/public/v1/templates' \
  -H 'Authorization: API-Key <PANDADOC_API_KEY>'
Note

Create at least 5 PandaDoc templates matching the client's most common proposal types: 1) General Contractor Bid, 2) Subcontractor Proposal, 3) Design-Build Proposal, 4) Government/Public Works Bid, 5) T&M/Cost-Plus Proposal. Each template should include: cover page, table of contents, content sections with merge fields for AI-generated text, pricing table, schedule section, qualifications/experience, safety plan, and signature block. Enable the PandaDoc analytics features so the client can track when owners/GCs open proposals and which sections they spend the most time on.

Step 10: Deploy Scanning Hardware and Configure Document Workflow

Install the Fujitsu ScanSnap iX1600 and (if applicable) the Canon wide-format scanner. Configure ScanSnap Home software to auto-route scanned documents to the SharePoint 'RFP Inbox' library with OCR text extraction enabled. Set up a Power Automate flow to trigger AI processing when new RFPs are uploaded.

  • ScanSnap iX1600 Configuration: Install ScanSnap Home software from https://www.pfu.ricoh.com/global/scanners/scansnap/dl/
  • Connect scanner via WiFi to office network
  • Configure scan profile: Name: 'RFP to SharePoint', Color Mode: Auto, Resolution: 300 DPI (optimal for OCR), File Format: Searchable PDF, Save to: SharePoint Online > Proposals > RFP Inbox, OCR: Enabled (built into ScanSnap Home)
  • Power Automate Flow for new RFP processing: Create flow in https://make.powerautomate.com
  • Trigger: 'When a file is created' in SharePoint 'RFP Inbox' library
  • Actions: (1) Get file content, (2) HTTP POST to proposal-rag-api.azurewebsites.net/api/rfp/analyze, (3) Create item in SharePoint 'Active RFPs' list with analysis results, (4) Send Teams notification to Estimating channel, (5) Create Planner task for RFP response
  • Test scanning workflow: (1) Scan sample RFP document, (2) Verify document appears in SharePoint RFP Inbox, (3) Verify Power Automate flow triggers, (4) Verify Teams notification received, (5) Verify AI analysis results populated
Note

The ScanSnap iX1600 supports scanning directly to cloud via WiFi without a computer, but initial configuration requires ScanSnap Home on a PC/Mac. For the wide-format Canon scanner, use the Canon imagePROGRAF Print/Scan software which also supports scan-to-cloud. Test OCR quality with construction-specific documents — if OCR struggles with handwritten annotations on plans, consider Azure AI Document Intelligence (Form Recognizer) as a preprocessing step ($1.50 per 1,000 pages for prebuilt read model).

Step 11: Security Hardening and Compliance Configuration

Implement security controls for bid data protection, configure Azure AD Conditional Access policies, enable audit logging, and ensure compliance with construction industry requirements including potential CMMC alignment for federal contractors.

Azure AD Conditional Access, audit logging, Log Analytics, Application Insights, and DLP configuration
bash
# Enable Azure AD Conditional Access for AI apps
# Policy 1: Require MFA for all AI platform access
az ad policy create --display-name 'Require MFA for AI Platforms' \
  --definition '{"conditions":{"applications":{"includeApplications":["AutoRFP-App-ID","PandaDoc-App-ID","proposal-rag-api-App-ID"]}},"grantControls":{"builtInControls":["mfa"]}}'

# Policy 2: Block access from non-compliant devices
# Configure in Azure AD Portal > Security > Conditional Access

# Enable Azure AD audit logging
az monitor diagnostic-settings create \
  --name 'ai-platform-audit' \
  --resource /subscriptions/<SUB_ID>/resourceGroups/rg-proposal-ai \
  --logs '[{"category":"AuditEvent","enabled":true,"retentionPolicy":{"enabled":true,"days":365}}]' \
  --workspace /subscriptions/<SUB_ID>/resourceGroups/rg-proposal-ai/providers/Microsoft.OperationalInsights/workspaces/proposal-ai-logs

# Create Log Analytics workspace
az monitor log-analytics workspace create \
  --resource-group rg-proposal-ai \
  --workspace-name proposal-ai-logs

# Enable Application Insights for the RAG API
az monitor app-insights component create \
  --app proposal-rag-insights \
  --location eastus2 \
  --resource-group rg-proposal-ai \
  --application-type web \
  --workspace proposal-ai-logs

# Configure data loss prevention - prevent bid data from leaving tenant
# In Microsoft Purview Compliance Center:
# 1. Create DLP policy for 'Financial Data - Bid Pricing'
# 2. Apply to SharePoint, OneDrive, Teams, Exchange
# 3. Detect patterns: dollar amounts + keywords (bid, proposal, estimate, unit price)
# 4. Action: Block external sharing, notify compliance admin
Critical

CRITICAL COMPLIANCE ITEMS: 1) Verify Azure OpenAI data privacy — confirm in Azure portal that 'Abuse monitoring' is configured appropriately and no data is stored beyond 30 days. 2) Review AutoRFP.ai's data processing agreement — ensure bid data is not used for training their models. 3) For clients bidding federal work, Azure OpenAI in Azure Government meets FedRAMP High — standard Azure OpenAI meets FedRAMP Moderate. 4) ANTITRUST WARNING: Ensure the AI system NEVER accesses competitor bid data or uses shared industry pricing databases for bid amount generation. Each client's instance must be completely data-siloed. Document this in the client agreement.

Step 12: User Training and Pilot Deployment

Conduct structured training sessions for the pilot group of 2-3 power users. Start with a 2-week pilot period using the system on real (but non-critical) bids to validate output quality and refine prompts. Train users on the complete workflow from RFP intake through proposal delivery.

  • Training Agenda (2 sessions × 2 hours each)
  • Session 1: System Overview and Basic Workflow
  • Demo: Scan RFP → SharePoint upload → AI analysis notification
  • Demo: AutoRFP.ai Chrome extension on BuildingConnected
  • Demo: Custom RAG pipeline web interface for proposal drafting
  • Hands-on: Each user processes a past RFP through the system
  • Review: Compare AI output to the actual winning proposal
  • Action: Assign each user 2-3 low-stakes RFPs for pilot week
  • Session 2: Advanced Features and Quality Control
  • Demo: Editing and refining AI-generated sections in Word with Copilot
  • Demo: PandaDoc template selection and proposal formatting
  • Demo: Procore integration - pulling project history into proposals
  • Process: Mandatory human review checklist before submission
  • Process: Feedback loop - how to flag good/bad AI outputs
  • Process: When to override AI suggestions with manual writing
Note

Training is the make-or-break step. Construction professionals are often skeptical of AI tools — demonstrate value immediately by processing one of their recent painful RFPs through the system in real-time. The pilot period should use real RFPs but not high-value must-win bids. Establish the iron rule: NO AI-generated proposal leaves the building without human review by the estimator/PM who owns the bid. Create a simple feedback form (Microsoft Forms) where users can rate each AI-generated section and provide corrections — this feedback is used to refine prompts in the optimization phase.

Custom AI Components

RFP Parser and Requirements Extractor

Type: skill Parses incoming RFP documents (PDF, DOCX) and extracts structured requirements including submission deadlines, required sections, qualification criteria, bonding requirements, insurance minimums, DBE/MBE participation goals, page limits, formatting requirements, and evaluation criteria with scoring weights. Outputs a structured JSON requirements matrix that drives the proposal generation pipeline.

Implementation:

rfp_parser.py
python
# rfp_parser.py
import os
import json
from datetime import datetime
from langchain_openai import AzureChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

class RFPParser:
    def __init__(self):
        self.llm = AzureChatOpenAI(
            azure_deployment=os.environ['AZURE_OPENAI_DEPLOYMENT'],
            azure_endpoint=os.environ['AZURE_OPENAI_ENDPOINT'],
            api_key=os.environ['AZURE_OPENAI_API_KEY'],
            api_version='2025-03-01-preview',
            temperature=0.1,
            max_tokens=4096
        )
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=8000,
            chunk_overlap=500,
            separators=['\n\n', '\n', '. ', ' ']
        )
        self.extraction_prompt = ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction RFP analyst. Extract ALL requirements from this RFP section.
Return a JSON object with the following structure:
{
  "project_name": "string",
  "project_type": "commercial|residential|industrial|infrastructure|government",
  "owner_entity": "string",
  "bid_type": "GC|subcontractor|design-build|CM_at_risk|IDIQ|JOC",
  "submission_deadline": "ISO 8601 datetime or null",
  "submission_method": "email|portal|physical|electronic_only",
  "submission_address_or_portal": "string",
  "pre_bid_conference": {"date": "string", "mandatory": true/false, "location": "string"},
  "project_location": {"address": "string", "city": "string", "state": "string"},
  "estimated_budget_range": "string or null",
  "project_duration": "string or null",
  "required_sections": [
    {"section_name": "string", "page_limit": "int or null", "required": true/false, "description": "string"}
  ],
  "qualification_requirements": [
    {"requirement": "string", "details": "string", "mandatory": true/false}
  ],
  "bonding_requirements": {"bid_bond": "string", "performance_bond": "string", "payment_bond": "string"},
  "insurance_minimums": [
    {"type": "string", "minimum_amount": "string"}
  ],
  "dbe_mbe_goals": {"percentage": "float or null", "type": "DBE|MBE|WBE|SDVOSB|HUBZone", "details": "string"},
  "prevailing_wage": true/false,
  "evaluation_criteria": [
    {"criterion": "string", "weight_percentage": "float or null", "description": "string"}
  ],
  "page_limit_total": "int or null",
  "formatting_requirements": {"font": "string", "font_size": "string", "margins": "string", "other": "string"},
  "questions_deadline": "ISO 8601 datetime or null",
  "questions_contact": {"name": "string", "email": "string", "phone": "string"},
  "key_personnel_required": ["string"],
  "similar_project_experience_required": {"count": "int", "years": "int", "min_value": "string"},
  "special_requirements": ["string"],
  "addenda": [{"number": "int", "date": "string", "summary": "string"}]
}

If information is not found in this section, use null. Do NOT invent or assume information.'''),
            ('human', 'Extract requirements from this RFP section:\n\n{rfp_text}')
        ])

    def load_document(self, file_path: str) -> str:
        ext = os.path.splitext(file_path)[1].lower()
        if ext == '.pdf':
            loader = PyPDFLoader(file_path)
        elif ext in ('.docx', '.doc'):
            loader = Docx2txtLoader(file_path)
        else:
            raise ValueError(f'Unsupported file type: {ext}')
        pages = loader.load()
        return '\n\n'.join([p.page_content for p in pages])

    def parse_rfp(self, file_path: str) -> dict:
        full_text = self.load_document(file_path)
        chunks = self.text_splitter.split_text(full_text)
        
        # Process each chunk and merge results
        all_extractions = []
        for chunk in chunks:
            chain = self.extraction_prompt | self.llm
            result = chain.invoke({'rfp_text': chunk})
            try:
                parsed = json.loads(result.content)
                all_extractions.append(parsed)
            except json.JSONDecodeError:
                # Try to extract JSON from markdown code blocks
                content = result.content
                if '                    content = content.split('                    parsed = json.loads(content)
                    all_extractions.append(parsed)
        
        # Merge extractions (later chunks fill in nulls from earlier)
        merged = self._merge_extractions(all_extractions)
        merged['_metadata'] = {
            'source_file': os.path.basename(file_path),
            'parsed_at': datetime.utcnow().isoformat(),
            'chunk_count': len(chunks),
            'full_text_length': len(full_text)
        }
        return merged

    def _merge_extractions(self, extractions: list) -> dict:
        if not extractions:
            return {}
        merged = extractions[0].copy()
        for ext in extractions[1:]:
            for key, value in ext.items():
                if value is not None and (key not in merged or merged[key] is None):
                    merged[key] = value
                elif isinstance(value, list) and isinstance(merged.get(key), list):
                    # Merge lists, avoiding duplicates
                    existing = [json.dumps(item, sort_keys=True) for item in merged[key]]
                    for item in value:
                        if json.dumps(item, sort_keys=True) not in existing:
                            merged[key].append(item)
        return merged

    def generate_compliance_matrix(self, requirements: dict) -> list:
        matrix = []
        for section in requirements.get('required_sections', []):
            matrix.append({
                'requirement': section['section_name'],
                'type': 'section',
                'mandatory': section.get('required', True),
                'page_limit': section.get('page_limit'),
                'status': 'pending',
                'assigned_to': None,
                'notes': section.get('description', '')
            })
        for qual in requirements.get('qualification_requirements', []):
            matrix.append({
                'requirement': qual['requirement'],
                'type': 'qualification',
                'mandatory': qual.get('mandatory', True),
                'status': 'pending',
                'assigned_to': None,
                'notes': qual.get('details', '')
            })
        return matrix

Proposal Knowledge Base Ingestion Pipeline

Type: workflow Processes historical proposals, boilerplate content, certifications, and crew resumes into vector embeddings stored in Pinecone. Handles PDF and DOCX parsing, intelligent chunking by proposal section, metadata enrichment, and incremental updates. Designed to be run initially for bulk ingestion and then periodically as new proposals are completed. Implementation:

ingest_proposals.py
python
# ingest_proposals.py
import os
import sys
import argparse
import hashlib
import json
from datetime import datetime
from typing import List, Dict, Optional

from langchain_openai import AzureOpenAIEmbeddings
from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from pinecone import Pinecone
import tiktoken

class ProposalIngestionPipeline:
    def __init__(self):
        self.embeddings = AzureOpenAIEmbeddings(
            azure_deployment=os.environ.get('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-ada-002'),
            azure_endpoint=os.environ['AZURE_OPENAI_ENDPOINT'],
            api_key=os.environ['AZURE_OPENAI_API_KEY'],
            api_version='2025-03-01-preview'
        )
        self.pc = Pinecone(api_key=os.environ['PINECONE_API_KEY'])
        self.index = self.pc.Index(os.environ.get('PINECONE_INDEX', 'proposal-knowledge-base'))
        self.tokenizer = tiktoken.encoding_for_model('text-embedding-ada-002')
        
        # Construction proposal section headers for intelligent chunking
        self.section_markers = [
            'EXECUTIVE SUMMARY', 'COVER LETTER', 'TABLE OF CONTENTS',
            'PROJECT UNDERSTANDING', 'SCOPE OF WORK', 'APPROACH AND METHODOLOGY',
            'PROJECT SCHEDULE', 'SCHEDULE', 'TIMELINE',
            'QUALIFICATIONS', 'EXPERIENCE', 'FIRM QUALIFICATIONS',
            'KEY PERSONNEL', 'PROJECT TEAM', 'ORGANIZATIONAL CHART',
            'SIMILAR PROJECTS', 'RELEVANT EXPERIENCE', 'PROJECT REFERENCES',
            'SAFETY', 'SAFETY PLAN', 'SAFETY PROGRAM',
            'QUALITY CONTROL', 'QUALITY ASSURANCE', 'QA/QC PLAN',
            'PRICING', 'COST PROPOSAL', 'BID FORM', 'FEE PROPOSAL',
            'APPENDIX', 'ATTACHMENTS', 'EXHIBITS',
            'DBE PARTICIPATION', 'SUBCONTRACTING PLAN',
            'VALUE ENGINEERING', 'ALTERNATES',
            'WARRANTY', 'MAINTENANCE', 'CLOSEOUT'
        ]

    def load_document(self, file_path: str) -> str:
        ext = os.path.splitext(file_path)[1].lower()
        if ext == '.pdf':
            loader = PyPDFLoader(file_path)
        elif ext in ('.docx', '.doc'):
            loader = Docx2txtLoader(file_path)
        else:
            print(f'Skipping unsupported file: {file_path}')
            return ''
        pages = loader.load()
        return '\n\n'.join([p.page_content for p in pages])

    def intelligent_chunk(self, text: str, chunk_size: int = 1500, chunk_overlap: int = 200) -> List[Dict]:
        """Chunk by proposal section when possible, fall back to recursive splitting."""
        sections = []
        current_section = 'GENERAL'
        current_text = ''
        
        lines = text.split('\n')
        for line in lines:
            line_upper = line.strip().upper()
            matched_section = None
            for marker in self.section_markers:
                if marker in line_upper and len(line.strip()) < 100:
                    matched_section = marker
                    break
            
            if matched_section:
                if current_text.strip():
                    sections.append({'section': current_section, 'text': current_text.strip()})
                current_section = matched_section
                current_text = line + '\n'
            else:
                current_text += line + '\n'
        
        if current_text.strip():
            sections.append({'section': current_section, 'text': current_text.strip()})
        
        # Now chunk each section
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=['\n\n', '\n', '. ', ' ']
        )
        
        chunks = []
        for section in sections:
            sub_chunks = splitter.split_text(section['text'])
            for i, chunk in enumerate(sub_chunks):
                token_count = len(self.tokenizer.encode(chunk))
                chunks.append({
                    'text': chunk,
                    'section': section['section'],
                    'chunk_index': i,
                    'total_chunks_in_section': len(sub_chunks),
                    'token_count': token_count
                })
        
        return chunks

    def generate_doc_id(self, file_path: str, chunk_index: int) -> str:
        file_hash = hashlib.md5(file_path.encode()).hexdigest()[:12]
        return f'{file_hash}-{chunk_index:04d}'

    def ingest_file(self, file_path: str, namespace: str, metadata: Optional[Dict] = None) -> int:
        print(f'Processing: {os.path.basename(file_path)}')
        text = self.load_document(file_path)
        if not text:
            return 0
        
        chunks = self.intelligent_chunk(text)
        if not chunks:
            print(f'  No chunks generated for {file_path}')
            return 0
        
        # Generate embeddings in batches of 16
        batch_size = 16
        total_upserted = 0
        
        for i in range(0, len(chunks), batch_size):
            batch = chunks[i:i + batch_size]
            texts = [c['text'] for c in batch]
            embeddings = self.embeddings.embed_documents(texts)
            
            vectors = []
            for j, (chunk, embedding) in enumerate(zip(batch, embeddings)):
                doc_id = self.generate_doc_id(file_path, i + j)
                vec_metadata = {
                    'text': chunk['text'],
                    'section': chunk['section'],
                    'chunk_index': chunk['chunk_index'],
                    'source_file': os.path.basename(file_path),
                    'token_count': chunk['token_count'],
                    'ingested_at': datetime.utcnow().isoformat()
                }
                if metadata:
                    vec_metadata.update(metadata)
                
                vectors.append({
                    'id': doc_id,
                    'values': embedding,
                    'metadata': vec_metadata
                })
            
            self.index.upsert(vectors=vectors, namespace=namespace)
            total_upserted += len(vectors)
        
        print(f'  Ingested {total_upserted} chunks from {os.path.basename(file_path)}')
        return total_upserted

    def ingest_directory(self, directory: str, namespace: str, metadata: Optional[Dict] = None) -> int:
        total = 0
        for root, dirs, files in os.walk(directory):
            for file in files:
                if file.lower().endswith(('.pdf', '.docx', '.doc')):
                    file_path = os.path.join(root, file)
                    total += self.ingest_file(file_path, namespace, metadata)
        print(f'\nTotal chunks ingested: {total}')
        return total


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Ingest proposals into vector database')
    parser.add_argument('--directory', required=True, help='Directory containing proposal documents')
    parser.add_argument('--namespace', required=True, help='Pinecone namespace (e.g., won_proposals, boilerplate)')
    parser.add_argument('--project-type', help='Project type metadata (commercial, residential, etc.)')
    parser.add_argument('--bid-type', help='Bid type metadata (GC, subcontractor, design-build, etc.)')
    parser.add_argument('--outcome', default='won', help='Bid outcome (won, lost)')
    parser.add_argument('--chunk-size', type=int, default=1500)
    parser.add_argument('--chunk-overlap', type=int, default=200)
    args = parser.parse_args()
    
    metadata = {'outcome': args.outcome}
    if args.project_type:
        metadata['project_type'] = args.project_type
    if args.bid_type:
        metadata['bid_type'] = args.bid_type
    
    pipeline = ProposalIngestionPipeline()
    pipeline.ingest_directory(args.directory, args.namespace, metadata)

Proposal Section Generator (RAG Engine)

Type: agent The core AI agent that generates proposal sections using Retrieval-Augmented Generation. Takes an RFP requirements matrix, the desired proposal section, and optional context (project details, pricing guidance) and generates a draft section by retrieving the most relevant past proposal content from the vector database and using it as context for GPT-4.1 generation. Maintains the contractor's brand voice and technical accuracy.

proposal_generator.py
python
# proposal_generator.py
import os
import json
from typing import List, Dict, Optional
from langchain_openai import AzureChatOpenAI, AzureOpenAIEmbeddings
from langchain.prompts import ChatPromptTemplate
from pinecone import Pinecone

class ProposalSectionGenerator:
    def __init__(self):
        self.llm = AzureChatOpenAI(
            azure_deployment=os.environ['AZURE_OPENAI_DEPLOYMENT'],
            azure_endpoint=os.environ['AZURE_OPENAI_ENDPOINT'],
            api_key=os.environ['AZURE_OPENAI_API_KEY'],
            api_version='2025-03-01-preview',
            temperature=0.4,
            max_tokens=4096
        )
        self.embeddings = AzureOpenAIEmbeddings(
            azure_deployment=os.environ.get('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-ada-002'),
            azure_endpoint=os.environ['AZURE_OPENAI_ENDPOINT'],
            api_key=os.environ['AZURE_OPENAI_API_KEY'],
            api_version='2025-03-01-preview'
        )
        self.pc = Pinecone(api_key=os.environ['PINECONE_API_KEY'])
        self.index = self.pc.Index(os.environ.get('PINECONE_INDEX', 'proposal-knowledge-base'))

        # Section-specific prompt templates
        self.section_prompts = {
            'EXECUTIVE_SUMMARY': self._executive_summary_prompt(),
            'PROJECT_UNDERSTANDING': self._project_understanding_prompt(),
            'SCOPE_AND_APPROACH': self._scope_approach_prompt(),
            'QUALIFICATIONS': self._qualifications_prompt(),
            'SIMILAR_PROJECTS': self._similar_projects_prompt(),
            'SCHEDULE': self._schedule_prompt(),
            'SAFETY_PLAN': self._safety_plan_prompt(),
            'KEY_PERSONNEL': self._key_personnel_prompt(),
            'COVER_LETTER': self._cover_letter_prompt(),
            'GENERAL': self._general_section_prompt()
        }

    def retrieve_context(self, query: str, namespaces: List[str], 
                         top_k: int = 8, filter_dict: Optional[Dict] = None) -> List[Dict]:
        query_embedding = self.embeddings.embed_query(query)
        all_results = []
        for namespace in namespaces:
            results = self.index.query(
                vector=query_embedding,
                top_k=top_k,
                namespace=namespace,
                include_metadata=True,
                filter=filter_dict
            )
            all_results.extend([
                {
                    'text': match.metadata.get('text', ''),
                    'section': match.metadata.get('section', 'UNKNOWN'),
                    'source': match.metadata.get('source_file', 'unknown'),
                    'score': match.score,
                    'namespace': namespace
                }
                for match in results.matches
            ])
        # Sort by relevance score and deduplicate
        all_results.sort(key=lambda x: x['score'], reverse=True)
        return all_results[:top_k * 2]

    def generate_section(self, section_type: str, rfp_requirements: Dict,
                         project_context: Optional[str] = None,
                         additional_instructions: Optional[str] = None) -> Dict:
        # Build retrieval query from RFP requirements
        query_parts = [
            rfp_requirements.get('project_name', ''),
            rfp_requirements.get('project_type', ''),
            section_type.replace('_', ' ')
        ]
        if project_context:
            query_parts.append(project_context[:500])
        retrieval_query = ' '.join(filter(None, query_parts))

        # Determine which namespaces to search
        namespace_map = {
            'EXECUTIVE_SUMMARY': ['won_proposals', 'boilerplate'],
            'PROJECT_UNDERSTANDING': ['won_proposals'],
            'SCOPE_AND_APPROACH': ['won_proposals', 'boilerplate'],
            'QUALIFICATIONS': ['won_proposals', 'certifications', 'boilerplate'],
            'SIMILAR_PROJECTS': ['won_proposals'],
            'SCHEDULE': ['won_proposals'],
            'SAFETY_PLAN': ['boilerplate', 'won_proposals'],
            'KEY_PERSONNEL': ['resumes', 'won_proposals'],
            'COVER_LETTER': ['won_proposals', 'boilerplate'],
            'GENERAL': ['won_proposals', 'boilerplate']
        }
        namespaces = namespace_map.get(section_type, ['won_proposals', 'boilerplate'])

        # Retrieve relevant context
        context_docs = self.retrieve_context(retrieval_query, namespaces)
        context_text = '\n\n---\n\n'.join([
            f"[Source: {doc['source']} | Section: {doc['section']} | Relevance: {doc['score']:.2f}]\n{doc['text']}"
            for doc in context_docs
        ])

        # Select prompt template
        prompt_template = self.section_prompts.get(section_type, self.section_prompts['GENERAL'])

        # Build the full prompt
        chain = prompt_template | self.llm
        result = chain.invoke({
            'rfp_requirements': json.dumps(rfp_requirements, indent=2),
            'context': context_text,
            'project_context': project_context or 'No additional project context provided.',
            'additional_instructions': additional_instructions or 'None.',
            'section_type': section_type.replace('_', ' ').title()
        })

        return {
            'section_type': section_type,
            'content': result.content,
            'sources_used': [doc['source'] for doc in context_docs[:5]],
            'retrieval_scores': [doc['score'] for doc in context_docs[:5]],
            'token_count': len(result.content.split()) * 1.3  # rough estimate
        }

    def generate_full_proposal(self, rfp_requirements: Dict,
                               project_context: Optional[str] = None) -> Dict:
        sections_to_generate = []
        for req_section in rfp_requirements.get('required_sections', []):
            section_name = req_section.get('section_name', '').upper().replace(' ', '_')
            # Map RFP section names to our section types
            mapped = self._map_section_name(section_name)
            sections_to_generate.append({
                'type': mapped,
                'original_name': req_section.get('section_name'),
                'page_limit': req_section.get('page_limit'),
                'description': req_section.get('description', '')
            })
        
        if not sections_to_generate:
            # Default proposal structure
            sections_to_generate = [
                {'type': 'COVER_LETTER', 'original_name': 'Cover Letter', 'page_limit': 1},
                {'type': 'EXECUTIVE_SUMMARY', 'original_name': 'Executive Summary', 'page_limit': 2},
                {'type': 'PROJECT_UNDERSTANDING', 'original_name': 'Project Understanding', 'page_limit': 3},
                {'type': 'SCOPE_AND_APPROACH', 'original_name': 'Scope and Approach', 'page_limit': 5},
                {'type': 'QUALIFICATIONS', 'original_name': 'Firm Qualifications', 'page_limit': 3},
                {'type': 'SIMILAR_PROJECTS', 'original_name': 'Similar Project Experience', 'page_limit': 4},
                {'type': 'KEY_PERSONNEL', 'original_name': 'Key Personnel', 'page_limit': 4},
                {'type': 'SCHEDULE', 'original_name': 'Project Schedule', 'page_limit': 2},
                {'type': 'SAFETY_PLAN', 'original_name': 'Safety Plan', 'page_limit': 2}
            ]

        proposal = {
            'project_name': rfp_requirements.get('project_name', 'Untitled Project'),
            'generated_at': __import__('datetime').datetime.utcnow().isoformat(),
            'sections': []
        }

        for section_info in sections_to_generate:
            page_instruction = ''
            if section_info.get('page_limit'):
                page_instruction = f'Target approximately {section_info["page_limit"]} pages (roughly {section_info["page_limit"] * 400} words).'
            
            result = self.generate_section(
                section_type=section_info['type'],
                rfp_requirements=rfp_requirements,
                project_context=project_context,
                additional_instructions=f"{page_instruction} {section_info.get('description', '')}"
            )
            result['original_section_name'] = section_info['original_name']
            result['page_limit'] = section_info.get('page_limit')
            proposal['sections'].append(result)

        return proposal

    def _map_section_name(self, name: str) -> str:
        mapping = {
            'COVER_LETTER': 'COVER_LETTER', 'LETTER_OF_TRANSMITTAL': 'COVER_LETTER',
            'EXECUTIVE_SUMMARY': 'EXECUTIVE_SUMMARY', 'SUMMARY': 'EXECUTIVE_SUMMARY',
            'PROJECT_UNDERSTANDING': 'PROJECT_UNDERSTANDING', 'UNDERSTANDING': 'PROJECT_UNDERSTANDING',
            'SCOPE': 'SCOPE_AND_APPROACH', 'APPROACH': 'SCOPE_AND_APPROACH',
            'SCOPE_OF_WORK': 'SCOPE_AND_APPROACH', 'METHODOLOGY': 'SCOPE_AND_APPROACH',
            'SCOPE_AND_APPROACH': 'SCOPE_AND_APPROACH',
            'QUALIFICATIONS': 'QUALIFICATIONS', 'FIRM_QUALIFICATIONS': 'QUALIFICATIONS',
            'EXPERIENCE': 'QUALIFICATIONS',
            'SIMILAR_PROJECTS': 'SIMILAR_PROJECTS', 'RELEVANT_EXPERIENCE': 'SIMILAR_PROJECTS',
            'PROJECT_REFERENCES': 'SIMILAR_PROJECTS', 'REFERENCES': 'SIMILAR_PROJECTS',
            'SCHEDULE': 'SCHEDULE', 'PROJECT_SCHEDULE': 'SCHEDULE', 'TIMELINE': 'SCHEDULE',
            'SAFETY': 'SAFETY_PLAN', 'SAFETY_PLAN': 'SAFETY_PLAN', 'SAFETY_PROGRAM': 'SAFETY_PLAN',
            'KEY_PERSONNEL': 'KEY_PERSONNEL', 'PROJECT_TEAM': 'KEY_PERSONNEL',
            'ORGANIZATIONAL_CHART': 'KEY_PERSONNEL', 'TEAM': 'KEY_PERSONNEL'
        }
        return mapping.get(name, 'GENERAL')

    def _executive_summary_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting an Executive Summary for a bid proposal.

RULES:
- Write in first person plural ("we", "our firm", "our team")
- Lead with the most compelling differentiator (safety record, local presence, similar project experience)
- Reference specific project details from the RFP (project name, owner, location)
- Include 1-2 sentences about relevant past project successes with specific metrics (delivered $X project Y% under budget, Z days ahead of schedule)
- Close with a confident but professional commitment statement
- Match the tone and style of the reference materials provided
- DO NOT fabricate specific numbers, project names, or personnel names — use placeholders like [INSERT PROJECT NAME] if not available in context
- DO NOT include pricing or cost information in the executive summary

Here are excerpts from our past winning proposals for reference on tone, style, and content:\n{context}'''),
            ('human', '''Generate an Executive Summary for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _project_understanding_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Project Understanding section.

RULES:
- Demonstrate deep understanding of the owner\'s goals, not just repeating the scope
- Identify potential challenges specific to the project (site access, phasing, weather, utilities, adjacent operations)
- Show how your firm\'s experience with similar projects informs your understanding
- Reference specific RFP requirements to show thorough reading
- Use construction industry terminology accurately
- DO NOT fabricate details — use placeholders if information is not in the context

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate a Project Understanding section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _scope_approach_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Scope of Work and Approach/Methodology section.

RULES:
- Organize by construction phases (Pre-Construction, Mobilization, Construction, Closeout)
- Address each scope item from the RFP requirements explicitly
- Include specific methodologies for key activities (excavation, concrete, structural steel, MEP coordination, etc.)
- Describe your QA/QC approach for each major trade
- Address phasing and sequencing if multiple buildings or areas
- Reference BIM coordination, prefabrication, or lean construction practices where applicable
- DO NOT include pricing — focus on HOW the work will be done
- Use placeholders for specific details not in context

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate a Scope and Approach section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _qualifications_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Firm Qualifications section.

RULES:
- Lead with years in business, bonding capacity, and current insurance coverage
- Highlight relevant licenses and certifications
- Include EMR (Experience Modification Rate) and safety statistics
- Reference number and value of similar projects completed
- Mention any relevant DBE/MBE/WBE/SDVOSB certifications
- Include notable awards or recognitions
- Reference financial stability indicators (bonding capacity, banking relationships)
- Use actual data from the context; use [INSERT] placeholders for missing info

Reference materials from past proposals and certifications:\n{context}'''),
            ('human', '''Generate a Firm Qualifications section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _similar_projects_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Similar Projects / Relevant Experience section.

RULES:
- Present each project in a consistent format: Project Name, Owner, Location, Contract Value, Completion Date, Scope Description, Key Challenges, Outcomes
- Select projects most similar to the RFP\'s project type, size, and complexity
- Include quantifiable results (on-time delivery, budget adherence, safety record, change order percentage)
- Include owner reference contact information where available
- Aim for 3-5 similar projects unless the RFP specifies a different number
- Match the required experience (years, value, type) from the RFP evaluation criteria
- Use actual project data from context; use [INSERT] for missing details

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate a Similar Projects section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _schedule_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Project Schedule section.

RULES:
- Describe your scheduling methodology (CPM, Last Planner System, pull planning)
- Break the project into major milestones and phases
- Address the RFP\'s required completion date explicitly
- Discuss schedule risk mitigation (weather days, long-lead items, permit timelines)
- Reference your use of scheduling software (Primavera P6, Microsoft Project, Procore)
- Mention look-ahead scheduling and progress tracking approach
- Note: Do NOT create an actual Gantt chart — describe the approach and key milestones in narrative form

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate a Project Schedule section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _safety_plan_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Safety Plan section.

RULES:
- Lead with your firm\'s EMR (Experience Modification Rate) and OSHA recordable rates
- Reference your OSHA compliance program and any VPP Star status
- Describe site-specific safety planning process
- Address key hazards for this project type (fall protection, excavation, confined space, electrical, crane operations)
- Describe your safety training and orientation programs
- Mention drug testing policies and requirements
- Address COVID-19 / infectious disease protocols if relevant
- Reference safety technology (wearables, drone inspections, AI monitoring)
- Use actual safety statistics from context; use [INSERT] for missing data

Reference materials from past proposals and safety programs:\n{context}'''),
            ('human', '''Generate a Safety Plan section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _key_personnel_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the Key Personnel section.

RULES:
- Present each key team member in a consistent format: Name, Title, Years of Experience, Relevant Certifications, Education, Similar Project Experience
- Include: Project Executive, Project Manager, Superintendent, Safety Manager, and any other roles required by the RFP
- Highlight relevant certifications (PE, PMP, LEED AP, OSHA 30, CPR/First Aid)
- Reference specific similar projects each person has worked on
- Describe the reporting structure and communication plan
- Use actual personnel data from context; use [INSERT NAME/TITLE] for missing info

Reference materials from resumes and past proposals:\n{context}'''),
            ('human', '''Generate a Key Personnel section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _cover_letter_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting a Cover Letter / Letter of Transmittal.

RULES:
- Address to the specific person/entity named in the RFP
- Reference the RFP number, project name, and submission date
- Express enthusiasm for the project in 1-2 sentences
- Briefly state your top 3 differentiators (2-3 sentences total)
- Acknowledge all addenda received
- Include required statements (non-collusion, no conflict of interest) if required by the RFP
- Sign off with the company principal\'s name and title
- Keep to 1 page maximum
- Use actual company details from context

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate a Cover Letter for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

    def _general_section_prompt(self) -> ChatPromptTemplate:
        return ChatPromptTemplate.from_messages([
            ('system', '''You are an expert construction proposal writer drafting the {section_type} section of a bid proposal.

RULES:
- Write in first person plural ("we", "our firm", "our team")
- Use construction industry terminology accurately
- Reference specific RFP requirements to demonstrate compliance
- Draw from past proposal language and style in the reference materials
- DO NOT fabricate specific details — use [INSERT] placeholders for missing information
- Match the professional tone of past winning proposals

Reference materials from past proposals:\n{context}'''),
            ('human', '''Generate the {section_type} section for this RFP:\n\nRFP Requirements:\n{rfp_requirements}\n\nAdditional Project Context:\n{project_context}\n\nSpecial Instructions:\n{additional_instructions}''')
        ])

Executive Summary Prompt

You are an expert construction proposal writer drafting an Executive Summary for a bid proposal. RULES: - Write in first person plural ("we", "our firm", "our team") - Lead with the most compelling differentiator (safety record, local presence, similar project experience) - Reference specific project details from the RFP (project name, owner, location) - Include 1-2 sentences about relevant past project successes with specific metrics (delivered $X project Y% under budget, Z days ahead of schedule) - Close with a confident but professional commitment statement - Match the tone and style of the reference materials provided - DO NOT fabricate specific numbers, project names, or personnel names — use placeholders like [INSERT PROJECT NAME] if not available in context - DO NOT include pricing or cost information in the executive summary Here are excerpts from our past winning proposals for reference on tone, style, and content: {context} --- Generate an Executive Summary for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Project Understanding Prompt

You are an expert construction proposal writer drafting the Project Understanding section. RULES: - Demonstrate deep understanding of the owner's goals, not just repeating the scope - Identify potential challenges specific to the project (site access, phasing, weather, utilities, adjacent operations) - Show how your firm's experience with similar projects informs your understanding - Reference specific RFP requirements to show thorough reading - Use construction industry terminology accurately - DO NOT fabricate details — use placeholders if information is not in the context Reference materials from past proposals: {context} --- Generate a Project Understanding section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Scope and Approach Prompt

You are an expert construction proposal writer drafting the Scope of Work and Approach/Methodology section. RULES: - Organize by construction phases (Pre-Construction, Mobilization, Construction, Closeout) - Address each scope item from the RFP requirements explicitly - Include specific methodologies for key activities (excavation, concrete, structural steel, MEP coordination, etc.) - Describe your QA/QC approach for each major trade - Address phasing and sequencing if multiple buildings or areas - Reference BIM coordination, prefabrication, or lean construction practices where applicable - DO NOT include pricing — focus on HOW the work will be done - Use placeholders for specific details not in context Reference materials from past proposals: {context} --- Generate a Scope and Approach section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Firm Qualifications Prompt

You are an expert construction proposal writer drafting the Firm Qualifications section. RULES: - Lead with years in business, bonding capacity, and current insurance coverage - Highlight relevant licenses and certifications - Include EMR (Experience Modification Rate) and safety statistics - Reference number and value of similar projects completed - Mention any relevant DBE/MBE/WBE/SDVOSB certifications - Include notable awards or recognitions - Reference financial stability indicators (bonding capacity, banking relationships) - Use actual data from the context; use [INSERT] placeholders for missing info Reference materials from past proposals and certifications: {context} --- Generate a Firm Qualifications section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Similar Projects Prompt

You are an expert construction proposal writer drafting the Similar Projects / Relevant Experience section. RULES: - Present each project in a consistent format: Project Name, Owner, Location, Contract Value, Completion Date, Scope Description, Key Challenges, Outcomes - Select projects most similar to the RFP's project type, size, and complexity - Include quantifiable results (on-time delivery, budget adherence, safety record, change order percentage) - Include owner reference contact information where available - Aim for 3-5 similar projects unless the RFP specifies a different number - Match the required experience (years, value, type) from the RFP evaluation criteria - Use actual project data from context; use [INSERT] for missing details Reference materials from past proposals: {context} --- Generate a Similar Projects section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Project Schedule Prompt

You are an expert construction proposal writer drafting the Project Schedule section. RULES: - Describe your scheduling methodology (CPM, Last Planner System, pull planning) - Break the project into major milestones and phases - Address the RFP's required completion date explicitly - Discuss schedule risk mitigation (weather days, long-lead items, permit timelines) - Reference your use of scheduling software (Primavera P6, Microsoft Project, Procore) - Mention look-ahead scheduling and progress tracking approach - Note: Do NOT create an actual Gantt chart — describe the approach and key milestones in narrative form Reference materials from past proposals: {context} --- Generate a Project Schedule section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Safety Plan Prompt

You are an expert construction proposal writer drafting the Safety Plan section. RULES: - Lead with your firm's EMR (Experience Modification Rate) and OSHA recordable rates - Reference your OSHA compliance program and any VPP Star status - Describe site-specific safety planning process - Address key hazards for this project type (fall protection, excavation, confined space, electrical, crane operations) - Describe your safety training and orientation programs - Mention drug testing policies and requirements - Address COVID-19 / infectious disease protocols if relevant - Reference safety technology (wearables, drone inspections, AI monitoring) - Use actual safety statistics from context; use [INSERT] for missing data Reference materials from past proposals and safety programs: {context} --- Generate a Safety Plan section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Key Personnel Prompt

You are an expert construction proposal writer drafting the Key Personnel section. RULES: - Present each key team member in a consistent format: Name, Title, Years of Experience, Relevant Certifications, Education, Similar Project Experience - Include: Project Executive, Project Manager, Superintendent, Safety Manager, and any other roles required by the RFP - Highlight relevant certifications (PE, PMP, LEED AP, OSHA 30, CPR/First Aid) - Reference specific similar projects each person has worked on - Describe the reporting structure and communication plan - Use actual personnel data from context; use [INSERT NAME/TITLE] for missing info Reference materials from resumes and past proposals: {context} --- Generate a Key Personnel section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

Cover Letter Prompt

You are an expert construction proposal writer drafting a Cover Letter / Letter of Transmittal. RULES: - Address to the specific person/entity named in the RFP - Reference the RFP number, project name, and submission date - Express enthusiasm for the project in 1-2 sentences - Briefly state your top 3 differentiators (2-3 sentences total) - Acknowledge all addenda received - Include required statements (non-collusion, no conflict of interest) if required by the RFP - Sign off with the company principal's name and title - Keep to 1 page maximum - Use actual company details from context Reference materials from past proposals: {context} --- Generate a Cover Letter for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

General Section Prompt

You are an expert construction proposal writer drafting the {section_type} section of a bid proposal. RULES: - Write in first person plural ("we", "our firm", "our team") - Use construction industry terminology accurately - Reference specific RFP requirements to demonstrate compliance - Draw from past proposal language and style in the reference materials - DO NOT fabricate specific details — use [INSERT] placeholders for missing information - Match the professional tone of past winning proposals Reference materials from past proposals: {context} --- Generate the {section_type} section for this RFP: RFP Requirements: {rfp_requirements} Additional Project Context: {project_context} Special Instructions: {additional_instructions}
Sonnet 4.6

REST API Application (FastAPI)

Type: integration The FastAPI web application that serves as the REST API for the entire proposal generation system. Exposes endpoints for RFP upload and parsing, proposal generation, knowledge base management, Procore data sync, and PandaDoc document creation. Deployed on Azure App Service.

Implementation:

app.py - Main FastAPI application
python
# app.py - Main FastAPI application
import os
import json
import tempfile
from datetime import datetime
from typing import Optional, List
from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from rfp_parser import RFPParser
from proposal_generator import ProposalSectionGenerator
from ingest_proposals import ProposalIngestionPipeline

app = FastAPI(
    title='Construction Proposal AI API',
    description='AI-powered RFP response and bid proposal generation for construction contractors',
    version='1.0.0'
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[os.environ.get('ALLOWED_ORIGINS', 'https://*.azurewebsites.net').split(',')],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*']
)

rfp_parser = RFPParser()
proposal_generator = ProposalSectionGenerator()
ingestion_pipeline = ProposalIngestionPipeline()


class GenerateSectionRequest(BaseModel):
    section_type: str
    rfp_requirements: dict
    project_context: Optional[str] = None
    additional_instructions: Optional[str] = None


class GenerateFullProposalRequest(BaseModel):
    rfp_requirements: dict
    project_context: Optional[str] = None
    sections: Optional[List[str]] = None


class IngestRequest(BaseModel):
    file_url: str
    namespace: str
    metadata: Optional[dict] = None


@app.get('/health')
async def health_check():
    return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}


@app.post('/api/rfp/parse')
async def parse_rfp(file: UploadFile = File(...)):
    """Upload and parse an RFP document to extract requirements."""
    if not file.filename.lower().endswith(('.pdf', '.docx', '.doc')):
        raise HTTPException(status_code=400, detail='Unsupported file type. Upload PDF or DOCX.')
    
    with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename)[1]) as tmp:
        content = await file.read()
        tmp.write(content)
        tmp_path = tmp.name
    
    try:
        requirements = rfp_parser.parse_rfp(tmp_path)
        compliance_matrix = rfp_parser.generate_compliance_matrix(requirements)
        return {
            'requirements': requirements,
            'compliance_matrix': compliance_matrix,
            'parsed_at': datetime.utcnow().isoformat()
        }
    finally:
        os.unlink(tmp_path)


@app.post('/api/proposal/section')
async def generate_section(request: GenerateSectionRequest):
    """Generate a single proposal section."""
    valid_sections = [
        'EXECUTIVE_SUMMARY', 'PROJECT_UNDERSTANDING', 'SCOPE_AND_APPROACH',
        'QUALIFICATIONS', 'SIMILAR_PROJECTS', 'SCHEDULE', 'SAFETY_PLAN',
        'KEY_PERSONNEL', 'COVER_LETTER', 'GENERAL'
    ]
    if request.section_type.upper() not in valid_sections:
        raise HTTPException(status_code=400, detail=f'Invalid section type. Valid types: {valid_sections}')
    
    result = proposal_generator.generate_section(
        section_type=request.section_type.upper(),
        rfp_requirements=request.rfp_requirements,
        project_context=request.project_context,
        additional_instructions=request.additional_instructions
    )
    return result


@app.post('/api/proposal/full')
async def generate_full_proposal(request: GenerateFullProposalRequest):
    """Generate a complete proposal from RFP requirements."""
    result = proposal_generator.generate_full_proposal(
        rfp_requirements=request.rfp_requirements,
        project_context=request.project_context
    )
    return result


@app.post('/api/knowledge-base/ingest')
async def ingest_document(file: UploadFile = File(...), namespace: str = 'won_proposals',
                          project_type: Optional[str] = None, bid_type: Optional[str] = None,
                          outcome: str = 'won'):
    """Ingest a document into the proposal knowledge base."""
    with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename)[1]) as tmp:
        content = await file.read()
        tmp.write(content)
        tmp_path = tmp.name
    
    try:
        metadata = {'outcome': outcome}
        if project_type:
            metadata['project_type'] = project_type
        if bid_type:
            metadata['bid_type'] = bid_type
        
        count = ingestion_pipeline.ingest_file(tmp_path, namespace, metadata)
        return {
            'status': 'success',
            'chunks_ingested': count,
            'namespace': namespace,
            'file': file.filename
        }
    finally:
        os.unlink(tmp_path)


@app.get('/api/knowledge-base/stats')
async def knowledge_base_stats():
    """Get statistics about the knowledge base."""
    stats = ingestion_pipeline.index.describe_index_stats()
    return {
        'total_vectors': stats.total_vector_count,
        'namespaces': {ns: data.vector_count for ns, data in stats.namespaces.items()},
        'dimension': stats.dimension
    }
requirements.txt
text
fastapi==0.115.0
uvicorn==0.30.0
python-multipart==0.0.9
langchain==0.3.0
langchain-openai==0.2.0
langchain-community==0.3.0
pinecone-client==5.0.0
python-docx==1.1.0
PyPDF2==3.0.1
tiktoken==0.7.0
pydantic==2.9.0
Procfile for Azure App Service
text
web: uvicorn app:app --host 0.0.0.0 --port 8000

Go/No-Go Decision Analyzer

Type: skill Analyzes an incoming RFP against the contractor's capabilities, current workload, and strategic priorities to produce a data-driven Go/No-Go recommendation. Scores the opportunity across multiple dimensions including project fit, competition level, resource availability, profitability potential, and strategic alignment.

Implementation:

go_no_go_analyzer.py
python
# go_no_go_analyzer.py
import os
import json
from langchain_openai import AzureChatOpenAI
from langchain.prompts import ChatPromptTemplate
from typing import Dict, Optional

class GoNoGoAnalyzer:
    def __init__(self):
        self.llm = AzureChatOpenAI(
            azure_deployment=os.environ['AZURE_OPENAI_DEPLOYMENT'],
            azure_endpoint=os.environ['AZURE_OPENAI_ENDPOINT'],
            api_key=os.environ['AZURE_OPENAI_API_KEY'],
            api_version='2025-03-01-preview',
            temperature=0.2,
            max_tokens=3000
        )
        self.prompt = ChatPromptTemplate.from_messages([
            ('system', '''You are a construction business development advisor performing a Go/No-Go analysis on an RFP opportunity.

Analyze the RFP requirements against the contractor profile and produce a structured recommendation.

Score each dimension from 1-10 (10 = strongest fit) and provide a brief justification.

Dimensions to evaluate:
1. PROJECT TYPE FIT: How well does this project type match the firm's core competencies?
2. SIZE/VALUE FIT: Is the project value in the firm's sweet spot? Not too small (overhead ratio) or too large (bonding/capacity)?
3. GEOGRAPHIC FIT: Is the project location within the firm's operating radius? Are they licensed in that jurisdiction?
4. SCHEDULE FIT: Can the firm meet the timeline given current backlog and resource availability?
5. QUALIFICATION MATCH: Does the firm meet all mandatory qualification requirements (certifications, experience, similar projects)?
6. RELATIONSHIP: Does the firm have an existing relationship with the owner, architect, or CM?
7. COMPETITION LEVEL: How competitive is this bid likely to be? Are there incumbent advantages?
8. PROFITABILITY POTENTIAL: Based on project type and delivery method, what's the likely margin?
9. STRATEGIC VALUE: Does this project open new markets, clients, or project types?
10. RESOURCE AVAILABILITY: Are the key personnel and crews available for this project?

Return a JSON object with this structure:
{
  "recommendation": "GO" | "NO-GO" | "CONDITIONAL GO",
  "overall_score": float (average of all dimension scores),
  "confidence": "HIGH" | "MEDIUM" | "LOW",
  "dimensions": [
    {"name": "string", "score": int, "justification": "string"}
  ],
  "key_risks": ["string"],
  "key_strengths": ["string"],
  "conditions": ["string (only if CONDITIONAL GO)"],
  "estimated_proposal_effort_hours": int,
  "estimated_win_probability": "HIGH (>40%) | MEDIUM (20-40%) | LOW (<20%)",
  "summary": "2-3 sentence recommendation summary"
}

Guidelines:
- Recommend GO if overall score >= 6.5
- Recommend NO-GO if overall score < 4.5
- Recommend CONDITIONAL GO if 4.5-6.5 with identifiable mitigations
- Be realistic about win probability — most competitive bids have <25% win rates
- Factor in the opportunity cost of pursuing this bid vs. other opportunities'''),
            ('human', '''Analyze this opportunity:\n\nRFP Requirements:\n{rfp_requirements}\n\nContractor Profile:\n{contractor_profile}\n\nCurrent Backlog & Resources:\n{current_status}\n\nAdditional Context:\n{additional_context}''')
        ])

    def analyze(self, rfp_requirements: Dict, contractor_profile: Dict,
                current_status: Optional[str] = None,
                additional_context: Optional[str] = None) -> Dict:
        chain = self.prompt | self.llm
        result = chain.invoke({
            'rfp_requirements': json.dumps(rfp_requirements, indent=2),
            'contractor_profile': json.dumps(contractor_profile, indent=2),
            'current_status': current_status or 'No current backlog information provided.',
            'additional_context': additional_context or 'None.'
        })
        
        try:
            analysis = json.loads(result.content)
        except json.JSONDecodeError:
            content = result.content
            if '```json' in content:
                content = content.split('```json')[1].split('```')[0].strip()
                analysis = json.loads(content)
            else:
                analysis = {'error': 'Failed to parse analysis', 'raw': result.content}
        
        return analysis


# Example contractor_profile structure:
# {
#   "company_name": "ABC Construction Co.",
#   "years_in_business": 25,
#   "annual_revenue": "$35M",
#   "bonding_capacity": "$20M single / $50M aggregate",
#   "emr": 0.78,
#   "core_competencies": ["K-12 Education", "Healthcare", "Commercial Office", "Multifamily"],
#   "licenses": ["CA Class A General", "NV Class A General"],
#   "certifications": ["LEED AP on staff", "OSHA VPP Star"],
#   "operating_radius_miles": 150,
#   "home_office_city": "Sacramento, CA",
#   "avg_project_size": "$2M-$15M",
#   "current_backlog": "$45M",
#   "available_supers": 2,
#   "available_pms": 1
# }

Go/No-Go Analysis System Prompt

You are a construction business development advisor performing a Go/No-Go analysis on an RFP opportunity. Analyze the RFP requirements against the contractor profile and produce a structured recommendation. Score each dimension from 1-10 (10 = strongest fit) and provide a brief justification. Dimensions to evaluate: 1. PROJECT TYPE FIT: How well does this project type match the firm's core competencies? 2. SIZE/VALUE FIT: Is the project value in the firm's sweet spot? Not too small (overhead ratio) or too large (bonding/capacity)? 3. GEOGRAPHIC FIT: Is the project location within the firm's operating radius? Are they licensed in that jurisdiction? 4. SCHEDULE FIT: Can the firm meet the timeline given current backlog and resource availability? 5. QUALIFICATION MATCH: Does the firm meet all mandatory qualification requirements (certifications, experience, similar projects)? 6. RELATIONSHIP: Does the firm have an existing relationship with the owner, architect, or CM? 7. COMPETITION LEVEL: How competitive is this bid likely to be? Are there incumbent advantages? 8. PROFITABILITY POTENTIAL: Based on project type and delivery method, what's the likely margin? 9. STRATEGIC VALUE: Does this project open new markets, clients, or project types? 10. RESOURCE AVAILABILITY: Are the key personnel and crews available for this project? Return a JSON object with this structure: { "recommendation": "GO" | "NO-GO" | "CONDITIONAL GO", "overall_score": float (average of all dimension scores), "confidence": "HIGH" | "MEDIUM" | "LOW", "dimensions": [ {"name": "string", "score": int, "justification": "string"} ], "key_risks": ["string"], "key_strengths": ["string"], "conditions": ["string (only if CONDITIONAL GO)"], "estimated_proposal_effort_hours": int, "estimated_win_probability": "HIGH (>40%) | MEDIUM (20-40%) | LOW (<20%)", "summary": "2-3 sentence recommendation summary" } Guidelines: - Recommend GO if overall score >= 6.5 - Recommend NO-GO if overall score < 4.5 - Recommend CONDITIONAL GO if 4.5-6.5 with identifiable mitigations - Be realistic about win probability — most competitive bids have <25% win rates - Factor in the opportunity cost of pursuing this bid vs. other opportunities
Sonnet 4.6

Proposal Output Formatter (Word/PandaDoc)

Type: workflow Takes the AI-generated proposal sections and formats them into a professional Word document using the contractor's branded template, or pushes them to PandaDoc via API for final formatting and e-signature. Handles page numbering, table of contents, headers/footers, logo placement, and section formatting.

Implementation:

proposal_formatter.py
python
# proposal_formatter.py
import os
import json
import requests
from datetime import datetime
from typing import Dict, List, Optional
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.section import WD_ORIENT

class ProposalWordFormatter:
    def __init__(self, template_path: Optional[str] = None):
        if template_path and os.path.exists(template_path):
            self.doc = Document(template_path)
        else:
            self.doc = Document()
            self._setup_default_styles()

    def _setup_default_styles(self):
        style = self.doc.styles['Normal']
        font = style.font
        font.name = 'Calibri'
        font.size = Pt(11)
        font.color.rgb = RGBColor(0x33, 0x33, 0x33)
        
        paragraph_format = style.paragraph_format
        paragraph_format.space_after = Pt(6)
        paragraph_format.line_spacing = 1.15

        for level in range(1, 4):
            heading_style = self.doc.styles[f'Heading {level}']
            heading_style.font.name = 'Calibri'
            heading_style.font.bold = True
            if level == 1:
                heading_style.font.size = Pt(18)
                heading_style.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)  # Dark blue
            elif level == 2:
                heading_style.font.size = Pt(14)
                heading_style.font.color.rgb = RGBColor(0x2C, 0x5F, 0x8A)
            elif level == 3:
                heading_style.font.size = Pt(12)
                heading_style.font.color.rgb = RGBColor(0x3D, 0x85, 0xC6)

    def add_cover_page(self, project_name: str, owner: str,
                       company_name: str, date: str,
                       logo_path: Optional[str] = None):
        if logo_path and os.path.exists(logo_path):
            paragraph = self.doc.add_paragraph()
            paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
            run = paragraph.add_run()
            run.add_picture(logo_path, width=Inches(3))
        
        self.doc.add_paragraph()  # Spacer
        self.doc.add_paragraph()  # Spacer
        
        title = self.doc.add_paragraph()
        title.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = title.add_run('PROPOSAL FOR')
        run.font.size = Pt(14)
        run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
        
        project = self.doc.add_paragraph()
        project.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = project.add_run(project_name.upper())
        run.font.size = Pt(24)
        run.font.bold = True
        run.font.color.rgb = RGBColor(0x1B, 0x3A, 0x5C)
        
        self.doc.add_paragraph()  # Spacer
        
        submitted = self.doc.add_paragraph()
        submitted.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = submitted.add_run(f'Submitted to: {owner}')
        run.font.size = Pt(14)
        
        self.doc.add_paragraph()  # Spacer
        
        by = self.doc.add_paragraph()
        by.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = by.add_run(f'Prepared by: {company_name}')
        run.font.size = Pt(14)
        
        date_p = self.doc.add_paragraph()
        date_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = date_p.add_run(date)
        run.font.size = Pt(12)
        
        self.doc.add_page_break()

    def add_table_of_contents(self, sections: List[Dict]):
        self.doc.add_heading('Table of Contents', level=1)
        for i, section in enumerate(sections, 1):
            toc_entry = self.doc.add_paragraph()
            toc_entry.style = self.doc.styles['Normal']
            run = toc_entry.add_run(f'{i}. {section.get("original_section_name", section["section_type"])}')
            run.font.size = Pt(12)
        self.doc.add_page_break()

    def add_section(self, title: str, content: str, heading_level: int = 1):
        self.doc.add_heading(title, level=heading_level)
        paragraphs = content.split('\n\n')
        for para_text in paragraphs:
            if para_text.strip():
                if para_text.strip().startswith(('- ', '• ', '* ')):
                    for line in para_text.strip().split('\n'):
                        line = line.lstrip('-•* ').strip()
                        if line:
                            p = self.doc.add_paragraph(line, style='List Bullet')
                elif para_text.strip().startswith(('#')):
                    heading_text = para_text.strip().lstrip('#').strip()
                    self.doc.add_heading(heading_text, level=2)
                else:
                    self.doc.add_paragraph(para_text.strip())

    def format_proposal(self, proposal: Dict, company_info: Dict,
                        logo_path: Optional[str] = None) -> str:
        project_name = proposal.get('project_name', 'Untitled Project')
        owner = proposal.get('requirements', {}).get('owner_entity', '[Owner Name]')
        company_name = company_info.get('company_name', '[Company Name]')
        date = datetime.now().strftime('%B %d, %Y')
        
        self.add_cover_page(project_name, owner, company_name, date, logo_path)
        
        sections = proposal.get('sections', [])
        self.add_table_of_contents(sections)
        
        for section in sections:
            title = section.get('original_section_name', section['section_type'].replace('_', ' ').title())
            self.add_section(title, section['content'])
        
        output_path = f'proposal_{project_name.replace(" ", "_")[:50]}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.docx'
        self.doc.save(output_path)
        return output_path


class PandaDocFormatter:
    def __init__(self):
        self.api_key = os.environ.get('PANDADOC_API_KEY')
        self.base_url = 'https://api.pandadoc.com/public/v1'
        self.headers = {
            'Authorization': f'API-Key {self.api_key}',
            'Content-Type': 'application/json'
        }

    def create_document_from_template(self, template_id: str, proposal: Dict,
                                       company_info: Dict, recipient: Dict) -> Dict:
        sections_content = {}
        for section in proposal.get('sections', []):
            field_name = section['section_type'].lower()
            sections_content[field_name] = section['content']
        
        payload = {
            'name': f"Proposal - {proposal.get('project_name', 'Untitled')}",
            'template_uuid': template_id,
            'recipients': [{
                'email': recipient.get('email'),
                'first_name': recipient.get('first_name'),
                'last_name': recipient.get('last_name'),
                'role': 'Client'
            }],
            'tokens': [
                {'name': 'project_name', 'value': proposal.get('project_name', '')},
                {'name': 'company_name', 'value': company_info.get('company_name', '')},
                {'name': 'date', 'value': datetime.now().strftime('%B %d, %Y')},
                {'name': 'contact_name', 'value': company_info.get('contact_name', '')},
                {'name': 'contact_email', 'value': company_info.get('contact_email', '')},
                {'name': 'contact_phone', 'value': company_info.get('contact_phone', '')}
            ],
            'fields': sections_content,
            'metadata': {
                'generated_by': 'proposal-ai-system',
                'generated_at': datetime.utcnow().isoformat()
            }
        }
        
        response = requests.post(
            f'{self.base_url}/documents',
            headers=self.headers,
            json=payload
        )
        response.raise_for_status()
        return response.json()

    def list_templates(self) -> List[Dict]:
        response = requests.get(
            f'{self.base_url}/templates',
            headers=self.headers
        )
        response.raise_for_status()
        return response.json().get('results', [])

Procore Project History Sync Agent

Type: integration Synchronizes project history, bid packages, and documentation from Procore into the AI knowledge base. Runs on a daily schedule via Azure Functions timer trigger and performs incremental syncs. Extracts project metadata (type, value, dates, client) and project documents for embedding in the vector database.

Implementation:

procore_sync.py
python
# Procore API sync agent with Azure Functions timer trigger for daily
# incremental ingestion

# procore_sync.py
import os
import json
import requests
import tempfile
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from ingest_proposals import ProposalIngestionPipeline

class ProcoreSync:
    def __init__(self):
        self.client_id = os.environ['PROCORE_CLIENT_ID']
        self.client_secret = os.environ['PROCORE_CLIENT_SECRET']
        self.company_id = os.environ['PROCORE_COMPANY_ID']
        self.base_url = 'https://api.procore.com/rest/v1.0'
        self.token = None
        self.ingestion = ProposalIngestionPipeline()

    def authenticate(self) -> str:
        response = requests.post(
            'https://login.procore.com/oauth/token',
            data={
                'grant_type': 'client_credentials',
                'client_id': self.client_id,
                'client_secret': self.client_secret
            }
        )
        response.raise_for_status()
        self.token = response.json()['access_token']
        return self.token

    def _headers(self) -> Dict:
        if not self.token:
            self.authenticate()
        return {
            'Authorization': f'Bearer {self.token}',
            'Procore-Company-Id': str(self.company_id)
        }

    def get_projects(self, status: str = 'Active', updated_since: Optional[str] = None) -> List[Dict]:
        params = {'company_id': self.company_id, 'filters[status]': status}
        if updated_since:
            params['filters[updated_at]'] = f'>={updated_since}'
        
        response = requests.get(
            f'{self.base_url}/projects',
            headers=self._headers(),
            params=params
        )
        response.raise_for_status()
        return response.json()

    def get_project_details(self, project_id: int) -> Dict:
        response = requests.get(
            f'{self.base_url}/projects/{project_id}',
            headers=self._headers()
        )
        response.raise_for_status()
        return response.json()

    def get_bid_packages(self, project_id: int) -> List[Dict]:
        response = requests.get(
            f'{self.base_url}/projects/{project_id}/bid_packages',
            headers=self._headers()
        )
        response.raise_for_status()
        return response.json()

    def sync_projects_to_knowledge_base(self, full_sync: bool = False):
        if full_sync:
            projects = self.get_projects(status='Active') + self.get_projects(status='Completed')
        else:
            yesterday = (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%dT00:00:00Z')
            projects = self.get_projects(updated_since=yesterday)

        print(f'Syncing {len(projects)} projects from Procore...')
        
        for project in projects:
            project_text = self._format_project_for_embedding(project)
            
            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tmp:
                tmp.write(project_text)
                tmp_path = tmp.name
            
            try:
                metadata = {
                    'source': 'procore',
                    'procore_project_id': str(project.get('id')),
                    'project_name': project.get('name', ''),
                    'project_status': project.get('status', ''),
                    'project_type': project.get('project_type', {}).get('name', '') if project.get('project_type') else '',
                }
                self.ingestion.ingest_file(tmp_path, 'procore_projects', metadata)
            finally:
                os.unlink(tmp_path)

    def _format_project_for_embedding(self, project: Dict) -> str:
        lines = [
            f"PROJECT: {project.get('name', 'Unknown')}",
            f"Status: {project.get('status', 'Unknown')}",
            f"Address: {project.get('address', 'N/A')}, {project.get('city', '')}, {project.get('state_code', '')}",
            f"Project Type: {project.get('project_type', {}).get('name', 'N/A') if project.get('project_type') else 'N/A'}",
            f"Start Date: {project.get('start_date', 'N/A')}",
            f"Estimated Completion: {project.get('estimated_completion_date', 'N/A')}",
            f"Total Value: ${project.get('total_value', 'N/A')}",
            f"Square Footage: {project.get('square_feet', 'N/A')}",
            f"Description: {project.get('description', 'No description available.')}",
            f"Owner: {project.get('owner', {}).get('name', 'N/A') if project.get('owner') else 'N/A'}",
        ]
        return '\n'.join(lines)


# Azure Function timer trigger for daily sync
# function.json:
# {
#   "scriptFile": "procore_sync_function.py",
#   "bindings": [
#     {
#       "name": "timer",
#       "type": "timerTrigger",
#       "direction": "in",
#       "schedule": "0 0 2 * * *"
#     }
#   ]
# }
#
# procore_sync_function.py:
# import azure.functions as func
# from procore_sync import ProcoreSync
# 
# def main(timer: func.TimerRequest) -> None:
#     sync = ProcoreSync()
#     sync.sync_projects_to_knowledge_base(full_sync=False)

Testing & Validation

  • RFP PARSER TEST: Upload a sample public works RFP (PDF, 30+ pages) to the /api/rfp/parse endpoint. Verify that the returned JSON includes: project_name, submission_deadline (correct date), at least 3 required_sections, bonding_requirements, and evaluation_criteria with weights. Cross-check extracted requirements against the actual RFP document manually.
  • KNOWLEDGE BASE INGESTION TEST: Ingest 5 sample proposals (mix of PDF and DOCX) into the 'won_proposals' namespace. Query the Pinecone index stats endpoint to verify vector count increased. Run a semantic search for 'K-12 school renovation experience' and verify that school-related proposal chunks are returned with relevance scores > 0.75.
  • PROPOSAL GENERATION TEST - SINGLE SECTION: Call /api/proposal/section with section_type='EXECUTIVE_SUMMARY' and a parsed RFP requirements JSON. Verify the output: uses first-person plural, references the project name from the RFP, mentions relevant past experience from the knowledge base, and does NOT fabricate specific dollar amounts or project names not in the context.
  • FULL PROPOSAL GENERATION TEST: Call /api/proposal/full with a complete RFP requirements JSON. Verify all required sections are generated, each section addresses specific RFP requirements, and the total output length is proportional to any page limits specified. Time the request — full proposal generation should complete in under 90 seconds.
  • COMPLIANCE MATRIX TEST: Parse an RFP that requires specific certifications (e.g., LEED AP, OSHA 30-hour, DBE participation). Verify the compliance matrix correctly identifies all mandatory requirements and flags items that cannot be verified from the knowledge base as 'pending manual review'.
  • GO/NO-GO ANALYZER TEST: Submit an RFP for a $50M project to a contractor profiled with $20M bonding capacity. Verify the analyzer returns 'NO-GO' or 'CONDITIONAL GO' and correctly identifies bonding capacity as a key risk. Then test with a well-matched RFP and verify 'GO' recommendation with appropriate score.
  • PROCORE INTEGRATION TEST: Trigger the Procore sync with a test company. Verify at least 3 projects are synced to the 'procore_projects' namespace. Query the knowledge base for a specific project name that exists in Procore and verify it returns with correct metadata.
  • PANDADOC OUTPUT TEST: Generate a proposal and send it to PandaDoc via the formatter. Verify the PandaDoc document is created with the correct template, all merge fields are populated, the recipient is set correctly, and the document status is 'draft' ready for review.
  • WORD OUTPUT TEST: Generate a complete proposal and export to Word format. Open the .docx file and verify: cover page has correct project name and company info, table of contents lists all sections, section headings use the branded style, body text is properly formatted, and there are no raw markdown artifacts in the output.
  • END-TO-END WORKFLOW TEST: Scan a paper RFP using the Fujitsu ScanSnap → verify it appears in SharePoint RFP Inbox → verify Power Automate triggers and sends Teams notification → parse the RFP → run Go/No-Go analysis → generate full proposal → export to Word and PandaDoc. Measure total time from scan to draft completion (target: under 15 minutes).
  • SECURITY TEST: Attempt to access the RAG API without authentication and verify 401 response. Verify Azure AD Conditional Access blocks access from a non-compliant device. Check Application Insights logs to confirm API calls are being recorded with user identity. Verify Azure OpenAI content filtering is active and blocks inappropriate content generation.
  • LOAD TEST: Simulate 5 concurrent proposal generation requests. Verify all complete successfully within 120 seconds each and the App Service does not crash or return 500 errors. Monitor Azure metrics for CPU and memory utilization — both should stay below 80%.

Client Handoff

Client handoff should be structured as a half-day session with all stakeholders (estimating team, PMs, principals, admin staff) covering the following areas:

1. System Overview (30 minutes)

  • Architecture walkthrough: what lives where (Azure, Pinecone, AutoRFP.ai, PandaDoc)
  • Data flow: RFP in → AI analysis → draft generation → human review → formatted output → delivery
  • What the AI does well vs. what requires human judgment (pricing, relationships, strategic positioning)

2. Hands-On Workflow Training (90 minutes)

  • Scanning and uploading RFPs (ScanSnap workflow to SharePoint)
  • Using AutoRFP.ai Chrome extension on BuildingConnected and other bid portals
  • Running the Go/No-Go analyzer for new opportunities
  • Generating proposal sections individually and reviewing/editing
  • Generating full proposals and the review checklist
  • Formatting and sending via PandaDoc with e-signature tracking
  • Using Microsoft Copilot in Word for on-the-fly editing and refinement

3. Knowledge Base Management (30 minutes)

  • How to add new winning proposals to the knowledge base (drag-and-drop to SharePoint)
  • When and how to update boilerplate sections (certifications, insurance, bonding changes)
  • Adding new crew resumes and qualifications
  • Understanding how better knowledge base = better outputs

4. Quality Control Process (30 minutes)

  • The mandatory human review checklist before any AI proposal is submitted
  • Common AI mistakes to watch for (fabricated project names, incorrect certifications, outdated pricing references)
  • How to provide feedback for prompt improvement (Microsoft Forms feedback loop)
  • Escalation path to MSP for quality issues

5. Documentation Package to Leave Behind

  • Quick-start guide (laminated 1-pager for each user's desk)
  • Full user manual with screenshots (PDF + SharePoint)
  • Video recordings of training sessions (Teams recordings)
  • Troubleshooting guide (common issues and fixes)
  • Emergency contact card (MSP help desk, escalation contacts)
  • Monthly knowledge base update checklist
  • Proposal quality review checklist (printable)
  • System architecture diagram for their records

6. Success Criteria Review

  • First 30 days: 5+ proposals generated through the system with human comparison to manual process
  • 60 days: All new proposals use the AI system for first draft generation
  • 90 days: Measured time savings per proposal (target: 60%+ reduction in drafting time)
  • 6 months: Win rate comparison (AI-assisted vs. historical baseline)
  • Agree on monthly check-in schedule for the first 90 days

Maintenance

Ongoing MSP Responsibilities

Weekly (30 minutes)

  • Monitor Azure OpenAI API usage and costs via Azure Cost Management dashboard
  • Check Application Insights for error rates, failed requests, and latency anomalies
  • Review Pinecone index health (vector count trends, query latency)
  • Verify daily Procore sync function executed successfully (Azure Functions logs)

Monthly (2-4 hours)

  • Knowledge base update: Ingest new winning proposals from the past month (client uploads to SharePoint 'Won Proposals' folder; MSP runs ingestion pipeline)
  • Review and update boilerplate sections for any changes (insurance renewals, bonding capacity changes, new certifications, personnel changes)
  • Prompt optimization: Review user feedback from Microsoft Forms; adjust prompt templates for sections that consistently need heavy editing
  • AutoRFP.ai content library refresh: Update response templates with new project completions and qualifications
  • Security audit: Review Azure AD sign-in logs for anomalies; verify Conditional Access policies are enforced
  • Cost optimization: Review API token usage patterns and adjust model selection (use GPT-4.1 Mini for first drafts if costs are high)

Quarterly (4-8 hours)

  • Win/loss analysis: Compare win rates for AI-assisted proposals vs. historical baseline; identify patterns in successful vs. unsuccessful bids
  • Prompt engineering review: Major prompt template updates based on accumulated feedback and win/loss data
  • Platform updates: Apply Azure OpenAI model updates (new versions), update LangChain and dependencies, review AutoRFP.ai feature releases
  • Security & compliance review: Verify data handling practices, review API access logs, update sensitivity labels if needed
  • Capacity planning: Assess if App Service tier needs scaling based on usage growth
  • Client business review: Meet with principal/estimating lead to review ROI and identify expansion opportunities

Annual

  • Full knowledge base audit: Remove outdated proposals (>5 years), verify all vector metadata is current, re-embed if embedding models have been upgraded
  • Software license renewals: M365, Copilot, AutoRFP.ai, Bluebeam, PandaDoc
  • Hardware refresh assessment: Workstation upgrades, scanner maintenance
  • CMMC/compliance re-certification support (if applicable to federal contractors)
  • Strategic planning: Evaluate new AI capabilities, additional automation opportunities (e.g., automated estimating, schedule generation)

SLA Considerations

  • Response time for system-down issues: 2 hours during business hours
  • Response time for degraded performance: 4 hours
  • Knowledge base ingestion requests: completed within 2 business days
  • Prompt optimization requests: completed within 5 business days
  • Target system uptime: 99.5% during business hours (M-F 6AM-8PM local)

Escalation Path

1
L1 MSP Help Desk: Basic troubleshooting (login issues, browser problems, scanner config)
2
L2 MSP Engineer: API errors, integration failures, Azure service issues
3
L3 MSP Solutions Architect: Prompt engineering, knowledge base optimization, architecture changes
4
Vendor Support: AutoRFP.ai support, Microsoft Azure support, Pinecone support

Model Retraining/Update Triggers

  • Client wins a significantly new project type not represented in knowledge base
  • Client hires new key personnel whose resumes need ingestion
  • Client obtains new certifications or licenses
  • Insurance, bonding, or safety records are updated
  • Azure OpenAI releases a new model version with significant improvements
  • User feedback indicates consistent quality issues in a specific section type

Alternatives

SaaS-Only Approach (No Custom RAG Pipeline)

Use AutoRFP.ai ($1,299/month) as the sole AI platform with Microsoft 365 Copilot ($30/user/month) for in-document assistance. Skip the custom RAG pipeline, Pinecone vector database, and Azure OpenAI API entirely. Rely on AutoRFP.ai's built-in content library and AI engine for all proposal generation, supplemented by Copilot for editing.

Microsoft Copilot-Only Approach (Budget Option)

Use only Microsoft 365 Copilot ($30/user/month) with well-structured SharePoint document libraries containing past proposals, templates, and boilerplate sections. No dedicated RFP platform, no custom development. Users draft proposals in Word using Copilot to reference past proposals stored in SharePoint, with PandaDoc ($49/user/month) for final delivery.

ContraVault AI + Custom RAG (Enterprise Option)

Replace AutoRFP.ai with ContraVault AI, which is purpose-built for AEC firms and includes SF-330 automation, Go/No-Go analysis, and qualification mapping. Pair with the custom RAG pipeline for maximum construction-specific intelligence. Add on-premises NVIDIA GPU server for running local inference on sensitive federal contract proposals.

Open-Source LLM On-Premises (Maximum Data Privacy)

Replace Azure OpenAI with a self-hosted open-source LLM (Llama 3.1 70B or Mistral Large) running on an on-premises NVIDIA GPU server. All data processing stays within the client's network. Use ChromaDB (open-source) instead of Pinecone for the vector database. Pair with AutoRFP.ai or a self-built web interface.

1up.ai Budget Platform Approach

Use 1up.ai ($250/month) as the primary RFP response platform instead of AutoRFP.ai ($1,299/month). 1up.ai provides a centralized knowledge base, auto-populated responses, and Slack/Teams integration at a fraction of the cost. Supplement with Microsoft 365 Copilot for document editing.

Want early access to the full toolkit?