Photo-to-Rough-Estimate for Instant Quotes
The Problem
Customer sends photos and wants a ballpark. Estimator is booked 3 days out. Customer calls someone else. This automation uses AI vision to identify the issue and severity, cross-references your pricing database, and sends back an honest range with caveats and a booking link within 5 minutes.
How It Works
Customer photos + zip code
AI vision identifies issue and severity. Cross-refs pricing database. Generates honest range with caveats.
Rough estimate in 5 min: '$800-1,400, final after on-site.' Booking link included.
PRD
# Product Requirements Document
Recipe 099 -- Photo-to-Rough-Estimate for Instant Quotes
THE AI TRADES Platform
---
Recipe Slug: `photo-to-rough-estimate`
Recipe Number: 099
Difficulty: Replit Build
Time Estimate: Full day
Category: Estimates & Quoting
Software Required: ChatGPT (OpenAI API)
Roles: CSR, Estimator, Owner
Trades: Roofing, Painting, Remodeling, General Contractor
Principles Applied: Speed Wins the Job, Look 10x Your Size
---
Reusable Modules Referenced:
- Module 0: UX Philosophy (`modules/ux-philosophy-module.md`)
- Module 9: Onboarding Wizard (`modules/onboarding-wizard-module.md`)
- Module 10: Settings Panel (`modules/settings-panel-module.md`)
- Module 11: Notification / Toast (`modules/notification-toast-module.md`)
- Module 12: Data Table / List View (`modules/data-table-list-view-module.md`)
- Module 18: Document Generator (`modules/document-generator-module.md`)
Integration Docs (include with build):
- OpenAI / Anthropic (`integrations/openai-anthropic.md`) -- GPT-4o vision for photo analysis
---
Table of Contents
2. The Problem
3. The Solution
5. Architecture and Data Model
7. Step-by-Step Build Instructions
10. Example Output
---
1. Recipe Overview
A homeowner texts your office three photos of their peeling exterior paint and asks "how much would it cost to repaint the front of my house?" Your estimator is booked for three days. The CSR says "we can have someone out next week to take a look." The homeowner calls two more companies. One of them texts back a ballpark within 20 minutes. That company gets the job.
This recipe builds a web application that takes customer-submitted photos, analyzes them with AI vision to identify the scope of work, matches the identified work against your pricing database, and generates a rough estimate in minutes. The CSR uploads the photos, answers 2-3 qualifying questions, and the system produces a ballpark range with line items. The customer gets a fast answer. Your estimator reviews and refines later.
This is not a replacement for a proper site visit and detailed estimate. It is the first response that keeps the customer engaged while your estimator catches up. The rough estimate sets the expectation. The site visit confirms it. But by the time your competitor calls back, the customer is already working with you.
Input: Customer photos (exterior, interior, damage, project area) + your pricing database (labor rates, material costs, common scopes).
Output: Rough estimate with line items and range: "Based on photos: approximately $7,500 to $9,200 for this scope." Customer gets a fast answer. Estimator confirms later.
---
2. The Problem
Three Days to a Quote Means Zero Days to a Sale
Speed is the single biggest differentiator in residential contracting sales. The first company to deliver a number wins the job 60-70% of the time. Not because the number is the lowest. Because the customer is tired of waiting and calling around.
The timeline that kills your close rate:
| Step | Typical Timeline | What the Customer Does |
|---|---|---|
| Customer sends photos, asks for ballpark | Day 0 | Excited, ready to buy |
| CSR says "we will have someone out next week" | Day 0 | Calls 2 more companies |
| Estimator visits the site | Day 3-5 | Already has a competitor's quote |
| Estimator builds detailed quote | Day 5-7 | Leaning toward the fast company |
| Quote delivered to customer | Day 5-7 | Decision already made |
The cost of slow response:
| Metric | Slow Response (3-5 days) | Fast Response (same day) |
|---|---|---|
| Leads per month | 30 | 30 |
| Close rate | 20-30% | 45-60% |
| Jobs booked per month | 6-9 | 14-18 |
| Average job value | $6,000 | $6,000 |
| Monthly revenue from leads | $36,000-$54,000 | $84,000-$108,000 |
The math is clear. Same leads, same quality work, same prices. The only variable is speed. A rough estimate within 30 minutes keeps the customer engaged. A detailed estimate 5 days later confirms what they already believe.
The Estimator Bottleneck
Your estimator can only visit 3-4 sites per day. Each visit takes 1-2 hours including travel. That means 15-20 site visits per week maximum. If you get 30 leads per month, half of them wait a week or longer for a visit. By then, they are gone.
The rough estimate does not replace the site visit. It buys you time. The customer knows the ballpark. They stop calling competitors. They wait for your estimator because they already have a number they can plan around.
---
3. The Solution
A web application where your CSR uploads customer photos, the AI vision model identifies what it sees (scope, materials, conditions), and the system matches against your pricing database to generate a ballpark estimate.
How it works:
1. CSR receives photos from the customer via text, email, or your website form.
2. CSR opens the Photo Estimate app and creates a new estimate request: customer name, address, project type (paint, roof, remodel, etc.), and uploads 1-5 photos.
3. AI vision analyzes each photo and extracts: what it sees (surface type, condition, approximate area), what work is needed (prep, repair, material, labor), and any special conditions (height, access issues, damage).
4. The system matches the AI analysis against your pricing database to generate line items with estimated quantities and price ranges.
5. A rough estimate is generated with a low-high range, broken down by line item.
6. CSR reviews the estimate (15-30 seconds), adjusts if needed, and sends it to the customer via text or email.
7. Estimator reviews the rough estimate later and either confirms, adjusts, or schedules a site visit for detailed measurement.
What changes:
| Metric | Before | After |
|---|---|---|
| Time from photos to ballpark | 3-5 days (waiting for site visit) | 15-30 minutes |
| Customer's first impression | "They will get back to me sometime" | "They already gave me a number" |
| Leads that ghost before estimate | 30-40% | Under 10% |
| Estimator site visits needed | Every lead | Only serious leads (pre-qualified by rough estimate) |
| Close rate | 20-30% | 45-60% |
---
4. Prerequisites
Before you start, gather these:
- [ ] Replit account for building and deploying the app
- [ ] OpenAI API key with GPT-4o access (vision model for photo analysis). Cost: $0.05-$0.15 per estimate (3-5 photos analyzed)
- [ ] Your pricing database. This is the most important input. You need:
- Labor rates per hour by trade/task
- Material costs for common items (paint per gallon, shingles per square, drywall per sheet)
- Common scope templates with price ranges (e.g., "exterior repaint, 2-story, 2000 sq ft = $5,500-$8,000")
- Adjustment factors for complexity (high ceilings, difficult access, extensive prep)
- [ ] Historical estimates (optional but valuable): past 20-50 completed estimates with photos and final prices, used to calibrate the AI's accuracy
Build your pricing database first. Start with a spreadsheet. Here is the minimum structure:
| Scope Item | Unit | Low Price | High Price | Notes |
|---|---|---|---|---|
| Exterior paint - prep (scraping, sanding) | per sq ft | $0.50 | $1.50 | Higher for lead paint or heavy peeling |
| Exterior paint - prime and 2 coats | per sq ft | $1.50 | $3.00 | Includes primer. Higher for multiple colors |
| Roof tear-off (asphalt shingles) | per square (100 sq ft) | $100 | $175 | Additional for multiple layers |
| Roof install (architectural shingles) | per square | $350 | $500 | Material + labor |
| Drywall repair (small patch) | per patch | $150 | $300 | Under 2 sq ft |
| Drywall install (new) | per sq ft | $1.50 | $3.00 | Includes tape and mud |
| Interior paint (walls, standard) | per sq ft | $1.00 | $2.50 | 2 coats, standard height |
You need 30-50 line items covering your most common work. This is your company's pricing, not generic numbers. The AI uses your prices, not the internet's.
Monthly cost: $10-$30 for OpenAI API (depends on estimate volume). Replit free tier covers hosting.
---
5. Architecture and Data Model
System Architecture
```
[Customer sends photos via text/email/form]
|
v
[CSR opens Photo Estimate App]
|
v
[CSR uploads photos + project details]
|
v
[Express API Server]
|
+---> [File Storage] -- store original photos
|
+---> [OpenAI GPT-4o Vision API]
| - Analyze each photo
| - Return structured scope assessment
|
+---> [Pricing Engine]
| - Match scope items to pricing database
| - Calculate line items with quantities
| - Generate low-high range
|
+---> [PostgreSQL Database]
| - estimates table
| - estimate_line_items table
| - pricing_items table
| - photo_analyses table
|
v
[CSR reviews rough estimate on screen]
|
v
[Send to customer via email/SMS]
```
Data Model
Table: pricing_items
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| trade | text | "painting", "roofing", "remodeling", "general" |
| scope_item | text | "Exterior paint - prep (scraping, sanding)" |
| unit | text | "sq_ft", "linear_ft", "each", "square", "hour" |
| unit_label | text | "per sq ft", "per linear ft", "each" |
| low_price | numeric | Low end of range per unit |
| high_price | numeric | High end of range per unit |
| notes | text | "Higher for lead paint or heavy peeling" |
| ai_match_keywords | text[] | Keywords the AI should use to match: ["scraping", "sanding", "paint prep"] |
| is_active | boolean | Default true. Set false to hide without deleting |
| created_at | timestamptz | Auto-generated |
Table: estimates
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| customer_name | text | "Mike and Sarah Thompson" |
| customer_email | text | For sending the estimate |
| customer_phone | text | For SMS delivery |
| address | text | Project address |
| project_type | text | "exterior_paint", "roof_replacement", "interior_remodel", "general" |
| status | text | "draft", "sent", "confirmed", "expired" |
| total_low | numeric | Sum of all line item lows |
| total_high | numeric | Sum of all line item highs |
| ai_summary | text | AI-generated plain-English scope summary |
| estimator_notes | text | Notes from estimator review |
| photo_urls | text[] | Array of photo storage URLs |
| created_by | uuid | CSR who created it |
| reviewed_by | uuid | Estimator who reviewed (nullable) |
| sent_at | timestamptz | When sent to customer |
| created_at | timestamptz | Auto-generated |
Table: estimate_line_items
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| estimate_id | uuid | FK to estimates |
| pricing_item_id | uuid | FK to pricing_items (nullable if custom) |
| description | text | Line item description |
| quantity | numeric | Estimated quantity |
| unit | text | "sq_ft", "each", etc. |
| low_total | numeric | quantity x low_price |
| high_total | numeric | quantity x high_price |
| ai_confidence | numeric | 0.0-1.0: how confident the AI is in this line item |
| notes | text | "Estimated from photo. Verify on site." |
| sort_order | integer | Display order |
Table: photo_analyses
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| estimate_id | uuid | FK to estimates |
| photo_url | text | Photo storage URL |
| ai_response | jsonb | Full structured response from GPT-4o |
| identified_scope | text[] | Scope items identified: ["paint prep needed", "wood rot at fascia"] |
| estimated_area_sqft | numeric | AI's estimate of the visible area |
| condition_notes | text | "Heavy peeling on south-facing wall. Wood rot visible at 2 fascia boards." |
| confidence | numeric | 0.0-1.0 |
| created_at | timestamptz | Auto-generated |
Relationships
```
estimates.id --> estimate_line_items.estimate_id
estimates.id --> photo_analyses.estimate_id
pricing_items.id --> estimate_line_items.pricing_item_id
```
---
6. Key Screens / UI
Screen 1: New Estimate (CSR Primary Screen)
Clean form the CSR fills out during or after a customer call/text.
- Customer name, email, phone fields at top
- Project address field
- Project type dropdown: Exterior Paint, Interior Paint, Roof Replacement, Siding, Remodel, General
- Photo upload area: drag-and-drop or tap to select. Supports 1-10 photos. Shows thumbnails as they upload.
- "Additional context" text area where the CSR pastes any notes from the customer ("they want to change the color from beige to navy blue", "there is some wood rot on the garage trim")
- Big "Generate Estimate" button at the bottom
- Loading state: progress bar showing "Analyzing photo 1 of 4...", "Matching pricing...", "Building estimate..."
Screen 2: Estimate Review
Shown after the AI generates the rough estimate. The CSR reviews before sending.
- Customer info and address at top
- AI Summary section: 2-3 sentences describing what the AI saw and the scope of work. Example: "Exterior repaint of a two-story home, approximately 2,400 sq ft of painted surface. Moderate prep needed with peeling on south and west walls. Two fascia boards show wood rot and need replacement before painting."
- Line items table:
- Each row: description, quantity, unit, low price, high price, confidence indicator
- Rows color-coded: green (high confidence), yellow (medium), red (low, needs review)
- Editable: CSR can adjust quantities or remove line items
- "Add Line Item" button for manual additions
- Total at bottom: "$7,500 - $9,200"
- Disclaimer text (auto-included): "This is a preliminary estimate based on photos. Final pricing will be confirmed after an on-site visit."
- "Send to Customer" button (email + SMS)
- "Save as Draft" button
- "Request Estimator Review" button (assigns to estimator queue)
Screen 3: Pricing Database (Admin)
Where the owner manages the pricing database that powers the estimates.
- Data table of all pricing items: scope item, trade, unit, low price, high price, status
- Sortable and filterable by trade
- "Add Item" form for new pricing entries
- Bulk import from CSV
- "Edit" and "Deactivate" per row
- Last updated timestamp so the owner knows if prices are stale
Screen 4: Estimate History
List of all estimates generated, with status tracking.
- Data table: date, customer, project type, total range, status, created by
- Filters: by status (draft, sent, confirmed), by project type, by date range
- Click to view the full estimate and photo analyses
- "Accuracy tracker" column: after the estimator does the site visit, they enter the final number. The system calculates how close the rough estimate was. Over time this calibrates the AI.
Screen 5: Onboarding Wizard
First-time setup for the owner.
- Step 1: Company name, trade(s), service area
- Step 2: Upload or build your pricing database (CSV upload or manual entry)
- Step 3: Set default estimate disclaimer text
- Step 4: Configure email/SMS delivery (connect email and optionally Twilio for SMS)
- Step 5: Invite CSRs and estimators
---
7. Step-by-Step Build Instructions
Step 1: Set Up the Database
1. Set up a PostgreSQL database for the project
2. Create the tables using the schema above
3. Configure file storage for photo uploads (local disk or cloud storage service)
Step 2: Build the Vision Analysis Service
This is the core AI service. It takes a photo URL and returns a structured scope assessment.
```typescript
// server/services/photo-analyzer.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const PHOTO_ANALYSIS_PROMPT = `You are an estimating assistant for a home service contractor. You will receive a photo of a residential property or project area. Your job is to identify the scope of work visible in the photo and return structured data for generating a rough cost estimate.
ANALYZE THE PHOTO AND RETURN THIS JSON:
{
"description": "Plain English description of what you see (2-3 sentences)",
"project_type": "exterior_paint | interior_paint | roof | siding | remodel | general",
"scope_items": [
{
"item": "Short description of work needed",
"category": "prep | material | labor | repair | removal",
"estimated_quantity": 0,
"unit": "sq_ft | linear_ft | each | square | hour",
"match_keywords": ["keyword1", "keyword2"],
"confidence": 0.8,
"notes": "Any relevant notes about this item"
}
],
"conditions": {
"height": "single_story | two_story | three_plus",
"access": "easy | moderate | difficult",
"existing_condition": "good | fair | poor",
"special_factors": ["wood rot", "lead paint concern", "steep pitch", "multiple colors"]
},
"estimated_total_area_sqft": 0,
"overall_confidence": 0.7,
"photo_quality_notes": "Any issues with the photo that affect analysis accuracy"
}
RULES:
1. Base estimates on what you can actually see. Do not assume hidden conditions.
2. For exterior surfaces, estimate area using visual cues (windows for scale, story count, apparent proportions). A standard window is roughly 3x4 feet. A standard door is 3x7 feet. A single story wall is typically 9 feet tall.
3. When estimating roof area from a ground photo, use the visible pitch and footprint to approximate. A standard 2,000 sq ft home has roughly 22-28 squares of roofing.
4. Flag anything you are uncertain about with a confidence below 0.5.
5. Include prep work and repair items when you can see they are needed (peeling paint, damaged materials, rot).
6. Return ONLY the JSON. No preamble.`;
export async function analyzePhoto(photoUrl: string): Promise<any> {
console.log(`[PhotoAnalyzer] Analyzing photo: ${photoUrl}`);
const response = await openai.chat.completions.create({
model: "gpt-4o",
max_tokens: 2000,
temperature: 0.2,
messages: [
{
role: "system",
content: PHOTO_ANALYSIS_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: photoUrl,
detail: "high",
},
},
],
},
],
});
const raw = response.choices[0].message.content;
console.log(`[PhotoAnalyzer] Raw response length: ${raw?.length}`);
try {
const parsed = JSON.parse(raw || "{}");
console.log(`[PhotoAnalyzer] Confidence: ${parsed.overall_confidence}, Items: ${parsed.scope_items?.length}`);
return parsed;
} catch (err) {
console.error(`[PhotoAnalyzer] Failed to parse response: ${err.message}`);
return null;
}
}
```
Cost per analysis: GPT-4o vision with `detail: "high"` costs roughly $0.01-$0.03 per image depending on resolution. Analyzing 4 photos for one estimate costs $0.04-$0.12.
Step 3: Build the Pricing Engine
This service takes the AI scope items and matches them against your pricing database.
```typescript
// server/services/pricing-engine.ts
import { db } from "../lib/db";
interface ScopeItem {
item: string;
category: string;
estimated_quantity: number;
unit: string;
match_keywords: string[];
confidence: number;
notes: string;
}
interface LineItem {
pricing_item_id: string | null;
description: string;
quantity: number;
unit: string;
low_total: number;
high_total: number;
ai_confidence: number;
notes: string;
}
export async function generateLineItems(
scopeItems: ScopeItem[],
projectType: string
): Promise<{ lineItems: LineItem[]; totalLow: number; totalHigh: number }> {
console.log(`[Pricing] Generating line items for ${scopeItems.length} scope items`);
// Fetch all active pricing items for this trade
const pricingItems = await db.query(
"SELECT * FROM pricing_items WHERE is_active = true"
);
if (!pricingItems?.length) {
console.error("[Pricing] No pricing items found in database");
return { lineItems: [], totalLow: 0, totalHigh: 0 };
}
const lineItems: LineItem[] = [];
for (const scope of scopeItems) {
// Find the best matching pricing item using keyword overlap
let bestMatch = null;
let bestOverlap = 0;
for (const pricing of pricingItems) {
const keywords = pricing.ai_match_keywords || [];
const overlap = scope.match_keywords.filter((k) =>
keywords.some((pk: string) => pk.toLowerCase().includes(k.toLowerCase()) || k.toLowerCase().includes(pk.toLowerCase()))
).length;
if (overlap > bestOverlap && pricing.unit === scope.unit) {
bestOverlap = overlap;
bestMatch = pricing;
}
}
// If no keyword match, try matching by unit type and category
if (!bestMatch) {
bestMatch = pricingItems.find(
(p) => p.unit === scope.unit && p.trade === projectType
);
}
if (bestMatch && scope.estimated_quantity > 0) {
lineItems.push({
pricing_item_id: bestMatch.id,
description: bestMatch.scope_item,
quantity: scope.estimated_quantity,
unit: scope.unit,
low_total: Math.round(scope.estimated_quantity * bestMatch.low_price),
high_total: Math.round(scope.estimated_quantity * bestMatch.high_price),
ai_confidence: scope.confidence,
notes: scope.notes || bestMatch.notes || "",
});
} else {
// No match found. Create a flagged line item for manual review
lineItems.push({
pricing_item_id: null,
description: scope.item,
quantity: scope.estimated_quantity,
unit: scope.unit,
low_total: 0,
high_total: 0,
ai_confidence: scope.confidence,
notes: "NO PRICING MATCH. Needs manual pricing.",
});
}
}
const totalLow = lineItems.reduce((sum, li) => sum + li.low_total, 0);
const totalHigh = lineItems.reduce((sum, li) => sum + li.high_total, 0);
console.log(`[Pricing] Generated ${lineItems.length} line items. Range: $${totalLow} - $${totalHigh}`);
return { lineItems, totalLow, totalHigh };
}
```
Step 4: Build the Estimate Generation API
```typescript
// server/routes/estimates.ts
import { Router } from "express";
import { db } from "../lib/db";
import { analyzePhoto } from "../services/photo-analyzer";
import { generateLineItems } from "../services/pricing-engine";
const router = Router();
router.post("/api/estimates/generate", async (req, res) => {
const { customer_name, customer_email, customer_phone, address, project_type, photo_urls, context_notes } = req.body;
console.log(`[Estimate] Starting generation for ${customer_name}, ${photo_urls.length} photos`);
try {
// Step 1: Analyze each photo
const analyses = [];
const allScopeItems = [];
for (let i = 0; i < photo_urls.length; i++) {
console.log(`[Estimate] Analyzing photo ${i + 1} of ${photo_urls.length}`);
const analysis = await analyzePhoto(photo_urls[i]);
if (analysis) {
analyses.push(analysis);
allScopeItems.push(...(analysis.scope_items || []));
}
}
if (allScopeItems.length === 0) {
return res.status(400).json({
error: "Could not identify scope from the provided photos. Try clearer photos or add context notes.",
});
}
// Step 2: Deduplicate scope items (multiple photos may show the same thing)
const deduped = deduplicateScopeItems(allScopeItems);
// Step 3: Generate line items from pricing database
const { lineItems, totalLow, totalHigh } = await generateLineItems(deduped, project_type);
// Step 4: Generate a plain-English summary
const summary = analyses
.map((a) => a.description)
.filter(Boolean)
.join(" ");
// Step 5: Create the estimate record
const estimate = await db.query(
`INSERT INTO estimates (customer_name, customer_email, customer_phone, address, project_type, status, total_low, total_high, ai_summary, photo_urls, created_by)
VALUES ($1, $2, $3, $4, $5, 'draft', $6, $7, $8, $9, $10)
RETURNING *`,
[customer_name, customer_email, customer_phone, address, project_type, totalLow, totalHigh, summary, photo_urls, req.user?.id]
).then(r => r[0]);
// Step 6: Insert line items
const lineItemRows = lineItems.map((li, idx) => ({
estimate_id: estimate.id,
...li,
sort_order: idx,
}));
// Insert line items into database
for (const row of lineItemRows) {
await db.query(
`INSERT INTO estimate_line_items (estimate_id, pricing_item_id, description, quantity, unit, low_total, high_total, ai_confidence, notes, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[row.estimate_id, row.pricing_item_id, row.description, row.quantity, row.unit, row.low_total, row.high_total, row.ai_confidence, row.notes, row.sort_order]
);
}
// Step 7: Insert photo analyses
for (let i = 0; i < analyses.length; i++) {
await db.query(
`INSERT INTO photo_analyses (estimate_id, photo_url, ai_response, identified_scope, estimated_area_sqft, condition_notes, confidence)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
estimate.id,
photo_urls[i],
JSON.stringify(analyses[i]),
analyses[i].scope_items?.map((s: any) => s.item) || [],
analyses[i].estimated_total_area_sqft || 0,
analyses[i].conditions
? `Height: ${analyses[i].conditions.height}, Access: ${analyses[i].conditions.access}, Condition: ${analyses[i].conditions.existing_condition}`
: "",
analyses[i].overall_confidence || 0.5,
]
);
}
console.log(`[Estimate] Complete. ID: ${estimate.id}, Range: $${totalLow}-$${totalHigh}`);
return res.json({
estimate,
lineItems: lineItemRows,
analyses,
});
} catch (err) {
console.error(`[Estimate] Generation failed: ${err.message}`);
return res.status(500).json({ error: "Estimate generation failed" });
}
});
function deduplicateScopeItems(items: any[]): any[] {
// Group by item description similarity and take the highest-confidence version
const groups = new Map();
for (const item of items) {
const key = item.match_keywords.sort().join(",");
const existing = groups.get(key);
if (!existing || item.confidence > existing.confidence) {
// Use the higher-confidence or higher-quantity estimate
if (existing && existing.estimated_quantity > item.estimated_quantity) {
item.estimated_quantity = existing.estimated_quantity;
}
groups.set(key, item);
}
}
return Array.from(groups.values());
}
export default router;
```
Step 5: Build the Estimate Delivery Service
```typescript
// server/services/estimate-delivery.ts
interface EstimateWithLines {
id: string;
customer_name: string;
customer_email: string;
customer_phone: string;
address: string;
total_low: number;
total_high: number;
ai_summary: string;
line_items: Array<{
description: string;
quantity: number;
unit: string;
low_total: number;
high_total: number;
}>;
}
export function formatEstimateEmail(estimate: EstimateWithLines, companyName: string): string {
const lineItemsText = estimate.line_items
.map(
(li) =>
` ${li.description} (${li.quantity} ${li.unit}): $${li.low_total.toLocaleString()} - $${li.high_total.toLocaleString()}`
)
.join("\n");
return `Hi ${estimate.customer_name.split(" ")[0]},
Thanks for reaching out to ${companyName}. Based on the photos you sent, here is a preliminary estimate for the work at ${estimate.address}:
${estimate.ai_summary}
Estimated Scope:
${lineItemsText}
Estimated Total: $${estimate.total_low.toLocaleString()} - $${estimate.total_high.toLocaleString()}
This is a rough estimate based on photos. We would like to schedule a quick visit to confirm measurements and finalize pricing. The final number is typically within 10-15% of this range.
Want to move forward? Reply to this email or call us to schedule the site visit.
${companyName}`;
}
export function formatEstimateSMS(estimate: EstimateWithLines, companyName: string): string {
return `Hi ${estimate.customer_name.split(" ")[0]}, this is ${companyName}. Based on your photos, we estimate the work at ${estimate.address} at approximately $${estimate.total_low.toLocaleString()} - $${estimate.total_high.toLocaleString()}. We will confirm with a quick site visit. Want to schedule? Reply YES or call us.`;
}
```
Step 6: Build the CSR Interface
Use the Screen 1 and Screen 2 specs from Key Screens above. The critical flow:
1. CSR clicks "New Estimate"
2. Fills customer info + uploads photos
3. Clicks "Generate Estimate"
4. Reviews the AI-generated estimate with line items
5. Adjusts any line items if needed
6. Clicks "Send to Customer"
Keep the review screen editable. The CSR should be able to remove line items that do not look right, adjust quantities, or add manual items before sending.
Step 7: Seed the Pricing Database
Load your pricing items. Here is a starter set for painting:
```typescript
// server/seed/painting-pricing.ts
export const paintingPricingItems = [
{
trade: "painting",
scope_item: "Exterior paint prep - scraping and sanding",
unit: "sq_ft",
unit_label: "per sq ft",
low_price: 0.50,
high_price: 1.50,
notes: "Higher for lead paint or heavy peeling",
ai_match_keywords: ["scraping", "sanding", "paint prep", "peeling", "prep work"],
},
{
trade: "painting",
scope_item: "Exterior paint - prime and 2 coats",
unit: "sq_ft",
unit_label: "per sq ft",
low_price: 1.50,
high_price: 3.00,
notes: "Includes primer. Higher for multiple colors or dark-to-light changes",
ai_match_keywords: ["exterior paint", "repaint", "prime", "two coats", "wall paint"],
},
{
trade: "painting",
scope_item: "Trim and fascia paint",
unit: "linear_ft",
unit_label: "per linear ft",
low_price: 2.00,
high_price: 5.00,
notes: "Includes prep. Higher for ornate trim or high access",
ai_match_keywords: ["trim", "fascia", "eaves", "soffit", "molding"],
},
{
trade: "painting",
scope_item: "Wood rot repair (fascia/trim)",
unit: "each",
unit_label: "per section",
low_price: 150,
high_price: 400,
notes: "Per damaged section. Remove and replace with primed lumber.",
ai_match_keywords: ["wood rot", "rotted", "damaged wood", "replace fascia", "replace trim"],
},
{
trade: "painting",
scope_item: "Power wash - exterior surfaces",
unit: "sq_ft",
unit_label: "per sq ft",
low_price: 0.15,
high_price: 0.40,
notes: "Pre-paint power wash. Whole house exterior.",
ai_match_keywords: ["power wash", "pressure wash", "cleaning", "wash"],
},
{
trade: "painting",
scope_item: "Caulking - windows and doors",
unit: "each",
unit_label: "per opening",
low_price: 8,
high_price: 20,
notes: "Re-caulk around each window or door",
ai_match_keywords: ["caulk", "caulking", "seal", "window seal", "door seal"],
},
];
```
---
8. Deployment on Replit
Step 1: Environment Variables
In Replit Secrets:
| Secret | Value |
|---|---|
| OPENAI_API_KEY | Your OpenAI API key with GPT-4o access |
| DATABASE_URL | PostgreSQL connection string |
| COMPANY_NAME | Your company name (used in estimate delivery) |
Step 2: Deploy
1. Build command: `npm run build`
2. Run command: `npm start`
3. Deploy via Replit Deployments (Autoscale)
4. Share the URL with your CSR team
Step 3: Configure Photo Uploads
Configure file storage for photo uploads. Set a 10 MB max file size per photo. The frontend should compress images before uploading if they exceed this limit (phone cameras produce 5-15 MB images).
```typescript
// client/src/lib/upload.ts
export async function uploadPhoto(file: File, estimateId: string): Promise<string> {
const formData = new FormData();
formData.append("file", file);
formData.append("estimateId", estimateId);
const res = await fetch("/api/upload", { method: "POST", body: formData });
const { url } = await res.json();
return url;
}
```
---
9. Testing and Validation
Test 1: Photo Analysis Accuracy
Upload 5 test photos of different project types. For each, verify:
| Photo | Expected Scope Items | AI Identifies Them? |
|---|---|---|
| Peeling exterior wall | Paint prep, exterior paint | Yes/No |
| Damaged roof shingles | Tear-off, re-roof, flashing | Yes/No |
| Kitchen needing repaint | Interior paint, trim paint, ceiling | Yes/No |
| Rotted fascia boards | Wood rot repair, paint, trim | Yes/No |
| Bathroom remodel | Demo, tile, plumbing fixtures, paint | Yes/No |
Pass criteria: AI correctly identifies at least 70% of the expected scope items across all test photos.
Test 2: Pricing Match Accuracy
For each AI-identified scope item, verify it matches the correct pricing item in your database. Check:
- [ ] Unit types match (sq ft to sq ft, not sq ft to linear ft)
- [ ] Keywords produce correct matches
- [ ] No scope items are left unmatched (flagged for manual review)
Pass criteria: 80%+ of scope items match a pricing item correctly.
Test 3: Estimate Range Accuracy
Compare AI rough estimates against 5 historical estimates where you know the final price:
| Project | AI Rough Estimate | Actual Final Price | Within Range? |
|---|---|---|---|
| Exterior repaint, 2-story | $6,800-$9,100 | $7,950 | Yes/No |
| Roof replacement, ranch | $8,500-$12,000 | $10,200 | Yes/No |
Pass criteria: AI range contains the actual price for 70%+ of test cases.
Test 4: End-to-End CSR Flow
1. CSR creates new estimate, uploads 3 photos, fills customer info
2. Clicks "Generate Estimate"
3. Reviews the output
4. Sends to customer
Pass criteria: Full flow completes in under 3 minutes. Estimate is readable and reasonable.
Test 5: Photo Quality Edge Cases
- Blurry photo: AI should flag low confidence and note the quality issue
- Dark/underexposed photo: same handling
- Photo of something unrelated (a pet, a car): AI should return no scope items with a note
- Very close-up photo (just a crack): AI should note limited visibility
Pass criteria: All edge cases handled without crashes or wildly wrong estimates.
Test 6: Pricing Database Empty
Generate an estimate with an empty pricing database. The app should:
- Still show the AI scope analysis
- Flag all line items as "needs manual pricing"
- Not crash or show $0-$0 estimates without explanation
Pass criteria: Clear messaging that pricing data is needed.
---
10. Example Output
Scenario: Exterior Repaint Request
9:45 AM. A homeowner texts 4 photos to Peak Painting's office: the front of the house (two-story colonial, beige paint peeling on the south wall), a close-up of the peeling area, the back of the house, and a shot of the garage trim where wood rot is visible.
9:46 AM. Jess, the CSR, opens the Photo Estimate app and creates a new estimate:
- Customer: Tom Bradley
- Email: tom.bradley@email.com
- Phone: (469) 555-0221
- Address: 2811 Willow Creek Dr, Frisco TX
- Project Type: Exterior Paint
- Photos: uploads all 4
- Context: "Wants full exterior repaint. Noticed peeling on the sunny side and some rot on the garage."
9:46 AM. Jess clicks "Generate Estimate." The loading bar shows progress.
9:48 AM. The estimate appears:
AI Summary:
"Two-story colonial home, approximately 2,800 sq ft of exterior painted surface. South and west walls show moderate-to-heavy paint peeling, likely 5+ years since last paint. Two fascia board sections on the garage show wood rot and need replacement. Trim around 14 visible windows and 2 doors needs repainting. Recommend full power wash, prep work on peeling surfaces, wood rot repair at garage, prime and 2 coats on all surfaces."
Line Items:
| Description | Qty | Unit | Low | High | Confidence |
|---|---|---|---|---|---|
| Power wash - exterior surfaces | 2,800 | sq ft | $420 | $1,120 | High |
| Exterior paint prep - scraping and sanding | 1,200 | sq ft | $600 | $1,800 | High |
| Exterior paint - prime and 2 coats | 2,800 | sq ft | $4,200 | $8,400 | High |
| Trim and fascia paint | 280 | linear ft | $560 | $1,400 | Medium |
| Wood rot repair (fascia/trim) | 2 | each | $300 | $800 | High |
| Caulking - windows and doors | 16 | each | $128 | $320 | Medium |
| Total | $6,208 | $13,840 |
Jess notices the range is wide. She knows this house is a standard repaint, not a full restoration. She adjusts the prep quantity down from 1,200 to 800 sq ft (the peeling is only on two walls, not all four) and the paint price to the lower-mid range since the color is staying similar (beige to off-white). The adjusted range becomes:
Adjusted Total: $6,008 - $10,440
Jess rounds it: "Approximately $7,200 - $9,500" and clicks Send.
9:50 AM. Tom gets a text:
> "Hi Tom, this is Peak Painting. Based on your photos, we estimate the exterior repaint at 2811 Willow Creek Dr at approximately $7,200 - $9,500. Includes full prep, 2 coats, trim, and fixing the rot on the garage. We will confirm with a quick site visit. Want to schedule? Reply YES or call us."
9:52 AM. Tom replies: "YES. Thursday work?"
Tom never called another painter. He has a number. He has a company that responded in 6 minutes. The estimator visits Thursday, measures, and sends a final quote of $8,350. Tom signs that afternoon.
Total time from photos to ballpark: 6 minutes. The competition has not even returned Tom's call yet.
---
11. Quick Reference Card
```
PHOTO-TO-ROUGH-ESTIMATE
================================
WHAT IT DOES:
Customer sends photos. AI analyzes the scope.
System matches your pricing database.
Rough estimate generated in minutes.
Customer gets a fast ballpark. Estimator confirms later.
CSR WORKFLOW:
1. Customer sends photos (text, email, form)
2. Open Photo Estimate app > New Estimate
3. Enter customer info + upload photos
4. Click "Generate Estimate"
5. Review and adjust line items if needed
6. Click "Send to Customer"
7. Total time: 3-5 minutes
WHAT THE AI DOES:
- Identifies surfaces, materials, conditions
- Estimates area and quantities from visual cues
- Flags damage and special conditions
- Matches scope to YOUR pricing database
ACCURACY:
Rough estimate range should contain the final
price 70-80% of the time. Always frame it as
"preliminary, confirmed after site visit."
PRICING DATABASE (OWNER SETUP):
Enter your actual rates, not generic numbers.
Need 30-50 items for your most common work.
Update quarterly or when costs change.
TECH STACK:
Frontend: React + Tailwind
Backend: Express on Replit
Database: PostgreSQL
Storage: File storage (photos)
AI: OpenAI GPT-4o Vision
Auth: Magic link
COST:
OpenAI: $10-$30/month (photo analysis)
Replit: Free or $7/mo
Total: $10-$37/month
WHAT THIS CHANGES:
Time to ballpark: 3-5 days -> 15 minutes
Leads that ghost: 30-40% -> under 10%
Close rate: 20-30% -> 45-60%
Estimator visits: every lead -> only serious leads
DISCLAIMER (ALWAYS INCLUDE):
"This is a preliminary estimate based on photos.
Final pricing confirmed after on-site visit."
```
---
Recipe 099 -- Photo-to-Rough-Estimate for Instant Quotes
THE AI TRADES Platform
Difficulty: Replit Build | Time: Full day | Category: Estimates & Quoting